Write procedural macro to reduce boiler plate for actions

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

View File

@@ -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<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)]
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<Action>,
}
// 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<Action>,
) -> Result<(), SendError<Action>> {
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 {
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>,
) {
action_tx: &'static UnboundedSender<Action>,
) -> Result<(), SendError<Action>> {
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<Action>,
) -> Result<(), SendError<Action>> {
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<Action>,
) -> Result<(), SendError<Action>> {
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>,
action_tx: &'static UnboundedSender<Action>,
) -> Result<(), SendError<Action>> {
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>,
action_tx: &'static UnboundedSender<Action>,
) -> Result<(), SendError<Action>> {
status_info!(tx, "Fetch action triggered from SnowballingComponent")
}
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)
}
}
status_info!(
action_tx,
"Fetch action triggered"
)
}
pub fn handle_key(
&mut self,
key: KeyCode,
action_tx: &'static UnboundedSender<Action>,
) -> Result<(), SendError<Action>> {
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())?;
}
_ => {}
}

View File

@@ -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<T> {
fn handle_action(
&mut self,
action: T,
tx: &'static mpsc::UnboundedSender<Action>,
) -> Result<(), SendError<Action>>;
}
#[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<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 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<Publication>,
}
#[component(SeedingAction)]
impl SeedingComponent {
pub fn clear_input(&mut self) {
self.input.clear();
#[action]
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) {
self.input.push(c);
#[action]
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 {
self.input.truncate(self.input.len() - 1);
}
}
}
impl Component<SeedingAction> for SeedingComponent {
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()),
}
Ok(())
}
}

View File

@@ -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<Publication>,
}
#[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<crate::app::Action>,
) -> Result<(), SendError<crate::app::Action>> {
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<crate::app::Action>,
) -> Result<(), SendError<crate::app::Action>> {
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<crate::app::Action>,
) -> Result<(), SendError<crate::app::Action>> {
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<SnowballingAction> for SnowballingComponent {
fn handle_action(
#[action]
fn next_item(
&mut self,
action: SnowballingAction,
_: &mpsc::UnboundedSender<Action>,
) -> Result<(), SendError<Action>> {
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<crate::app::Action>,
) -> Result<(), SendError<crate::app::Action>> {
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<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 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<AppState, Box<dyn Error>> {
// setup terminal
enable_raw_mode()?;
@@ -54,6 +58,32 @@ static ACTION_QUEUE_TX: StaticCell<UnboundedSender<Action>> = StaticCell::new();
static ACTION_QUEUE_RX: StaticCell<UnboundedReceiver<Action>> =
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>(
terminal: &mut Terminal<B>,
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 {