Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
bin: Browser state improvements
Merged did:key:z6MkswQE...2C1V opened 1 year ago

This adds a generic browser state that can be used in selection interfaces.

5 files changed +200 -204 233b3bcb 1114112b
modified bin/commands/issue/select.rs
@@ -36,8 +36,8 @@ use tui::{BoxedAny, Channel, Exit, PageStack};

use crate::cob::issue;
use crate::settings::{self, ThemeBundle, ThemeMode};
-
use crate::ui::items::{CommentItem, Filter, IssueItem, IssueItemFilter};
-
use crate::ui::widget::{IssueDetails, IssueDetailsProps};
+
use crate::ui::items::{CommentItem, IssueItem, IssueItemFilter};
+
use crate::ui::widget::{BrowserState, IssueDetails, IssueDetailsProps};
use crate::ui::TerminalInfo;

use self::ui::{Browser, BrowserProps};
@@ -96,60 +96,6 @@ impl From<Section> for usize {
}

#[derive(Clone, Debug)]
-
pub struct BrowserState {
-
    items: Vec<IssueItem>,
-
    selected: Option<usize>,
-
    filter: IssueItemFilter,
-
    search: store::StateValue<String>,
-
    show_search: bool,
-
}
-

-
impl BrowserState {
-
    pub fn issues(&self) -> Vec<IssueItem> {
-
        self.issues_ref().into_iter().cloned().collect()
-
    }
-

-
    pub fn issues_ref(&self) -> Vec<&IssueItem> {
-
        self.items
-
            .iter()
-
            .filter(|patch| self.filter.matches(patch))
-
            .collect()
-
    }
-

-
    pub fn selected_issue(&self) -> Option<&IssueItem> {
-
        self.selected
-
            .and_then(|selected| self.issues_ref().get(selected).copied())
-
    }
-
}
-

-
impl BrowserState {
-
    pub fn show_search(&mut self) {
-
        self.show_search = true;
-
    }
-

-
    pub fn hide_search(&mut self) {
-
        self.show_search = false;
-
    }
-

-
    pub fn apply_search(&mut self) {
-
        self.search.apply();
-
    }
-

-
    pub fn reset_search(&mut self) {
-
        self.search.reset();
-
    }
-

-
    pub fn search(&mut self, value: String) {
-
        self.search.write(value);
-
        self.filter_items();
-
    }
-

-
    pub fn filter_items(&mut self) {
-
        self.filter = IssueItemFilter::from_str(&self.search.read()).unwrap_or_default();
-
    }
-
}
-

-
#[derive(Clone, Debug)]
pub struct PreviewState {
    /// If preview is visible.
    show: bool,
@@ -213,7 +159,7 @@ pub struct HelpState {
pub struct State {
    mode: Mode,
    pages: PageStack<AppPage>,
-
    browser: BrowserState,
+
    browser: BrowserState<IssueItem, IssueItemFilter>,
    preview: PreviewState,
    section: Option<Section>,
    help: HelpState,
@@ -269,13 +215,7 @@ impl TryFrom<(&Context, &TerminalInfo)> for State {
        Ok(Self {
            mode: context.mode.clone(),
            pages: PageStack::new(vec![AppPage::Browser]),
-
            browser: BrowserState {
-
                items: items.clone(),
-
                selected: Some(0),
-
                filter,
-
                search,
-
                show_search: false,
-
            },
+
            browser: BrowserState::build(items.clone(), filter, search),
            preview: PreviewState {
                show: true,
                issue: items.first().cloned(),
@@ -315,7 +255,7 @@ impl store::State<Selection> for State {
    fn update(&mut self, message: Message) -> Option<Exit<Selection>> {
        match message {
            Message::Quit => Some(Exit { value: None }),
-
            Message::Exit { operation } => self.browser.selected_issue().map(|issue| Exit {
+
            Message::Exit { operation } => self.browser.selected_item().map(|issue| Exit {
                value: Some(Selection {
                    operation: operation.map(|op| op.to_string()),
                    ids: vec![issue.id],
@@ -328,7 +268,7 @@ impl store::State<Selection> for State {
                    Mode::Id => None,
                };

-
                self.browser.selected_issue().map(|issue| Exit {
+
                self.browser.selected_item().map(|issue| Exit {
                    value: Some(Selection {
                        operation,
                        ids: vec![issue.id],
@@ -337,8 +277,8 @@ impl store::State<Selection> for State {
                })
            }
            Message::SelectIssue { selected } => {
-
                self.browser.selected = selected;
-
                self.preview.issue = self.browser.selected_issue().cloned();
+
                self.browser.select_item(selected);
+
                self.preview.issue = self.browser.selected_item().cloned();
                self.preview.comment.reset_cursor();
                None
            }
@@ -369,19 +309,8 @@ impl store::State<Selection> for State {
                None
            }
            Message::UpdateSearch { value } => {
-
                self.browser.search(value);
-

-
                if let Some(selected) = self.browser.selected {
-
                    if selected > self.browser.issues().len() {
-
                        self.browser.selected = Some(0);
-
                        self.preview.issue = self.browser.issues().first().cloned();
-
                    } else {
-
                        self.preview.issue = self.browser.issues().get(selected).cloned();
-
                    }
-
                } else {
-
                    self.preview.issue = None;
-
                }
-

+
                self.browser.update_search(value);
+
                self.preview.issue = self.browser.select_first_item().cloned();
                None
            }
            Message::ApplySearch => {
@@ -392,9 +321,8 @@ impl store::State<Selection> for State {
            Message::CloseSearch => {
                self.browser.hide_search();
                self.browser.reset_search();
-
                self.browser.filter_items();

-
                self.preview.issue = self.browser.selected_issue().cloned();
+
                self.preview.issue = self.browser.selected_item().cloned();
                self.preview.comment.reset_cursor();
                None
            }
@@ -448,7 +376,7 @@ fn browser_page(channel: &Channel<Message>) -> Widget<State, Message> {
    let shortcuts = Shortcuts::default()
        .to_widget(tx.clone())
        .on_update(|state: &State| {
-
            let shortcuts = if state.browser.show_search {
+
            let shortcuts = if state.browser.is_search_shown() {
                vec![("esc", "cancel"), ("enter", "apply")]
            } else {
                let mut shortcuts = match state.mode {
@@ -487,7 +415,7 @@ fn browser_page(channel: &Channel<Message>) -> Widget<State, Message> {
                })
                .on_update(|state: &State| {
                    SectionGroupProps::default()
-
                        .handle_keys(state.preview.show && !state.browser.show_search)
+
                        .handle_keys(state.preview.show && !state.browser.is_search_shown())
                        .layout(PredefinedLayout::Expandable3 {
                            left_only: !state.preview.show,
                        })
@@ -521,7 +449,7 @@ fn browser_page(channel: &Channel<Message>) -> Widget<State, Message> {
        })
        .on_update(|state: &State| {
            PageProps::default()
-
                .handle_keys(!state.browser.show_search)
+
                .handle_keys(!state.browser.is_search_shown())
                .to_boxed_any()
                .into()
        })
modified bin/commands/issue/select/ui.rs
@@ -52,7 +52,7 @@ impl<'a> From<&State> for BrowserProps<'a> {
    fn from(state: &State) -> Self {
        use radicle::issue::State;

-
        let issues = state.browser.issues();
+
        let issues = state.browser.items();

        let mut open = 0;
        let mut other = 0;
@@ -104,8 +104,8 @@ impl<'a> From<&State> for BrowserProps<'a> {
                Column::new("Opened", Constraint::Length(16)).hide_small(),
            ]
            .to_vec(),
-
            search: state.browser.search.read(),
-
            show_search: state.browser.show_search,
+
            search: state.browser.read_search(),
+
            show_search: state.browser.is_search_shown(),
        }
    }
}
@@ -147,8 +147,8 @@ impl Browser {

                            TableProps::default()
                                .columns(props.columns)
-
                                .items(state.browser.issues())
-
                                .selected(state.browser.selected)
+
                                .items(state.browser.items())
+
                                .selected(state.browser.selected())
                                .dim(state.theme.dim_no_focus)
                                .to_boxed_any()
                                .into()
@@ -182,7 +182,7 @@ impl Browser {
                })
                .on_update(|state: &State| {
                    TextFieldProps::default()
-
                        .text(&state.browser.search.read().to_string())
+
                        .text(&state.browser.read_search())
                        .title("Search")
                        .inline(true)
                        .to_boxed_any()
modified bin/commands/patch/select.rs
@@ -27,10 +27,11 @@ use tui::{BoxedAny, Channel, Exit, PageStack};

use self::ui::{Browser, BrowserProps};

-
use super::common::Mode;
+
use super::common::{Mode, PatchOperation};

use crate::cob::patch;
-
use crate::ui::items::{Filter, PatchItem, PatchItemFilter};
+
use crate::ui::items::{PatchItem, PatchItemFilter};
+
use crate::ui::widget::BrowserState;

type Selection = tui::Selection<PatchId>;

@@ -52,25 +53,6 @@ pub enum AppPage {
}

#[derive(Clone, Debug)]
-
pub struct BrowserState {
-
    items: Vec<PatchItem>,
-
    selected: Option<usize>,
-
    filter: PatchItemFilter,
-
    search: store::StateValue<String>,
-
    show_search: bool,
-
}
-

-
impl BrowserState {
-
    pub fn patches(&self) -> Vec<PatchItem> {
-
        self.items
-
            .iter()
-
            .filter(|patch| self.filter.matches(patch))
-
            .cloned()
-
            .collect()
-
    }
-
}
-

-
#[derive(Clone, Debug)]
pub struct HelpState {
    text: TextViewState,
}
@@ -79,7 +61,7 @@ pub struct HelpState {
pub struct State {
    mode: Mode,
    pages: PageStack<AppPage>,
-
    browser: BrowserState,
+
    browser: BrowserState<PatchItem, PatchItemFilter>,
    help: HelpState,
}

@@ -103,13 +85,7 @@ impl TryFrom<&Context> for State {
        Ok(Self {
            mode: context.mode.clone(),
            pages: PageStack::new(vec![AppPage::Browse]),
-
            browser: BrowserState {
-
                items,
-
                selected: Some(0),
-
                filter,
-
                search,
-
                show_search: false,
-
            },
+
            browser: BrowserState::build(items.clone(), filter, search),
            help: HelpState {
                text: TextViewState::default().content(help_text()),
            },
@@ -118,8 +94,10 @@ impl TryFrom<&Context> for State {
}

pub enum Message {
-
    Exit { selection: Option<Selection> },
-
    Select { selected: Option<usize> },
+
    Quit,
+
    Exit { operation: Option<PatchOperation> },
+
    ExitFromMode,
+
    SelectPatch { selected: Option<usize> },
    OpenSearch,
    UpdateSearch { value: String },
    ApplySearch,
@@ -134,48 +112,56 @@ impl store::State<Selection> for State {

    fn update(&mut self, message: Message) -> Option<Exit<Selection>> {
        match message {
-
            Message::Exit { selection } => Some(Exit { value: selection }),
-
            Message::Select { selected } => {
-
                self.browser.selected = selected;
+
            Message::Quit => Some(Exit { value: None }),
+
            Message::Exit { operation } => self.browser.selected_item().map(|issue| Exit {
+
                value: Some(Selection {
+
                    operation: operation.map(|op| op.to_string()),
+
                    ids: vec![issue.id],
+
                    args: vec![],
+
                }),
+
            }),
+
            Message::ExitFromMode => {
+
                let operation = match self.mode {
+
                    Mode::Operation => Some(PatchOperation::Show.to_string()),
+
                    Mode::Id => None,
+
                };
+

+
                self.browser.selected_item().map(|issue| Exit {
+
                    value: Some(Selection {
+
                        operation,
+
                        ids: vec![issue.id],
+
                        args: vec![],
+
                    }),
+
                })
+
            }
+
            Message::SelectPatch { selected } => {
+
                self.browser.select_item(selected);
                None
            }
            Message::OpenSearch => {
-
                self.browser.show_search = true;
+
                self.browser.show_search();
                None
            }
            Message::UpdateSearch { value } => {
-
                self.browser.search.write(value);
-
                self.browser.filter =
-
                    PatchItemFilter::from_str(&self.browser.search.read()).unwrap_or_default();
-

-
                if let Some(selected) = self.browser.selected {
-
                    if selected > self.browser.patches().len() {
-
                        self.browser.selected = Some(0);
-
                    }
-
                }
-

+
                self.browser.update_search(value);
+
                self.browser.select_first_item();
                None
            }
            Message::ApplySearch => {
-
                self.browser.search.apply();
-
                self.browser.show_search = false;
+
                self.browser.hide_search();
+
                self.browser.apply_search();
                None
            }
            Message::CloseSearch => {
-
                self.browser.search.reset();
-
                self.browser.show_search = false;
-
                self.browser.filter =
-
                    PatchItemFilter::from_str(&self.browser.search.read()).unwrap_or_default();
-

+
                self.browser.hide_search();
+
                self.browser.reset_search();
                None
            }
            Message::OpenHelp => {
-
                log::warn!("OpenHelp");
                self.pages.push(AppPage::Help);
                None
            }
            Message::LeavePage => {
-
                log::warn!("LeavePage");
                self.pages.pop();
                None
            }
@@ -222,7 +208,7 @@ fn browser_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Mes
    let shortcuts = Shortcuts::default()
        .to_widget(tx.clone())
        .on_update(|state: &State| {
-
            let shortcuts = if state.browser.show_search {
+
            let shortcuts = if state.browser.is_search_shown() {
                vec![("esc", "cancel"), ("enter", "apply")]
            } else {
                match state.mode {
@@ -255,8 +241,15 @@ fn browser_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Mes

            if props.handle_keys {
                match key {
-
                    Key::Esc | Key::Ctrl('c') => Some(Message::Exit { selection: None }),
+
                    Key::Esc | Key::Ctrl('c') => Some(Message::Quit),
                    Key::Char('?') => Some(Message::OpenHelp),
+
                    Key::Char('\n') => Some(Message::ExitFromMode),
+
                    Key::Char('c') => Some(Message::Exit {
+
                        operation: Some(PatchOperation::Checkout),
+
                    }),
+
                    Key::Char('d') => Some(Message::Exit {
+
                        operation: Some(PatchOperation::Diff),
+
                    }),
                    _ => None,
                }
            } else {
@@ -265,7 +258,7 @@ fn browser_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Mes
        })
        .on_update(|state: &State| {
            PageProps::default()
-
                .handle_keys(!state.browser.show_search)
+
                .handle_keys(!state.browser.is_search_shown())
                .to_boxed_any()
                .into()
        })
@@ -329,7 +322,7 @@ fn help_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Messag
        .shortcuts(shortcuts)
        .to_widget(tx.clone())
        .on_event(|key, _, _| match key {
-
            Key::Esc | Key::Ctrl('c') => Some(Message::Exit { selection: None }),
+
            Key::Esc | Key::Ctrl('c') => Some(Message::Quit),
            Key::Char('?') => Some(Message::LeavePage),
            _ => None,
        })
modified bin/commands/patch/select/ui.rs
@@ -26,10 +26,8 @@ use tui::ui::widget::list::{Table, TableProps};
use tui::ui::widget::ViewProps;
use tui::ui::widget::{RenderProps, ToWidget, View};

-
use tui::{BoxedAny, Selection};
+
use tui::BoxedAny;

-
use crate::tui_patch::common::Mode;
-
use crate::tui_patch::common::PatchOperation;
use crate::ui::items::{PatchItem, PatchItemFilter};

use super::{Message, State};
@@ -38,12 +36,8 @@ type Widget = widget::Widget<State, Message>;

#[derive(Clone, Default)]
pub struct BrowserProps<'a> {
-
    /// Application mode: openation and id or id only.
-
    mode: Mode,
    /// Filtered patches.
    patches: Vec<PatchItem>,
-
    /// Current (selected) table index
-
    selected: Option<usize>,
    /// Patch statistics.
    stats: HashMap<String, usize>,
    /// Header columns
@@ -63,7 +57,7 @@ impl<'a> From<&State> for BrowserProps<'a> {
        let mut archived = 0;
        let mut merged = 0;

-
        let patches = state.browser.patches();
+
        let patches = state.browser.items();

        for patch in &patches {
            match patch.state {
@@ -85,9 +79,7 @@ impl<'a> From<&State> for BrowserProps<'a> {
        ]);

        Self {
-
            mode: state.mode.clone(),
            patches,
-
            selected: state.browser.selected,
            stats,
            header: [
                Column::new(" ● ", Constraint::Length(3)),
@@ -113,8 +105,8 @@ impl<'a> From<&State> for BrowserProps<'a> {
                Column::new("Updated", Constraint::Length(16)).hide_small(),
            ]
            .to_vec(),
-
            show_search: state.browser.show_search,
-
            search: state.browser.search.read(),
+
            show_search: state.browser.is_search_shown(),
+
            search: state.browser.read_search(),
        }
    }
}
@@ -144,7 +136,7 @@ impl Browser {
                        .on_event(|_, s, _| {
                            let (selected, _) =
                                s.and_then(|s| s.unwrap_table()).unwrap_or_default();
-
                            Some(Message::Select {
+
                            Some(Message::SelectPatch {
                                selected: Some(selected),
                            })
                        })
@@ -153,8 +145,8 @@ impl Browser {
                            let props = BrowserProps::from(state);
                            TableProps::default()
                                .columns(props.columns)
-
                                .items(state.browser.patches())
-
                                .selected(state.browser.selected)
+
                                .items(state.browser.items())
+
                                .selected(state.browser.selected())
                                .to_boxed_any()
                                .into()
                        }),
@@ -184,7 +176,7 @@ impl Browser {
                })
                .on_update(|state: &State| {
                    TextFieldProps::default()
-
                        .text(&state.browser.search.read().to_string())
+
                        .text(&state.browser.read_search())
                        .title("Search")
                        .inline(true)
                        .to_boxed_any()
@@ -218,45 +210,7 @@ impl View for Browser {
            }
        } else {
            match key {
-
                Key::Esc | Key::Ctrl('c') => Some(Message::Exit { selection: None }),
                Key::Char('/') => Some(Message::OpenSearch),
-
                Key::Char('\n') => {
-
                    let operation = match props.mode {
-
                        Mode::Operation => Some(PatchOperation::Show.to_string()),
-
                        Mode::Id => None,
-
                    };
-

-
                    props
-
                        .selected
-
                        .and_then(|selected| props.patches.get(selected))
-
                        .map(|patch| Message::Exit {
-
                            selection: Some(Selection {
-
                                operation,
-
                                ids: vec![patch.id],
-
                                args: vec![],
-
                            }),
-
                        })
-
                }
-
                Key::Char('c') => props
-
                    .selected
-
                    .and_then(|selected| props.patches.get(selected))
-
                    .map(|patch| Message::Exit {
-
                        selection: Some(Selection {
-
                            operation: Some(PatchOperation::Checkout.to_string()),
-
                            ids: vec![patch.id],
-
                            args: vec![],
-
                        }),
-
                    }),
-
                Key::Char('d') => props
-
                    .selected
-
                    .and_then(|selected| props.patches.get(selected))
-
                    .map(|patch| Message::Exit {
-
                        selection: Some(Selection {
-
                            operation: Some(PatchOperation::Diff.to_string()),
-
                            ids: vec![patch.id],
-
                            args: vec![],
-
                        }),
-
                    }),
                _ => {
                    self.patches.handle_event(key);
                    None
modified bin/ui/widget.rs
@@ -1,4 +1,5 @@
use std::marker::PhantomData;
+
use std::str::FromStr;

use radicle::issue::{self, CloseReason};
use ratatui::layout::{Constraint, Layout};
@@ -9,6 +10,7 @@ use ratatui::Frame;

use radicle_tui as tui;

+
use tui::store;
use tui::ui::theme::style;
use tui::ui::widget::{RenderProps, View, ViewProps};
use tui::ui::{layout, span};
@@ -16,6 +18,125 @@ use tui::ui::{layout, span};
use super::format;
use super::items::IssueItem;

+
use crate::ui::items::Filter;
+

+
/// A `BrowserState` represents the internal state of a browser widget.
+
/// A browser widget would consist of 2 child widgets: a list of items and a
+
/// buffered search field. The search fields value is used to build an
+
/// item filter that the item list reacts on dynamically.
+
#[derive(Clone, Debug)]
+
pub struct BrowserState<I, F> {
+
    items: Vec<I>,
+
    selected: Option<usize>,
+
    filter: F,
+
    search: store::StateValue<String>,
+
    show_search: bool,
+
}
+

+
impl<I, F> Default for BrowserState<I, F>
+
where
+
    I: Clone,
+
    F: Filter<I> + Default + FromStr,
+
{
+
    fn default() -> Self {
+
        Self {
+
            items: vec![],
+
            selected: None,
+
            filter: F::default(),
+
            search: store::StateValue::new(String::default()),
+
            show_search: false,
+
        }
+
    }
+
}
+

+
impl<I, F> BrowserState<I, F>
+
where
+
    I: Clone,
+
    F: Filter<I> + Default + FromStr,
+
{
+
    pub fn build(items: Vec<I>, filter: F, search: store::StateValue<String>) -> Self {
+
        let selected = items.first().map(|_| 0);
+

+
        Self {
+
            items,
+
            selected,
+
            filter,
+
            search,
+
            ..Default::default()
+
        }
+
    }
+

+
    pub fn items(&self) -> Vec<I> {
+
        self.items_ref().into_iter().cloned().collect()
+
    }
+

+
    pub fn items_ref(&self) -> Vec<&I> {
+
        self.items
+
            .iter()
+
            .filter(|patch| self.filter.matches(patch))
+
            .collect()
+
    }
+

+
    pub fn selected(&self) -> Option<usize> {
+
        self.selected
+
    }
+

+
    pub fn selected_item(&self) -> Option<&I> {
+
        self.selected
+
            .and_then(|selected| self.items_ref().get(selected).copied())
+
    }
+

+
    pub fn select_item(&mut self, selected: Option<usize>) -> Option<&I> {
+
        self.selected = selected;
+
        self.selected_item()
+
    }
+

+
    pub fn select_first_item(&mut self) -> Option<&I> {
+
        self.selected.and_then(|selected| {
+
            if selected > self.items_ref().len() {
+
                self.selected = Some(0);
+
                self.items_ref().first().cloned()
+
            } else {
+
                self.items_ref().get(selected).cloned()
+
            }
+
        })
+
    }
+

+
    fn filter_items(&mut self) {
+
        self.filter = F::from_str(&self.search.read()).unwrap_or_default();
+
    }
+

+
    pub fn update_search(&mut self, value: String) {
+
        self.search.write(value);
+
        self.filter_items();
+
    }
+

+
    pub fn show_search(&mut self) {
+
        self.show_search = true;
+
    }
+

+
    pub fn hide_search(&mut self) {
+
        self.show_search = false;
+
    }
+

+
    pub fn apply_search(&mut self) {
+
        self.search.apply();
+
    }
+

+
    pub fn reset_search(&mut self) {
+
        self.search.reset();
+
        self.filter_items();
+
    }
+

+
    pub fn is_search_shown(&self) -> bool {
+
        self.show_search
+
    }
+

+
    pub fn read_search(&self) -> String {
+
        self.search.read()
+
    }
+
}
+

#[derive(Clone, Default)]
pub struct IssueDetailsProps {
    issue: Option<IssueItem>,