From 4eeb84bc4e42605a8a833876cf86bf9c7d3af6cd Mon Sep 17 00:00:00 2001 From: Erik Kundt Date: Sat, 20 Apr 2024 18:03:03 +0200 Subject: [PATCH] inbox: Restructure application --- bin/commands/inbox/select.rs | 146 +++---- bin/commands/inbox/select/ui.rs | 679 +++++++++++++++++++------------- 2 files changed, 470 insertions(+), 355 deletions(-) diff --git a/bin/commands/inbox/select.rs b/bin/commands/inbox/select.rs index d5feda8d..2c662660 100644 --- a/bin/commands/inbox/select.rs +++ b/bin/commands/inbox/select.rs @@ -23,9 +23,10 @@ use tui::ui::items::{Filter, NotificationItem, NotificationItemFilter}; use tui::ui::Frontend; use tui::Exit; -use ui::ListPage; +use tui::PageStack; + +use crate::tui_inbox::select::ui::Window; -use super::common::SelectionMode; use super::common::{Mode, RepositoryMode}; type Selection = tui::Selection; @@ -42,66 +43,25 @@ pub struct App { context: Context, } +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub enum Page { + Browse, + Help, +} + #[derive(Clone, Debug)] -pub struct UIState { +pub struct BrowserState { + items: Vec, + selected: Option, + filter: NotificationItemFilter, + search: store::StateValue, page_size: usize, show_search: bool, } -impl Default for UIState { - fn default() -> Self { - Self { - page_size: 1, - show_search: false, - } - } -} - -#[derive(Clone, Debug)] -pub struct NotificationsState { - items: Vec, - selected: Option, -} - -#[derive(Clone, Debug)] -pub struct HelpState { - show: bool, - progress: usize, -} - -#[derive(Clone, Debug)] -pub struct State { - notifications: NotificationsState, - help: HelpState, - mode: Mode, - project: Project, - filter: NotificationItemFilter, - search: StateValue, - ui: UIState, -} - -impl State { - pub fn shortcuts(&self) -> Vec<(&str, &str)> { - if self.ui.show_search { - vec![("esc", "cancel"), ("enter", "apply")] - } else if self.help.show { - vec![("?", "close")] - } else { - match self.mode.selection() { - SelectionMode::Id => vec![("enter", "select"), ("/", "search")], - SelectionMode::Operation => vec![ - ("enter", "show"), - ("c", "clear"), - ("/", "search"), - ("?", "help"), - ], - } - } - } - +impl BrowserState { pub fn notifications(&self) -> Vec { - self.notifications - .items + self.items .iter() .filter(|patch| self.filter.matches(patch)) .cloned() @@ -109,6 +69,21 @@ impl State { } } +#[derive(Clone, Debug)] +pub struct HelpState { + progress: usize, + page_size: usize, +} + +#[derive(Clone, Debug)] +pub struct State { + mode: Mode, + project: Project, + pages: PageStack, + browser: BrowserState, + help: HelpState, +} + impl TryFrom<&Context> for State { type Error = anyhow::Error; @@ -200,19 +175,21 @@ impl TryFrom<&Context> for State { } Ok(Self { - notifications: NotificationsState { + mode: context.mode.clone(), + project, + pages: PageStack::new(vec![Page::Browse]), + browser: BrowserState { items: notifications, selected: Some(0), + filter, + search, + show_search: false, + page_size: 1, }, help: HelpState { - show: false, progress: 0, + page_size: 1, }, - mode: mode.clone(), - project, - filter, - search, - ui: UIState::default(), }) } } @@ -220,13 +197,14 @@ impl TryFrom<&Context> for State { pub enum Action { Exit { selection: Option }, Select { selected: Option }, - PageSize(usize), + BrowserPageSize(usize), + HelpPageSize(usize), OpenSearch, UpdateSearch { value: String }, ApplySearch, CloseSearch, OpenHelp, - CloseHelp, + LeavePage, ScrollHelp { progress: usize }, } @@ -237,43 +215,47 @@ impl store::State for State { match action { Action::Exit { selection } => Some(Exit { value: selection }), Action::Select { selected } => { - self.notifications.selected = selected; + self.browser.selected = selected; None } - Action::PageSize(size) => { - self.ui.page_size = size; + Action::BrowserPageSize(size) => { + self.browser.page_size = size; + None + } + Action::HelpPageSize(size) => { + self.help.page_size = size; None } Action::OpenSearch => { - self.ui.show_search = true; + self.browser.show_search = true; None } Action::UpdateSearch { value } => { - self.search.write(value); - self.filter = - NotificationItemFilter::from_str(&self.search.read()).unwrap_or_default(); + self.browser.search.write(value); + self.browser.filter = NotificationItemFilter::from_str(&self.browser.search.read()) + .unwrap_or_default(); None } Action::ApplySearch => { - self.search.apply(); - self.ui.show_search = false; + self.browser.search.apply(); + self.browser.show_search = false; None } Action::CloseSearch => { - self.search.reset(); - self.ui.show_search = false; - self.filter = - NotificationItemFilter::from_str(&self.search.read()).unwrap_or_default(); + self.browser.search.reset(); + self.browser.show_search = false; + self.browser.filter = NotificationItemFilter::from_str(&self.browser.search.read()) + .unwrap_or_default(); None } Action::OpenHelp => { - self.help.show = true; + self.pages.push(Page::Help); None } - Action::CloseHelp => { - self.help.show = false; + Action::LeavePage => { + self.pages.pop(); None } Action::ScrollHelp { progress } => { @@ -297,7 +279,7 @@ impl App { tokio::try_join!( store.main_loop(state, terminator, action_rx, interrupt_rx.resubscribe()), - frontend.main_loop::, Selection>( + frontend.main_loop::, Selection>( state_rx, interrupt_rx.resubscribe() ), diff --git a/bin/commands/inbox/select/ui.rs b/bin/commands/inbox/select/ui.rs index f3102277..12e62add 100644 --- a/bin/commands/inbox/select/ui.rs +++ b/bin/commands/inbox/select/ui.rs @@ -16,7 +16,9 @@ use radicle_tui as tui; use tui::ui::items::{NotificationItem, NotificationItemFilter, NotificationState}; use tui::ui::span; -use tui::ui::widget::container::{Container, Footer, FooterProps, Header, HeaderProps}; +use tui::ui::widget::container::{ + Container, ContainerProps, Footer, FooterProps, Header, HeaderProps, +}; use tui::ui::widget::input::{TextField, TextFieldProps, TextFieldState}; use tui::ui::widget::text::{Paragraph, ParagraphProps, ParagraphState}; use tui::ui::widget::{self, TableUtils}; @@ -28,217 +30,108 @@ use tui::Selection; use crate::tui_inbox::common::{InboxOperation, Mode, RepositoryMode, SelectionMode}; -use super::{Action, State}; +use super::{Action, Page, State}; type BoxedWidget = widget::BoxedWidget; -pub struct ListPageProps { - show_search: bool, - show_help: bool, - help_progress: usize, - page_size: usize, - focus: bool, +#[derive(Clone)] +pub struct WindowProps { + page: Page, } -impl From<&State> for ListPageProps { +impl From<&State> for WindowProps { fn from(state: &State) -> Self { Self { - show_search: state.ui.show_search, - show_help: state.help.show, - help_progress: state.help.progress, - page_size: state.ui.page_size, - focus: false, + page: state.pages.peek().unwrap_or(&Page::Browse).clone(), } } } -pub struct ListPage { +impl Properties for WindowProps {} + +pub struct Window { /// Internal properties - props: ListPageProps, + props: WindowProps, /// Message sender - action_tx: UnboundedSender, + _action_tx: UnboundedSender, /// Custom update handler on_update: Option>, /// Additional custom event handler on_change: Option>, - /// Patches widget - notifications: BoxedWidget, - /// Search widget - search: BoxedWidget, - /// Help widget - help: BoxedWidget, - /// Shortcut widget - shortcuts: BoxedWidget, + /// All pages known + pages: HashMap>, } -impl<'a: 'static, B: Backend + 'a> View for ListPage { +impl<'a: 'static, B> View for Window +where + B: Backend + 'a, +{ fn new(state: &State, action_tx: UnboundedSender) -> Self where Self: Sized, { Self { - action_tx: action_tx.clone(), - props: ListPageProps::from(state), - notifications: Notifications::new(state, action_tx.clone()).to_boxed(), - search: Search::new(state, action_tx.clone()).to_boxed(), - help: Container::new(state, action_tx.clone()) - .header( - Header::new(state, action_tx.clone()) - .on_update(|state| { - let props = ListPageProps::from(state); - - HeaderProps::default() - .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec()) - .focus(props.focus) - .to_boxed() - }) - .to_boxed(), - ) - .content( - Paragraph::new(state, action_tx.clone()) - .on_update(|state| { - let props = ListPageProps::from(state); - - ParagraphProps::default() - .text(&help_text()) - .page_size(props.page_size) - .focus(props.focus) - .to_boxed() - }) - .on_change(|state, action_tx| { - state.downcast_ref::().and_then(|state| { - action_tx - .send(Action::ScrollHelp { - progress: state.progress, - }) - .ok() - }); - }) - .to_boxed(), - ) - .footer( - Footer::new(state, action_tx.clone()) - .on_update(|state| { - let props = ListPageProps::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(), - ) - .focus(props.focus) - .to_boxed() - }) - .to_boxed(), - ) - .to_boxed(), - shortcuts: Shortcuts::new(state, action_tx.clone()) - .on_update(|state| { - ShortcutsProps::default() - .shortcuts(&state.shortcuts()) - .to_boxed() - }) - .to_boxed(), + _action_tx: action_tx.clone(), + props: WindowProps::from(state), + pages: HashMap::from([ + ( + Page::Browse, + BrowsePage::new(state, action_tx.clone()).to_boxed() as BoxedWidget, + ), + ( + Page::Help, + HelpPage::new(state, action_tx.clone()).to_boxed() as BoxedWidget, + ), + ]), on_update: None, on_change: None, } } - fn on_change(mut self, callback: EventCallback) -> Self { - self.on_change = Some(callback); - self - } - fn on_update(mut self, callback: UpdateCallback) -> Self { self.on_update = Some(callback); self } - fn update(&mut self, state: &State) { - self.props = ListPageProps::from(state); + fn on_change(mut self, callback: EventCallback) -> Self { + self.on_change = Some(callback); + self + } - self.notifications.update(state); - self.search.update(state); - self.help.update(state); - self.shortcuts.update(state); + fn update(&mut self, state: &State) { + self.props = WindowProps::from(state); + + if let Some(page) = self.pages.get_mut(&self.props.page) { + page.update(state); + } } fn handle_key_event(&mut self, key: termion::event::Key) { - if self.props.show_search { - self.search.handle_key_event(key); - } else if self.props.show_help { - match key { - Key::Esc | Key::Ctrl('c') => { - let _ = self.action_tx.send(Action::Exit { selection: None }); - } - Key::Char('?') => { - let _ = self.action_tx.send(Action::CloseHelp); - } - _ => { - self.help.handle_key_event(key); - } - } - } else { - match key { - Key::Esc | Key::Ctrl('c') => { - let _ = self.action_tx.send(Action::Exit { selection: None }); - } - Key::Char('/') => { - let _ = self.action_tx.send(Action::OpenSearch); - } - Key::Char('?') => { - let _ = self.action_tx.send(Action::OpenHelp); - } - _ => { - self.notifications.handle_key_event(key); - } - } + if let Some(page) = self.pages.get_mut(&self.props.page) { + page.handle_key_event(key); } } } -impl<'a: 'static, B> Widget for ListPage +impl<'a: 'static, B> Widget for Window where B: Backend + 'a, { - fn render(&self, frame: &mut ratatui::Frame, _area: Rect, props: Option<&dyn Any>) { + fn render(&self, frame: &mut ratatui::Frame, _area: Rect, props: Option>) { let props = props - .and_then(|props| props.downcast_ref::()) - .unwrap_or(&self.props); + .and_then(|props| WindowProps::from_boxed_any(props)) + .unwrap_or(self.props.clone()); let area = frame.size(); - let layout = tui::ui::layout::default_page(area, 0u16, 1u16); - let page_size = area.height.saturating_sub(6) as usize; - - if props.show_search { - let component_layout = Layout::vertical([Constraint::Min(1), Constraint::Length(2)]) - .split(layout.component); - - self.notifications.render(frame, component_layout[0], None); - self.search.render(frame, component_layout[1], None); - } else if self.props.show_help { - self.help.render(frame, layout.component, None); - } else { - self.notifications.render(frame, layout.component, None); - } - - self.shortcuts.render(frame, layout.shortcuts, None); - - if page_size != props.page_size { - let _ = self.action_tx.send(Action::PageSize(page_size)); + if let Some(page) = self.pages.get(&props.page) { + page.render(frame, area, None); } } } -struct NotificationsProps<'a> { +#[derive(Clone)] +struct BrowsePageProps<'a> { notifications: Vec, selected: Option, mode: Mode, @@ -250,14 +143,15 @@ struct NotificationsProps<'a> { page_size: usize, search: String, show_search: bool, + shortcuts: Vec<(&'a str, &'a str)>, } -impl<'a> From<&State> for NotificationsProps<'a> { +impl<'a> From<&State> for BrowsePageProps<'a> { fn from(state: &State) -> Self { let mut seen = 0; let mut unseen = 0; - let notifications = state.notifications(); + let notifications = state.browser.notifications(); // Compute statistics for notification in ¬ifications { @@ -272,7 +166,6 @@ impl<'a> From<&State> for NotificationsProps<'a> { Self { notifications, - selected: state.notifications.selected, mode: state.mode.clone(), stats, columns: [ @@ -291,36 +184,48 @@ impl<'a> From<&State> for NotificationsProps<'a> { cutoff: 200, cutoff_after: 5, focus: false, - page_size: state.ui.page_size, - show_search: state.ui.show_search, - search: state.search.read(), + search: state.browser.search.read(), + page_size: state.browser.page_size, + show_search: state.browser.show_search, + selected: state.browser.selected, + shortcuts: match state.mode.selection() { + SelectionMode::Id => vec![("enter", "select"), ("/", "search")], + SelectionMode::Operation => vec![ + ("enter", "show"), + ("c", "clear"), + ("/", "search"), + ("?", "help"), + ], + }, } } } -impl<'a> Properties for NotificationsProps<'a> {} +impl<'a> Properties for BrowsePageProps<'a> {} -struct Notifications<'a, B: Backend> { +struct BrowsePage<'a, B> { /// Internal properties - props: NotificationsProps<'a>, + props: BrowsePageProps<'a>, /// Message sender action_tx: UnboundedSender, /// Custom update handler on_update: Option>, /// Additional custom event handler on_change: Option>, - /// Table widget - table: BoxedWidget, - /// Footer widget w/ context - footer: BoxedWidget, + /// Notifications widget + notifications: BoxedWidget, + /// Search widget + search: BoxedWidget, + /// Shortcut widget + shortcuts: BoxedWidget, } -impl<'a: 'static, B> View for Notifications<'a, B> +impl<'a: 'static, B> View for BrowsePage<'a, B> where B: Backend + 'a, { fn new(state: &State, action_tx: UnboundedSender) -> Self { - let props = NotificationsProps::from(state); + let props = BrowsePageProps::from(state); let name = match state.mode.repository() { RepositoryMode::Contextual => state.project.name().to_string(), RepositoryMode::All => "All repositories".to_string(), @@ -329,44 +234,58 @@ where Self { action_tx: action_tx.clone(), - props: NotificationsProps::from(state), - table: Box::>::new( - Table::new(state, action_tx.clone()) - .header( - Header::new(state, action_tx.clone()) - .columns( - [ - Column::new("", Constraint::Length(0)), - Column::new(Text::from(name), Constraint::Fill(1)), - ] - .to_vec(), - ) - .cutoff(props.cutoff, props.cutoff_after) - .focus(props.focus) - .to_boxed(), - ) - .on_change(|state, action_tx| { - state.downcast_ref::().and_then(|state| { - action_tx - .send(Action::Select { - selected: state.selected(), - }) - .ok() - }); - }) - .on_update(|state| { - let props = NotificationsProps::from(state); + props: BrowsePageProps::from(state), + notifications: Container::new(state, action_tx.clone()) + .header( + Header::new(state, action_tx.clone()) + .columns( + [ + Column::new("", Constraint::Length(0)), + Column::new(Text::from(name), Constraint::Fill(1)), + ] + .to_vec(), + ) + .cutoff(props.cutoff, props.cutoff_after) + .focus(props.focus) + .to_boxed(), + ) + .content(Box::>::new( + Table::new(state, action_tx.clone()) + .on_change(|state, action_tx| { + state.downcast_ref::().and_then(|state| { + action_tx + .send(Action::Select { + selected: state.selected(), + }) + .ok() + }); + }) + .on_update(|state| { + let props = BrowsePageProps::from(state); - TableProps::default() - .columns(props.columns) - .items(state.notifications()) - .footer(!state.ui.show_search) - .page_size(state.ui.page_size) - .cutoff(props.cutoff, props.cutoff_after) - .to_boxed() - }), - ), - footer: Footer::new(state, action_tx).to_boxed(), + TableProps::default() + .columns(props.columns) + .items(state.browser.notifications()) + .footer(!state.browser.show_search) + .page_size(state.browser.page_size) + .cutoff(props.cutoff, props.cutoff_after) + .to_boxed() + }), + )) + .footer( + Footer::new(state, action_tx.clone()) + .on_update(|state| { + let props = BrowsePageProps::from(state); + + FooterProps::default() + .columns(Self::build_footer(&props, props.selected)) + .to_boxed() + }) + .to_boxed(), + ) + .to_boxed(), + search: Search::new(state, action_tx.clone()).to_boxed(), + shortcuts: Shortcuts::new(state, action_tx.clone()).to_boxed(), on_update: None, on_change: None, } @@ -384,58 +303,72 @@ where fn update(&mut self, state: &State) { // TODO call mapper here instead? - self.props = NotificationsProps::from(state); + self.props = BrowsePageProps::from(state); - self.table.update(state); - self.footer.update(state); + self.notifications.update(state); + self.search.update(state); + self.shortcuts.update(state); } fn handle_key_event(&mut self, key: Key) { - match key { - Key::Char('\n') => { - self.props - .selected - .and_then(|selected| self.props.notifications.get(selected)) - .and_then(|notif| { - let selection = match self.props.mode.selection() { - SelectionMode::Operation => Selection::default() - .with_operation(InboxOperation::Show.to_string()) - .with_id(notif.id), - SelectionMode::Id => Selection::default().with_id(notif.id), - }; + if self.props.show_search { + self.search.handle_key_event(key); + } else { + match key { + Key::Esc | Key::Ctrl('c') => { + let _ = self.action_tx.send(Action::Exit { selection: None }); + } + Key::Char('?') => { + let _ = self.action_tx.send(Action::OpenHelp); + } + Key::Char('/') => { + let _ = self.action_tx.send(Action::OpenSearch); + } + Key::Char('\n') => { + self.props + .selected + .and_then(|selected| self.props.notifications.get(selected)) + .and_then(|notif| { + let selection = match self.props.mode.selection() { + SelectionMode::Operation => Selection::default() + .with_operation(InboxOperation::Show.to_string()) + .with_id(notif.id), + SelectionMode::Id => Selection::default().with_id(notif.id), + }; - self.action_tx - .send(Action::Exit { - selection: Some(selection), - }) - .ok() - }); - } - Key::Char('c') => { - self.props - .selected - .and_then(|selected| self.props.notifications.get(selected)) - .and_then(|notif| { - self.action_tx - .send(Action::Exit { - selection: Some( - Selection::default() - .with_operation(InboxOperation::Clear.to_string()) - .with_id(notif.id), - ), - }) - .ok() - }); - } - _ => { - self.table.handle_key_event(key); + self.action_tx + .send(Action::Exit { + selection: Some(selection), + }) + .ok() + }); + } + Key::Char('c') => { + self.props + .selected + .and_then(|selected| self.props.notifications.get(selected)) + .and_then(|notif| { + self.action_tx + .send(Action::Exit { + selection: Some( + Selection::default() + .with_operation(InboxOperation::Clear.to_string()) + .with_id(notif.id), + ), + }) + .ok() + }); + } + _ => { + self.notifications.handle_key_event(key); + } } } } } -impl<'a, B: Backend> Notifications<'a, B> { - fn build_footer(props: &NotificationsProps<'a>, selected: Option) -> Vec> { +impl<'a, B: Backend> BrowsePage<'a, B> { + fn build_footer(props: &BrowsePageProps<'a>, selected: Option) -> Vec> { let search = Line::from( [ span::default(" Search ".to_string()) @@ -509,36 +442,58 @@ impl<'a, B: Backend> Notifications<'a, B> { } } -impl<'a: 'static, B> Widget for Notifications<'a, B> +impl<'a: 'static, B> Widget for BrowsePage<'a, B> where B: Backend + 'a, { - fn render(&self, frame: &mut ratatui::Frame, area: Rect, props: Option<&dyn Any>) { + fn render(&self, frame: &mut ratatui::Frame, area: Rect, props: Option>) { let props = props - .and_then(|props| props.downcast_ref::()) - .unwrap_or(&self.props); + .and_then(|props| BrowsePageProps::from_boxed_any(props)) + .unwrap_or(self.props.clone()); - let header_height = 3_usize; + let page_size = area.height.saturating_sub(6) as usize; - let page_size = if props.show_search { - self.table.render(frame, area, None); + let [content_area, shortcuts_area] = + Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(area); - (area.height as usize).saturating_sub(header_height) - } else { - let layout = Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).split(area); + if props.show_search { + let [table_area, search_area] = + Layout::vertical([Constraint::Min(1), Constraint::Length(2)]).areas(content_area); - self.table.render(frame, layout[0], None); - self.footer.render( + self.notifications.render( frame, - layout[1], - Some(&FooterProps::default().columns(Self::build_footer(props, props.selected))), + table_area, + Some( + ContainerProps::default() + .hide_footer(props.show_search) + .to_boxed(), + ), ); + self.search.render(frame, search_area, None); + } else { + self.notifications.render( + frame, + content_area, + Some( + ContainerProps::default() + .hide_footer(props.show_search) + .to_boxed(), + ), + ); + } - (area.height as usize).saturating_sub(header_height) - }; + self.shortcuts.render( + frame, + shortcuts_area, + Some( + ShortcutsProps::default() + .shortcuts(&props.shortcuts) + .to_boxed(), + ), + ); if page_size != props.page_size { - let _ = self.action_tx.send(Action::PageSize(page_size)); + let _ = self.action_tx.send(Action::BrowserPageSize(page_size)); } } } @@ -564,14 +519,14 @@ impl View for Search { state.downcast_ref::().and_then(|state| { action_tx .send(Action::UpdateSearch { - value: state.text.clone(), + value: state.text.clone().unwrap_or_default(), }) .ok() }); }) .on_update(|state| { TextFieldProps::default() - .text(&state.search.read().to_string()) + .text(&state.browser.search.read().to_string()) .title("Search") .inline(true) .to_boxed() @@ -618,7 +573,7 @@ impl Widget for Search where B: Backend, { - fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: Option<&dyn Any>) { + fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: Option>) { let layout = Layout::horizontal(Constraint::from_mins([0])) .horizontal_margin(1) .split(area); @@ -627,6 +582,182 @@ where } } +#[derive(Clone)] +struct HelpPageProps<'a> { + focus: bool, + page_size: usize, + help_progress: usize, + shortcuts: Vec<(&'a str, &'a str)>, +} + +impl<'a> From<&State> for HelpPageProps<'a> { + fn from(state: &State) -> Self { + Self { + focus: false, + page_size: state.help.page_size, + help_progress: state.help.progress, + shortcuts: vec![("?", "close")], + } + } +} + +impl<'a> Properties for HelpPageProps<'a> {} + +pub struct HelpPage<'a, B> +where + B: Backend, +{ + /// Internal properties + props: HelpPageProps<'a>, + /// Message sender + action_tx: UnboundedSender, + /// Custom update handler + on_update: Option>, + /// Additional custom event handler + on_change: Option>, + /// Content widget + content: BoxedWidget, + /// Shortcut widget + shortcuts: BoxedWidget, +} + +impl<'a: 'static, B> View for HelpPage<'a, B> +where + B: Backend + 'a, +{ + fn new(state: &State, action_tx: UnboundedSender) -> Self + where + Self: Sized, + { + Self { + action_tx: action_tx.clone(), + props: HelpPageProps::from(state), + content: Container::new(state, action_tx.clone()) + .header( + Header::new(state, action_tx.clone()) + .on_update(|state| { + let props = HelpPageProps::from(state); + + HeaderProps::default() + .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec()) + .focus(props.focus) + .to_boxed() + }) + .to_boxed(), + ) + .content( + Paragraph::new(state, action_tx.clone()) + .on_update(|state| { + let props = HelpPageProps::from(state); + + ParagraphProps::default() + .text(&help_text()) + .page_size(props.page_size) + .focus(props.focus) + .to_boxed() + }) + .on_change(|state, action_tx| { + state.downcast_ref::().and_then(|state| { + action_tx + .send(Action::ScrollHelp { + progress: state.progress, + }) + .ok() + }); + }) + .to_boxed(), + ) + .footer( + Footer::new(state, action_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(), + ) + .focus(props.focus) + .to_boxed() + }) + .to_boxed(), + ) + .to_boxed(), + shortcuts: Shortcuts::new(state, action_tx.clone()).to_boxed(), + on_update: None, + on_change: None, + } + } + + fn on_update(mut self, callback: UpdateCallback) -> Self { + self.on_update = Some(callback); + self + } + + fn on_change(mut self, callback: EventCallback) -> Self { + self.on_change = Some(callback); + self + } + + fn update(&mut self, state: &State) { + self.props = HelpPageProps::from(state); + + self.content.update(state); + } + + 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('?') => { + let _ = self.action_tx.send(Action::LeavePage); + } + _ => { + self.content.handle_key_event(key); + } + } + } +} + +impl<'a: 'static, B> Widget for HelpPage<'a, B> +where + B: Backend + 'a, +{ + fn render(&self, frame: &mut ratatui::Frame, area: Rect, props: Option>) { + let props = props + .and_then(|props| HelpPageProps::from_boxed_any(props)) + .unwrap_or(self.props.clone()); + + let page_size = area.height.saturating_sub(6) as usize; + + let [content_area, shortcuts_area] = + Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(area); + + self.content.render(frame, content_area, None); + self.shortcuts.render( + frame, + shortcuts_area, + Some( + ShortcutsProps::default() + .shortcuts(&props.shortcuts) + .to_boxed(), + ), + ); + + if page_size != props.page_size { + let _ = self.action_tx.send(Action::HelpPageSize(page_size)); + } + } +} + fn help_text() -> Text<'static> { Text::from( [ @@ -752,6 +883,8 @@ fn help_text() -> Text<'static> { ] .to_vec(), ), + Line::raw(""), + Line::raw(""), ] .to_vec(), )