From 498cb4339038c1fab30a961e4991c4212e808c00 Mon Sep 17 00:00:00 2001 From: Andreas Tsouchlos Date: Wed, 31 Dec 2025 18:07:13 +0200 Subject: [PATCH] Write procedural macro to reduce boiler plate for actions --- Cargo.lock | 59 ++++++++------ Cargo.toml | 4 + macros/Cargo.toml | 12 +++ macros/src/lib.rs | 126 ++++++++++++++++++++++++++++++ src/app.rs | 169 +++++++++++++++-------------------------- src/app/common.rs | 27 ++++--- src/app/seeding.rs | 50 ++++++------ src/app/snowballing.rs | 126 +++++++++++++++--------------- src/crossterm.rs | 50 +++++++++++- 9 files changed, 389 insertions(+), 234 deletions(-) create mode 100644 macros/Cargo.toml create mode 100644 macros/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 0e39171..632fc0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -154,6 +154,7 @@ name = "brittling" version = "0.1.0" dependencies = [ "ammonia", + "brittling_macros", "clap", "crossterm", "env_logger", @@ -170,6 +171,14 @@ dependencies = [ "unicode-general-category", ] +[[package]] +name = "brittling_macros" +version = "0.1.0" +dependencies = [ + "quote", + "syn 2.0.112", +] + [[package]] name = "bumpalo" version = "3.19.1" @@ -250,7 +259,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -380,7 +389,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -404,7 +413,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -415,7 +424,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -452,7 +461,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -473,7 +482,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -1044,7 +1053,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -1133,7 +1142,7 @@ checksum = "b787bebb543f8969132630c51fd0afab173a86c6abae56ff3b9e5e3e3f9f6e58" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -1267,7 +1276,7 @@ checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -1375,7 +1384,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -1442,7 +1451,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -1537,7 +1546,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -1590,7 +1599,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -2010,7 +2019,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -2190,7 +2199,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -2212,9 +2221,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.111" +version = "2.0.112" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "21f182278bf2d2bcb3c88b1b08a37df029d71ce3d3ae26168e3c653b213b99d4" dependencies = [ "proc-macro2", "quote", @@ -2238,7 +2247,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -2386,7 +2395,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -2397,7 +2406,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -2456,7 +2465,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -2759,7 +2768,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", "wasm-bindgen-shared", ] @@ -3110,7 +3119,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", "synstructure", ] @@ -3131,7 +3140,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", "synstructure", ] @@ -3171,7 +3180,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b148383..752742f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,6 @@ +[workspace] +members = [".", "macros"] + [package] name = "brittling" version = "0.1.0" @@ -19,3 +22,4 @@ static_cell = "2.1.1" textwrap = "0.16.2" tokio = { version = "1.48.0", features = ["full"] } unicode-general-category = "1.1.0" +brittling_macros = { path = "macros" } diff --git a/macros/Cargo.toml b/macros/Cargo.toml new file mode 100644 index 0000000..8105205 --- /dev/null +++ b/macros/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "brittling_macros" +version = "0.1.0" +edition = "2024" + +[lib] +proc-macro = true + +[dependencies] +quote = "1.0.42" +syn = "2.0.112" + diff --git a/macros/src/lib.rs b/macros/src/lib.rs new file mode 100644 index 0000000..07e1287 --- /dev/null +++ b/macros/src/lib.rs @@ -0,0 +1,126 @@ +// TODO: Clean this up + +use proc_macro::TokenStream; +use quote::{format_ident, quote}; +use syn::{FnArg, ImplItem, ItemImpl, Pat, Type, parse_macro_input}; + +#[proc_macro_attribute] +pub fn component(attr: TokenStream, item: TokenStream) -> TokenStream { + let mut input = parse_macro_input!(item as ItemImpl); + let struct_name = &input.self_ty; + + // Use the attribute argument as the Enum name, + // or fallback to the [Struct]Action logic if empty. + let enum_name = if !attr.is_empty() { + format_ident!("{}", attr.to_string().trim()) + } else { + let struct_name_str = quote!(#struct_name).to_string().replace(" ", ""); + format_ident!("{}Action", struct_name_str) + }; + + let mut enum_variants = Vec::new(); + let mut match_arms = Vec::new(); + + for item in &mut input.items { + if let ImplItem::Fn(method) = item { + // Check for #[action] + let has_action = method + .attrs + .iter() + .any(|attr| attr.path().is_ident("action")); + + if has_action { + // Remove the #[action] attribute so it doesn't cause compilation errors + method.attrs.retain(|attr| !attr.path().is_ident("action")); + + let method_name = &method.sig.ident; + let variant_name = format_ident!( + "{}", + snake_to_pascal(&method_name.to_string()) + ); + + let mut variant_fields = Vec::new(); + let mut call_args = Vec::new(); + + for arg in method.sig.inputs.iter().skip(1) { + // Skip 'self' + if let FnArg::Typed(pat_type) = arg { + if is_sender_type(&pat_type.ty) { + call_args.push(quote!(tx)); + continue; + } + + let ty = &pat_type.ty; + variant_fields.push(quote!(#ty)); + + if let Pat::Ident(pi) = &*pat_type.pat { + let arg_ident = &pi.ident; + call_args.push(quote!(#arg_ident)); + } + } + } + + if variant_fields.is_empty() { + enum_variants.push(quote!(#variant_name)); + match_arms.push(quote! { + #enum_name::#variant_name => self.#method_name(tx) + }); + } else { + enum_variants + .push(quote!(#variant_name(#(#variant_fields),*))); + // Extracting bindings for the match arm: Enum::Var(a, b) -> self.method(a, b, tx) + let pattern_args = (0..variant_fields.len()) + .map(|i| format_ident!("arg_{}", i)); + let pattern_args_clone = pattern_args.clone(); + + match_arms.push(quote! { + #enum_name::#variant_name(#(#pattern_args),*) => self.#method_name(#(#pattern_args_clone,)* tx) + }); + } + } + } + } + + let expanded = quote! { + #input + + #[derive(::core::clone::Clone, ::core::fmt::Debug)] + pub enum #enum_name { + #(#enum_variants),* + } + + impl crate::app::common::Component<#enum_name> for #struct_name { + fn handle_action( + &mut self, + action: #enum_name, + tx: &'static ::tokio::sync::mpsc::UnboundedSender, + ) -> ::core::result::Result<(), ::tokio::sync::mpsc::error::SendError> { + match action { + #(#match_arms),* + } + } + } + }; + + TokenStream::from(expanded) +} + +fn snake_to_pascal(s: &str) -> String { + s.split('_') + .map(|word| { + let mut chars = word.chars(); + match chars.next() { + None => String::new(), + Some(f) => { + f.to_uppercase().collect::() + chars.as_str() + } + } + }) + .collect() +} + +// Helper to detect if an argument is the Sender to inject it from handle_action +fn is_sender_type(ty: &Type) -> bool { + let s = quote!(#ty).to_string(); + s.contains("UnboundedSender") +} diff --git a/src/app.rs b/src/app.rs index fc9f821..c49db3d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,21 +2,22 @@ pub mod common; pub mod seeding; pub mod snowballing; +use std::time::Duration; + +use brittling_macros::component; use log::{error, info, warn}; use ratatui::crossterm::event::KeyCode; use serde::{Deserialize, Serialize}; -use std::time::Duration; use tokio::{ - sync::mpsc::{self, error::SendError}, + sync::mpsc::{UnboundedSender, error::SendError}, time::sleep, }; +use crate::crossterm::Action; use crate::{ - app::common::Component, literature::{Publication, SnowballingHistory, get_publication_by_id}, status_error, status_info, }; - use seeding::{SeedingAction, SeedingComponent}; use snowballing::{SnowballingAction, SnowballingComponent}; @@ -33,42 +34,6 @@ impl Default for StatusMessage { } } -#[derive(Clone, Debug)] -pub enum GlobalAction { - ShowStatMsg(StatusMessage), - ClearStatMsg, - AddIncludedPub(Publication), - FetchPub, - NextTab, - Quit, - Fetch, -} - -#[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) - } -} - #[derive(Serialize, Deserialize, Default, PartialEq, Copy, Clone)] pub enum Tab { #[default] @@ -101,7 +66,6 @@ impl AppState { pub struct App { pub state: AppState, pub should_quit: bool, - pub action_tx: &'static mpsc::UnboundedSender, } // TODO: Implement moving through steps and iterations (populating pending papers) @@ -109,24 +73,37 @@ 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) +#[component(GlobalAction)] impl App { - fn quit(&mut self) { + #[action] + fn quit( + &mut self, + _: &'static UnboundedSender, + ) -> Result<(), SendError> { self.should_quit = true; + Ok(()) } - fn next_tab(&mut self) { + #[action] + fn next_tab( + &mut self, + _: &'static UnboundedSender, + ) -> Result<(), SendError> { self.state.current_tab = match self.state.current_tab { Tab::Seeding => Tab::Snowballing, Tab::Snowballing => Tab::Seeding, }; + + Ok(()) } // TODO: Have status messages always last the same amount of time + #[action] fn show_stat_msg( &mut self, msg: StatusMessage, - action_tx: &'static mpsc::UnboundedSender, - ) { + action_tx: &'static UnboundedSender, + ) -> Result<(), SendError> { match &msg { StatusMessage::Error(_) => { error!("Status message: {:?}", msg) @@ -149,24 +126,40 @@ impl App { error!("{}", err); } }); + + Ok(()) } - fn clear_stat_msg(&mut self) { + #[action] + fn clear_stat_msg( + &mut self, + _: &'static UnboundedSender, + ) -> Result<(), SendError> { self.state.status_message = StatusMessage::Info("".to_string()); + + Ok(()) } // TODO: Is deduplication necessary here? - fn add_included_publ(&mut self, publ: Publication) { + #[action] + fn add_included_pub( + &mut self, + publ: Publication, + _: &'static UnboundedSender, + ) -> Result<(), SendError> { self.state .history .current_iteration .included_publications .push(publ.clone()); + + Ok(()) } - fn fetch_publication( + #[action] + fn fetch_pub( &self, - action_tx: &'static mpsc::UnboundedSender, + action_tx: &'static UnboundedSender, ) -> Result<(), SendError> { if !self .state @@ -175,14 +168,14 @@ impl App { .starts_with("https://openalex.org/") { status_error!( - self.action_tx, + action_tx, "Seed link must start with 'https://openalex.org/'" )?; return Ok(()); } status_info!( - self.action_tx, + action_tx, "Submitting seed link: {}", &self.state.seeding.input )?; @@ -217,93 +210,57 @@ impl App { } }); - self.action_tx.send(SeedingAction::ClearInput.into()) + action_tx.send(SeedingAction::ClearInput.into()) } // TODO: Implement + #[action] fn fetch( &self, - tx: &mpsc::UnboundedSender, + action_tx: &'static UnboundedSender, ) -> Result<(), SendError> { - status_info!(tx, "Fetch action triggered from SnowballingComponent") - } - - fn handle_global_action( - &mut self, - action: GlobalAction, - tx: &'static mpsc::UnboundedSender, - ) -> Result<(), SendError> { - match action { - GlobalAction::Quit => Ok(self.quit()), - GlobalAction::NextTab => Ok(self.next_tab()), - GlobalAction::ClearStatMsg => Ok(self.clear_stat_msg()), - GlobalAction::ShowStatMsg(msg) => Ok(self.show_stat_msg(msg, tx)), - GlobalAction::FetchPub => self.fetch_publication(tx), - GlobalAction::AddIncludedPub(publ) => { - Ok(self.add_included_publ(publ)) - } - GlobalAction::Fetch => self.fetch(tx), - } - } - - 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) - } - } + status_info!( + action_tx, + "Fetch action triggered" + ) } pub fn handle_key( &mut self, key: KeyCode, + action_tx: &'static UnboundedSender, ) -> Result<(), SendError> { match (self.state.current_tab, key) { - (_, KeyCode::Esc) => { - self.action_tx.send(GlobalAction::Quit.into())? - } + (_, KeyCode::Esc) => action_tx.send(GlobalAction::Quit.into())?, (_, KeyCode::Tab) => { - self.action_tx.send(GlobalAction::NextTab.into())? + action_tx.send(GlobalAction::NextTab.into())? } (Tab::Seeding, KeyCode::Char(c)) => { - self.action_tx.send(SeedingAction::EnterChar(c).into())?; + action_tx.send(SeedingAction::EnterChar(c).into())?; } (Tab::Seeding, KeyCode::Backspace) => { - self.action_tx.send(SeedingAction::EnterBackspace.into())?; + action_tx.send(SeedingAction::EnterBackspace.into())?; } (Tab::Seeding, KeyCode::Enter) => { - self.action_tx.send(GlobalAction::FetchPub.into())?; + action_tx.send(GlobalAction::FetchPub.into())?; } (Tab::Snowballing, KeyCode::Enter) => { - self.action_tx.send(SnowballingAction::Search.into())?; + action_tx.send(SnowballingAction::Search.into())?; } (Tab::Snowballing, KeyCode::Char('h')) => { - self.action_tx - .send(SnowballingAction::SelectLeftPane.into())?; + action_tx.send(SnowballingAction::SelectLeftPane.into())?; } (Tab::Snowballing, KeyCode::Char('l')) => { - self.action_tx - .send(SnowballingAction::SelectRightPane.into())?; + action_tx.send(SnowballingAction::SelectRightPane.into())?; } (Tab::Snowballing, KeyCode::Char('j')) => { - self.action_tx.send(SnowballingAction::NextItem.into())?; + action_tx.send(SnowballingAction::NextItem.into())?; } (Tab::Snowballing, KeyCode::Char('k')) => { - self.action_tx.send(SnowballingAction::PrevItem.into())?; + action_tx.send(SnowballingAction::PrevItem.into())?; } (Tab::Snowballing, KeyCode::Char(' ')) => { - self.action_tx.send(GlobalAction::Fetch.into())?; + action_tx.send(GlobalAction::Fetch.into())?; } _ => {} } diff --git a/src/app/common.rs b/src/app/common.rs index 741a757..bb199c7 100644 --- a/src/app/common.rs +++ b/src/app/common.rs @@ -1,13 +1,22 @@ +use crate::app::Action; use tokio::sync::mpsc::{self, error::SendError}; -use crate::app::Action; +// TODO: Put this somewhere closer to the procedural macro definitions +pub trait Component { + fn handle_action( + &mut self, + action: T, + tx: &'static mpsc::UnboundedSender, + ) -> Result<(), SendError>; +} #[allow(unused_macros)] #[macro_export] macro_rules! status_info { ($action_tx:expr, $text:expr $(, $args:expr)*) => { $action_tx.send( - crate::app::GlobalAction::ShowStatMsg(crate::app::StatusMessage::Info(format!($text, $($args)*))) + crate::app::GlobalAction::ShowStatMsg( + crate::app::StatusMessage::Info(format!($text, $($args)*))) .into(), ) }; @@ -18,7 +27,8 @@ macro_rules! status_info { macro_rules! status_warn { ($action_tx:expr, $text:expr $(, $args:expr)*) => { $action_tx.send( - crate::app::GlobalAction::ShowStatMsg(crate::app::StatusMessage::Info(format!($text, $($args)*))) + crate::app::GlobalAction::ShowStatMsg( + crate::app::StatusMessage::Info(format!($text, $($args)*))) .into(), ) }; @@ -29,16 +39,9 @@ macro_rules! status_warn { macro_rules! status_error { ($action_tx:expr, $text:expr $(, $args:expr)*) => { $action_tx.send( - crate::app::GlobalAction::ShowStatMsg(crate::app::StatusMessage::Info(format!($text, $($args)*))) + crate::app::GlobalAction::ShowStatMsg( + crate::app::StatusMessage::Info(format!($text, $($args)*))) .into(), ) }; } - -pub trait Component { - fn handle_action( - &mut self, - action: T, - tx: &mpsc::UnboundedSender, - ) -> Result<(), SendError>; -} diff --git a/src/app/seeding.rs b/src/app/seeding.rs index 7d994c9..ef06d84 100644 --- a/src/app/seeding.rs +++ b/src/app/seeding.rs @@ -1,14 +1,8 @@ use serde::{Deserialize, Serialize}; -use tokio::sync::mpsc::{self, error::SendError}; +use tokio::sync::mpsc::{UnboundedSender, error::SendError}; -use crate::{app::Action, app::common::Component, literature::Publication}; - -#[derive(Clone, Debug)] -pub enum SeedingAction { - EnterChar(char), - EnterBackspace, - ClearInput, -} +use crate::literature::Publication; +use brittling_macros::component; #[derive(Serialize, Deserialize, Default)] pub struct SeedingComponent { @@ -17,32 +11,34 @@ pub struct SeedingComponent { pub included_publications: Vec, } +#[component(SeedingAction)] impl SeedingComponent { - pub fn clear_input(&mut self) { - self.input.clear(); + #[action] + pub fn clear_input( + &mut self, + _: &UnboundedSender, + ) -> Result<(), SendError> { + Ok(self.input.clear()) } - pub fn enter_char(&mut self, c: char) { - self.input.push(c); + #[action] + pub fn enter_char( + &mut self, + c: char, + _: &UnboundedSender, + ) -> Result<(), SendError> { + Ok(self.input.push(c)) } - pub fn enter_backspace(&mut self) { + #[action] + pub fn enter_backspace( + &mut self, + _: &UnboundedSender, + ) -> Result<(), SendError> { if self.input.len() > 0 { self.input.truncate(self.input.len() - 1); } - } -} -impl Component for SeedingComponent { - fn handle_action( - &mut self, - action: SeedingAction, - _: &mpsc::UnboundedSender, - ) -> Result<(), SendError> { - match action { - SeedingAction::ClearInput => Ok(self.clear_input()), - SeedingAction::EnterChar(c) => Ok(self.enter_char(c)), - SeedingAction::EnterBackspace => Ok(self.enter_backspace()), - } + Ok(()) } } diff --git a/src/app/snowballing.rs b/src/app/snowballing.rs index 0b7cea7..d28079c 100644 --- a/src/app/snowballing.rs +++ b/src/app/snowballing.rs @@ -1,9 +1,9 @@ +use brittling_macros::component; use ratatui::widgets::ListState; use serde::{Deserialize, Serialize}; -use tokio::sync::mpsc; +use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::error::SendError; -use crate::app::{Action, common::Component}; use crate::literature::Publication; #[derive(Serialize, Deserialize, Default, PartialEq)] @@ -13,15 +13,6 @@ pub enum ActivePane { PendingPublications, } -#[derive(Clone, Debug)] -pub enum SnowballingAction { - SelectLeftPane, - SelectRightPane, - Search, - NextItem, - PrevItem, -} - #[derive(Serialize, Deserialize)] struct SerializableListState { pub offset: usize, @@ -73,6 +64,7 @@ pub struct SnowballingComponent { pub pending_publications: Vec, } +#[component(SnowballingAction)] impl SnowballingComponent { fn select_next_item_impl( list_state: &mut ListState, @@ -108,23 +100,39 @@ impl SnowballingComponent { list_state.select(Some(i)); } - fn select_left_pane(&mut self) { + #[action] + fn select_left_pane( + &mut self, + _: &UnboundedSender, + ) -> Result<(), SendError> { self.active_pane = ActivePane::IncludedPublications; if let None = self.included_list_state.selected() { self.included_list_state.select(Some(0)); } + + Ok(()) } - fn select_right_pane(&mut self) { + #[action] + fn select_right_pane( + &mut self, + _: &UnboundedSender, + ) -> Result<(), SendError> { self.active_pane = ActivePane::PendingPublications; if let None = self.pending_list_state.selected() { self.pending_list_state.select(Some(0)); } + + Ok(()) } - fn search(&self) { + #[action] + fn search( + &self, + _: &UnboundedSender, + ) -> Result<(), SendError> { match self.active_pane { ActivePane::IncludedPublications => { if let Some(idx) = self.included_list_state.selected() { @@ -137,55 +145,53 @@ impl SnowballingComponent { } } } + + Ok(()) } - fn next_item(&mut self) { - match self.active_pane { - ActivePane::IncludedPublications => { - Self::select_next_item_impl( - &mut self.included_list_state, - &self.included_publications, - ); - } - ActivePane::PendingPublications => { - Self::select_next_item_impl( - &mut self.pending_list_state, - &self.pending_publications, - ); - } - } - } - - fn prev_item(&mut self) { - match self.active_pane { - ActivePane::IncludedPublications => { - Self::select_prev_item_impl( - &mut self.included_list_state, - &self.included_publications, - ); - } - ActivePane::PendingPublications => { - Self::select_prev_item_impl( - &mut self.pending_list_state, - &self.pending_publications, - ); - } - } - } -} - -impl Component for SnowballingComponent { - fn handle_action( + #[action] + fn next_item( &mut self, - action: SnowballingAction, - _: &mpsc::UnboundedSender, - ) -> Result<(), SendError> { - match action { - SnowballingAction::SelectLeftPane => Ok(self.select_left_pane()), - SnowballingAction::SelectRightPane => Ok(self.select_right_pane()), - SnowballingAction::Search => Ok(self.search()), - SnowballingAction::NextItem => Ok(self.next_item()), - SnowballingAction::PrevItem => Ok(self.prev_item()), + _: &UnboundedSender, + ) -> Result<(), SendError> { + match self.active_pane { + ActivePane::IncludedPublications => { + Self::select_next_item_impl( + &mut self.included_list_state, + &self.included_publications, + ); + } + ActivePane::PendingPublications => { + Self::select_next_item_impl( + &mut self.pending_list_state, + &self.pending_publications, + ); + } } + + Ok(()) + } + + #[action] + fn prev_item( + &mut self, + _: &UnboundedSender, + ) -> Result<(), SendError> { + match self.active_pane { + ActivePane::IncludedPublications => { + Self::select_prev_item_impl( + &mut self.included_list_state, + &self.included_publications, + ); + } + ActivePane::PendingPublications => { + Self::select_prev_item_impl( + &mut self.pending_list_state, + &self.pending_publications, + ); + } + } + + Ok(()) } } diff --git a/src/crossterm.rs b/src/crossterm.rs index 337ef0c..3d8ae06 100644 --- a/src/crossterm.rs +++ b/src/crossterm.rs @@ -16,10 +16,14 @@ use ratatui::{ use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; use crate::{ - app::{Action, App, AppState}, + app::{App, AppState, common::Component}, ui, }; +use crate::app::GlobalAction; +use crate::app::seeding::SeedingAction; +use crate::app::snowballing::SnowballingAction; + pub async fn run(app_state: AppState) -> Result> { // setup terminal enable_raw_mode()?; @@ -54,6 +58,32 @@ static ACTION_QUEUE_TX: StaticCell> = StaticCell::new(); static ACTION_QUEUE_RX: StaticCell> = StaticCell::new(); +// TODO: Move this somewhere sensible +#[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) + } +} + async fn run_app( terminal: &mut Terminal, app_state: AppState, @@ -71,7 +101,6 @@ where let mut app = App { state: app_state, - action_tx: action_tx_ref, should_quit: false, }; @@ -81,12 +110,25 @@ where if event::poll(Duration::from_millis(100))? { if let Event::Key(key) = event::read()? { - app.handle_key(key.code)?; + app.handle_key(key.code, action_tx_ref)?; } } while let Ok(action) = action_rx_ref.try_recv() { - app.handle_action(action)?; + // TODO: Handle errors + match action { + Action::Seeding(seeding_action) => app + .state + .seeding + .handle_action(seeding_action, action_tx_ref), + Action::Snowballing(snowballing_action) => app + .state + .snowballing + .handle_action(snowballing_action, action_tx_ref), + Action::Global(global_action) => { + app.handle_action(global_action, action_tx_ref) + } + }; } if app.should_quit {