Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
patch: Add selection help
Archived did:key:z6MkgFq6...nBGz opened 2 years ago
4 files changed +461 -18 52b47a7c 3da86484
modified bin/commands/patch/flux/select.rs
@@ -37,6 +37,7 @@ pub struct App {
pub struct UIState {
    page_size: usize,
    show_search: bool,
+
    show_help: bool,
}

impl Default for UIState {
@@ -44,6 +45,7 @@ impl Default for UIState {
        Self {
            page_size: 1,
            show_search: false,
+
            show_help: false,
        }
    }
}
@@ -91,6 +93,8 @@ pub enum Action {
    UpdateSearch { value: String },
    ApplySearch,
    CloseSearch,
+
    OpenHelp,
+
    CloseHelp,
}

impl store::State<Action, Selection> for State {
@@ -125,6 +129,14 @@ impl store::State<Action, Selection> for State {
                self.ui.show_search = false;
                None
            }
+
            Action::OpenHelp => {
+
                self.ui.show_help = true;
+
                None
+
            }
+
            Action::CloseHelp => {
+
                self.ui.show_help = false;
+
                None
+
            }
        }
    }
}
@@ -142,10 +154,7 @@ impl App {

        tokio::try_join!(
            store.main_loop(state, terminator, action_rx, interrupt_rx.resubscribe()),
-
            frontend.main_loop::<State, 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/patch/flux/select/ui.rs
@@ -2,8 +2,6 @@ use std::collections::HashMap;
use std::str::FromStr;
use std::vec;

-
use radicle::patch::{self, Status};
-

use tokio::sync::mpsc::UnboundedSender;

use termion::event::Key;
@@ -11,7 +9,9 @@ use termion::event::Key;
use ratatui::backend::Backend;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::Stylize;
-
use ratatui::text::{Line, Text};
+
use ratatui::text::{Line, Span, Text};
+

+
use radicle::patch::{self, Status};

use radicle_tui as tui;

@@ -20,6 +20,7 @@ use tui::flux::ui::items::{PatchItem, PatchItemFilter};
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::text::{Paragraph, ParagraphProps};
use tui::flux::ui::widget::{
    Render, Shortcut, Shortcuts, ShortcutsProps, Table, TableProps, Widget,
};
@@ -34,6 +35,7 @@ pub struct ListPageProps {
    selected: Option<PatchItem>,
    mode: Mode,
    show_search: bool,
+
    show_help: bool,
}

impl From<&State> for ListPageProps {
@@ -42,11 +44,12 @@ impl From<&State> for ListPageProps {
            selected: state.selected.clone(),
            mode: state.mode.clone(),
            show_search: state.ui.show_search,
+
            show_help: state.ui.show_help,
        }
    }
}

-
pub struct ListPage {
+
pub struct ListPage<'a> {
    /// Action sender
    pub action_tx: UnboundedSender<Action>,
    /// State mapped props
@@ -55,11 +58,13 @@ pub struct ListPage {
    patches: Patches,
    /// Search widget
    search: Search,
+
    /// Help widget
+
    help: Help<'a>,
    /// Shortcut widget
    shortcuts: Shortcuts<Action>,
}

-
impl Widget<State, Action> for ListPage {
+
impl<'a> Widget<State, Action> for ListPage<'a> {
    fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self
    where
        Self: Sized,
@@ -69,6 +74,7 @@ impl Widget<State, Action> for ListPage {
            props: ListPageProps::from(state),
            patches: Patches::new(state, action_tx.clone()),
            search: Search::new(state, action_tx.clone()),
+
            help: Help::new(state, action_tx.clone()),
            shortcuts: Shortcuts::new(state, action_tx),
        }
        .move_with_state(state)
@@ -82,6 +88,7 @@ impl Widget<State, Action> for ListPage {
            patches: self.patches.move_with_state(state),
            search: self.search.move_with_state(state),
            shortcuts: self.shortcuts.move_with_state(state),
+
            help: self.help.move_with_state(state),
            props: ListPageProps::from(state),
            ..self
        }
@@ -94,6 +101,8 @@ impl Widget<State, Action> for ListPage {
    fn handle_key_event(&mut self, key: termion::event::Key) {
        if self.props.show_search {
            <Search as Widget<State, Action>>::handle_key_event(&mut self.search, key)
+
        } else if self.props.show_help {
+
            <Help as Widget<State, Action>>::handle_key_event(&mut self.help, key)
        } else {
            match key {
                Key::Esc | Key::Ctrl('c') => {
@@ -141,27 +150,29 @@ impl Widget<State, Action> for ListPage {
                Key::Char('/') => {
                    let _ = self.action_tx.send(Action::OpenSearch);
                }
+
                Key::Char('?') => {
+
                    let _ = self.action_tx.send(Action::OpenHelp);
+
                }
                _ => {
-
                    <Patches as Widget<State, Action>>::handle_key_event(
-
                        &mut self.patches,
-
                        key,
-
                    );
+
                    <Patches as Widget<State, Action>>::handle_key_event(&mut self.patches, key);
                }
            }
        }
    }
}

-
impl Render<()> for ListPage {
+
impl<'a> Render<()> for ListPage<'a> {
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, _area: Rect, _props: ()) {
        let area = frame.size();
        let layout = tui::flux::ui::layout::default_page(area, 0u16, 1u16);

        let shortcuts = if self.props.show_search {
            vec![
-
                Shortcut::new("esc", "back"),
-
                Shortcut::new("enter", "search"),
+
                Shortcut::new("esc", "cancel"),
+
                Shortcut::new("enter", "apply"),
            ]
+
        } else if self.props.show_help {
+
            vec![Shortcut::new("?", "close")]
        } else {
            match self.props.mode {
                Mode::Id => vec![
@@ -173,6 +184,7 @@ impl Render<()> for ListPage {
                    Shortcut::new("c", "checkout"),
                    Shortcut::new("d", "diff"),
                    Shortcut::new("/", "search"),
+
                    Shortcut::new("?", "help"),
                ],
            }
        };
@@ -184,6 +196,8 @@ impl Render<()> for ListPage {
            self.patches.render::<B>(frame, component_layout[0], ());
            self.search
                .render::<B>(frame, component_layout[1], SearchProps {});
+
        } else if self.props.show_help {
+
            self.help.render::<B>(frame, layout.component, ());
        } else {
            self.patches.render::<B>(frame, layout.component, ());
        }
@@ -581,8 +595,7 @@ impl Widget<State, Action> for Search {
    where
        Self: Sized,
    {
-
        let mut input =
-
            <TextField as Widget<State, Action>>::move_with_state(self.input, state);
+
        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 }
@@ -627,3 +640,286 @@ impl Render<SearchProps> for Search {
        );
    }
}
+

+
pub struct HelpProps<'a> {
+
    content: Text<'a>,
+
    focus: bool,
+
    page_size: usize,
+
}
+

+
impl<'a> From<&State> for HelpProps<'a> {
+
    fn from(state: &State) -> Self {
+
        let content = Text::from(
+
            [
+
                Line::from(Span::raw("Generic keybindings").cyan()),
+
                Line::raw(""),
+
                Line::from(
+
                    [
+
                        Span::raw(format!("{key:>10}", key = "↑,k")).gray(),
+
                        Span::raw(" "),
+
                        Span::raw("move cursor one line up").gray().dim(),
+
                    ]
+
                    .to_vec(),
+
                ),
+
                Line::from(
+
                    [
+
                        Span::raw(format!("{key:>10}", key = "↓,j")).gray(),
+
                        Span::raw(" "),
+
                        Span::raw("move cursor one line down").gray().dim(),
+
                    ]
+
                    .to_vec(),
+
                ),
+
                Line::from(
+
                    [
+
                        Span::raw(format!("{key:>10}", key = "PageUp")).gray(),
+
                        Span::raw(" "),
+
                        Span::raw("move cursor one page up").gray().dim(),
+
                    ]
+
                    .to_vec(),
+
                ),
+
                Line::from(
+
                    [
+
                        Span::raw(format!("{key:>10}", key = "PageDown")).gray(),
+
                        Span::raw(" "),
+
                        Span::raw("move cursor one page down").gray().dim(),
+
                    ]
+
                    .to_vec(),
+
                ),
+
                Line::from(
+
                    [
+
                        Span::raw(format!("{key:>10}", key = "Home")).gray(),
+
                        Span::raw(" "),
+
                        Span::raw("move cursor to the first line").gray().dim(),
+
                    ]
+
                    .to_vec(),
+
                ),
+
                Line::from(
+
                    [
+
                        Span::raw(format!("{key:>10}", key = "End")).gray(),
+
                        Span::raw(" "),
+
                        Span::raw("move cursor to the last line").gray().dim(),
+
                    ]
+
                    .to_vec(),
+
                ),
+
                Line::raw(""),
+
                Line::from(Span::raw("Specific keybindings").cyan()),
+
                Line::raw(""),
+
                Line::from(
+
                    [
+
                        Span::raw(format!("{key:>10}", key = "enter")).gray(),
+
                        Span::raw(" "),
+
                        Span::raw("Select patch (if --mode id)").gray().dim(),
+
                    ]
+
                    .to_vec(),
+
                ),
+
                Line::from(
+
                    [
+
                        Span::raw(format!("{key:>10}", key = "enter")).gray(),
+
                        Span::raw(" "),
+
                        Span::raw("Show patch").gray().dim(),
+
                    ]
+
                    .to_vec(),
+
                ),
+
                Line::from(
+
                    [
+
                        Span::raw(format!("{key:>10}", key = "c")).gray(),
+
                        Span::raw(" "),
+
                        Span::raw("Checkout patch").gray().dim(),
+
                    ]
+
                    .to_vec(),
+
                ),
+
                Line::from(
+
                    [
+
                        Span::raw(format!("{key:>10}", key = "d")).gray(),
+
                        Span::raw(" "),
+
                        Span::raw("Show patch diff").gray().dim(),
+
                    ]
+
                    .to_vec(),
+
                ),
+
                Line::from(
+
                    [
+
                        Span::raw(format!("{key:>10}", key = "/")).gray(),
+
                        Span::raw(" "),
+
                        Span::raw("Search").gray().dim(),
+
                    ]
+
                    .to_vec(),
+
                ),
+
                Line::from(
+
                    [
+
                        Span::raw(format!("{key:>10}", key = "?")).gray(),
+
                        Span::raw(" "),
+
                        Span::raw("Show help").gray().dim(),
+
                    ]
+
                    .to_vec(),
+
                ),
+
                Line::from(
+
                    [
+
                        Span::raw(format!("{key:>10}", key = "Esc")).gray(),
+
                        Span::raw(" "),
+
                        Span::raw("Quit / cancel").gray().dim(),
+
                    ]
+
                    .to_vec(),
+
                ),
+
                Line::raw(""),
+
                Line::from(Span::raw("Searching").cyan()),
+
                Line::raw(""),
+
                Line::from(
+
                    [
+
                        Span::raw(format!("{key:>10}", key = "Pattern")).gray(),
+
                        Span::raw(" "),
+
                        Span::raw("is:<state> | is:authored | authors:[<did>, <did>] | <search>")
+
                            .gray()
+
                            .dim(),
+
                    ]
+
                    .to_vec(),
+
                ),
+
                Line::from(
+
                    [
+
                        Span::raw(format!("{key:>10}", key = "Example")).gray(),
+
                        Span::raw(" "),
+
                        Span::raw("is:open is:authored improve").gray().dim(),
+
                    ]
+
                    .to_vec(),
+
                ),
+
            ]
+
            .to_vec(),
+
        );
+

+
        Self {
+
            content,
+
            focus: false,
+
            page_size: state.ui.page_size,
+
        }
+
    }
+
}
+

+
pub struct Help<'a> {
+
    /// Send messages
+
    pub action_tx: UnboundedSender<Action>,
+
    /// This widget's render properties
+
    pub props: HelpProps<'a>,
+
    /// Container header
+
    header: Header<Action>,
+
    /// Content widget
+
    content: Paragraph<Action>,
+
    /// Container footer
+
    footer: Footer<Action>,
+
}
+

+
impl<'a> Widget<State, Action> for Help<'a> {
+
    fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self {
+
            action_tx: action_tx.clone(),
+
            props: HelpProps::from(state),
+
            header: Header::new(state, action_tx.clone()),
+
            content: Paragraph::new(state, action_tx.clone()),
+
            footer: Footer::new(state, action_tx),
+
        }
+
        .move_with_state(state)
+
    }
+

+
    fn move_with_state(self, state: &State) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self {
+
            props: HelpProps::from(state),
+
            header: self.header.move_with_state(state),
+
            content: self.content.move_with_state(state),
+
            footer: self.footer.move_with_state(state),
+
            ..self
+
        }
+
    }
+

+
    fn name(&self) -> &str {
+
        "help"
+
    }
+

+
    fn handle_key_event(&mut self, key: termion::event::Key) {
+
        let len = self.props.content.lines.len() + 1;
+
        let page_size = self.props.page_size;
+
        match key {
+
            Key::Esc => {
+
                let _ = self.action_tx.send(Action::Exit { selection: None });
+
            }
+
            Key::Char('?') => {
+
                let _ = self.action_tx.send(Action::CloseHelp);
+
            }
+
            Key::Up | Key::Char('k') => {
+
                self.content.prev(len, page_size);
+
            }
+
            Key::Down | Key::Char('j') => {
+
                self.content.next(len, page_size);
+
            }
+
            Key::PageUp => {
+
                self.content.prev_page(len, page_size);
+
            }
+
            Key::PageDown => {
+
                self.content.next_page(len, page_size);
+
            }
+
            Key::Home => {
+
                self.content.begin(len, page_size);
+
            }
+
            Key::End => {
+
                self.content.end(len, page_size);
+
            }
+
            _ => {}
+
        }
+
    }
+
}
+

+
impl<'a> Render<()> for Help<'a> {
+
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
+
        let [header_area, content_area, footer_area] = Layout::vertical([
+
            Constraint::Length(3),
+
            Constraint::Min(1),
+
            Constraint::Length(3),
+
        ])
+
        .areas(area);
+

+
        self.header.render::<B>(
+
            frame,
+
            header_area,
+
            HeaderProps {
+
                cells: [String::from(" Help ").into()],
+
                widths: [Constraint::Fill(1)],
+
                focus: self.props.focus,
+
                cutoff: usize::MIN,
+
                cutoff_after: usize::MAX,
+
            },
+
        );
+

+
        self.content.render::<B>(
+
            frame,
+
            content_area,
+
            ParagraphProps {
+
                content: self.props.content.clone(),
+
                focus: self.props.focus,
+
                has_footer: true,
+
                has_header: true,
+
            },
+
        );
+

+
        let progress = span::default(format!("{}%", self.content.progress())).dim();
+

+
        self.footer.render::<B>(
+
            frame,
+
            footer_area,
+
            FooterProps {
+
                cells: [String::new().into(), progress.clone().into()],
+
                widths: [Constraint::Fill(1), Constraint::Min(4)],
+
                focus: self.props.focus,
+
                cutoff: usize::MAX,
+
                cutoff_after: usize::MAX,
+
            },
+
        );
+

+
        let page_size = content_area.height as usize;
+
        if page_size != self.props.page_size {
+
            let _ = self.action_tx.send(Action::PageSize(page_size));
+
        }
+
    }
+
}
modified src/flux/ui/widget.rs
@@ -1,5 +1,6 @@
pub mod container;
pub mod input;
+
pub mod text;

use std::cmp;
use std::fmt::Debug;
added src/flux/ui/widget/text.rs
@@ -0,0 +1,137 @@
+
use tokio::sync::mpsc::UnboundedSender;
+

+
use termion::event::Key;
+

+
use ratatui::backend::Backend;
+
use ratatui::layout::{Constraint, Layout, Rect};
+
use ratatui::text::Text;
+
use ratatui::widgets::{Block, BorderType, Borders};
+

+
use crate::flux::ui::theme::style;
+

+
use super::{Render, Widget};
+

+
pub struct ParagraphProps<'a> {
+
    pub content: Text<'a>,
+
    pub focus: bool,
+
    pub has_header: bool,
+
    pub has_footer: bool,
+
}
+

+
pub struct Paragraph<A> {
+
    /// Sending actions to the state store
+
    pub action_tx: UnboundedSender<A>,
+
    /// Internal offset
+
    offset: usize,
+
    /// Internal progress
+
    progress: usize,
+
}
+

+
impl<A> Paragraph<A> {
+
    pub fn scroll(&self) -> (u16, u16) {
+
        (self.offset as u16, 0)
+
    }
+

+
    pub fn prev(&mut self, len: usize, page_size: usize) -> (u16, u16) {
+
        self.offset = self.offset.saturating_sub(1);
+
        self.progress = Self::scroll_percent(self.offset, len, page_size);
+
        self.scroll()
+
    }
+

+
    pub fn next(&mut self, len: usize, page_size: usize) -> (u16, u16) {
+
        if self.progress < 100 {
+
            self.offset = self.offset.saturating_add(1);
+
            self.progress = Self::scroll_percent(self.offset, len, page_size);
+
        }
+

+
        self.scroll()
+
    }
+

+
    pub fn prev_page(&mut self, len: usize, page_size: usize) -> (u16, u16) {
+
        self.offset = self.offset.saturating_sub(page_size);
+
        self.progress = Self::scroll_percent(self.offset, len, page_size);
+
        self.scroll()
+
    }
+

+
    pub fn next_page(&mut self, len: usize, page_size: usize) -> (u16, u16) {
+
        let end = len.saturating_sub(page_size);
+

+
        self.offset = std::cmp::min(self.offset.saturating_add(page_size), end);
+
        self.progress = Self::scroll_percent(self.offset, len, page_size);
+
        self.scroll()
+
    }
+

+
    pub fn begin(&mut self, len: usize, page_size: usize) -> (u16, u16) {
+
        self.offset = 0;
+
        self.progress = Self::scroll_percent(self.offset, len, page_size);
+
        self.scroll()
+
    }
+

+
    pub fn end(&mut self, len: usize, page_size: usize) -> (u16, u16) {
+
        self.offset = len.saturating_sub(page_size);
+
        self.progress = Self::scroll_percent(self.offset, len, page_size);
+
        self.scroll()
+
    }
+

+
    pub fn progress(&self) -> usize {
+
        self.progress
+
    }
+

+
    fn scroll_percent(offset: usize, len: usize, height: usize) -> usize {
+
        if height >= len {
+
            100
+
        } else {
+
            let y = offset as f64;
+
            let h = height as f64;
+
            let t = len.saturating_sub(1) as f64;
+
            let v = y / (t - h) * 100_f64;
+

+
            std::cmp::max(0, std::cmp::min(100, v as usize))
+
        }
+
    }
+
}
+

+
impl<S, A> Widget<S, A> for Paragraph<A> {
+
    fn new(state: &S, action_tx: UnboundedSender<A>) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self {
+
            action_tx: action_tx.clone(),
+
            offset: 0,
+
            progress: 0,
+
        }
+
        .move_with_state(state)
+
    }
+

+
    fn move_with_state(self, _state: &S) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self { ..self }
+
    }
+

+
    fn name(&self) -> &str {
+
        "paragraph"
+
    }
+

+
    fn handle_key_event(&mut self, _key: Key) {}
+
}
+

+
impl<'a, A> Render<ParagraphProps<'a>> for Paragraph<A> {
+
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, props: ParagraphProps) {
+
        let block = Block::default()
+
            .borders(Borders::LEFT | Borders::RIGHT)
+
            .border_type(BorderType::Rounded)
+
            .border_style(style::border(props.focus));
+
        frame.render_widget(block, area);
+

+
        let [content_area] = Layout::horizontal([Constraint::Min(1)])
+
            .horizontal_margin(2)
+
            .areas(area);
+
        let content =
+
            ratatui::widgets::Paragraph::new(props.content.clone()).scroll((self.offset as u16, 0));
+

+
        frame.render_widget(content, content_area);
+
    }
+
}