diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..c431dba --- /dev/null +++ b/src/app.rs @@ -0,0 +1,194 @@ +use ratatui::{crossterm::event::KeyCode, widgets::ListState}; +use serde::{Deserialize, Serialize}; + +use crate::snowballing::Publication; + +#[derive(Serialize, Deserialize, Default, PartialEq)] +pub enum ActivePane { + IncludedPublications, + #[default] + PendingPublications, +} + +#[derive(Serialize, Deserialize, Default)] +pub enum SnowballingStep { + #[default] + Forward, + Backward, +} + +impl ToString for SnowballingStep { + fn to_string(&self) -> String { + match self { + SnowballingStep::Forward => String::from("forward"), + SnowballingStep::Backward => String::from("backward"), + } + } +} + +#[derive(Serialize, Deserialize)] +struct SerializableListState { + pub offset: usize, + pub selected: Option, +} + +pub mod liststate_serde { + use serde::{Deserializer, Serializer}; + + use super::*; + + pub fn serialize( + state: &ListState, + serializer: S, + ) -> Result + where + S: Serializer, + { + let surrogate = SerializableListState { + offset: state.offset(), + selected: state.selected(), + }; + surrogate.serialize(serializer) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = SerializableListState::deserialize(deserializer)?; + Ok(ListState::default() + .with_offset(s.offset) + .with_selected(s.selected)) + } +} + +#[derive(Serialize, Deserialize, Default)] +pub struct App { + /// List of Publications that have been conclusively included + pub included_publications: Vec, + + /// List of Publications pending screening + pub pending_publications: Vec, + + /// List of Publications that have been conclusively excluded + pub excluded_publications: Vec, + + /// UI state: included publications list + #[serde(with = "liststate_serde")] + pub included_list_state: ListState, + + /// UI state: pending publications list + #[serde(with = "liststate_serde")] + pub pending_list_state: ListState, + + /// UI state: active pane + #[serde(skip)] + pub active_pane: ActivePane, + + pub snowballing_iteration: usize, + + pub snowballing_step: SnowballingStep, + + #[serde(skip)] + pub should_quit: bool, +} + +// TODO: Implement exclusion and inclusion of papers (e.g., X and Y chars) +// TODO: Implement moving through steps and iterations (populating pending papers) +// TODO: Implement input of seed papers using IDs +// TODO: Implement possibility of pushing excluded papers back into pending +// TODO: Implement export of included papers as csv for keywording with a spreadsheet +// TODO: Implement export of included papers into zotero (Use RIS format somehow) +impl App { + pub fn handle_key(&mut self, key: KeyCode) { + match key { + KeyCode::Char('q') => { + self.should_quit = true; + } + KeyCode::Enter => match self.active_pane { + ActivePane::IncludedPublications => { + if let Some(idx) = self.included_list_state.selected() { + open::that(&self.included_publications[idx].id) + .unwrap(); + } + } + ActivePane::PendingPublications => { + if let Some(idx) = self.pending_list_state.selected() { + open::that(&self.pending_publications[idx].id).unwrap(); + } + } + }, + KeyCode::Char('j') => match self.active_pane { + ActivePane::IncludedPublications => { + let i = match self.included_list_state.selected() { + Some(i) => { + if i >= self.included_publications.len() - 1 { + 0 + } else { + i + 1 + } + } + None => 0, + }; + self.included_list_state.select(Some(i)); + } + ActivePane::PendingPublications => { + let i = match self.pending_list_state.selected() { + Some(i) => { + if i >= self.pending_publications.len() - 1 { + 0 + } else { + i + 1 + } + } + None => 0, + }; + self.pending_list_state.select(Some(i)); + } + }, + KeyCode::Char('k') => match self.active_pane { + ActivePane::IncludedPublications => { + let i = match self.included_list_state.selected() { + Some(i) => { + if i == 0 { + self.included_publications.len() - 1 + } else { + i - 1 + } + } + None => 0, + }; + self.included_list_state.select(Some(i)); + } + ActivePane::PendingPublications => { + let i = match self.pending_list_state.selected() { + Some(i) => { + if i == 0 { + self.pending_publications.len() - 1 + } else { + i - 1 + } + } + None => 0, + }; + self.pending_list_state.select(Some(i)); + } + }, + KeyCode::Char('h') => { + self.active_pane = ActivePane::IncludedPublications; + + if let None = self.included_list_state.selected() { + self.included_list_state.select(Some(0)); + } + } + KeyCode::Char('l') => { + self.active_pane = ActivePane::PendingPublications; + + if let None = self.pending_list_state.selected() { + self.pending_list_state.select(Some(0)); + } + } + _ => {} + } + } +} diff --git a/src/crossterm.rs b/src/crossterm.rs new file mode 100644 index 0000000..fc180af --- /dev/null +++ b/src/crossterm.rs @@ -0,0 +1,85 @@ +use std::{error::Error, io, time::Duration}; + +use ratatui::{ + Terminal, + backend::{Backend, CrosstermBackend}, + crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event}, + execute, + terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, + enable_raw_mode, + }, + }, +}; + +use crate::{app::App, ui}; + +pub fn run(app: App) -> Result<(), Box> { + // setup terminal + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + // create app and run it + let app_result = run_app(&mut terminal, app); + + // restore terminal + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + + if let Err(err) = app_result { + println!("{err:?}"); + } + + Ok(()) +} + +// TODO: Implement save on quit +fn run_app( + terminal: &mut Terminal, + mut app: App, +) -> io::Result<()> +where + io::Error: From, +{ + loop { + terminal.draw(|frame| ui::draw(frame, &mut app))?; + + if event::poll(Duration::from_millis(10))? { + if let Event::Key(key) = event::read()? { + app.handle_key(key.code); + } + } + + if app.should_quit { + return Ok(()); + } + } +} + +// pub fn run_app( +// terminal: &mut Terminal, +// mut app: App, +// ) -> io::Result +// where +// io::Error: From, +// { +// loop { +// terminal.draw(|f| ui(f, &app))?; +// +// if let Event::Key(key) = event::read()? { +// app.handle_key(key.code); +// if app.should_quit { +// return Ok(app); +// } +// } +// } +// } diff --git a/src/gui.rs b/src/gui.rs deleted file mode 100644 index 68415f4..0000000 --- a/src/gui.rs +++ /dev/null @@ -1,330 +0,0 @@ -use ratatui::{ - crossterm::event::{self, Event, KeyCode}, - prelude::*, - widgets::{Block, Borders, List, ListItem}, -}; -use serde::{Deserialize, Serialize}; -use std::io; - -use crate::snowballing::{Publication, reconstruct_abstract}; - -#[derive(Serialize, Deserialize, Default)] -enum ActivePane { - #[default] - IncludedPublications, - PendingPublications, -} - -#[derive(Serialize, Deserialize, Default)] -enum SnowballingStep { - #[default] - Forward, - Backward, -} - -impl ToString for SnowballingStep { - fn to_string(&self) -> String { - match self { - SnowballingStep::Forward => String::from("forward"), - SnowballingStep::Backward => String::from("backward"), - } - } -} - -#[derive(Serialize, Deserialize, Default)] -pub struct App { - /// List of Publications that have been conclusively included - pub included_publications: Vec, - /// List of Publications pending screening - pub pending_publications: Vec, - /// List of Publications that have been conclusively excluded - pub excluded_publications: Vec, - /// Index of the selected item in the included publications list (UI) - pub included_selected_idx: usize, - /// Index of the selected item in the pending publications list (UI) - pub pending_selected_idx: usize, - pub active_pane: ActivePane, - pub snowballing_iteration: usize, - pub snowballing_step: SnowballingStep, - pub should_quit: bool, -} - -// TODO: The whole architecture of the app may be a bit unusual. Compare with -// example on https://ratatui.rs/examples/widgets/scrollbar/ -// TODO: Implement exclusion and inclusion of papers (e.g., X and Y chars) -// TODO: Implement moving through steps and iterations (populating pending papers) -// TODO: Implement input of seed papers using IDs -// TODO: Implement possibility of pushing excluded papers back into pending -// TODO: Implement export of included papers as csv for keywording with a spreadsheet -// TODO: Implement export of included papers into zotero (Use RIS format somehow) -impl App { - fn handle_key(&mut self, key: KeyCode) { - match key { - KeyCode::Char('q') => { - self.should_quit = true; - } - KeyCode::Enter => match self.active_pane { - ActivePane::IncludedPublications => { - open::that( - &self.included_publications[self.included_selected_idx] - .id, - ) - .unwrap(); - } - ActivePane::PendingPublications => { - open::that( - &self.pending_publications[self.pending_selected_idx] - .id, - ) - .unwrap(); - } - }, - KeyCode::Char('h') => { - self.active_pane = ActivePane::IncludedPublications; - } - KeyCode::Char('l') => { - self.active_pane = ActivePane::PendingPublications; - } - KeyCode::Char('k') => match self.active_pane { - ActivePane::IncludedPublications => { - if self.included_selected_idx > 0 { - self.included_selected_idx -= 1; - } - } - ActivePane::PendingPublications => { - if self.pending_selected_idx > 0 { - self.pending_selected_idx -= 1; - } - } - }, - KeyCode::Char('j') => match self.active_pane { - ActivePane::IncludedPublications => { - if self.included_selected_idx - < self.included_publications.len().saturating_sub(1) - { - self.included_selected_idx += 1; - } - } - ActivePane::PendingPublications => { - if self.pending_selected_idx - < self.pending_publications.len().saturating_sub(1) - { - self.pending_selected_idx += 1; - } - } - }, - _ => {} - } - } -} - -pub fn run_app( - terminal: &mut Terminal, - mut app: App, -) -> io::Result -where - io::Error: From, -{ - loop { - terminal.draw(|f| ui(f, &app))?; - - if let Event::Key(key) = event::read()? { - app.handle_key(key.code); - if app.should_quit { - return Ok(app); - } - } - } -} - -// TODO: Implement scrolling. See -//https://ratatui.rs/examples/widgets/scrollbar/ -fn ui(f: &mut Frame, app: &App) { - // Root layout - - let panes = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(25), Constraint::Percentage(75)]) - .split(f.area()); - - // Left pane - - let left_pane = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(10), Constraint::Length(2)]) - .split(panes[0]); - - let included_pubs: Vec = app - .included_publications - .iter() - .enumerate() - .map(|(idx, item)| { - let style = if idx == app.included_selected_idx - && matches!(app.active_pane, ActivePane::IncludedPublications) - { - Style::default().bg(Color::Rgb(50, 50, 50)) - } else { - Style::default() - }; - - let mut author_str = item - .authorships - .first() - .map(|authorship| authorship.raw_author_name.clone()) - .expect( - "Papers are required to always have at least one author", - ); - - if item.authorships.len() > 1 { - author_str.push_str(" et al."); - } - - ListItem::new(vec![ - Line::from(vec![ - Span::raw( - item.display_name.clone().unwrap_or("".to_string()), - ), - Span::styled( - format!( - " ({})", - item.publication_year - .map_or("".to_string(), |val| val.to_string()) - ), - Style::default().fg(Color::Yellow), - ), - ]), - Line::from(Span::styled( - author_str, - Style::default().fg(Color::Blue), - )), - ]) - .style(style) - }) - .collect(); - - let num_included_pubs = included_pubs.len(); - let included_pub_list = List::new(included_pubs) - .block( - Block::default() - .title_top(Line::from("Included Publications").centered()) - .title_top( - Line::from(Span::styled( - format!("{} entries", num_included_pubs), - Style::default().fg(Color::Yellow), - )) - .right_aligned(), - ) - .borders(Borders::ALL), - ) - .style(Style::default()); - - f.render_widget(included_pub_list, left_pane[0]); - - let status = 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::Yellow), - ), - ]), - ]; - - let status_widget = - ratatui::widgets::Paragraph::new(status).style(Style::default()); - f.render_widget(status_widget, left_pane[1]); - - // Right pane - - let pending_pubs: Vec = app - .pending_publications - .iter() - .enumerate() - .map(|(idx, item)| { - let style = if idx == app.pending_selected_idx - && matches!(app.active_pane, ActivePane::PendingPublications) - { - Style::default().bg(Color::Rgb(50, 50, 50)) - } else { - Style::default() - }; - - let mut author_str = item - .authorships - .first() - .map(|authorship| authorship.raw_author_name.clone()) - .expect( - "Papers are required to always have at least one author", - ); - - if item.authorships.len() > 1 { - author_str.push_str(" et al."); - } - - let mut lines = vec![ - Line::from(vec![ - Span::raw( - item.display_name.clone().unwrap_or("".to_string()), - ), - Span::styled( - format!( - " ({})", - item.publication_year - .map_or("".to_string(), |val| val.to_string()) - ), - Style::default().fg(Color::Yellow), - ), - ]), - Line::from(Span::styled( - author_str, - Style::default().fg(Color::Blue), - )), - ]; - - let available_width = panes[1].width.saturating_sub(4) as usize; // subtract for borders/padding - - let abstract_text = item - .abstract_inverted_index - .clone() - .map_or("[No abstact available]".to_string(), |content| { - reconstruct_abstract(&content) - }); - let wrapped = textwrap::fill( - &abstract_text, - available_width.saturating_sub(2), - ); - for wrapped_line in wrapped.lines() { - lines.push(Line::from(format!(" {}", wrapped_line))); - } - - ListItem::new(lines).style(style) - }) - .collect(); - - let num_pending_pubs = pending_pubs.len(); - let pengind_pub_list = List::new(pending_pubs) - .block( - Block::default() - .title_top( - Line::from("Publications Pending Screening").centered(), - ) - .title_top( - Line::from(Span::styled( - format!("{} entries", num_pending_pubs), - Style::default().fg(Color::Yellow), - )) - .right_aligned(), - ) - .borders(Borders::ALL), - ) - .style(Style::default()); - - f.render_widget(pengind_pub_list, panes[1]); -} diff --git a/src/main.rs b/src/main.rs index 9fe972a..d27e17c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,30 +1,27 @@ -use std::io; - -use ratatui::Terminal; -use ratatui::crossterm::{ - execute, - terminal::{ - EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, - enable_raw_mode, - }, -}; -use ratatui::prelude::CrosstermBackend; use serde_json; -mod gui; -use crate::gui::{App, run_app}; -use crate::snowballing::get_citing_papers; +mod app; +mod ui; +use crate::app::App; mod snowballing; -use clap::Parser; - -#[derive(Parser)] -#[command(name = "Brittling")] -#[command(about = "A tool to perform snowballing for literature studies", long_about = None)] -struct Args { - #[arg(short, long)] - savefile: String, -} +// use crate::snowballing::get_citing_papers; +// #[tokio::main] +// async fn main() -> Result<(), reqwest::Error> { +// let publications = get_citing_papers("w2963127785", "an.tsouchlos@gmail.com").await?; +// +// let app = App { +// pending_publications: publications, +// ..Default::default() +// }; +// +// if let Ok(serialized) = serde_json::to_string_pretty(&app) { +// std::fs::write("temp.json", serialized) +// .expect("We can't really deal with io errors ourselves"); +// } +// +// Ok(()) +// } fn load_savefile(filename: &String) -> Result { match std::fs::read_to_string(filename) { @@ -43,84 +40,23 @@ fn load_savefile(filename: &String) -> Result { } } -#[derive(Debug)] -enum Error { - IOError(std::io::Error), - JsonError(serde_json::Error), +use clap::Parser; +mod crossterm; +use std::error::Error; + +#[derive(Parser)] +#[command(name = "Brittling")] +#[command(about = "A tool to perform snowballing for literature studies", long_about = None)] +struct Args { + #[arg(short, long)] + savefile: String, } -impl From for Error { - fn from(value: std::io::Error) -> Self { - Error::IOError(value) - } -} - -impl From for Error { - fn from(value: serde_json::Error) -> Self { - Error::JsonError(value) - } -} - -// TODO: The whole initialization and deinitialization and use of crossterm -// might be unnecessary. Compare with example on -// https://ratatui.rs/examples/widgets/scrollbar/ -fn main() -> io::Result<()> { +fn main() -> Result<(), Box> { let args = Args::parse(); + let app = load_savefile(&args.savefile)?; - // - enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen)?; - // - - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - - let mut err: Option = None; - - let app = load_savefile(&args.savefile); - if let Ok(app) = app { - let result = run_app(&mut terminal, app); - - match result { - Ok(app) => match serde_json::to_string_pretty(&app) { - Ok(serialized) => std::fs::write(args.savefile, serialized)?, - Err(ser_err) => err = Some(ser_err.into()), - }, - Err(app_err) => { - err = Some(app_err.into()); - } - } - } else { - err = Some(app.err().unwrap().into()); - } - - // - disable_raw_mode()?; - execute!(terminal.backend_mut(), LeaveAlternateScreen)?; - terminal.show_cursor()?; - // - - if let Some(err) = err { - eprintln!("{:?}", err); - } + crate::crossterm::run(app)?; Ok(()) } - -// #[tokio::main] -// async fn main() -> Result<(), reqwest::Error> { -// let publications = get_citing_papers("w2963127785", "an.tsouchlos@gmail.com").await?; -// -// let app = App { -// pending_publications: publications, -// ..Default::default() -// }; -// -// if let Ok(serialized) = serde_json::to_string_pretty(&app) { -// std::fs::write("temp.json", serialized) -// .expect("We can't really deal with io errors ourselves"); -// } -// -// Ok(()) -// } diff --git a/src/snowballing.rs b/src/snowballing.rs index 0b9cbcf..185cb40 100644 --- a/src/snowballing.rs +++ b/src/snowballing.rs @@ -1,7 +1,7 @@ use ammonia::Builder; use reqwest::Error; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::{collections::HashMap}; #[derive(Serialize, Deserialize, Debug)] pub struct Authorship { @@ -20,33 +20,59 @@ pub struct Publication { pub referenced_works: Vec, } +impl Publication { + pub fn get_title(&self) -> Option { + self.display_name.clone() + } + + pub fn get_year(&self) -> Option { + self.publication_year + } + + pub fn get_author_text(&self) -> String { + let mut author_str = self + .authorships + .first() + .map(|authorship| authorship.raw_author_name.clone()) + .expect("Papers are required to always have at least one author"); + + if self.authorships.len() > 1 { + author_str.push_str(" et al."); + } + + author_str + } + + pub fn get_abstract(&self) -> Option { + self.abstract_inverted_index.clone().map(|content| { + let mut words_with_pos: Vec<(u32, &String)> = Vec::new(); + + for (word, positions) in &content { + for pos in positions { + words_with_pos.push((*pos, word)); + } + } + + words_with_pos.sort_by_key(|k| k.0); + + let unsanitized = words_with_pos + .into_iter() + .map(|(_, word)| word.as_str()) + .collect::>() + .join(" "); + + let cleaner = Builder::empty(); + let sanitized = cleaner.clean(&unsanitized).to_string(); + sanitized.replace("\u{a0}", " ").trim().to_string() + }) + } +} + #[derive(Serialize, Deserialize, Debug)] pub struct OpenAlexResponse { pub results: Vec, } -pub fn reconstruct_abstract(inverted_index: &HashMap>) -> String { - let mut words_with_pos: Vec<(u32, &String)> = Vec::new(); - - for (word, positions) in inverted_index { - for &pos in positions { - words_with_pos.push((pos, word)); - } - } - - words_with_pos.sort_by_key(|k| k.0); - - let unsanitized = words_with_pos - .into_iter() - .map(|(_, word)| word.as_str()) - .collect::>() - .join(" "); - - let cleaner = Builder::empty(); - let sanitized = cleaner.clean(&unsanitized).to_string(); - sanitized.replace("\u{a0}", " ").trim().to_string() -} - // TODO: Get all papers, not just the first page pub async fn get_citing_papers( target_id: &str, diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..4f22d96 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,192 @@ +use std::fmt::format; + +use crate::{ + app::{ActivePane, App}, + snowballing::Publication, +}; +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{ + Color, Modifier, Style, Stylize, palette::material::AccentedPalette, + }, + text::{Line, Span}, + widgets::{ + Block, Borders, List, ListItem, Scrollbar, ScrollbarOrientation, + ScrollbarState, + }, +}; + +pub fn draw(f: &mut Frame, app: &mut App) { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(25), Constraint::Percentage(75)]) + .split(f.area()); + + draw_left_pane(f, app, chunks[0]); + draw_right_pane(f, app, chunks[1]); +} + +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![ + Line::from(vec![ + Span::styled(border_char, border_style), + Span::raw(format!("[{}] ", idx + 1)), + Span::raw( + publ.get_title() + .unwrap_or("[No title available]".to_string()), + ), + Span::styled( + format!( + " ({})", + publ.publication_year + .map_or("".to_string(), |val| val.to_string()) + ), + Style::default().fg(Color::Yellow), + ), + ]), + 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 status + + let status = 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::Yellow), + ), + ]), + ]; + + let status_widget = + ratatui::widgets::Paragraph::new(status).style(Style::default()); + frame.render_widget(status_widget, chunks[1]); +} + +fn draw_right_pane(frame: &mut Frame, app: &mut App, area: Rect) { + // Included Publication list + + let items = create_publication_item_list( + &app.included_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); +}