Compare commits

...

2 Commits

Author SHA1 Message Date
2e51ae8064 Implement snowballing tab controls 2025-12-31 02:46:14 +02:00
8c11630801 Code cleanup 2025-12-31 02:37:16 +02:00
4 changed files with 245 additions and 212 deletions

View File

@ -33,19 +33,12 @@ impl Default for StatusMessage {
} }
} }
#[derive(Serialize, Deserialize, Default, PartialEq, Copy, Clone)]
pub enum Tab {
#[default]
Seeding,
Snowballing,
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum GlobalAction { pub enum GlobalAction {
ShowStatusMessage(StatusMessage), ShowStatMsg(StatusMessage),
ClearStatusMessage, ClearStatMsg,
AddIncludedPublication(Publication), AddIncludedPub(Publication),
SubmitSeedLink, FetchPub,
NextTab, NextTab,
Quit, Quit,
} }
@ -75,6 +68,13 @@ impl From<SeedingAction> for Action {
} }
} }
#[derive(Serialize, Deserialize, Default, PartialEq, Copy, Clone)]
pub enum Tab {
#[default]
Seeding,
Snowballing,
}
#[derive(Serialize, Deserialize, Default)] #[derive(Serialize, Deserialize, Default)]
pub struct AppState { pub struct AppState {
// UI state // UI state
@ -109,21 +109,23 @@ pub struct App {
// 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)
impl App { impl App {
// TODO: Have status messages always last the same amount of time fn quit(&mut self) {
fn handle_global_action( self.should_quit = true;
&mut self, }
action: GlobalAction,
action_tx: &'static mpsc::UnboundedSender<Action>, fn next_tab(&mut self) {
) -> Result<(), SendError<Action>> {
match action {
GlobalAction::NextTab => {
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(())
} }
GlobalAction::ShowStatusMessage(msg) => {
// TODO: Have status messages always last the same amount of time
fn show_stat_msg(
&mut self,
msg: StatusMessage,
action_tx: &'static mpsc::UnboundedSender<Action>,
) {
match &msg { match &msg {
StatusMessage::Error(_) => { StatusMessage::Error(_) => {
error!("Status message: {:?}", msg) error!("Status message: {:?}", msg)
@ -139,22 +141,35 @@ impl App {
self.state.status_message = msg; self.state.status_message = msg;
tokio::spawn(async move { tokio::spawn(async move {
sleep(Duration::from_millis(1000)).await; sleep(Duration::from_millis(4000)).await;
if let Err(err) = if let Err(err) = action_tx.send(GlobalAction::ClearStatMsg.into())
action_tx.send(GlobalAction::ClearStatusMessage.into())
{ {
error!("{}", err); error!("{}", err);
} }
}); });
}
Ok(()) fn clear_stat_msg(&mut self) {
}
GlobalAction::ClearStatusMessage => {
self.state.status_message = StatusMessage::Info("".to_string()); self.state.status_message = StatusMessage::Info("".to_string());
}
fn add_included_publ(
&mut self,
publ: Publication,
) -> Result<(), SendError<Action>> {
self.state
.history
.current_iteration
.included_publications
.push(publ.clone());
Ok(()) Ok(())
} }
GlobalAction::SubmitSeedLink => {
fn fetch_publication(
&self,
action_tx: &'static mpsc::UnboundedSender<Action>,
) -> Result<(), SendError<Action>> {
if !self if !self
.state .state
.seeding .seeding
@ -183,47 +198,42 @@ impl App {
); );
tokio::spawn(async move { tokio::spawn(async move {
let publ = get_publication_by_id( let publ =
&api_link, get_publication_by_id(&api_link, "an.tsouchlos@gmail.com");
"an.tsouchlos@gmail.com",
);
match publ.await { match publ.await {
Ok(publ) => { Ok(publ) => {
let _ = status_info!( let _ = status_info!(
action_tx, action_tx,
"Seed paper obtained successfully: {}", "Seed paper obtained successfully: {}",
publ.get_title().unwrap_or( publ.get_title()
"[title unavailable]".to_string() .unwrap_or("[title unavailable]".to_string())
)
); );
let _ = action_tx.send( let _ = action_tx
GlobalAction::AddIncludedPublication(publ) .send(GlobalAction::AddIncludedPub(publ).into());
.into(),
);
} }
Err(e) => { Err(e) => {
let _ = let _ = status_error!(action_tx, "{}", e.to_string());
status_error!(action_tx, "{}", e.to_string());
} }
} }
}); });
self.action_tx.send(SeedingAction::ClearInput.into()) self.action_tx.send(SeedingAction::ClearInput.into())
} }
GlobalAction::Quit => {
self.should_quit = true; fn handle_global_action(
Ok(()) &mut self,
} action: GlobalAction,
GlobalAction::AddIncludedPublication(publ) => { tx: &'static mpsc::UnboundedSender<Action>,
self.state ) -> Result<(), SendError<Action>> {
.history match action {
.current_iteration GlobalAction::Quit => Ok(self.quit()),
.included_publications GlobalAction::NextTab => Ok(self.next_tab()),
.push(publ.clone()); GlobalAction::ClearStatMsg => Ok(self.clear_stat_msg()),
Ok(()) GlobalAction::ShowStatMsg(msg) => Ok(self.show_stat_msg(msg, tx)),
} GlobalAction::FetchPub => self.fetch_publication(tx),
GlobalAction::AddIncludedPub(publ) => self.add_included_publ(publ),
} }
} }
@ -252,10 +262,10 @@ impl App {
) -> Result<(), SendError<Action>> { ) -> Result<(), SendError<Action>> {
match (self.state.current_tab, key) { match (self.state.current_tab, key) {
(_, KeyCode::Esc) => { (_, KeyCode::Esc) => {
self.action_tx.send(GlobalAction::Quit.into())?; self.action_tx.send(GlobalAction::Quit.into())?
} }
(_, KeyCode::Tab) => { (_, KeyCode::Tab) => {
self.action_tx.send(GlobalAction::NextTab.into())?; self.action_tx.send(GlobalAction::NextTab.into())?
} }
(Tab::Seeding, KeyCode::Char(c)) => { (Tab::Seeding, KeyCode::Char(c)) => {
self.action_tx.send(SeedingAction::EnterChar(c).into())?; self.action_tx.send(SeedingAction::EnterChar(c).into())?;
@ -264,7 +274,24 @@ impl App {
self.action_tx.send(SeedingAction::EnterBackspace.into())?; self.action_tx.send(SeedingAction::EnterBackspace.into())?;
} }
(Tab::Seeding, KeyCode::Enter) => { (Tab::Seeding, KeyCode::Enter) => {
self.action_tx.send(GlobalAction::SubmitSeedLink.into())?; self.action_tx.send(GlobalAction::FetchPub.into())?;
}
(Tab::Snowballing, KeyCode::Enter) => {
self.action_tx.send(SnowballingAction::Search.into())?;
}
(Tab::Snowballing, KeyCode::Char('h')) => {
self.action_tx
.send(SnowballingAction::SelectLeftPane.into())?;
}
(Tab::Snowballing, KeyCode::Char('l')) => {
self.action_tx
.send(SnowballingAction::SelectRightPane.into())?;
}
(Tab::Snowballing, KeyCode::Char('j')) => {
self.action_tx.send(SnowballingAction::NextItem.into())?;
}
(Tab::Snowballing, KeyCode::Char('k')) => {
self.action_tx.send(SnowballingAction::PrevItem.into())?;
} }
_ => {} _ => {}
} }

View File

@ -7,7 +7,7 @@ use crate::app::Action;
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(
GlobalAction::ShowStatusMessage(StatusMessage::Info(format!($text, $($args)*))) GlobalAction::ShowStatMsg(StatusMessage::Info(format!($text, $($args)*)))
.into(), .into(),
) )
}; };
@ -18,7 +18,7 @@ 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(
GlobalAction::ShowStatusMessage(StatusMessage::Warning(format!($text, $($args)*))) GlobalAction::ShowStatMsg(StatusMessage::Warning(format!($text, $($args)*)))
.into(), .into(),
) )
}; };
@ -29,7 +29,7 @@ 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(
GlobalAction::ShowStatusMessage(StatusMessage::Error(format!($text, $($args)*))) GlobalAction::ShowStatMsg(StatusMessage::Error(format!($text, $($args)*)))
.into(), .into(),
) )
}; };

View File

@ -17,6 +17,22 @@ pub struct SeedingComponent {
pub included_publications: Vec<Publication>, pub included_publications: Vec<Publication>,
} }
impl SeedingComponent {
pub fn clear_input(&mut self) {
self.input.clear();
}
pub fn enter_char(&mut self, c: char) {
self.input.push(c);
}
pub fn enter_backspace(&mut self) {
if self.input.len() > 0 {
self.input.truncate(self.input.len() - 1);
}
}
}
impl Component<SeedingAction> for SeedingComponent { impl Component<SeedingAction> for SeedingComponent {
fn handle_action( fn handle_action(
&mut self, &mut self,
@ -24,21 +40,9 @@ impl Component<SeedingAction> for SeedingComponent {
_: &mpsc::UnboundedSender<Action>, _: &mpsc::UnboundedSender<Action>,
) -> Result<(), SendError<Action>> { ) -> Result<(), SendError<Action>> {
match action { match action {
SeedingAction::ClearInput => { SeedingAction::ClearInput => Ok(self.clear_input()),
self.input.clear(); SeedingAction::EnterChar(c) => Ok(self.enter_char(c)),
Ok(()) SeedingAction::EnterBackspace => Ok(self.enter_backspace()),
}
SeedingAction::EnterChar(c) => {
self.input.push(c);
Ok(())
}
SeedingAction::EnterBackspace => {
if self.input.len() > 0 {
self.input.truncate(self.input.len() - 1);
}
Ok(())
}
} }
} }
} }

View File

@ -17,11 +17,9 @@ pub enum ActivePane {
pub enum SnowballingAction { pub enum SnowballingAction {
SelectLeftPane, SelectLeftPane,
SelectRightPane, SelectRightPane,
SearchForSelected, Search,
NextItem, NextItem,
PrevItem, PrevItem,
ShowIncludedPublication(Publication),
ShowPendingPublication(Publication),
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@ -76,7 +74,7 @@ pub struct SnowballingComponent {
} }
impl SnowballingComponent { impl SnowballingComponent {
fn next_list_item( fn select_next_item_impl(
list_state: &mut ListState, list_state: &mut ListState,
publications: &Vec<Publication>, publications: &Vec<Publication>,
) { ) {
@ -93,7 +91,7 @@ impl SnowballingComponent {
list_state.select(Some(i)); list_state.select(Some(i));
} }
fn prev_list_item( fn select_prev_item_impl(
list_state: &mut ListState, list_state: &mut ListState,
publications: &Vec<Publication>, publications: &Vec<Publication>,
) { ) {
@ -109,6 +107,71 @@ impl SnowballingComponent {
}; };
list_state.select(Some(i)); list_state.select(Some(i));
} }
fn select_left_pane(&mut self) {
self.active_pane = ActivePane::IncludedPublications;
if let None = self.included_list_state.selected() {
self.included_list_state.select(Some(0));
}
}
fn select_right_pane(&mut self) {
self.active_pane = ActivePane::PendingPublications;
if let None = self.pending_list_state.selected() {
self.pending_list_state.select(Some(0));
}
}
fn search(&self) {
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();
}
}
}
}
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 { impl Component<SnowballingAction> for SnowballingComponent {
@ -118,72 +181,11 @@ impl Component<SnowballingAction> for SnowballingComponent {
_: &mpsc::UnboundedSender<Action>, _: &mpsc::UnboundedSender<Action>,
) -> Result<(), SendError<Action>> { ) -> Result<(), SendError<Action>> {
match action { match action {
SnowballingAction::SelectLeftPane => { SnowballingAction::SelectLeftPane => Ok(self.select_left_pane()),
self.active_pane = ActivePane::IncludedPublications; SnowballingAction::SelectRightPane => Ok(self.select_right_pane()),
SnowballingAction::Search => Ok(self.search()),
if let None = self.included_list_state.selected() { SnowballingAction::NextItem => Ok(self.next_item()),
self.included_list_state.select(Some(0)); SnowballingAction::PrevItem => Ok(self.prev_item()),
}
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(()),
} }
} }
} }