Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
inbox: Add search widget
Erik Kundt committed 2 years ago
commit 3c8eada3ba8080a68e8b14487368a3c245f366a3
parent 0140229c5a229fb6e0b08a679ef75063ceccf574
2 files changed +297 -94
modified 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<NotificationItem>,
    selected: Option<NotificationItem>,
    mode: Mode,
    project: Project,
+
    search: StateValue<String>,
    ui: UIState,
}

-
impl TryFrom<&Context> for InboxState {
+
impl TryFrom<&Context> for State {
    type Error = anyhow::Error;

    fn try_from(context: &Context) -> Result<Self, Self::Error> {
@@ -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<Selection> },
    Select { item: NotificationItem },
    PageSize(usize),
+
    OpenSearch,
+
    UpdateSearch { value: String },
+
    ApplySearch,
+
    CloseSearch,
}

-
impl State<Action, Selection> for InboxState {
+
impl store::State<Action, Selection> for State {
    fn tick(&self) {}

    fn handle_action(&mut self, action: Action) -> Option<Exit<Selection>> {
@@ -176,6 +187,24 @@ impl State<Action, Selection> 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<Option<Selection>> {
        let (terminator, mut interrupt_rx) = task::create_termination();
-
        let (store, state_rx) = Store::<Action, InboxState, Selection>::new();
+
        let (store, state_rx) = store::Store::<Action, State, Selection>::new();
        let (frontend, action_rx) = Frontend::<Action>::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::<InboxState, ListPage, Selection>(state_rx, interrupt_rx.resubscribe()),
+
            frontend.main_loop::<State, ListPage, Selection>(state_rx, interrupt_rx.resubscribe()),
        )?;

        if let Ok(reason) = interrupt_rx.recv().await {
modified 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<NotificationItem>,
    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<Action>,
}

-
impl Widget<InboxState, Action> for ListPage {
-
    fn new(state: &InboxState, action_tx: UnboundedSender<Action>) -> Self
+
impl Widget<State, Action> for ListPage {
+
    fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self
    where
        Self: Sized,
    {
@@ -59,12 +65,13 @@ impl Widget<InboxState, Action> 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<InboxState, Action> 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 {
+
            <Search as Widget<State, Action>>::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);
+
                }
+
                _ => {
+
                    <Notifications as Widget<State, Action>>::handle_key_event(
+
                        &mut self.notifications,
+
                        key,
+
                    );
                }
-
            }
-
            _ => {
-
                <Notifications as Widget<InboxState, Action>>::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::<B>(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::<B>(frame, component_layout[0], ());
+
            self.search
+
                .render::<B>(frame, component_layout[1], SearchProps {});
+
        } else {
+
            self.notifications.render::<B>(frame, layout.component, ());
+
        }
+

        self.shortcuts.render::<B>(
            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::<Vec<_>>();
+

+
        // 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<Action>,
}

-
impl Widget<InboxState, Action> for Notifications {
-
    fn new(state: &InboxState, action_tx: UnboundedSender<Action>) -> Self {
+
impl Widget<State, Action> for Notifications {
+
    fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self {
        Self {
            action_tx: action_tx.clone(),
            props: NotificationsProps::from(state),
@@ -205,14 +257,23 @@ impl Widget<InboxState, Action> 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<B: Backend>(&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::<B>(
-
            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::<B>(
+
                    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::<B>(
+
                    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<B: Backend>(&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::<B>(frame, layout[0]);
+
            self.render_list::<B>(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::<B>(frame, layout[0]);
-
        self.render_list::<B>(frame, layout[1]);
-
        self.render_footer::<B>(frame, layout[2]);
+
            self.render_header::<B>(frame, layout[0]);
+
            self.render_list::<B>(frame, layout[1]);
+
            self.render_footer::<B>(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<Action>,
+
    pub input: TextField,
+
}
+

+
impl Widget<State, Action> for Search {
+
    fn new(state: &State, action_tx: UnboundedSender<Action>) -> 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 = <TextField as Widget<State, Action>>::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);
+
            }
+
            _ => {
+
                <TextField as Widget<State, Action>>::handle_key_event(&mut self.input, key);
+
                let _ = self.action_tx.send(Action::UpdateSearch {
+
                    value: self.input.text().to_string(),
+
                });
+
            }
+
        }
+
    }
+
}
+

+
impl Render<SearchProps> for Search {
+
    fn render<B: Backend>(&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::<B>(
+
            frame,
+
            layout[0],
+
            TextFieldProps {
+
                titles: ("/".into(), "Search".into()),
+
                show_cursor: true,
+
                inline_label: true,
+
            },
+
        );
+
    }
+
}