Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
issue: Add search widget
Erik Kundt committed 2 years ago
commit 6371ef0aac474c9ffa900531204e9333ecf3bcb0
parent 901c1d209c0460174a6ea8ebac61cf5432d5e874
2 files changed +269 -86
modified bin/commands/issue/flux/select.rs
@@ -10,7 +10,7 @@ use radicle::Profile;
use radicle_tui as tui;

use tui::common::cob::issue::{self, Filter};
-
use tui::flux::store;
+
use tui::flux::store::{self, StateValue};
use tui::flux::task::{self, Interrupted};
use tui::flux::ui::cob::IssueItem;
use tui::flux::ui::Frontend;
@@ -36,11 +36,15 @@ 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,
+
        }
    }
}

@@ -49,7 +53,7 @@ pub struct State {
    issues: Vec<IssueItem>,
    selected: Option<IssueItem>,
    mode: Mode,
-
    filter: Filter,
+
    search: StateValue<String>,
    ui: UIState,
}

@@ -58,28 +62,22 @@ impl TryFrom<&Context> for State {

    fn try_from(context: &Context) -> Result<Self, Self::Error> {
        let issues = issue::all(&context.profile, &context.repository)?;
-
        let issues = issues
-
            .iter()
-
            .filter(|(_, issue)| context.filter.matches(&context.profile, issue));
-

-
        let mut items = vec![];

        // Convert into UI items
+
        let mut items = vec![];
        for issue in issues {
            if let Ok(item) = IssueItem::new(&context.profile, issue.clone()) {
                items.push(item);
            }
        }

-
        // Apply sorting
-
        items.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
        let selected = items.first().cloned();

        Ok(Self {
            issues: items,
            selected,
            mode: context.mode.clone(),
-
            filter: context.filter.clone(),
+
            search: StateValue::new(context.filter.to_string()),
            ui: UIState::default(),
        })
    }
@@ -89,6 +87,10 @@ pub enum Action {
    Exit { selection: Option<Selection> },
    Select { item: IssueItem },
    PageSize(usize),
+
    OpenSearch,
+
    UpdateSearch { value: String },
+
    ApplySearch,
+
    CloseSearch,
}

impl store::State<Action, Selection> for State {
@@ -96,6 +98,7 @@ impl store::State<Action, Selection> for State {

    fn handle_action(&mut self, action: Action) -> Option<Exit<Selection>> {
        match action {
+
            Action::Exit { selection } => Some(Exit { value: selection }),
            Action::Select { item } => {
                self.selected = Some(item);
                None
@@ -104,7 +107,24 @@ impl store::State<Action, Selection> for State {
                self.ui.page_size = size;
                None
            }
-
            Action::Exit { selection } => Some(Exit { value: selection }),
+
            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
+
            }
        }
    }
}
modified bin/commands/issue/flux/select/ui.rs
@@ -1,22 +1,23 @@
use std::collections::HashMap;
+
use std::str::FromStr;
use std::vec;

+
use radicle::issue::{self, CloseReason};
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;

use radicle_tui as tui;

-
use tui::common::cob;
-
use tui::common::cob::issue::Filter;
-
use tui::flux::ui::cob::IssueItem;
+
use tui::flux::ui::cob::{IssueItem, IssueItemFilter};
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,
};
@@ -30,6 +31,7 @@ use super::{Action, State};
pub struct ListPageProps {
    selected: Option<IssueItem>,
    mode: Mode,
+
    show_search: bool,
}

impl From<&State> for ListPageProps {
@@ -37,6 +39,7 @@ impl From<&State> for ListPageProps {
        Self {
            selected: state.selected.clone(),
            mode: state.mode.clone(),
+
            show_search: state.ui.show_search,
        }
    }
}
@@ -48,6 +51,8 @@ pub struct ListPage {
    props: ListPageProps,
    /// Notification widget
    issues: Issues,
+
    /// Search widget
+
    search: Search,
    /// Shortcut widget
    shortcuts: Shortcuts<Action>,
}
@@ -61,6 +66,7 @@ impl Widget<State, Action> for ListPage {
            action_tx: action_tx.clone(),
            props: ListPageProps::from(state),
            issues: Issues::new(state, action_tx.clone()),
+
            search: Search::new(state, action_tx.clone()),
            shortcuts: Shortcuts::new(state, action_tx.clone()),
        }
        .move_with_state(state)
@@ -83,38 +89,45 @@ impl Widget<State, 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 operation = match self.props.mode {
-
                        Mode::Operation => Some(IssueOperation::Show.to_string()),
-
                        Mode::Id => None,
-
                    };
-
                    let _ = self.action_tx.send(Action::Exit {
-
                        selection: Some(Selection {
-
                            operation,
-
                            ids: vec![selected.id],
-
                            args: vec![],
-
                        }),
-
                    });
+
        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('e') => {
-
                if let Some(selected) = &self.props.selected {
-
                    let _ = self.action_tx.send(Action::Exit {
-
                        selection: Some(Selection {
-
                            operation: Some(IssueOperation::Edit.to_string()),
-
                            ids: vec![selected.id],
-
                            args: vec![],
-
                        }),
-
                    });
+
                Key::Char('\n') => {
+
                    if let Some(selected) = &self.props.selected {
+
                        let operation = match self.props.mode {
+
                            Mode::Operation => Some(IssueOperation::Show.to_string()),
+
                            Mode::Id => None,
+
                        };
+
                        let _ = self.action_tx.send(Action::Exit {
+
                            selection: Some(Selection {
+
                                operation,
+
                                ids: vec![selected.id],
+
                                args: vec![],
+
                            }),
+
                        });
+
                    }
+
                }
+
                Key::Char('e') => {
+
                    if let Some(selected) = &self.props.selected {
+
                        let _ = self.action_tx.send(Action::Exit {
+
                            selection: Some(Selection {
+
                                operation: Some(IssueOperation::Edit.to_string()),
+
                                ids: vec![selected.id],
+
                                args: vec![],
+
                            }),
+
                        });
+
                    }
+
                }
+
                Key::Char('/') => {
+
                    let _ = self.action_tx.send(Action::OpenSearch);
+
                }
+
                _ => {
+
                    <Issues as Widget<State, Action>>::handle_key_event(&mut self.issues, key);
                }
-
            }
-
            _ => {
-
                <Issues as Widget<State, Action>>::handle_key_event(&mut self.issues, key);
            }
        }
    }
@@ -125,17 +138,36 @@ 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 {
-
            Mode::Id => vec![Shortcut::new("enter", "select")],
-
            Mode::Operation => vec![
-
                Shortcut::new("enter", "show"),
-
                Shortcut::new("c", "comment"),
-
                Shortcut::new("e", "edit"),
-
                Shortcut::new("d", "delete"),
-
            ],
+
        let shortcuts = if self.props.show_search {
+
            vec![
+
                Shortcut::new("esc", "back"),
+
                Shortcut::new("enter", "search"),
+
            ]
+
        } else {
+
            match self.props.mode {
+
                Mode::Id => vec![
+
                    Shortcut::new("enter", "select"),
+
                    Shortcut::new("/", "search"),
+
                ],
+
                Mode::Operation => vec![
+
                    Shortcut::new("enter", "show"),
+
                    Shortcut::new("e", "edit"),
+
                    Shortcut::new("/", "search"),
+
                ],
+
            }
        };

-
        self.issues.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.issues.render::<B>(frame, component_layout[0], ());
+
            self.search
+
                .render::<B>(frame, component_layout[1], SearchProps {});
+
        } else {
+
            self.issues.render::<B>(frame, layout.component, ());
+
        }
+

        self.shortcuts.render::<B>(
            frame,
            layout.shortcuts,
@@ -149,13 +181,14 @@ impl Render<()> for ListPage {

struct IssuesProps {
    issues: Vec<IssueItem>,
-
    filter: Filter,
+
    search: String,
    stats: HashMap<String, usize>,
    widths: [Constraint; 8],
    cutoff: usize,
    cutoff_after: usize,
    focus: bool,
    page_size: usize,
+
    show_search: bool,
}

impl From<&State> for IssuesProps {
@@ -163,19 +196,45 @@ impl From<&State> for IssuesProps {
        use radicle::issue::State;

        let mut open = 0;
-
        let mut closed = 0;
+
        let mut other = 0;
+
        let mut solved = 0;
+

+
        // Filter by search string
+
        let filter = IssueItemFilter::from_str(&state.search.read()).unwrap_or_default();
+
        let mut issues = state
+
            .issues
+
            .clone()
+
            .into_iter()
+
            .filter(|issue| filter.matches(issue))
+
            .collect::<Vec<_>>();
+

+
        // Apply sorting
+
        issues.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));

        for issue in &state.issues {
            match issue.state {
                State::Open => open += 1,
-
                State::Closed { reason: _ } => closed += 1,
+
                State::Closed {
+
                    reason: CloseReason::Other,
+
                } => other += 1,
+
                State::Closed {
+
                    reason: CloseReason::Solved,
+
                } => solved += 1,
            }
        }
-
        let stats = HashMap::from([("Open".to_string(), open), ("Closed".to_string(), closed)]);
+

+
        let closed = solved + other;
+

+
        let stats = HashMap::from([
+
            ("Open".to_string(), open),
+
            ("Other".to_string(), other),
+
            ("Solved".to_string(), solved),
+
            ("Closed".to_string(), closed),
+
        ]);

        Self {
-
            issues: state.issues.clone(),
-
            filter: state.filter.clone(),
+
            issues,
+
            search: state.search.read(),
            widths: [
                Constraint::Length(3),
                Constraint::Length(8),
@@ -191,6 +250,7 @@ impl From<&State> for IssuesProps {
            focus: false,
            stats,
            page_size: state.ui.page_size,
+
            show_search: state.ui.show_search,
        }
    }
}
@@ -223,9 +283,18 @@ impl Widget<State, Action> for Issues {
    where
        Self: Sized,
    {
+
        let props = IssuesProps::from(state);
+
        let mut table = self.table.move_with_state(state);
+

+
        if let Some(selected) = table.selected() {
+
            if selected > props.issues.len() {
+
                table.begin();
+
            }
+
        }
+

        Self {
-
            props: IssuesProps::from(state),
-
            table: self.table.move_with_state(state),
+
            props,
+
            table,
            header: self.header.move_with_state(state),
            footer: self.footer.move_with_state(state),
            ..self
@@ -302,7 +371,7 @@ impl Issues {
            area,
            TableProps {
                items: self.props.issues.to_vec(),
-
                has_footer: true,
+
                has_footer: !self.props.show_search,
                has_header: true,
                widths: self.props.widths,
                focus: self.props.focus,
@@ -313,19 +382,31 @@ impl Issues {
    }

    fn render_footer<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect) {
-
        use cob::issue::State;
+
        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 filter = Line::from(
+
        let open = Line::from(
            [
-
                span::default(" ".to_string()),
-
                span::default(self.props.filter.to_string()).magenta().dim(),
+
                span::positive(self.props.stats.get("Open").unwrap_or(&0).to_string()).dim(),
+
                span::default(" Open".to_string()).dim(),
            ]
            .to_vec(),
        );
-
        let open = Line::from(
+
        let solved = Line::from(
            [
-
                span::positive(self.props.stats.get("Open").unwrap_or(&0).to_string()).dim(),
-
                span::default(" Open".to_string()).dim(),
+
                span::default(self.props.stats.get("Solved").unwrap_or(&0).to_string())
+
                    .magenta()
+
                    .dim(),
+
                span::default(" Solved".to_string()).dim(),
            ]
            .to_vec(),
        );
@@ -351,18 +432,26 @@ impl Issues {
            .progress_percentage(self.props.issues.len(), self.props.page_size);
        let progress = span::default(format!("{}%", progress)).dim();

-
        match self.props.filter.state() {
+
        match IssueItemFilter::from_str(&self.props.search)
+
            .unwrap_or_default()
+
            .state()
+
        {
            Some(state) => {
                let block = match state {
-
                    State::Open => open,
-
                    State::Closed | State::Solved => closed,
+
                    issue::State::Open => open,
+
                    issue::State::Closed {
+
                        reason: issue::CloseReason::Other,
+
                    } => closed,
+
                    issue::State::Closed {
+
                        reason: issue::CloseReason::Solved,
+
                    } => solved,
                };

                self.footer.render::<B>(
                    frame,
                    area,
                    FooterProps {
-
                        cells: [filter.into(), block.clone().into(), progress.clone().into()],
+
                        cells: [search.into(), block.clone().into(), progress.clone().into()],
                        widths: [
                            Constraint::Fill(1),
                            Constraint::Min(block.width() as u16),
@@ -380,7 +469,7 @@ impl Issues {
                    area,
                    FooterProps {
                        cells: [
-
                            filter.into(),
+
                            search.into(),
                            open.clone().into(),
                            closed.clone().into(),
                            sum.clone().into(),
@@ -405,24 +494,98 @@ impl Issues {

impl Render<()> for Issues {
    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,
+
            },
+
        );
+
    }
+
}