Write procedural macro to reduce boiler plate for actions

This commit is contained in:
Andreas Tsouchlos 2025-12-31 18:07:13 +02:00
parent a168c00ee7
commit 498cb43390
9 changed files with 389 additions and 234 deletions

59
Cargo.lock generated
View File

@ -154,6 +154,7 @@ name = "brittling"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"ammonia", "ammonia",
"brittling_macros",
"clap", "clap",
"crossterm", "crossterm",
"env_logger", "env_logger",
@ -170,6 +171,14 @@ dependencies = [
"unicode-general-category", "unicode-general-category",
] ]
[[package]]
name = "brittling_macros"
version = "0.1.0"
dependencies = [
"quote",
"syn 2.0.112",
]
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.19.1" version = "3.19.1"
@ -250,7 +259,7 @@ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.111", "syn 2.0.112",
] ]
[[package]] [[package]]
@ -380,7 +389,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
dependencies = [ dependencies = [
"quote", "quote",
"syn 2.0.111", "syn 2.0.112",
] ]
[[package]] [[package]]
@ -404,7 +413,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"strsim", "strsim",
"syn 2.0.111", "syn 2.0.112",
] ]
[[package]] [[package]]
@ -415,7 +424,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [ dependencies = [
"darling_core", "darling_core",
"quote", "quote",
"syn 2.0.111", "syn 2.0.112",
] ]
[[package]] [[package]]
@ -452,7 +461,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"rustc_version", "rustc_version",
"syn 2.0.111", "syn 2.0.112",
] ]
[[package]] [[package]]
@ -473,7 +482,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.111", "syn 2.0.112",
] ]
[[package]] [[package]]
@ -1044,7 +1053,7 @@ dependencies = [
"indoc", "indoc",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.111", "syn 2.0.112",
] ]
[[package]] [[package]]
@ -1133,7 +1142,7 @@ checksum = "b787bebb543f8969132630c51fd0afab173a86c6abae56ff3b9e5e3e3f9f6e58"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.111", "syn 2.0.112",
] ]
[[package]] [[package]]
@ -1267,7 +1276,7 @@ checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.111", "syn 2.0.112",
] ]
[[package]] [[package]]
@ -1375,7 +1384,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.111", "syn 2.0.112",
] ]
[[package]] [[package]]
@ -1442,7 +1451,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.111", "syn 2.0.112",
] ]
[[package]] [[package]]
@ -1537,7 +1546,7 @@ dependencies = [
"pest_meta", "pest_meta",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.111", "syn 2.0.112",
] ]
[[package]] [[package]]
@ -1590,7 +1599,7 @@ dependencies = [
"phf_shared", "phf_shared",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.111", "syn 2.0.112",
] ]
[[package]] [[package]]
@ -2010,7 +2019,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.111", "syn 2.0.112",
] ]
[[package]] [[package]]
@ -2190,7 +2199,7 @@ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.111", "syn 2.0.112",
] ]
[[package]] [[package]]
@ -2212,9 +2221,9 @@ dependencies = [
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.111" version = "2.0.112"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" checksum = "21f182278bf2d2bcb3c88b1b08a37df029d71ce3d3ae26168e3c653b213b99d4"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -2238,7 +2247,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.111", "syn 2.0.112",
] ]
[[package]] [[package]]
@ -2386,7 +2395,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.111", "syn 2.0.112",
] ]
[[package]] [[package]]
@ -2397,7 +2406,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.111", "syn 2.0.112",
] ]
[[package]] [[package]]
@ -2456,7 +2465,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.111", "syn 2.0.112",
] ]
[[package]] [[package]]
@ -2759,7 +2768,7 @@ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.111", "syn 2.0.112",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@ -3110,7 +3119,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.111", "syn 2.0.112",
"synstructure", "synstructure",
] ]
@ -3131,7 +3140,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.111", "syn 2.0.112",
"synstructure", "synstructure",
] ]
@ -3171,7 +3180,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.111", "syn 2.0.112",
] ]
[[package]] [[package]]

View File

@ -1,3 +1,6 @@
[workspace]
members = [".", "macros"]
[package] [package]
name = "brittling" name = "brittling"
version = "0.1.0" version = "0.1.0"
@ -19,3 +22,4 @@ static_cell = "2.1.1"
textwrap = "0.16.2" textwrap = "0.16.2"
tokio = { version = "1.48.0", features = ["full"] } tokio = { version = "1.48.0", features = ["full"] }
unicode-general-category = "1.1.0" unicode-general-category = "1.1.0"
brittling_macros = { path = "macros" }

12
macros/Cargo.toml Normal file
View File

@ -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"

126
macros/src/lib.rs Normal file
View File

@ -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<crate::app::Action>,
) -> ::core::result::Result<(), ::tokio::sync::mpsc::error::SendError<crate::app::Action>> {
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::<String>() + 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")
}

View File

@ -2,21 +2,22 @@ pub mod common;
pub mod seeding; pub mod seeding;
pub mod snowballing; pub mod snowballing;
use std::time::Duration;
use brittling_macros::component;
use log::{error, info, warn}; use log::{error, info, warn};
use ratatui::crossterm::event::KeyCode; use ratatui::crossterm::event::KeyCode;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::time::Duration;
use tokio::{ use tokio::{
sync::mpsc::{self, error::SendError}, sync::mpsc::{UnboundedSender, error::SendError},
time::sleep, time::sleep,
}; };
use crate::crossterm::Action;
use crate::{ use crate::{
app::common::Component,
literature::{Publication, SnowballingHistory, get_publication_by_id}, literature::{Publication, SnowballingHistory, get_publication_by_id},
status_error, status_info, status_error, status_info,
}; };
use seeding::{SeedingAction, SeedingComponent}; use seeding::{SeedingAction, SeedingComponent};
use snowballing::{SnowballingAction, SnowballingComponent}; 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<GlobalAction> for Action {
fn from(action: GlobalAction) -> Self {
Action::Global(action)
}
}
impl From<SnowballingAction> for Action {
fn from(action: SnowballingAction) -> Self {
Action::Snowballing(action)
}
}
impl From<SeedingAction> for Action {
fn from(action: SeedingAction) -> Self {
Action::Seeding(action)
}
}
#[derive(Serialize, Deserialize, Default, PartialEq, Copy, Clone)] #[derive(Serialize, Deserialize, Default, PartialEq, Copy, Clone)]
pub enum Tab { pub enum Tab {
#[default] #[default]
@ -101,7 +66,6 @@ impl AppState {
pub struct App { pub struct App {
pub state: AppState, pub state: AppState,
pub should_quit: bool, pub should_quit: bool,
pub action_tx: &'static mpsc::UnboundedSender<Action>,
} }
// TODO: Implement moving through steps and iterations (populating pending papers) // 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 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 as csv for keywording with a spreadsheet
// TODO: Implement export of included papers into zotero (Use RIS format somehow) // TODO: Implement export of included papers into zotero (Use RIS format somehow)
#[component(GlobalAction)]
impl App { impl App {
fn quit(&mut self) { #[action]
fn quit(
&mut self,
_: &'static UnboundedSender<Action>,
) -> Result<(), SendError<Action>> {
self.should_quit = true; self.should_quit = true;
Ok(())
} }
fn next_tab(&mut self) { #[action]
fn next_tab(
&mut self,
_: &'static UnboundedSender<Action>,
) -> Result<(), SendError<Action>> {
self.state.current_tab = match self.state.current_tab { self.state.current_tab = match self.state.current_tab {
Tab::Seeding => Tab::Snowballing, Tab::Seeding => Tab::Snowballing,
Tab::Snowballing => Tab::Seeding, Tab::Snowballing => Tab::Seeding,
}; };
Ok(())
} }
// TODO: Have status messages always last the same amount of time // TODO: Have status messages always last the same amount of time
#[action]
fn show_stat_msg( fn show_stat_msg(
&mut self, &mut self,
msg: StatusMessage, msg: StatusMessage,
action_tx: &'static mpsc::UnboundedSender<Action>, action_tx: &'static UnboundedSender<Action>,
) { ) -> Result<(), SendError<Action>> {
match &msg { match &msg {
StatusMessage::Error(_) => { StatusMessage::Error(_) => {
error!("Status message: {:?}", msg) error!("Status message: {:?}", msg)
@ -149,24 +126,40 @@ impl App {
error!("{}", err); error!("{}", err);
} }
}); });
Ok(())
} }
fn clear_stat_msg(&mut self) { #[action]
fn clear_stat_msg(
&mut self,
_: &'static UnboundedSender<Action>,
) -> Result<(), SendError<Action>> {
self.state.status_message = StatusMessage::Info("".to_string()); self.state.status_message = StatusMessage::Info("".to_string());
Ok(())
} }
// TODO: Is deduplication necessary here? // TODO: Is deduplication necessary here?
fn add_included_publ(&mut self, publ: Publication) { #[action]
fn add_included_pub(
&mut self,
publ: Publication,
_: &'static UnboundedSender<Action>,
) -> Result<(), SendError<Action>> {
self.state self.state
.history .history
.current_iteration .current_iteration
.included_publications .included_publications
.push(publ.clone()); .push(publ.clone());
Ok(())
} }
fn fetch_publication( #[action]
fn fetch_pub(
&self, &self,
action_tx: &'static mpsc::UnboundedSender<Action>, action_tx: &'static UnboundedSender<Action>,
) -> Result<(), SendError<Action>> { ) -> Result<(), SendError<Action>> {
if !self if !self
.state .state
@ -175,14 +168,14 @@ impl App {
.starts_with("https://openalex.org/") .starts_with("https://openalex.org/")
{ {
status_error!( status_error!(
self.action_tx, action_tx,
"Seed link must start with 'https://openalex.org/'" "Seed link must start with 'https://openalex.org/'"
)?; )?;
return Ok(()); return Ok(());
} }
status_info!( status_info!(
self.action_tx, action_tx,
"Submitting seed link: {}", "Submitting seed link: {}",
&self.state.seeding.input &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 // TODO: Implement
#[action]
fn fetch( fn fetch(
&self, &self,
tx: &mpsc::UnboundedSender<Action>, action_tx: &'static UnboundedSender<Action>,
) -> Result<(), SendError<Action>> { ) -> Result<(), SendError<Action>> {
status_info!(tx, "Fetch action triggered from SnowballingComponent") status_info!(
} action_tx,
"Fetch action triggered"
fn handle_global_action( )
&mut self,
action: GlobalAction,
tx: &'static mpsc::UnboundedSender<Action>,
) -> Result<(), SendError<Action>> {
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<Action>> {
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 handle_key( pub fn handle_key(
&mut self, &mut self,
key: KeyCode, key: KeyCode,
action_tx: &'static UnboundedSender<Action>,
) -> Result<(), SendError<Action>> { ) -> Result<(), SendError<Action>> {
match (self.state.current_tab, key) { match (self.state.current_tab, key) {
(_, KeyCode::Esc) => { (_, KeyCode::Esc) => action_tx.send(GlobalAction::Quit.into())?,
self.action_tx.send(GlobalAction::Quit.into())?
}
(_, KeyCode::Tab) => { (_, KeyCode::Tab) => {
self.action_tx.send(GlobalAction::NextTab.into())? action_tx.send(GlobalAction::NextTab.into())?
} }
(Tab::Seeding, KeyCode::Char(c)) => { (Tab::Seeding, KeyCode::Char(c)) => {
self.action_tx.send(SeedingAction::EnterChar(c).into())?; action_tx.send(SeedingAction::EnterChar(c).into())?;
} }
(Tab::Seeding, KeyCode::Backspace) => { (Tab::Seeding, KeyCode::Backspace) => {
self.action_tx.send(SeedingAction::EnterBackspace.into())?; action_tx.send(SeedingAction::EnterBackspace.into())?;
} }
(Tab::Seeding, KeyCode::Enter) => { (Tab::Seeding, KeyCode::Enter) => {
self.action_tx.send(GlobalAction::FetchPub.into())?; action_tx.send(GlobalAction::FetchPub.into())?;
} }
(Tab::Snowballing, KeyCode::Enter) => { (Tab::Snowballing, KeyCode::Enter) => {
self.action_tx.send(SnowballingAction::Search.into())?; action_tx.send(SnowballingAction::Search.into())?;
} }
(Tab::Snowballing, KeyCode::Char('h')) => { (Tab::Snowballing, KeyCode::Char('h')) => {
self.action_tx action_tx.send(SnowballingAction::SelectLeftPane.into())?;
.send(SnowballingAction::SelectLeftPane.into())?;
} }
(Tab::Snowballing, KeyCode::Char('l')) => { (Tab::Snowballing, KeyCode::Char('l')) => {
self.action_tx action_tx.send(SnowballingAction::SelectRightPane.into())?;
.send(SnowballingAction::SelectRightPane.into())?;
} }
(Tab::Snowballing, KeyCode::Char('j')) => { (Tab::Snowballing, KeyCode::Char('j')) => {
self.action_tx.send(SnowballingAction::NextItem.into())?; action_tx.send(SnowballingAction::NextItem.into())?;
} }
(Tab::Snowballing, KeyCode::Char('k')) => { (Tab::Snowballing, KeyCode::Char('k')) => {
self.action_tx.send(SnowballingAction::PrevItem.into())?; action_tx.send(SnowballingAction::PrevItem.into())?;
} }
(Tab::Snowballing, KeyCode::Char(' ')) => { (Tab::Snowballing, KeyCode::Char(' ')) => {
self.action_tx.send(GlobalAction::Fetch.into())?; action_tx.send(GlobalAction::Fetch.into())?;
} }
_ => {} _ => {}
} }

View File

@ -1,13 +1,22 @@
use crate::app::Action;
use tokio::sync::mpsc::{self, error::SendError}; use tokio::sync::mpsc::{self, error::SendError};
use crate::app::Action; // TODO: Put this somewhere closer to the procedural macro definitions
pub trait Component<T> {
fn handle_action(
&mut self,
action: T,
tx: &'static mpsc::UnboundedSender<Action>,
) -> Result<(), SendError<Action>>;
}
#[allow(unused_macros)] #[allow(unused_macros)]
#[macro_export] #[macro_export]
macro_rules! status_info { macro_rules! status_info {
($action_tx:expr, $text:expr $(, $args:expr)*) => { ($action_tx:expr, $text:expr $(, $args:expr)*) => {
$action_tx.send( $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(), .into(),
) )
}; };
@ -18,7 +27,8 @@ macro_rules! status_info {
macro_rules! status_warn { macro_rules! status_warn {
($action_tx:expr, $text:expr $(, $args:expr)*) => { ($action_tx:expr, $text:expr $(, $args:expr)*) => {
$action_tx.send( $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(), .into(),
) )
}; };
@ -29,16 +39,9 @@ macro_rules! status_warn {
macro_rules! status_error { macro_rules! status_error {
($action_tx:expr, $text:expr $(, $args:expr)*) => { ($action_tx:expr, $text:expr $(, $args:expr)*) => {
$action_tx.send( $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(), .into(),
) )
}; };
} }
pub trait Component<T> {
fn handle_action(
&mut self,
action: T,
tx: &mpsc::UnboundedSender<Action>,
) -> Result<(), SendError<Action>>;
}

View File

@ -1,14 +1,8 @@
use serde::{Deserialize, Serialize}; 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}; use crate::literature::Publication;
use brittling_macros::component;
#[derive(Clone, Debug)]
pub enum SeedingAction {
EnterChar(char),
EnterBackspace,
ClearInput,
}
#[derive(Serialize, Deserialize, Default)] #[derive(Serialize, Deserialize, Default)]
pub struct SeedingComponent { pub struct SeedingComponent {
@ -17,32 +11,34 @@ pub struct SeedingComponent {
pub included_publications: Vec<Publication>, pub included_publications: Vec<Publication>,
} }
#[component(SeedingAction)]
impl SeedingComponent { impl SeedingComponent {
pub fn clear_input(&mut self) { #[action]
self.input.clear(); pub fn clear_input(
&mut self,
_: &UnboundedSender<crate::app::Action>,
) -> Result<(), SendError<crate::app::Action>> {
Ok(self.input.clear())
} }
pub fn enter_char(&mut self, c: char) { #[action]
self.input.push(c); pub fn enter_char(
&mut self,
c: char,
_: &UnboundedSender<crate::app::Action>,
) -> Result<(), SendError<crate::app::Action>> {
Ok(self.input.push(c))
} }
pub fn enter_backspace(&mut self) { #[action]
pub fn enter_backspace(
&mut self,
_: &UnboundedSender<crate::app::Action>,
) -> Result<(), SendError<crate::app::Action>> {
if self.input.len() > 0 { if self.input.len() > 0 {
self.input.truncate(self.input.len() - 1); self.input.truncate(self.input.len() - 1);
} }
}
}
impl Component<SeedingAction> for SeedingComponent { Ok(())
fn handle_action(
&mut self,
action: SeedingAction,
_: &mpsc::UnboundedSender<Action>,
) -> Result<(), SendError<Action>> {
match action {
SeedingAction::ClearInput => Ok(self.clear_input()),
SeedingAction::EnterChar(c) => Ok(self.enter_char(c)),
SeedingAction::EnterBackspace => Ok(self.enter_backspace()),
}
} }
} }

View File

@ -1,9 +1,9 @@
use brittling_macros::component;
use ratatui::widgets::ListState; use ratatui::widgets::ListState;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::sync::mpsc; use tokio::sync::mpsc::UnboundedSender;
use tokio::sync::mpsc::error::SendError; use tokio::sync::mpsc::error::SendError;
use crate::app::{Action, common::Component};
use crate::literature::Publication; use crate::literature::Publication;
#[derive(Serialize, Deserialize, Default, PartialEq)] #[derive(Serialize, Deserialize, Default, PartialEq)]
@ -13,15 +13,6 @@ pub enum ActivePane {
PendingPublications, PendingPublications,
} }
#[derive(Clone, Debug)]
pub enum SnowballingAction {
SelectLeftPane,
SelectRightPane,
Search,
NextItem,
PrevItem,
}
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
struct SerializableListState { struct SerializableListState {
pub offset: usize, pub offset: usize,
@ -73,6 +64,7 @@ pub struct SnowballingComponent {
pub pending_publications: Vec<Publication>, pub pending_publications: Vec<Publication>,
} }
#[component(SnowballingAction)]
impl SnowballingComponent { impl SnowballingComponent {
fn select_next_item_impl( fn select_next_item_impl(
list_state: &mut ListState, list_state: &mut ListState,
@ -108,23 +100,39 @@ impl SnowballingComponent {
list_state.select(Some(i)); list_state.select(Some(i));
} }
fn select_left_pane(&mut self) { #[action]
fn select_left_pane(
&mut self,
_: &UnboundedSender<crate::app::Action>,
) -> Result<(), SendError<crate::app::Action>> {
self.active_pane = ActivePane::IncludedPublications; self.active_pane = ActivePane::IncludedPublications;
if let None = self.included_list_state.selected() { if let None = self.included_list_state.selected() {
self.included_list_state.select(Some(0)); self.included_list_state.select(Some(0));
} }
Ok(())
} }
fn select_right_pane(&mut self) { #[action]
fn select_right_pane(
&mut self,
_: &UnboundedSender<crate::app::Action>,
) -> Result<(), SendError<crate::app::Action>> {
self.active_pane = ActivePane::PendingPublications; self.active_pane = ActivePane::PendingPublications;
if let None = self.pending_list_state.selected() { if let None = self.pending_list_state.selected() {
self.pending_list_state.select(Some(0)); self.pending_list_state.select(Some(0));
} }
Ok(())
} }
fn search(&self) { #[action]
fn search(
&self,
_: &UnboundedSender<crate::app::Action>,
) -> Result<(), SendError<crate::app::Action>> {
match self.active_pane { match self.active_pane {
ActivePane::IncludedPublications => { ActivePane::IncludedPublications => {
if let Some(idx) = self.included_list_state.selected() { if let Some(idx) = self.included_list_state.selected() {
@ -137,55 +145,53 @@ impl SnowballingComponent {
} }
} }
} }
Ok(())
} }
fn next_item(&mut self) { #[action]
match self.active_pane { fn next_item(
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<SnowballingAction> for SnowballingComponent {
fn handle_action(
&mut self, &mut self,
action: SnowballingAction, _: &UnboundedSender<crate::app::Action>,
_: &mpsc::UnboundedSender<Action>, ) -> Result<(), SendError<crate::app::Action>> {
) -> Result<(), SendError<Action>> { match self.active_pane {
match action { ActivePane::IncludedPublications => {
SnowballingAction::SelectLeftPane => Ok(self.select_left_pane()), Self::select_next_item_impl(
SnowballingAction::SelectRightPane => Ok(self.select_right_pane()), &mut self.included_list_state,
SnowballingAction::Search => Ok(self.search()), &self.included_publications,
SnowballingAction::NextItem => Ok(self.next_item()), );
SnowballingAction::PrevItem => Ok(self.prev_item()), }
ActivePane::PendingPublications => {
Self::select_next_item_impl(
&mut self.pending_list_state,
&self.pending_publications,
);
} }
} }
Ok(())
}
#[action]
fn prev_item(
&mut self,
_: &UnboundedSender<crate::app::Action>,
) -> Result<(), SendError<crate::app::Action>> {
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(())
}
} }

View File

@ -16,10 +16,14 @@ use ratatui::{
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
use crate::{ use crate::{
app::{Action, App, AppState}, app::{App, AppState, common::Component},
ui, ui,
}; };
use crate::app::GlobalAction;
use crate::app::seeding::SeedingAction;
use crate::app::snowballing::SnowballingAction;
pub async fn run(app_state: AppState) -> Result<AppState, Box<dyn Error>> { pub async fn run(app_state: AppState) -> Result<AppState, Box<dyn Error>> {
// setup terminal // setup terminal
enable_raw_mode()?; enable_raw_mode()?;
@ -54,6 +58,32 @@ static ACTION_QUEUE_TX: StaticCell<UnboundedSender<Action>> = StaticCell::new();
static ACTION_QUEUE_RX: StaticCell<UnboundedReceiver<Action>> = static ACTION_QUEUE_RX: StaticCell<UnboundedReceiver<Action>> =
StaticCell::new(); StaticCell::new();
// TODO: Move this somewhere sensible
#[derive(Clone, Debug)]
pub enum Action {
Snowballing(SnowballingAction),
Seeding(SeedingAction),
Global(GlobalAction),
}
impl From<GlobalAction> for Action {
fn from(action: GlobalAction) -> Self {
Action::Global(action)
}
}
impl From<SnowballingAction> for Action {
fn from(action: SnowballingAction) -> Self {
Action::Snowballing(action)
}
}
impl From<SeedingAction> for Action {
fn from(action: SeedingAction) -> Self {
Action::Seeding(action)
}
}
async fn run_app<B: Backend>( async fn run_app<B: Backend>(
terminal: &mut Terminal<B>, terminal: &mut Terminal<B>,
app_state: AppState, app_state: AppState,
@ -71,7 +101,6 @@ where
let mut app = App { let mut app = App {
state: app_state, state: app_state,
action_tx: action_tx_ref,
should_quit: false, should_quit: false,
}; };
@ -81,12 +110,25 @@ where
if event::poll(Duration::from_millis(100))? { if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? { 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() { 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 { if app.should_quit {