From d676fe82f3299db8db596110d2d0b47fe779f6db Mon Sep 17 00:00:00 2001 From: Erik Kundt Date: Mon, 3 Jun 2024 16:33:24 +0200 Subject: [PATCH] inbox: Adjust new API --- bin/commands/inbox/select.rs | 288 ++++++++++++++++++-- bin/commands/inbox/select/ui.rs | 451 +++----------------------------- 2 files changed, 306 insertions(+), 433 deletions(-) diff --git a/bin/commands/inbox/select.rs b/bin/commands/inbox/select.rs index 4933fddb..2c560267 100644 --- a/bin/commands/inbox/select.rs +++ b/bin/commands/inbox/select.rs @@ -5,26 +5,38 @@ use std::str::FromStr; use anyhow::Result; +use termion::event::Key; + +use ratatui::layout::Constraint; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::text::Text; + use radicle::identity::Project; use radicle::node::notifications::NotificationId; +use radicle::storage::git::Repository; use radicle::storage::ReadRepository; use radicle::storage::ReadStorage; - -use radicle::storage::git::Repository; use radicle::Profile; + use radicle_tui as tui; use tui::cob::inbox; use tui::store; use tui::store::StateValue; use tui::ui::items::{Filter, NotificationItem, NotificationItemFilter}; -use tui::ui::widget::window::{Window, WindowProps}; -use tui::ui::widget::ToWidget; +use tui::ui::span; +use tui::ui::widget::container::{Column, Container, Footer, FooterProps, Header, HeaderProps}; +use tui::ui::widget::text::{Paragraph, ParagraphProps}; +use tui::ui::widget::window::{Page, PageProps, Shortcuts, ShortcutsProps, Window, WindowProps}; +use tui::ui::widget::{ToWidget, Widget}; use tui::{BoxedAny, Channel, Exit, PageStack}; -use self::ui::BrowserPage; -use self::ui::HelpPage; +use self::ui::Browser; +use self::ui::BrowserProps; +use super::common::SelectionMode; use super::common::{Mode, RepositoryMode}; type Selection = tui::Selection; @@ -42,7 +54,7 @@ pub struct App { } #[derive(Clone, Debug, Eq, PartialEq, Hash)] -pub enum Page { +pub enum AppPage { Browse, Help, } @@ -77,7 +89,7 @@ pub struct HelpState { pub struct State { mode: Mode, project: Project, - pages: PageStack, + pages: PageStack, browser: BrowserState, help: HelpState, } @@ -175,7 +187,7 @@ impl TryFrom<&Context> for State { Ok(Self { mode: context.mode.clone(), project, - pages: PageStack::new(vec![Page::Browse]), + pages: PageStack::new(vec![AppPage::Browse]), browser: BrowserState { items: notifications, selected: Some(0), @@ -257,7 +269,7 @@ impl store::State for State { None } Message::OpenHelp => { - self.pages.push(Page::Help); + self.pages.push(AppPage::Help); None } Message::LeavePage => { @@ -283,15 +295,12 @@ impl App { let tx = channel.tx.clone(); let window = Window::default() - .page( - Page::Browse, - BrowserPage::new(tx.clone()).to_widget(tx.clone()), - ) - .page(Page::Help, HelpPage::new(tx.clone()).to_widget(tx.clone())) + .page(AppPage::Browse, browser_page(&state, &channel)) + .page(AppPage::Help, help_page(&state, &channel)) .to_widget(tx.clone()) - .on_update(|state| { + .on_update(|state: &State| { WindowProps::default() - .current_page(state.pages.peek().unwrap_or(&Page::Browse).clone()) + .current_page(state.pages.peek().unwrap_or(&AppPage::Browse).clone()) .to_boxed_any() .into() }); @@ -299,3 +308,248 @@ impl App { tui::run(channel, state, window).await } } + +fn browser_page(_state: &State, channel: &Channel) -> Widget { + let tx = channel.tx.clone(); + + let content = Browser::new(tx.clone()) + .to_widget(tx.clone()) + .on_update(|state| BrowserProps::from(state).to_boxed_any().into()); + + let shortcuts = Shortcuts::default() + .to_widget(tx.clone()) + .on_update(|state: &State| { + let shortcuts = if state.browser.show_search { + vec![("esc", "cancel"), ("enter", "apply")] + } else { + match state.mode.selection() { + SelectionMode::Id => vec![("enter", "select"), ("/", "search")], + SelectionMode::Operation => vec![ + ("enter", "show"), + ("c", "clear"), + ("/", "search"), + ("?", "help"), + ], + } + }; + + ShortcutsProps::default() + .shortcuts(&shortcuts) + .to_boxed_any() + .into() + }); + + Page::default() + .content(content) + .shortcuts(shortcuts) + .to_widget(tx.clone()) + .on_event(|key, _, props| { + let default = PageProps::default(); + let props = props + .and_then(|props| props.inner_ref::()) + .unwrap_or(&default); + + if props.handle_keys { + match key { + Key::Esc | Key::Ctrl('c') => Some(Message::Exit { selection: None }), + Key::Char('?') => Some(Message::OpenHelp), + _ => None, + } + } else { + None + } + }) + .on_update(|state: &State| { + PageProps::default() + .page_size(state.browser.page_size) + .handle_keys(!state.browser.show_search) + .to_boxed_any() + .into() + }) + .on_render(|props, render| { + let default = PageProps::default(); + let props = props + .and_then(|props| props.inner_ref::()) + .unwrap_or(&default); + let page_size = render.area.height.saturating_sub(6) as usize; + + if page_size != props.page_size { + return Some(Message::BrowserPageSize(page_size)); + } + None + }) +} + +fn help_page(_state: &State, channel: &Channel) -> Widget { + let tx = channel.tx.clone(); + + let content = Container::default() + .header(Header::default().to_widget(tx.clone()).on_update(|_| { + HeaderProps::default() + .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec()) + .to_boxed_any() + .into() + })) + .content( + Paragraph::default() + .to_widget(tx.clone()) + .on_event(|_, s, _| { + Some(Message::ScrollHelp { + progress: s.and_then(|p| p.unwrap_usize()).unwrap_or_default(), + }) + }) + .on_update(|state: &State| { + ParagraphProps::default() + .text(&help_text()) + .page_size(state.help.page_size) + .to_boxed_any() + .into() + }), + ) + .footer( + Footer::default() + .to_widget(tx.clone()) + .on_update(|state: &State| { + FooterProps::default() + .columns( + [ + Column::new(Text::raw(""), Constraint::Fill(1)), + Column::new( + span::default(&format!("{}%", state.help.progress)).dim(), + Constraint::Min(4), + ), + ] + .to_vec(), + ) + .to_boxed_any() + .into() + }), + ) + .to_widget(tx.clone()); + + let shortcuts = Shortcuts::default().to_widget(tx.clone()).on_update(|_| { + ShortcutsProps::default() + .shortcuts(&[("?", "close")]) + .to_boxed_any() + .into() + }); + + Page::default() + .content(content) + .shortcuts(shortcuts) + .to_widget(tx.clone()) + .on_event(|key, _, _| match key { + Key::Esc | Key::Ctrl('c') => Some(Message::Exit { selection: None }), + Key::Char('?') => Some(Message::LeavePage), + _ => None, + }) + .on_update(|state: &State| { + PageProps::default() + .page_size(state.help.page_size) + .handle_keys(true) + .to_boxed_any() + .into() + }) + .on_render(|props, render| { + let default = PageProps::default(); + let props = props + .and_then(|props| props.inner_ref::()) + .unwrap_or(&default); + let page_size = render.area.height.saturating_sub(6) as usize; + + if page_size != props.page_size { + return Some(Message::HelpPageSize(page_size)); + } + None + }) +} + +fn help_text() -> Text<'static> { + Text::from( + [ + Line::from(Span::raw("Generic keybindings").cyan()), + Line::raw(""), + Line::from(vec![ + Span::raw(format!("{key:>10}", key = "↑,k")).gray(), + Span::raw(" "), + Span::raw("move cursor one line up").gray().dim(), + ]), + Line::from(vec![ + Span::raw(format!("{key:>10}", key = "↓,j")).gray(), + Span::raw(" "), + Span::raw("move cursor one line down").gray().dim(), + ]), + Line::from(vec![ + Span::raw(format!("{key:>10}", key = "PageUp")).gray(), + Span::raw(" "), + Span::raw("move cursor one page up").gray().dim(), + ]), + Line::from(vec![ + Span::raw(format!("{key:>10}", key = "PageDown")).gray(), + Span::raw(" "), + Span::raw("move cursor one page down").gray().dim(), + ]), + Line::from(vec![ + Span::raw(format!("{key:>10}", key = "Home")).gray(), + Span::raw(" "), + Span::raw("move cursor to the first line").gray().dim(), + ]), + Line::from(vec![ + Span::raw(format!("{key:>10}", key = "End")).gray(), + Span::raw(" "), + Span::raw("move cursor to the last line").gray().dim(), + ]), + Line::raw(""), + Line::from(Span::raw("Specific keybindings").cyan()), + Line::raw(""), + Line::from(vec![ + Span::raw(format!("{key:>10}", key = "enter")).gray(), + Span::raw(" "), + Span::raw("Select notification (if --mode id)").gray().dim(), + ]), + Line::from(vec![ + Span::raw(format!("{key:>10}", key = "enter")).gray(), + Span::raw(" "), + Span::raw("Show notification").gray().dim(), + ]), + Line::from(vec![ + Span::raw(format!("{key:>10}", key = "c")).gray(), + Span::raw(" "), + Span::raw("Clear notifications").gray().dim(), + ]), + Line::from(vec![ + Span::raw(format!("{key:>10}", key = "/")).gray(), + Span::raw(" "), + Span::raw("Search").gray().dim(), + ]), + Line::from(vec![ + Span::raw(format!("{key:>10}", key = "?")).gray(), + Span::raw(" "), + Span::raw("Show help").gray().dim(), + ]), + Line::from(vec![ + Span::raw(format!("{key:>10}", key = "Esc")).gray(), + Span::raw(" "), + Span::raw("Quit / cancel").gray().dim(), + ]), + Line::raw(""), + Line::from(Span::raw("Searching").cyan()), + Line::raw(""), + Line::from(vec![ + Span::raw(format!("{key:>10}", key = "Pattern")).gray(), + Span::raw(" "), + Span::raw("is: | is:patch | is:issue | ") + .gray() + .dim(), + ]), + Line::from(vec![ + Span::raw(format!("{key:>10}", key = "Example")).gray(), + Span::raw(" "), + Span::raw("is:unseen is:patch Print").gray().dim(), + ]), + Line::raw(""), + Line::raw(""), + ] + .to_vec(), + ) +} diff --git a/bin/commands/inbox/select/ui.rs b/bin/commands/inbox/select/ui.rs index c92001b6..ad9ed1aa 100644 --- a/bin/commands/inbox/select/ui.rs +++ b/bin/commands/inbox/select/ui.rs @@ -8,20 +8,17 @@ use termion::event::Key; use ratatui::layout::{Constraint, Layout}; use ratatui::style::Stylize; -use ratatui::text::{Line, Span, Text}; +use ratatui::text::{Line, Text}; use radicle_tui as tui; use tui::ui::items::{NotificationItem, NotificationItemFilter, NotificationState}; use tui::ui::span; use tui::ui::widget::container::{ - Column, Container, ContainerProps, Footer, FooterProps, Header, HeaderProps, SectionGroup, - SectionGroupProps, + Column, Container, ContainerProps, Footer, FooterProps, Header, HeaderProps, }; use tui::ui::widget::input::{TextField, TextFieldProps}; use tui::ui::widget::list::{Table, TableProps, TableUtils}; -use tui::ui::widget::text::{Paragraph, ParagraphProps}; -use tui::ui::widget::window::{Shortcuts, ShortcutsProps}; use tui::ui::widget::{self, ViewProps}; use tui::ui::widget::{RenderProps, ToWidget, View}; @@ -117,21 +114,24 @@ pub struct Browser { } impl Browser { - fn new(tx: UnboundedSender) -> Self { + pub fn new(tx: UnboundedSender) -> Self { Self { notifications: Container::default() - .header( - Header::default() - // .columns( - // [ - // Column::new("", Constraint::Length(0)), - // Column::new(Text::from(props.header), Constraint::Fill(1)), - // ] - // .to_vec(), - // ) - // .cutoff(props.cutoff, props.cutoff_after) - .to_widget(tx.clone()), - ) + .header(Header::default().to_widget(tx.clone()).on_update(|state| { + // TODO: remove and use state directly + let props = BrowserProps::from(state); + HeaderProps::default() + .columns( + [ + Column::new("", Constraint::Length(0)), + Column::new(Text::from(props.header), Constraint::Fill(1)), + ] + .to_vec(), + ) + .cutoff(props.cutoff, props.cutoff_after) + .to_boxed_any() + .into() + })) .content( Table::::default() .to_widget(tx.clone()) @@ -169,7 +169,23 @@ impl Browser { .to_boxed_any() .into() }), - search: Search::new(tx.clone()).to_widget(tx.clone()), + search: TextField::default() + .to_widget(tx.clone()) + .on_event(|key, s, _| match key { + Key::Esc => Some(Message::CloseSearch), + Key::Char('\n') => Some(Message::ApplySearch), + _ => Some(Message::UpdateSearch { + value: s.and_then(|i| i.unwrap_string()).unwrap_or_default(), + }), + }) + .on_update(|state: &State| { + TextFieldProps::default() + .text(&state.browser.search.read().to_string()) + .title("Search") + .inline(true) + .to_boxed_any() + .into() + }), } } } @@ -248,313 +264,6 @@ impl View for Browser { } } -#[derive(Clone, Default)] -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). - handle_keys: bool, - /// This pages' shortcuts. - shortcuts: Vec<(&'a str, &'a str)>, -} - -impl<'a> From<&State> for BrowserPageProps<'a> { - fn from(state: &State) -> Self { - Self { - page_size: state.browser.page_size, - handle_keys: !state.browser.show_search, - shortcuts: if state.browser.show_search { - vec![("esc", "cancel"), ("enter", "apply")] - } else { - match state.mode.selection() { - SelectionMode::Id => vec![("enter", "select"), ("/", "search")], - SelectionMode::Operation => vec![ - ("enter", "show"), - ("c", "clear"), - ("/", "search"), - ("?", "help"), - ], - } - }, - } - } -} - -pub struct BrowserPage<'a> { - /// Internal props - props: BrowserPageProps<'a>, - /// Sections widget - sections: Widget, - /// Shortcut widget - shortcuts: Widget, -} - -impl<'a: 'static> BrowserPage<'a> { - pub fn new(tx: UnboundedSender) -> Self { - Self { - props: BrowserPageProps::default(), - sections: SectionGroup::default() - .section(Browser::new(tx.clone()).to_widget(tx.clone())) - .to_widget(tx.clone()) - .on_update(|state| { - let props = BrowserPageProps::from(state); - SectionGroupProps::default() - .handle_keys(props.handle_keys) - .to_boxed_any() - .into() - }), - shortcuts: Shortcuts::default() - .to_widget(tx.clone()) - .on_update(|state| { - ShortcutsProps::default() - .shortcuts(&BrowserPageProps::from(state).shortcuts) - .to_boxed_any() - .into() - }), - } - } -} - -impl<'a: 'static> View for BrowserPage<'a> { - type Message = Message; - type State = State; - - fn handle_event(&mut self, _props: Option<&ViewProps>, key: Key) -> Option { - self.sections.handle_event(key); - - if self.props.handle_keys { - return match key { - Key::Esc | Key::Ctrl('c') => Some(Message::Exit { selection: None }), - Key::Char('?') => Some(Message::OpenHelp), - _ => None, - }; - } - - None - } - - fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) { - self.sections.update(state); - self.shortcuts.update(state); - } - - fn render(&self, _props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) { - let page_size = render.area.height.saturating_sub(6) as usize; - - let [content_area, shortcuts_area] = - Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(render.area); - - self.sections.render( - RenderProps::from(content_area) - .layout(Layout::horizontal([Constraint::Min(1)])) - .focus(true), - frame, - ); - self.shortcuts - .render(RenderProps::from(shortcuts_area), frame); - - // TODO: Find better solution - if page_size != self.props.page_size { - self.sections.send(Message::BrowserPageSize(page_size)); - } - } -} - -#[derive(Clone)] -pub struct SearchProps {} - -pub struct Search { - /// Internal props - props: SearchProps, - /// Search input field - input: Widget, -} - -impl Search { - fn new(tx: UnboundedSender) -> Self - where - Self: Sized, - { - Self { - props: SearchProps {}, - input: TextField::default() - .to_widget(tx.clone()) - .on_event(|_, s, _| { - Some(Message::UpdateSearch { - value: s.and_then(|i| i.unwrap_string()).unwrap_or_default(), - }) - }) - .on_update(|state: &State| { - TextFieldProps::default() - .text(&state.browser.search.read().to_string()) - .title("Search") - .inline(true) - .to_boxed_any() - .into() - }), - } - } -} - -impl View for Search { - type Message = Message; - type State = State; - - fn handle_event(&mut self, _props: Option<&ViewProps>, key: Key) -> Option { - match key { - Key::Esc => Some(Message::CloseSearch), - Key::Char('\n') => Some(Message::ApplySearch), - _ => { - self.input.handle_event(key); - None - } - } - } - - fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) { - self.input.update(state); - } - - fn render(&self, _props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) { - let layout = Layout::horizontal(Constraint::from_mins([0])) - .horizontal_margin(1) - .split(render.area); - - self.input.render(RenderProps::from(layout[0]), frame); - } -} - -#[derive(Clone, Default)] -struct HelpPageProps<'a> { - /// Current page size (height of table content). - page_size: usize, - /// Scroll progress of help paragraph. - help_progress: usize, - /// This pages' shortcuts. - shortcuts: Vec<(&'a str, &'a str)>, -} - -impl<'a> From<&State> for HelpPageProps<'a> { - fn from(state: &State) -> Self { - Self { - page_size: state.help.page_size, - help_progress: state.help.progress, - shortcuts: vec![("?", "close")], - } - } -} - -pub struct HelpPage { - /// Content widget - content: Widget, - /// Shortcut widget - shortcuts: Widget, -} - -impl HelpPage { - pub fn new(tx: UnboundedSender) -> Self - where - Self: Sized, - { - Self { - content: Container::default() - .header(Header::default().to_widget(tx.clone()).on_update(|_| { - HeaderProps::default() - .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec()) - .to_boxed_any() - .into() - })) - .content( - Paragraph::default() - .to_widget(tx.clone()) - .on_event(|_, s, _| { - Some(Message::ScrollHelp { - progress: s.and_then(|p| p.unwrap_usize()).unwrap_or_default(), - }) - }) - .on_update(|state| { - let props = HelpPageProps::from(state); - - ParagraphProps::default() - .text(&help_text()) - .page_size(props.page_size) - .to_boxed_any() - .into() - }), - ) - .footer(Footer::default().to_widget(tx.clone()).on_update(|state| { - let props = HelpPageProps::from(state); - - FooterProps::default() - .columns( - [ - Column::new(Text::raw(""), Constraint::Fill(1)), - Column::new( - span::default(&format!("{}%", props.help_progress)).dim(), - Constraint::Min(4), - ), - ] - .to_vec(), - ) - .to_boxed_any() - .into() - })) - .to_widget(tx.clone()), - shortcuts: Shortcuts::default() - .to_widget(tx.clone()) - .on_update(|state| { - ShortcutsProps::default() - .shortcuts(&HelpPageProps::from(state).shortcuts) - .to_boxed_any() - .into() - }), - } - } -} - -impl View for HelpPage { - type Message = Message; - type State = State; - - fn handle_event(&mut self, _props: Option<&ViewProps>, key: Key) -> Option { - match key { - Key::Esc | Key::Ctrl('c') => Some(Message::Exit { selection: None }), - Key::Char('?') => Some(Message::LeavePage), - _ => { - self.content.handle_event(key); - None - } - } - } - - fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) { - self.content.update(state); - self.shortcuts.update(state); - } - - fn render(&self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) { - let default = HelpPageProps::default(); - let props = props - .and_then(|props| props.inner_ref::()) - .unwrap_or(&default); - - let page_size = render.area.height.saturating_sub(6) as usize; - - let [content_area, shortcuts_area] = - Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(render.area); - - self.content - .render(RenderProps::from(content_area).focus(true), frame); - self.shortcuts - .render(RenderProps::from(content_area).focus(true), frame); - - // TODO: Find better solution - if page_size != props.page_size { - self.content.send(Message::HelpPageSize(page_size)); - } - } -} - fn browse_footer<'a>(props: &BrowserProps<'a>) -> Vec> { let search = Line::from(vec![ span::default(" Search ").cyan().dim().reversed(), @@ -614,93 +323,3 @@ fn browse_footer<'a>(props: &BrowserProps<'a>) -> Vec> { .to_vec(), } } - -fn help_text() -> Text<'static> { - Text::from( - [ - Line::from(Span::raw("Generic keybindings").cyan()), - Line::raw(""), - Line::from(vec![ - Span::raw(format!("{key:>10}", key = "↑,k")).gray(), - Span::raw(" "), - Span::raw("move cursor one line up").gray().dim(), - ]), - Line::from(vec![ - Span::raw(format!("{key:>10}", key = "↓,j")).gray(), - Span::raw(" "), - Span::raw("move cursor one line down").gray().dim(), - ]), - Line::from(vec![ - Span::raw(format!("{key:>10}", key = "PageUp")).gray(), - Span::raw(" "), - Span::raw("move cursor one page up").gray().dim(), - ]), - Line::from(vec![ - Span::raw(format!("{key:>10}", key = "PageDown")).gray(), - Span::raw(" "), - Span::raw("move cursor one page down").gray().dim(), - ]), - Line::from(vec![ - Span::raw(format!("{key:>10}", key = "Home")).gray(), - Span::raw(" "), - Span::raw("move cursor to the first line").gray().dim(), - ]), - Line::from(vec![ - Span::raw(format!("{key:>10}", key = "End")).gray(), - Span::raw(" "), - Span::raw("move cursor to the last line").gray().dim(), - ]), - Line::raw(""), - Line::from(Span::raw("Specific keybindings").cyan()), - Line::raw(""), - Line::from(vec![ - Span::raw(format!("{key:>10}", key = "enter")).gray(), - Span::raw(" "), - Span::raw("Select notification (if --mode id)").gray().dim(), - ]), - Line::from(vec![ - Span::raw(format!("{key:>10}", key = "enter")).gray(), - Span::raw(" "), - Span::raw("Show notification").gray().dim(), - ]), - Line::from(vec![ - Span::raw(format!("{key:>10}", key = "c")).gray(), - Span::raw(" "), - Span::raw("Clear notifications").gray().dim(), - ]), - Line::from(vec![ - Span::raw(format!("{key:>10}", key = "/")).gray(), - Span::raw(" "), - Span::raw("Search").gray().dim(), - ]), - Line::from(vec![ - Span::raw(format!("{key:>10}", key = "?")).gray(), - Span::raw(" "), - Span::raw("Show help").gray().dim(), - ]), - Line::from(vec![ - Span::raw(format!("{key:>10}", key = "Esc")).gray(), - Span::raw(" "), - Span::raw("Quit / cancel").gray().dim(), - ]), - Line::raw(""), - Line::from(Span::raw("Searching").cyan()), - Line::raw(""), - Line::from(vec![ - Span::raw(format!("{key:>10}", key = "Pattern")).gray(), - Span::raw(" "), - Span::raw("is: | is:patch | is:issue | ") - .gray() - .dim(), - ]), - Line::from(vec![ - Span::raw(format!("{key:>10}", key = "Example")).gray(), - Span::raw(" "), - Span::raw("is:unseen is:patch Print").gray().dim(), - ]), - Line::raw(""), - Line::raw(""), - ] - .to_vec(), - ) -}