From 3c8eada3ba8080a68e8b14487368a3c245f366a3 Mon Sep 17 00:00:00 2001 From: Erik Kundt Date: Wed, 20 Mar 2024 23:57:54 +0100 Subject: [PATCH] inbox: Add search widget --- bin/commands/inbox/flux/select.rs | 46 +++- bin/commands/inbox/flux/select/ui.rs | 345 ++++++++++++++++++++------- 2 files changed, 297 insertions(+), 94 deletions(-) diff --git a/bin/commands/inbox/flux/select.rs b/bin/commands/inbox/flux/select.rs index 5179dbfd..d7888c96 100644 --- a/bin/commands/inbox/flux/select.rs +++ b/bin/commands/inbox/flux/select.rs @@ -13,7 +13,8 @@ use radicle::Profile; use radicle_tui as tui; use tui::common::cob::inbox::{self}; -use tui::flux::store::{State, Store}; +use tui::flux::store; +use tui::flux::store::StateValue; use tui::flux::task::{self, Interrupted}; use tui::flux::ui::items::NotificationItem; use tui::flux::ui::Frontend; @@ -40,24 +41,29 @@ pub struct App { #[derive(Clone, Debug)] pub struct UIState { page_size: usize, + show_search: bool, } impl Default for UIState { fn default() -> Self { - Self { page_size: 1 } + Self { + page_size: 1, + show_search: false, + } } } #[derive(Clone, Debug)] -pub struct InboxState { +pub struct State { notifications: Vec, selected: Option, mode: Mode, project: Project, + search: StateValue, ui: UIState, } -impl TryFrom<&Context> for InboxState { +impl TryFrom<&Context> for State { type Error = anyhow::Error; fn try_from(context: &Context) -> Result { @@ -151,6 +157,7 @@ impl TryFrom<&Context> for InboxState { selected, mode: mode.clone(), project, + search: StateValue::new(String::new()), ui: UIState::default(), }) } @@ -160,9 +167,13 @@ pub enum Action { Exit { selection: Option }, Select { item: NotificationItem }, PageSize(usize), + OpenSearch, + UpdateSearch { value: String }, + ApplySearch, + CloseSearch, } -impl State for InboxState { +impl store::State for State { fn tick(&self) {} fn handle_action(&mut self, action: Action) -> Option> { @@ -176,6 +187,24 @@ impl State for InboxState { self.ui.page_size = size; None } + Action::OpenSearch => { + self.ui.show_search = true; + None + } + Action::UpdateSearch { value } => { + self.search.write(value); + None + } + Action::ApplySearch => { + self.search.apply(); + self.ui.show_search = false; + None + } + Action::CloseSearch => { + self.search.reset(); + self.ui.show_search = false; + None + } } } } @@ -187,14 +216,13 @@ impl App { pub async fn run(&self) -> Result> { let (terminator, mut interrupt_rx) = task::create_termination(); - let (store, state_rx) = Store::::new(); + let (store, state_rx) = store::Store::::new(); let (frontend, action_rx) = Frontend::::new(); - let state = InboxState::try_from(&self.context)?; + let state = State::try_from(&self.context)?; tokio::try_join!( store.main_loop(state, terminator, action_rx, interrupt_rx.resubscribe()), - frontend - .main_loop::(state_rx, interrupt_rx.resubscribe()), + frontend.main_loop::(state_rx, interrupt_rx.resubscribe()), )?; if let Ok(reason) = interrupt_rx.recv().await { diff --git a/bin/commands/inbox/flux/select/ui.rs b/bin/commands/inbox/flux/select/ui.rs index 6286c5f4..3d23d6f4 100644 --- a/bin/commands/inbox/flux/select/ui.rs +++ b/bin/commands/inbox/flux/select/ui.rs @@ -1,11 +1,12 @@ use std::collections::HashMap; +use std::str::FromStr; use tokio::sync::mpsc::UnboundedSender; use termion::event::Key; use ratatui::backend::Backend; -use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::Stylize; use ratatui::text::Line; @@ -13,9 +14,10 @@ use radicle::identity::Project; use radicle_tui as tui; -use tui::flux::ui::items::NotificationItem; +use tui::flux::ui::items::{NotificationItem, NotificationItemFilter, NotificationState}; use tui::flux::ui::span; use tui::flux::ui::widget::container::{Footer, FooterProps, Header, HeaderProps}; +use tui::flux::ui::widget::input::{TextField, TextFieldProps}; use tui::flux::ui::widget::{ Render, Shortcut, Shortcuts, ShortcutsProps, Table, TableProps, Widget, }; @@ -23,18 +25,20 @@ use tui::Selection; use crate::tui_inbox::common::{Mode, RepositoryMode, SelectionMode}; -use super::{Action, InboxState}; +use super::{Action, State}; pub struct ListPageProps { selected: Option, mode: Mode, + show_search: bool, } -impl From<&InboxState> for ListPageProps { - fn from(state: &InboxState) -> Self { +impl From<&State> for ListPageProps { + fn from(state: &State) -> Self { Self { selected: state.selected.clone(), mode: state.mode.clone(), + show_search: state.ui.show_search, } } } @@ -46,12 +50,14 @@ pub struct ListPage { props: ListPageProps, /// Notification widget notifications: Notifications, + /// Search widget + search: Search, /// Shortcut widget shortcuts: Shortcuts, } -impl Widget for ListPage { - fn new(state: &InboxState, action_tx: UnboundedSender) -> Self +impl Widget for ListPage { + fn new(state: &State, action_tx: UnboundedSender) -> Self where Self: Sized, { @@ -59,12 +65,13 @@ impl Widget for ListPage { action_tx: action_tx.clone(), props: ListPageProps::from(state), notifications: Notifications::new(state, action_tx.clone()), + search: Search::new(state, action_tx.clone()), shortcuts: Shortcuts::new(state, action_tx.clone()), } .move_with_state(state) } - fn move_with_state(self, state: &InboxState) -> Self + fn move_with_state(self, state: &State) -> Self where Self: Sized, { @@ -81,39 +88,46 @@ impl Widget for ListPage { } fn handle_key_event(&mut self, key: termion::event::Key) { - match key { - Key::Esc | Key::Ctrl('c') => { - let _ = self.action_tx.send(Action::Exit { selection: None }); - } - Key::Char('\n') => { - if let Some(selected) = &self.props.selected { - let selection = match self.props.mode.selection() { - SelectionMode::Operation => Selection::default() - .with_operation("show".to_string()) - .with_id(selected.id), - SelectionMode::Id => Selection::default().with_id(selected.id), - }; - let _ = self.action_tx.send(Action::Exit { - selection: Some(selection), - }); + if self.props.show_search { + >::handle_key_event(&mut self.search, key) + } else { + match key { + Key::Esc | Key::Ctrl('c') => { + let _ = self.action_tx.send(Action::Exit { selection: None }); } - } - Key::Char('c') => { - if let Some(selected) = &self.props.selected { - let _ = self.action_tx.send(Action::Exit { - selection: Some( - Selection::default() - .with_operation("clear".to_string()) + Key::Char('\n') => { + if let Some(selected) = &self.props.selected { + let selection = match self.props.mode.selection() { + SelectionMode::Operation => Selection::default() + .with_operation("show".to_string()) .with_id(selected.id), - ), - }); + SelectionMode::Id => Selection::default().with_id(selected.id), + }; + let _ = self.action_tx.send(Action::Exit { + selection: Some(selection), + }); + } + } + Key::Char('c') => { + if let Some(selected) = &self.props.selected { + let _ = self.action_tx.send(Action::Exit { + selection: Some( + Selection::default() + .with_operation("clear".to_string()) + .with_id(selected.id), + ), + }); + } + } + Key::Char('/') => { + let _ = self.action_tx.send(Action::OpenSearch); + } + _ => { + >::handle_key_event( + &mut self.notifications, + key, + ); } - } - _ => { - >::handle_key_event( - &mut self.notifications, - key, - ); } } } @@ -124,14 +138,37 @@ impl Render<()> for ListPage { let area = frame.size(); let layout = tui::flux::ui::layout::default_page(area, 0u16, 1u16); - let shortcuts = match self.props.mode.selection() { - SelectionMode::Id => vec![Shortcut::new("enter", "select")], - SelectionMode::Operation => { - vec![Shortcut::new("enter", "show"), Shortcut::new("c", "clear")] + let shortcuts = if self.props.show_search { + vec![ + Shortcut::new("esc", "back"), + Shortcut::new("enter", "search"), + ] + } else { + match self.props.mode.selection() { + SelectionMode::Id => vec![ + Shortcut::new("enter", "select"), + Shortcut::new("/", "search"), + ], + SelectionMode::Operation => vec![ + Shortcut::new("enter", "show"), + Shortcut::new("c", "clear"), + Shortcut::new("/", "search"), + ], } }; - self.notifications.render::(frame, layout.component, ()); + if self.props.show_search { + let component_layout = Layout::vertical([Constraint::Min(1), Constraint::Length(2)]) + .split(layout.component); + + self.notifications + .render::(frame, component_layout[0], ()); + self.search + .render::(frame, component_layout[1], SearchProps {}); + } else { + self.notifications.render::(frame, layout.component, ()); + } + self.shortcuts.render::( frame, layout.shortcuts, @@ -152,13 +189,25 @@ struct NotificationsProps { cutoff_after: usize, focus: bool, page_size: usize, + search: String, + show_search: bool, } -impl From<&InboxState> for NotificationsProps { - fn from(state: &InboxState) -> Self { +impl From<&State> for NotificationsProps { + fn from(state: &State) -> Self { let mut seen = 0; let mut unseen = 0; + // Filter by search string + let filter = NotificationItemFilter::from_str(&state.search.read()).unwrap_or_default(); + let notifications = state + .notifications + .clone() + .into_iter() + .filter(|issue| filter.matches(issue)) + .collect::>(); + + // Compute statistics for notification in &state.notifications { if notification.seen { seen += 1; @@ -166,10 +215,11 @@ impl From<&InboxState> for NotificationsProps { unseen += 1; } } + let stats = HashMap::from([("Seen".to_string(), seen), ("Unseen".to_string(), unseen)]); Self { - notifications: state.notifications.clone(), + notifications, mode: state.mode.clone(), project: state.project.clone(), stats, @@ -177,6 +227,8 @@ impl From<&InboxState> for NotificationsProps { cutoff_after: 5, focus: false, page_size: state.ui.page_size, + show_search: state.ui.show_search, + search: state.search.read(), } } } @@ -194,8 +246,8 @@ struct Notifications { footer: Footer, } -impl Widget for Notifications { - fn new(state: &InboxState, action_tx: UnboundedSender) -> Self { +impl Widget for Notifications { + fn new(state: &State, action_tx: UnboundedSender) -> Self { Self { action_tx: action_tx.clone(), props: NotificationsProps::from(state), @@ -205,14 +257,23 @@ impl Widget for Notifications { } } - fn move_with_state(self, state: &InboxState) -> Self + fn move_with_state(self, state: &State) -> Self where Self: Sized, { + let props = NotificationsProps::from(state); + let mut table = self.table.move_with_state(state); + + if let Some(selected) = table.selected() { + if selected > props.notifications.len() { + table.begin(); + } + } + Self { - props: NotificationsProps::from(state), + props, + table, header: self.header.move_with_state(state), - table: self.table.move_with_state(state), footer: self.footer.move_with_state(state), ..self } @@ -299,7 +360,7 @@ impl Notifications { TableProps { items: self.props.notifications.to_vec(), has_header: true, - has_footer: true, + has_footer: !self.props.show_search, widths, focus: self.props.focus, cutoff: self.props.cutoff, @@ -324,7 +385,7 @@ impl Notifications { TableProps { items: self.props.notifications.to_vec(), has_header: true, - has_footer: true, + has_footer: !self.props.show_search, widths, focus: self.props.focus, cutoff: self.props.cutoff, @@ -335,7 +396,18 @@ impl Notifications { } fn render_footer(&self, frame: &mut ratatui::Frame, area: Rect) { - let filter = Line::from([span::blank()].to_vec()); + let search = if self.props.search.is_empty() { + Line::from([span::default(self.props.search.to_string()).magenta().dim()].to_vec()) + } else { + Line::from( + [ + span::default(" / ".to_string()).magenta().dim(), + span::default(self.props.search.to_string()).magenta().dim(), + ] + .to_vec(), + ) + }; + let seen = Line::from( [ span::positive(self.props.stats.get("Seen").unwrap_or(&0).to_string()).dim(), @@ -358,50 +430,153 @@ impl Notifications { .progress_percentage(self.props.notifications.len(), self.props.page_size); let progress = span::default(format!("{}%", progress)).dim(); - self.footer.render::( - frame, - area, - FooterProps { - cells: [ - filter.into(), - seen.clone().into(), - unseen.clone().into(), - progress.clone().into(), - ], - widths: [ - Constraint::Fill(1), - Constraint::Min(seen.width() as u16), - Constraint::Min(unseen.width() as u16), - Constraint::Min(4), - ], - focus: self.props.focus, - cutoff: self.props.cutoff, - cutoff_after: self.props.cutoff_after, - }, - ); + match NotificationItemFilter::from_str(&self.props.search) + .unwrap_or_default() + .state() + { + Some(state) => { + let block = match state { + NotificationState::Seen => seen, + NotificationState::Unseen => unseen, + }; + + self.footer.render::( + frame, + area, + FooterProps { + cells: [search.into(), block.clone().into(), progress.clone().into()], + widths: [ + Constraint::Fill(1), + Constraint::Min(block.width() as u16), + Constraint::Min(4), + ], + focus: self.props.focus, + cutoff: self.props.cutoff, + cutoff_after: self.props.cutoff_after, + }, + ); + } + None => { + self.footer.render::( + frame, + area, + FooterProps { + cells: [ + search.into(), + seen.clone().into(), + unseen.clone().into(), + progress.clone().into(), + ], + widths: [ + Constraint::Fill(1), + Constraint::Min(seen.width() as u16), + Constraint::Min(unseen.width() as u16), + Constraint::Min(4), + ], + focus: self.props.focus, + cutoff: self.props.cutoff, + cutoff_after: self.props.cutoff_after, + }, + ); + } + } } } impl Render<()> for Notifications { fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) { - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints(vec![ + let page_size = if self.props.show_search { + let layout = Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).split(area); + + self.render_header::(frame, layout[0]); + self.render_list::(frame, layout[1]); + + layout[1].height as usize + } else { + let layout = Layout::vertical([ Constraint::Length(3), Constraint::Min(1), Constraint::Length(3), ]) .split(area); - self.render_header::(frame, layout[0]); - self.render_list::(frame, layout[1]); - self.render_footer::(frame, layout[2]); + self.render_header::(frame, layout[0]); + self.render_list::(frame, layout[1]); + self.render_footer::(frame, layout[2]); + + layout[1].height as usize + }; - let page_size = layout[1].height as usize; if page_size != self.props.page_size { - let _ = self - .action_tx - .send(Action::PageSize(layout[1].height as usize)); + let _ = self.action_tx.send(Action::PageSize(page_size)); } } } + +pub struct SearchProps {} + +pub struct Search { + pub action_tx: UnboundedSender, + pub input: TextField, +} + +impl Widget for Search { + fn new(state: &State, action_tx: UnboundedSender) -> Self + where + Self: Sized, + { + let mut input = TextField::new(state, action_tx.clone()); + input.set_text(&state.search.read().to_string()); + + Self { action_tx, input }.move_with_state(state) + } + + fn move_with_state(self, state: &State) -> Self + where + Self: Sized, + { + let mut input = >::move_with_state(self.input, state); + input.set_text(&state.search.read().to_string()); + + Self { input, ..self } + } + + fn name(&self) -> &str { + "filter-popup" + } + + fn handle_key_event(&mut self, key: termion::event::Key) { + match key { + Key::Esc => { + let _ = self.action_tx.send(Action::CloseSearch); + } + Key::Char('\n') => { + let _ = self.action_tx.send(Action::ApplySearch); + } + _ => { + >::handle_key_event(&mut self.input, key); + let _ = self.action_tx.send(Action::UpdateSearch { + value: self.input.text().to_string(), + }); + } + } + } +} + +impl Render for Search { + fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: SearchProps) { + let layout = Layout::horizontal(Constraint::from_mins([0])) + .horizontal_margin(1) + .split(area); + + self.input.render::( + frame, + layout[0], + TextFieldProps { + titles: ("/".into(), "Search".into()), + show_cursor: true, + inline_label: true, + }, + ); + } +}