use crate::{ app::{ActivePane, ActiveTab, App, StatusMessage}, snowballing::Publication, }; use ratatui::{ Frame, layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style, Stylize}, text::{Line, Span}, widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, }; pub fn draw(f: &mut Frame, app: &mut App) { match app.active_tab { ActiveTab::Seeding => draw_seeding_tab(f, app), ActiveTab::Snowballing => draw_snowballing_tab(f, app), } } fn draw_seeding_tab(f: &mut Frame, app: &mut App) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Min(3), Constraint::Length(3), Constraint::Length(1), ]) .split(f.area()); // Included publication list let items = create_publication_item_list( &app.included_publications, None, chunks[0].width.saturating_sub(4) as usize, false, ); let list = List::new(items).block( Block::default() .title_top(Line::from("Included Publications").centered()) .title_top( Line::from(Span::styled( format!("{} entries", app.included_publications.len()), Style::default().fg(Color::Yellow), )) .right_aligned(), ) .borders(Borders::ALL), ); f.render_stateful_widget( list, chunks[0], &mut ListState::default().with_selected( match app.included_publications.len() { 0 => None, _ => Some(app.included_publications.len() - 1), }, ), ); // Text entry let input = Paragraph::new(app.seeding_input.as_str()) .block(Block::bordered().title("Input")); f.render_widget(input, chunks[1]); // Status line draw_status_line(f, app, chunks[2]); } fn draw_snowballing_tab(f: &mut Frame, app: &mut App) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(2), Constraint::Length(1)]) .split(f.area()); let content_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(25), Constraint::Percentage(75)]) .split(chunks[0]); draw_left_pane(f, app, content_chunks[0]); draw_right_pane(f, app, content_chunks[1]); draw_status_line(f, app, chunks[1]); } fn format_title<'a>( idx: usize, title: &Option, year: &Option, available_width: usize, is_selected: bool, ) -> Vec> { let idx_text = format!("[{}]", idx + 1); let title_text = title.clone().unwrap_or("[No title available]".to_string()); let year_text = year .map(|val| format!("({})", val)) .unwrap_or("[unknown]".to_string()); let wrapped = textwrap::fill( &format!("{} {} {}", idx_text, title_text, year_text), available_width, ); let lines: Vec<&str> = wrapped.lines().collect(); let mut result = Vec::::new(); let (border_char, border_style) = if is_selected { ( "┃ ", Style::default() .fg(Color::Gray) .add_modifier(Modifier::BOLD), ) } else { (" ", Style::default()) }; if lines.len() == 1 { let line = lines[0]; result.push(Line::from(vec![ Span::styled(border_char, border_style), Span::styled( line[0..idx_text.len()].to_string(), Style::default().fg(Color::DarkGray), ), Span::raw( line[idx_text.len()..line.len() - year_text.len()].to_string(), ), Span::styled( line[line.len() - year_text.len()..].to_string(), Style::default().fg(Color::Yellow), ), ])); } else { let first_line = lines[0]; let middle_lines = &lines[1..lines.len() - 1]; let last_line = lines[lines.len() - 1]; result.push(Line::from(vec![ Span::styled(border_char, border_style), Span::styled( first_line[0..idx_text.len()].to_string(), Style::default().fg(Color::DarkGray), ), Span::raw(first_line[idx_text.len()..].to_string()), ])); for line in middle_lines { result.push(Line::from(vec![ Span::styled(border_char, border_style), Span::raw(line.to_string()), ])); } result.push(Line::from(vec![ Span::styled(border_char, border_style), Span::raw( last_line[..last_line.len() - year_text.len()].to_string(), ), Span::styled( last_line[last_line.len() - year_text.len()..].to_string(), Style::default().fg(Color::Yellow), ), ])); } result } fn create_publication_item_list( publications: &Vec, selected_idx: Option, available_width: usize, show_abstract: bool, ) -> Vec> { publications .iter() .enumerate() .map(|(idx, publ)| { let is_selected = Some(idx) == selected_idx; let (border_char, border_style) = if is_selected { ( "┃ ", Style::default() .fg(Color::Gray) .add_modifier(Modifier::BOLD), ) } else { (" ", Style::default()) }; let mut lines = Vec::::new(); lines.append(&mut format_title( idx, &publ.get_title(), &publ.get_year(), available_width, is_selected, )); lines.push(Line::from(vec![ Span::styled(border_char, border_style), Span::styled( publ.get_author_text(), Style::default().fg(Color::Blue), ), ])); if show_abstract { let wrapped = textwrap::fill( &publ .get_abstract() .unwrap_or("[No abstract available]".to_string()), available_width.saturating_sub(2), ); for wrapped_line in wrapped.lines() { lines.push(Line::from(vec![ Span::styled(border_char, border_style), Span::raw(format!(" {}", wrapped_line)), ])); } } if is_selected { lines = lines .iter() .map(|line| line.clone().add_modifier(Modifier::BOLD)) .collect(); } ListItem::new(lines) }) .collect() } fn draw_left_pane(frame: &mut Frame, app: &mut App, area: Rect) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(10), Constraint::Length(2)]) .split(area); // Included Publication list let items = create_publication_item_list( &app.included_publications, if app.active_pane == ActivePane::IncludedPublications { app.included_list_state.selected() } else { None }, chunks[0].width.saturating_sub(4) as usize, false, ); let list = List::new(items).block( Block::default() .title_top(Line::from("Included Publications").centered()) .title_top( Line::from(Span::styled( format!("{} entries", app.included_publications.len()), Style::default().fg(Color::Yellow), )) .right_aligned(), ) .borders(Borders::ALL), ); frame.render_stateful_widget(list, chunks[0], &mut app.included_list_state); // Snowballing progress let progress = vec![ Line::from(vec![ Span::raw("Iteration: "), Span::styled( app.snowballing_iteration.to_string(), Style::default().fg(Color::Cyan), ), ]), Line::from(vec![ Span::raw("Step: "), Span::styled( app.snowballing_step.to_string(), Style::default().fg(Color::Cyan), ), ]), ]; let progress_widget = ratatui::widgets::Paragraph::new(progress).style(Style::default()); frame.render_widget(progress_widget, chunks[1]); } fn draw_right_pane(frame: &mut Frame, app: &mut App, area: Rect) { let items = create_publication_item_list( &app.pending_publications, if app.active_pane == ActivePane::PendingPublications { app.pending_list_state.selected() } else { None }, area.width.saturating_sub(4) as usize, true, ); let list = List::new(items).block( Block::default() .title_top(Line::from("Publications Pending Screening").centered()) .title_top( Line::from(Span::styled( format!("{} entries", app.pending_publications.len()), Style::default().fg(Color::Yellow), )) .right_aligned(), ) .borders(Borders::ALL), ); frame.render_stateful_widget(list, area, &mut app.pending_list_state); } fn draw_status_line(frame: &mut Frame, app: &App, area: Rect) { let line = Paragraph::new(Line::from(match app.status_message.clone() { StatusMessage::Info(s) => Span::raw(s), StatusMessage::Warning(s) => { Span::styled(s, Style::default().fg(Color::Yellow)) } StatusMessage::Error(s) => { Span::styled(s, Style::default().fg(Color::Red)) } })) .style(Style::default().bg(Color::Rgb(60, 56, 54))); frame.render_widget(line, area); }