Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
Introduce section group for pages
Merged did:key:z6MkswQE...2C1V opened 1 year ago

Add a section group to the library and use it in the inbox selection interface.

3 files changed +186 -21 eb80772e cbf3ebef
modified bin/commands/inbox/select/ui.rs
@@ -14,12 +14,14 @@ use radicle_tui as tui;

use tui::ui::items::{NotificationItem, NotificationItemFilter, NotificationState};
use tui::ui::span;
+
use tui::ui::widget;
use tui::ui::widget::container::{
    Container, ContainerProps, Footer, FooterProps, Header, HeaderProps,
};
use tui::ui::widget::input::{TextField, TextFieldProps, TextFieldState};
+
use tui::ui::widget::page::{SectionGroup, SectionGroupProps};
use tui::ui::widget::text::{Paragraph, ParagraphProps, ParagraphState};
-
use tui::ui::widget::{self, BaseView, RenderProps, TableUtils, WidgetState};
+
use tui::ui::widget::{BaseView, RenderProps, TableUtils, WidgetState};
use tui::ui::widget::{Column, Properties, Shortcuts, ShortcutsProps, Table, TableProps, Widget};
use tui::Selection;

@@ -171,10 +173,8 @@ impl<'a: 'static> Widget for Browser<'a> {
                .footer(
                    Footer::new(state, action_tx.clone())
                        .on_update(|state| {
-
                            let props = BrowserProps::from(state);
-

                            FooterProps::default()
-
                                .columns(browse_footer(&props, props.selected))
+
                                .columns(browse_footer(&BrowserProps::from(state)))
                                .to_boxed()
                        })
                        .to_boxed(),
@@ -255,10 +255,9 @@ impl<'a: 'static> Widget for Browser<'a> {
            self.notifications
                .render(frame, RenderProps::from(table_area));
            self.search
-
                .render(frame, RenderProps::from(search_area).focus(true));
+
                .render(frame, RenderProps::from(search_area).focus(props.focus));
        } else {
-
            self.notifications
-
                .render(frame, RenderProps::from(props.area));
+
            self.notifications.render(frame, props);
        }
    }

@@ -272,7 +271,7 @@ struct BrowserPageProps<'a> {
    /// Current page size (height of table content).
    page_size: usize,
    /// If this pages' keys should be handled (`false` if search is shown).
-
    global_keys: bool,
+
    handle_keys: bool,
    /// This pages' shortcuts.
    shortcuts: Vec<(&'a str, &'a str)>,
}
@@ -281,7 +280,7 @@ impl<'a> From<&State> for BrowserPageProps<'a> {
    fn from(state: &State) -> Self {
        Self {
            page_size: state.browser.page_size,
-
            global_keys: !state.browser.show_search,
+
            handle_keys: !state.browser.show_search,
            shortcuts: if state.browser.show_search {
                vec![("esc", "cancel"), ("enter", "apply")]
            } else {
@@ -306,8 +305,8 @@ pub struct BrowserPage<'a> {
    base: BaseView<State, Action>,
    /// Internal props
    props: BrowserPageProps<'a>,
-
    /// Notifications widget
-
    browser: BoxedWidget,
+
    /// Sections widget
+
    sections: BoxedWidget,
    /// Shortcut widget
    shortcuts: BoxedWidget,
}
@@ -326,7 +325,15 @@ impl<'a: 'static> Widget for BrowserPage<'a> {
                on_event: None,
            },
            props: props.clone(),
-
            browser: Browser::new(state, action_tx.clone()).to_boxed(),
+
            sections: SectionGroup::new(state, action_tx.clone())
+
                .section(Browser::new(state, action_tx.clone()).to_boxed())
+
                .on_update(|state| {
+
                    let props = BrowserPageProps::from(state);
+
                    SectionGroupProps::default()
+
                        .handle_keys(props.handle_keys)
+
                        .to_boxed()
+
                })
+
                .to_boxed(),
            shortcuts: Shortcuts::new(state, action_tx.clone())
                .on_update(|state| {
                    ShortcutsProps::default()
@@ -338,9 +345,9 @@ impl<'a: 'static> Widget for BrowserPage<'a> {
    }

    fn handle_event(&mut self, key: Key) {
-
        self.browser.handle_event(key);
+
        self.sections.handle_event(key);

-
        if self.props.global_keys {
+
        if self.props.handle_keys {
            match key {
                Key::Esc | Key::Ctrl('c') => {
                    let _ = self.base.action_tx.send(Action::Exit { selection: None });
@@ -357,7 +364,7 @@ impl<'a: 'static> Widget for BrowserPage<'a> {
        self.props = BrowserPageProps::from_callback(self.base.on_update, state)
            .unwrap_or(BrowserPageProps::from(state));

-
        self.browser.update(state);
+
        self.sections.update(state);
        self.shortcuts.update(state);
    }

@@ -367,8 +374,12 @@ impl<'a: 'static> Widget for BrowserPage<'a> {
        let [content_area, shortcuts_area] =
            Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(props.area);

-
        self.browser
-
            .render(frame, RenderProps::from(content_area).focus(true));
+
        self.sections.render(
+
            frame,
+
            RenderProps::from(content_area)
+
                .layout(Layout::horizontal([Constraint::Min(1)]))
+
                .focus(true),
+
        );
        self.shortcuts
            .render(frame, RenderProps::from(shortcuts_area));

@@ -617,7 +628,7 @@ impl<'a: 'static> Widget for HelpPage<'a> {
    }
}

-
fn browse_footer<'a>(props: &BrowserProps<'a>, selected: Option<usize>) -> Vec<Column<'a>> {
+
fn browse_footer<'a>(props: &BrowserProps<'a>) -> Vec<Column<'a>> {
    let search = Line::from(vec![
        span::default(" Search ").cyan().dim().reversed(),
        span::default(" "),
@@ -635,7 +646,8 @@ fn browse_footer<'a>(props: &BrowserProps<'a>, selected: Option<usize>) -> Vec<C
        span::default(" Unseen").dim(),
    ]);

-
    let progress = selected
+
    let progress = props
+
        .selected
        .map(|selected| TableUtils::progress(selected, props.notifications.len(), props.page_size))
        .unwrap_or_default();
    let progress = span::default(&format!("{}%", progress)).dim();
modified src/ui/widget.rs
@@ -1,5 +1,6 @@
pub mod container;
pub mod input;
+
pub mod page;
pub mod text;

use std::any::Any;
@@ -37,8 +38,10 @@ pub struct BaseView<S, A> {
/// They can be passed to a widgets' `render` function.
#[derive(Clone, Default)]
pub struct RenderProps {
-
    /// Area of the render props
+
    /// Area of the render props.
    pub area: Rect,
+
    /// Layout to be rendered in.
+
    pub layout: Layout,
    /// Focus of the render props.
    pub focus: bool,
}
@@ -49,11 +52,21 @@ impl RenderProps {
        self.focus = focus;
        self
    }
+

+
    /// Sets the layout of these render props.
+
    pub fn layout(mut self, layout: Layout) -> Self {
+
        self.layout = layout;
+
        self
+
    }
}

impl From<Rect> for RenderProps {
    fn from(area: Rect) -> Self {
-
        Self { area, focus: false }
+
        Self {
+
            area,
+
            layout: Layout::default(),
+
            focus: false,
+
        }
    }
}

added src/ui/widget/page.rs
@@ -0,0 +1,140 @@
+
use tokio::sync::mpsc::UnboundedSender;
+

+
use termion::event::Key;
+

+
use super::{BaseView, BoxedWidget, Properties, RenderProps, Widget, WidgetState};
+

+
#[derive(Clone)]
+
pub struct SectionGroupState {
+
    /// Index of currently focused section.
+
    focus: Option<usize>,
+
}
+

+
impl WidgetState for SectionGroupState {}
+

+
#[derive(Clone, Default)]
+
pub struct SectionGroupProps {
+
    /// If this pages' keys should be handled.
+
    handle_keys: bool,
+
}
+

+
impl SectionGroupProps {
+
    pub fn handle_keys(mut self, handle_keys: bool) -> Self {
+
        self.handle_keys = handle_keys;
+
        self
+
    }
+
}
+

+
impl Properties for SectionGroupProps {}
+

+
pub struct SectionGroup<S, A> {
+
    /// Internal base
+
    base: BaseView<S, A>,
+
    /// Internal table properties
+
    props: SectionGroupProps,
+
    /// All sections
+
    sections: Vec<BoxedWidget<S, A>>,
+
    /// Internal selection and offset state
+
    state: SectionGroupState,
+
}
+

+
impl<S, A> SectionGroup<S, A> {
+
    pub fn section(mut self, section: BoxedWidget<S, A>) -> Self {
+
        self.sections.push(section);
+
        self
+
    }
+

+
    fn prev(&mut self) -> Option<usize> {
+
        let focus = self.state.focus.map(|current| current.saturating_sub(1));
+
        self.state.focus = focus;
+
        focus
+
    }
+

+
    fn next(&mut self, len: usize) -> Option<usize> {
+
        let focus = self.state.focus.map(|current| {
+
            if current < len.saturating_sub(1) {
+
                current.saturating_add(1)
+
            } else {
+
                current
+
            }
+
        });
+
        self.state.focus = focus;
+
        focus
+
    }
+
}
+

+
impl<S, A> Widget for SectionGroup<S, A> {
+
    type State = S;
+
    type Action = A;
+

+
    fn new(_state: &S, action_tx: UnboundedSender<A>) -> Self {
+
        Self {
+
            base: BaseView {
+
                action_tx: action_tx.clone(),
+
                on_update: None,
+
                on_event: None,
+
            },
+
            props: SectionGroupProps::default(),
+
            sections: vec![],
+
            state: SectionGroupState { focus: Some(0) },
+
        }
+
    }
+

+
    fn handle_event(&mut self, key: Key) {
+
        if let Some(section) = self
+
            .state
+
            .focus
+
            .and_then(|focus| self.sections.get_mut(focus))
+
        {
+
            section.handle_event(key);
+
        }
+

+
        if self.props.handle_keys {
+
            match key {
+
                Key::Left => {
+
                    self.prev();
+
                }
+
                Key::Right => {
+
                    self.next(self.sections.len());
+
                }
+
                _ => {}
+
            }
+
        }
+

+
        if let Some(on_event) = self.base.on_event {
+
            (on_event)(
+
                self.state.clone().to_boxed_any(),
+
                self.base.action_tx.clone(),
+
            );
+
        }
+
    }
+

+
    fn update(&mut self, state: &S) {
+
        self.props = SectionGroupProps::from_callback(self.base.on_update, state)
+
            .unwrap_or(self.props.clone());
+

+
        for section in &mut self.sections {
+
            section.update(state);
+
        }
+
    }
+

+
    fn render(&self, frame: &mut ratatui::Frame, props: RenderProps) {
+
        let areas = props.layout.split(props.area);
+

+
        for (index, area) in areas.iter().enumerate() {
+
            if let Some(section) = self.sections.get(index) {
+
                let focus = self
+
                    .state
+
                    .focus
+
                    .map(|focus_index| index == focus_index)
+
                    .unwrap_or_default();
+

+
                section.render(frame, RenderProps::from(*area).focus(focus));
+
            }
+
        }
+
    }
+

+
    fn base_mut(&mut self) -> &mut BaseView<S, A> {
+
        &mut self.base
+
    }
+
}