From 3806865ae4f6dd9c408fcd441b514b217b1d8c4f Mon Sep 17 00:00:00 2001 From: Andreas Tsouchlos Date: Wed, 31 Dec 2025 01:38:23 +0200 Subject: [PATCH] Implement proper multithreading --- Cargo.lock | 10 + Cargo.toml | 1 + src/app.rs | 755 +++++++++++++++++++++++++++++---------------- src/crossterm.rs | 50 ++- src/main.rs | 16 +- src/snowballing.rs | 50 +++ src/ui.rs | 71 +++-- 7 files changed, 651 insertions(+), 302 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a8721cd..0e39171 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -164,6 +164,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "static_cell", "textwrap", "tokio", "unicode-general-category", @@ -2131,6 +2132,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "static_cell" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0530892bb4fa575ee0da4b86f86c667132a94b74bb72160f58ee5a4afec74c23" +dependencies = [ + "portable-atomic", +] + [[package]] name = "string_cache" version = "0.8.9" diff --git a/Cargo.toml b/Cargo.toml index 4ca4870..b148383 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ ratatui = "0.30.0" reqwest = { version = "0.12.28", features = ["json"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.148" +static_cell = "2.1.1" textwrap = "0.16.2" tokio = { version = "1.48.0", features = ["full"] } unicode-general-category = "1.1.0" diff --git a/src/app.rs b/src/app.rs index 331fc5e..3ce1647 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,9 +1,17 @@ +use std::time::Duration; + use ratatui::{crossterm::event::KeyCode, widgets::ListState}; use serde::{Deserialize, Serialize}; +use tokio::{ + sync::mpsc::{self, error::SendError}, + time::sleep, +}; -use crate::snowballing::{Publication, get_publication_by_id}; +use crate::snowballing::{ + Publication, SnowballingHistory, SnowballingStep, get_publication_by_id, +}; -use log::warn; +use log::{error, info, warn}; #[derive(Serialize, Deserialize, Default, PartialEq)] pub enum ActivePane { @@ -12,21 +20,7 @@ pub enum ActivePane { PendingPublications, } -#[derive(Serialize, Deserialize, Default, PartialEq)] -pub enum ActiveTab { - #[default] - Seeding, - Snowballing, -} - -#[derive(Serialize, Deserialize, Default)] -pub enum SnowballingStep { - #[default] - Backward, - Forward, -} - -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, Debug)] pub enum StatusMessage { Info(String), Warning(String), @@ -39,15 +33,6 @@ impl Default for StatusMessage { } } -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, @@ -84,42 +69,287 @@ pub mod liststate_serde { } } +#[derive(Serialize, Deserialize, Default, PartialEq, Copy, Clone)] +pub enum Tab { + #[default] + Seeding, + Snowballing, +} + +#[derive(Clone, Debug)] +pub enum SeedingAction { + EnterChar(char), + EnterBackspace, + ClearInput, +} + +#[derive(Clone, Debug)] +pub enum SnowballingAction { + SelectLeftPane, + SelectRightPane, + SearchForSelected, + NextItem, + PrevItem, + ShowIncludedPublication(Publication), + ShowPendingPublication(Publication), +} + +#[derive(Clone, Debug)] +pub enum GlobalAction { + TriggerNextSnowballingStep, + ShowStatusMessage(StatusMessage), + ClearStatusMessage, + AddIncludedPublication(Publication), + SubmitSeedLink, + NextTab, + Quit, +} + +#[derive(Clone, Debug)] +pub enum Action { + Snowballing(SnowballingAction), + Seeding(SeedingAction), + Global(GlobalAction), +} + +impl From for Action { + fn from(action: GlobalAction) -> Self { + Action::Global(action) + } +} + +impl From for Action { + fn from(action: SnowballingAction) -> Self { + Action::Snowballing(action) + } +} + +impl From for Action { + fn from(action: SeedingAction) -> Self { + Action::Seeding(action) + } +} + +trait Component { + fn handle_action( + &mut self, + action: T, + tx: &mpsc::UnboundedSender, + ) -> Result<(), SendError>; +} + #[derive(Serialize, Deserialize, Default)] -pub struct App { - /// List of Publications that have been conclusively included +pub struct SeedingComponent { + pub input: String, + #[serde(skip)] pub included_publications: Vec, +} - /// List of Publications pending screening - pub pending_publications: Vec, +impl Component for SeedingComponent { + fn handle_action( + &mut self, + action: SeedingAction, + action_tx: &mpsc::UnboundedSender, + ) -> Result<(), SendError> { + match action { + SeedingAction::ClearInput => { + self.input.clear(); + Ok(()) + } + SeedingAction::EnterChar(c) => { + self.input.push(c); + Ok(()) + } + SeedingAction::EnterBackspace => { + if self.input.len() > 0 { + self.input.truncate(self.input.len() - 1); + } - /// List of Publications that have been conclusively excluded - pub excluded_publications: Vec, + Ok(()) + } + } + } +} - /// UI state: included publications list +#[derive(Serialize, Deserialize, Default)] +pub struct SnowballingComponent { #[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 pub active_pane: ActivePane, + /// Local component copy of the included publications list + #[serde(skip)] + pub included_publications: Vec, + /// Local component copy of the pending publications list + #[serde(skip)] + pub pending_publications: Vec, +} - /// UI state: active window - pub active_tab: ActiveTab, +impl SnowballingComponent { + fn next_list_item( + list_state: &mut ListState, + publications: &Vec, + ) { + let i = match list_state.selected() { + Some(i) => { + if i >= publications.len().wrapping_sub(1) { + 0 + } else { + i + 1 + } + } + None => 0, + }; + list_state.select(Some(i)); + } - pub seeding_input: String, + fn prev_list_item( + list_state: &mut ListState, + publications: &Vec, + ) { + let i = match list_state.selected() { + Some(i) => { + if i == 0 { + publications.len().wrapping_sub(1) + } else { + i - 1 + } + } + None => 0, + }; + list_state.select(Some(i)); + } +} - pub snowballing_iteration: usize, +impl Component for SnowballingComponent { + fn handle_action( + &mut self, + action: SnowballingAction, + action_tx: &mpsc::UnboundedSender, + ) -> Result<(), SendError> { + match action { + SnowballingAction::SelectLeftPane => { + self.active_pane = ActivePane::IncludedPublications; - pub snowballing_step: SnowballingStep, + if let None = self.included_list_state.selected() { + self.included_list_state.select(Some(0)); + } + Ok(()) + } + SnowballingAction::SelectRightPane => { + self.active_pane = ActivePane::IncludedPublications; + + if let None = self.included_list_state.selected() { + self.included_list_state.select(Some(0)); + } + + Ok(()) + } + SnowballingAction::SearchForSelected => match self.active_pane { + ActivePane::IncludedPublications => { + if let Some(idx) = self.included_list_state.selected() { + open::that(&self.included_publications[idx].id) + .unwrap(); + } + Ok(()) + } + ActivePane::PendingPublications => { + if let Some(idx) = self.pending_list_state.selected() { + open::that(&self.pending_publications[idx].id).unwrap(); + } + Ok(()) + } + }, + SnowballingAction::NextItem => match self.active_pane { + ActivePane::IncludedPublications => { + Self::next_list_item( + &mut self.included_list_state, + &self.included_publications, + ); + Ok(()) + } + ActivePane::PendingPublications => { + Self::next_list_item( + &mut self.pending_list_state, + &self.pending_publications, + ); + Ok(()) + } + }, + SnowballingAction::PrevItem => match self.active_pane { + ActivePane::IncludedPublications => { + Self::prev_list_item( + &mut self.included_list_state, + &self.included_publications, + ); + Ok(()) + } + ActivePane::PendingPublications => { + Self::prev_list_item( + &mut self.pending_list_state, + &self.pending_publications, + ); + Ok(()) + } + }, + _ => Ok(()), + } + } +} + +#[derive(Serialize, Deserialize, Default)] +pub struct AppState { + // UI state + pub seeding: SeedingComponent, + pub snowballing: SnowballingComponent, + pub current_tab: Tab, #[serde(skip)] pub status_message: StatusMessage, + // Internal state + pub history: SnowballingHistory, +} - #[serde(skip)] +impl AppState { + pub fn refresh_component_states(&mut self) { + self.seeding.included_publications = self.history.get_all_included(); + + self.snowballing.included_publications = + self.history.get_all_included(); + self.snowballing.pending_publications = self.history.get_all_pending(); + } +} + +pub struct App { + pub state: AppState, pub should_quit: bool, + pub action_tx: &'static mpsc::UnboundedSender, +} + +macro_rules! status_info { + ($action_tx:expr, $text:expr, $($args:expr)*) => { + $action_tx.send( + GlobalAction::ShowStatusMessage(StatusMessage::Info(format!($text, $($args)*))) + .into(), + ) + }; +} +macro_rules! status_warn { + ($action_tx:expr, $text:expr, $($args:expr)*) => { + $action_tx.send( + GlobalAction::ShowStatusMessage(StatusMessage::Warning(format!($text, $($args)*))) + .into(), + ) + }; +} +macro_rules! status_error { + ($action_tx:expr, $text:expr $(, $args:expr)*) => { + $action_tx.send( + GlobalAction::ShowStatusMessage(StatusMessage::Error(format!($text, $($args)*))) + .into(), + ) + }; } // TODO: Implement moving through steps and iterations (populating pending papers) @@ -127,229 +357,238 @@ pub struct App { // 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) -// TODO: Implement proper multithreading -// TODO: Log everything relevant impl App { - pub async fn add_seed_paper(&mut self, api_link: &String) { - let publ = - get_publication_by_id(api_link, "an.tsouchlos@gmail.com").await; + // TODO: Have status messages always last the same amount of time + fn handle_global_action( + &mut self, + action: GlobalAction, + action_tx: &'static mpsc::UnboundedSender, + ) -> Result<(), SendError> { + match action { + GlobalAction::NextTab => { + self.state.current_tab = match self.state.current_tab { + Tab::Seeding => Tab::Snowballing, + Tab::Snowballing => Tab::Seeding, + }; + Ok(()) + } + GlobalAction::ShowStatusMessage(msg) => { + match &msg { + StatusMessage::Error(_) => { + error!("Status message: {:?}", msg) + } + StatusMessage::Warning(_) => { + warn!("Status message: {:?}", msg) + } + StatusMessage::Info(_) => { + info!("Status message: {:?}", msg) + } + } - match publ { - Ok(publ) => self.included_publications.push(publ), - Err(err) => { - warn!( - "Failed to get publication metadata using OpenAlex API: {}", - err + self.state.status_message = msg; + + tokio::spawn(async move { + sleep(Duration::from_millis(1000)).await; + + if let Err(err) = + action_tx.send(GlobalAction::ClearStatusMessage.into()) + { + error!("{}", err); + } + }); + + Ok(()) + } + GlobalAction::ClearStatusMessage => { + self.state.status_message = StatusMessage::Info("".to_string()); + Ok(()) + } + GlobalAction::TriggerNextSnowballingStep => { + // if self.pending_publications.len() > 0 { + // warn!( + // "The next snowballing step can only be initiated \ + // after screening all pending publications" + // ); + // self.set_status_message(StatusMessage::Warning( + // "The next snowballing step can only be initiated \ + // after screening all pending publications" + // .to_string(), + // )); + // return; + // } + // + // match self.snowballing_step { + // SnowballingStep::Forward => { + // // TODO: Implement + // } + // SnowballingStep::Backward => { + // self.set_status_message(StatusMessage::Info( + // "Fetching references...".to_string(), + // )); + // + // // TODO: Find a way to not clone the publications + // for publication in self.included_publications.clone() { + // // TODO: In addition to the referenced_works do + // // an API call for citations + // for reference in &publication.referenced_works { + // let api_link = format!( + // "https://api.openalex.org/{}", + // &reference[21..] + // ); + // let publ = get_publication_by_id( + // &api_link, + // "an.tsouchlos@gmail.com", + // ) + // .await; + // + // match publ { + // Ok(publ) => { + // self.pending_publications.push(publ) + // } + // Err(err) => { + // warn!( + // "Failed to get publication\ + // metadata using OpenAlex API: \ + // {}", + // err + // ); + // + // self.set_status_message( + // StatusMessage::Error(format!( + // "Failed to get publication\ + // metadata using OpenAlex API: \ + // {}", + // err + // )), + // ); + // } + // } + // } + // + // self.set_status_message(StatusMessage::Info( + // "Done".to_string(), + // )); + // } + // } + // } + Ok(()) + } + GlobalAction::SubmitSeedLink => { + if !self + .state + .seeding + .input + .starts_with("https://openalex.org/") + { + status_error!( + self.action_tx, + "Seed link must start with 'https://openalex.org/'" + )?; + return Ok(()); + } + + status_info!( + self.action_tx, + "Submitting seed link: {}", + &self.state.seeding.input + )?; + + let api_link = format!( + "https://api.openalex.org/{}", + self.state + .seeding + .input + .trim_start_matches("https://openalex.org/") ); - self.set_status_message(StatusMessage::Error(format!( - "Failed to get publication metadata using OpenAlex API: {}", - err - ))); + + tokio::spawn(async move { + let publ = get_publication_by_id( + &api_link, + "an.tsouchlos@gmail.com", + ); + + match publ.await { + Ok(publ) => { + let _ = status_info!( + action_tx, + "Seed paper obtained successfully: {}", + publ.get_title().unwrap_or( + "[title unavailable]".to_string() + ) + ); + + let _ = action_tx.send( + GlobalAction::AddIncludedPublication(publ) + .into(), + ); + } + Err(e) => { + let _ = + status_error!(action_tx, "{}", e.to_string()); + } + } + }); + + self.action_tx.send(SeedingAction::ClearInput.into()) + } + GlobalAction::Quit => { + self.should_quit = true; + Ok(()) + } + GlobalAction::AddIncludedPublication(publ) => { + self.state + .history + .current_iteration + .included_publications + .push(publ.clone()); + Ok(()) + } + _ => Ok(()), + } + } + + pub fn handle_action( + &mut self, + action: Action, + ) -> Result<(), SendError> { + match action { + Action::Seeding(seeding_action) => self + .state + .seeding + .handle_action(seeding_action, self.action_tx), + Action::Snowballing(snowballing_action) => self + .state + .snowballing + .handle_action(snowballing_action, self.action_tx), + Action::Global(global_action) => { + self.handle_global_action(global_action, self.action_tx) } } } - pub fn set_status_message(&mut self, s: StatusMessage) { - self.status_message = s; - } - - pub async fn handle_key(&mut self, key: KeyCode) { - if KeyCode::Esc == key { - self.should_quit = true; - return; + pub fn handle_key( + &mut self, + key: KeyCode, + ) -> Result<(), SendError> { + match (self.state.current_tab, key) { + (_, KeyCode::Esc) => { + self.action_tx.send(GlobalAction::Quit.into())?; + } + (_, KeyCode::Tab) => { + self.action_tx.send(GlobalAction::NextTab.into())?; + } + (Tab::Seeding, KeyCode::Char(c)) => { + self.action_tx.send(SeedingAction::EnterChar(c).into())?; + } + (Tab::Seeding, KeyCode::Backspace) => { + self.action_tx.send(SeedingAction::EnterBackspace.into())?; + } + (Tab::Seeding, KeyCode::Enter) => { + self.action_tx.send(GlobalAction::SubmitSeedLink.into())?; + } + _ => {} } - match self.active_tab { - ActiveTab::Seeding => match key { - KeyCode::Tab => { - self.active_tab = ActiveTab::Snowballing; - } - KeyCode::Enter => { - self.add_seed_paper(&self.seeding_input.clone()).await; - self.seeding_input.clear(); - } - KeyCode::Char(to_insert) => self.seeding_input.push(to_insert), - KeyCode::Backspace => { - if self.seeding_input.len() > 0 { - self.seeding_input - .truncate(self.seeding_input.len() - 1); - } - } - _ => {} - }, - ActiveTab::Snowballing => match key { - KeyCode::Char(' ') => { - if self.pending_publications.len() > 0 { - warn!( - "The next snowballing step can only be initiated \ - after screening all pending publications" - ); - self.set_status_message(StatusMessage::Warning( - "The next snowballing step can only be initiated \ - after screening all pending publications" - .to_string(), - )); - return; - } - - match self.snowballing_step { - SnowballingStep::Forward => { - // TODO: Implement - } - SnowballingStep::Backward => { - self.set_status_message(StatusMessage::Info( - "Fetching references...".to_string(), - )); - - // TODO: Find a way to not clone the publications - for publication in - self.included_publications.clone() - { - // TODO: In addition to the referenced_works do - // an API call for citations - for reference in &publication.referenced_works { - let api_link = format!( - "https://api.openalex.org/{}", - &reference[21..] - ); - let publ = get_publication_by_id( - &api_link, - "an.tsouchlos@gmail.com", - ) - .await; - - match publ { - Ok(publ) => { - self.pending_publications.push(publ) - } - Err(err) => { - warn!( - "Failed to get publication\ - metadata using OpenAlex API: \ - {}", - err - ); - - self.set_status_message( - StatusMessage::Error(format!( - "Failed to get publication\ - metadata using OpenAlex API: \ - {}", - err - )), - ); - } - } - } - - self.set_status_message(StatusMessage::Info( - "Done".to_string(), - )); - } - } - } - } - 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::Tab => { - self.active_tab = ActiveTab::Seeding; - } - 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() - .wrapping_sub(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() - .wrapping_sub(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() - .wrapping_sub(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() - .wrapping_sub(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)); - } - } - _ => {} - }, - } + Ok(()) } } diff --git a/src/crossterm.rs b/src/crossterm.rs index 553c78f..6219fc5 100644 --- a/src/crossterm.rs +++ b/src/crossterm.rs @@ -1,6 +1,7 @@ use std::{error::Error, io, time::Duration}; -use log::{error}; +use crossterm::event::KeyCode; +use log::error; use ratatui::{ Terminal, backend::{Backend, CrosstermBackend}, @@ -13,10 +14,14 @@ use ratatui::{ }, }, }; +use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; -use crate::{app::App, ui}; +use crate::{ + app::{Action, App, AppState}, + ui, +}; -pub async fn run(app: App) -> Result> { +pub async fn run(app_state: AppState) -> Result> { // setup terminal enable_raw_mode()?; let mut stdout = io::stdout(); @@ -25,7 +30,7 @@ pub async fn run(app: App) -> Result> { let mut terminal = Terminal::new(backend)?; // create app and run it - let app_result = run_app(&mut terminal, app).await; + let app_result = run_app(&mut terminal, app_state).await; // restore terminal disable_raw_mode()?; @@ -44,24 +49,49 @@ pub async fn run(app: App) -> Result> { Ok(app_result?) } +use static_cell::StaticCell; + +static ACTION_QUEUE_TX: StaticCell> = StaticCell::new(); +static ACTION_QUEUE_RX: StaticCell> = + StaticCell::new(); + async fn run_app( terminal: &mut Terminal, - mut app: App, -) -> io::Result + app_state: AppState, +) -> Result> where - io::Error: From, + ::Error: 'static, { + let (action_tx, action_rx): ( + UnboundedSender, + UnboundedReceiver, + ) = mpsc::unbounded_channel(); + + let action_tx_ref = ACTION_QUEUE_TX.init(action_tx); + let action_rx_ref = ACTION_QUEUE_RX.init(action_rx); + + let mut app = App { + state: app_state, + action_tx: action_tx_ref, + should_quit: false, + }; + loop { - terminal.draw(|frame| ui::draw(frame, &mut app))?; + app.state.refresh_component_states(); // TODO: Is it a problem to call this every frame? + terminal.draw(|frame| ui::draw(frame, &mut app.state))?; if event::poll(Duration::from_millis(100))? { if let Event::Key(key) = event::read()? { - app.handle_key(key.code).await; + app.handle_key(key.code)?; } } + while let Ok(action) = action_rx_ref.try_recv() { + app.handle_action(action)?; + } + if app.should_quit { - return Ok(app); + return Ok(app.state); } } } diff --git a/src/main.rs b/src/main.rs index 1bdad29..db475e2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,10 @@ -use log::{error}; +use log::error; use serde_json; +use tokio::sync::mpsc; mod app; mod ui; -use crate::app::App; +use crate::app::{App, AppState, StatusMessage}; mod snowballing; // use crate::snowballing::get_citing_papers; @@ -24,15 +25,16 @@ mod snowballing; // Ok(()) // } -fn deserialize_savefile(filename: &String) -> Result { +fn deserialize_savefile( + filename: &String, +) -> Result { match std::fs::read_to_string(filename) { Ok(content) => { - let mut app: App = serde_json::from_str(&content)?; - app.should_quit = false; + let app: AppState = serde_json::from_str(&content)?; Ok(app) } Err(_) => { - let app = App::default(); + let app = AppState::default(); if let Ok(serialized) = serde_json::to_string_pretty(&app) { let _ = std::fs::write(filename, serialized); } @@ -42,7 +44,7 @@ fn deserialize_savefile(filename: &String) -> Result { } fn serialize_savefile( - app: &App, + app: &AppState, filename: &String, ) -> Result<(), serde_json::Error> { if let Ok(serialized) = serde_json::to_string_pretty(&app) { diff --git a/src/snowballing.rs b/src/snowballing.rs index 8774063..9d2cfa8 100644 --- a/src/snowballing.rs +++ b/src/snowballing.rs @@ -22,6 +22,56 @@ pub struct Publication { pub referenced_works: Vec, } +#[derive(Serialize, Deserialize, Default)] +pub enum SnowballingStep { + #[default] + Backward, + Forward, +} + +impl ToString for SnowballingStep { + fn to_string(&self) -> String { + match self { + SnowballingStep::Forward => String::from("forward"), + SnowballingStep::Backward => String::from("backward"), + } + } +} + +// TODO: Only store IDs of excluded publications? +#[derive(Serialize, Deserialize, Default)] +pub struct SnowballingIteration { + pub included_publications: Vec, + pub excluded_publications: Vec, + pub step: SnowballingStep, +} + +#[derive(Serialize, Deserialize, Default)] +pub struct SnowballingHistory { + pub seed: Vec, + pub current_iteration: SnowballingIteration, + pub previoius_iterations: Vec, + pub pending_publications: Vec, +} + +impl SnowballingHistory { + pub fn get_all_included(&self) -> Vec { + vec![self.current_iteration.included_publications.clone()] + .into_iter() + .chain( + self.previoius_iterations + .iter() + .map(|iter| iter.included_publications.clone()), + ) + .flatten() + .collect() + } + + pub fn get_all_pending(&self) -> Vec { + self.pending_publications.clone() + } +} + impl Publication { pub fn get_title(&self) -> Option { self.display_name.clone() diff --git a/src/ui.rs b/src/ui.rs index 6ea113d..a7cb9e3 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,5 +1,5 @@ use crate::{ - app::{ActivePane, ActiveTab, App, StatusMessage}, + app::{ActivePane, App, AppState, StatusMessage, Tab}, snowballing::Publication, }; use ratatui::{ @@ -10,14 +10,14 @@ use ratatui::{ 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), +pub fn draw(f: &mut Frame, app: &mut AppState) { + match app.current_tab { + Tab::Seeding => draw_seeding_tab(f, app), + Tab::Snowballing => draw_snowballing_tab(f, app), } } -fn draw_seeding_tab(f: &mut Frame, app: &mut App) { +fn draw_seeding_tab(f: &mut Frame, app: &mut AppState) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -30,7 +30,7 @@ fn draw_seeding_tab(f: &mut Frame, app: &mut App) { // Included publication list let items = create_publication_item_list( - &app.included_publications, + &app.seeding.included_publications, None, chunks[0].width.saturating_sub(4) as usize, false, @@ -41,7 +41,10 @@ fn draw_seeding_tab(f: &mut Frame, app: &mut App) { .title_top(Line::from("Included Publications").centered()) .title_top( Line::from(Span::styled( - format!("{} entries", app.included_publications.len()), + format!( + "{} entries", + app.seeding.included_publications.len() + ), Style::default().fg(Color::Yellow), )) .right_aligned(), @@ -53,16 +56,16 @@ fn draw_seeding_tab(f: &mut Frame, app: &mut App) { list, chunks[0], &mut ListState::default().with_selected( - match app.included_publications.len() { + match app.seeding.included_publications.len() { 0 => None, - _ => Some(app.included_publications.len() - 1), + _ => Some(app.seeding.included_publications.len() - 1), }, ), ); // Text entry - let input = Paragraph::new(app.seeding_input.as_str()) + let input = Paragraph::new(app.seeding.input.as_str()) .block(Block::bordered().title("Input")); f.render_widget(input, chunks[1]); @@ -72,7 +75,7 @@ fn draw_seeding_tab(f: &mut Frame, app: &mut App) { draw_status_line(f, app, chunks[2]); } -fn draw_snowballing_tab(f: &mut Frame, app: &mut App) { +fn draw_snowballing_tab(f: &mut Frame, app: &mut AppState) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(2), Constraint::Length(1)]) @@ -241,7 +244,7 @@ fn create_publication_item_list( .collect() } -fn draw_left_pane(frame: &mut Frame, app: &mut App, area: Rect) { +fn draw_left_pane(frame: &mut Frame, app: &mut AppState, area: Rect) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(10), Constraint::Length(2)]) @@ -250,9 +253,9 @@ fn draw_left_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::IncludedPublications { - app.included_list_state.selected() + &app.snowballing.included_publications, + if app.snowballing.active_pane == ActivePane::IncludedPublications { + app.snowballing.included_list_state.selected() } else { None }, @@ -265,7 +268,10 @@ fn draw_left_pane(frame: &mut Frame, app: &mut App, area: Rect) { .title_top(Line::from("Included Publications").centered()) .title_top( Line::from(Span::styled( - format!("{} entries", app.included_publications.len()), + format!( + "{} entries", + app.snowballing.included_publications.len() + ), Style::default().fg(Color::Yellow), )) .right_aligned(), @@ -273,7 +279,11 @@ fn draw_left_pane(frame: &mut Frame, app: &mut App, area: Rect) { .borders(Borders::ALL), ); - frame.render_stateful_widget(list, chunks[0], &mut app.included_list_state); + frame.render_stateful_widget( + list, + chunks[0], + &mut app.snowballing.included_list_state, + ); // Snowballing progress @@ -281,14 +291,14 @@ fn draw_left_pane(frame: &mut Frame, app: &mut App, area: Rect) { Line::from(vec![ Span::raw("Iteration: "), Span::styled( - app.snowballing_iteration.to_string(), + app.history.current_iteration.step.to_string(), Style::default().fg(Color::Cyan), ), ]), Line::from(vec![ Span::raw("Step: "), Span::styled( - app.snowballing_step.to_string(), + app.history.previoius_iterations.len().to_string(), Style::default().fg(Color::Cyan), ), ]), @@ -299,11 +309,11 @@ fn draw_left_pane(frame: &mut Frame, app: &mut App, area: Rect) { frame.render_widget(progress_widget, chunks[1]); } -fn draw_right_pane(frame: &mut Frame, app: &mut App, area: Rect) { +fn draw_right_pane(frame: &mut Frame, app: &mut AppState, area: Rect) { let items = create_publication_item_list( - &app.pending_publications, - if app.active_pane == ActivePane::PendingPublications { - app.pending_list_state.selected() + &app.snowballing.pending_publications, + if app.snowballing.active_pane == ActivePane::PendingPublications { + app.snowballing.pending_list_state.selected() } else { None }, @@ -316,7 +326,10 @@ fn draw_right_pane(frame: &mut Frame, app: &mut App, area: Rect) { .title_top(Line::from("Publications Pending Screening").centered()) .title_top( Line::from(Span::styled( - format!("{} entries", app.pending_publications.len()), + format!( + "{} entries", + app.snowballing.pending_publications.len() + ), Style::default().fg(Color::Yellow), )) .right_aligned(), @@ -324,10 +337,14 @@ fn draw_right_pane(frame: &mut Frame, app: &mut App, area: Rect) { .borders(Borders::ALL), ); - frame.render_stateful_widget(list, area, &mut app.pending_list_state); + frame.render_stateful_widget( + list, + area, + &mut app.snowballing.pending_list_state, + ); } -fn draw_status_line(frame: &mut Frame, app: &App, area: Rect) { +fn draw_status_line(frame: &mut Frame, app: &AppState, area: Rect) { let line = Paragraph::new(Line::from(match app.status_message.clone() { StatusMessage::Info(s) => Span::raw(s), StatusMessage::Warning(s) => {