inbox: Restructure application

This commit is contained in:
Erik Kundt
2024-04-20 18:11:13 +02:00
parent 2c69c19f41
commit 4eeb84bc4e
2 changed files with 470 additions and 355 deletions
+64 -82
View File
@@ -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<NotificationId>;
@@ -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<NotificationItem>,
selected: Option<usize>,
filter: NotificationItemFilter,
search: store::StateValue<String>,
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<NotificationItem>,
selected: Option<usize>,
}
#[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<String>,
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<NotificationItem> {
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<Page>,
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<Selection> },
Select { selected: Option<usize> },
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<Action, Selection> 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::<State, ListPage<terminal::Backend>, Selection>(
frontend.main_loop::<State, Window<terminal::Backend>, Selection>(
state_rx,
interrupt_rx.resubscribe()
),
+406 -273
View File
@@ -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<B> = widget::BoxedWidget<B, State, Action>;
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<B: Backend> {
impl Properties for WindowProps {}
pub struct Window<B: Backend> {
/// Internal properties
props: ListPageProps,
props: WindowProps,
/// Message sender
action_tx: UnboundedSender<Action>,
_action_tx: UnboundedSender<Action>,
/// Custom update handler
on_update: Option<UpdateCallback<State>>,
/// Additional custom event handler
on_change: Option<EventCallback<Action>>,
/// Patches widget
notifications: BoxedWidget<B>,
/// Search widget
search: BoxedWidget<B>,
/// Help widget
help: BoxedWidget<B>,
/// Shortcut widget
shortcuts: BoxedWidget<B>,
/// All pages known
pages: HashMap<Page, BoxedWidget<B>>,
}
impl<'a: 'static, B: Backend + 'a> View<State, Action> for ListPage<B> {
impl<'a: 'static, B> View<State, Action> for Window<B>
where
B: Backend + 'a,
{
fn new(state: &State, action_tx: UnboundedSender<Action>) -> 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::<ParagraphState>().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<B>,
),
(
Page::Help,
HelpPage::new(state, action_tx.clone()).to_boxed() as BoxedWidget<B>,
),
]),
on_update: None,
on_change: None,
}
}
fn on_change(mut self, callback: EventCallback<Action>) -> Self {
self.on_change = Some(callback);
self
}
fn on_update(mut self, callback: UpdateCallback<State>) -> 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<Action>) -> 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<B, State, Action> for ListPage<B>
impl<'a: 'static, B> Widget<B, State, Action> for Window<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<Box<dyn Any>>) {
let props = props
.and_then(|props| props.downcast_ref::<ListPageProps>())
.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<NotificationItem>,
selected: Option<usize>,
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 &notifications {
@@ -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<Action>,
/// Custom update handler
on_update: Option<UpdateCallback<State>>,
/// Additional custom event handler
on_change: Option<EventCallback<Action>>,
/// Table widget
table: BoxedWidget<B>,
/// Footer widget w/ context
footer: BoxedWidget<B>,
/// Notifications widget
notifications: BoxedWidget<B>,
/// Search widget
search: BoxedWidget<B>,
/// Shortcut widget
shortcuts: BoxedWidget<B>,
}
impl<'a: 'static, B> View<State, Action> for Notifications<'a, B>
impl<'a: 'static, B> View<State, Action> for BrowsePage<'a, B>
where
B: Backend + 'a,
{
fn new(state: &State, action_tx: UnboundedSender<Action>) -> 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::<Table<B, State, Action, NotificationItem>>::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::<TableState>().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::<Table<State, Action, NotificationItem>>::new(
Table::new(state, action_tx.clone())
.on_change(|state, action_tx| {
state.downcast_ref::<TableState>().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<usize>) -> Vec<Column<'a>> {
impl<'a, B: Backend> BrowsePage<'a, B> {
fn build_footer(props: &BrowsePageProps<'a>, selected: Option<usize>) -> Vec<Column<'a>> {
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<B, State, Action> for Notifications<'a, B>
impl<'a: 'static, B> Widget<B, State, Action> 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<Box<dyn Any>>) {
let props = props
.and_then(|props| props.downcast_ref::<NotificationsProps>())
.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<B: Backend> View<State, Action> for Search<B> {
state.downcast_ref::<TextFieldState>().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<B> Widget<B, State, Action> for Search<B>
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<Box<dyn Any>>) {
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<Action>,
/// Custom update handler
on_update: Option<UpdateCallback<State>>,
/// Additional custom event handler
on_change: Option<EventCallback<Action>>,
/// Content widget
content: BoxedWidget<B>,
/// Shortcut widget
shortcuts: BoxedWidget<B>,
}
impl<'a: 'static, B> View<State, Action> for HelpPage<'a, B>
where
B: Backend + 'a,
{
fn new(state: &State, action_tx: UnboundedSender<Action>) -> 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::<ParagraphState>().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<State>) -> Self {
self.on_update = Some(callback);
self
}
fn on_change(mut self, callback: EventCallback<Action>) -> 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<B, State, Action> for HelpPage<'a, B>
where
B: Backend + 'a,
{
fn render(&self, frame: &mut ratatui::Frame, area: Rect, props: Option<Box<dyn Any>>) {
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(),
)