Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
issue: Restructure application
Erik Kundt committed 2 years ago
commit e2346edc6fbc6466af6a78c0f9cd6a4612d1a5fe
parent 4eeb84bc4e42605a8a833876cf86bf9c7d3af6cd
2 files changed +456 -333
modified bin/commands/issue/select.rs
@@ -12,7 +12,6 @@ use radicle::Profile;
use radicle_tui as tui;

use tui::cob::issue;
-
use tui::store;
use tui::store::StateValue;
use tui::task;
use tui::task::Interrupted;
@@ -20,10 +19,10 @@ use tui::terminal;
use tui::ui::items::{Filter, IssueItem, IssueItemFilter};
use tui::ui::Frontend;
use tui::Exit;
-

-
use ui::ListPage;
+
use tui::{store, PageStack};

use super::common::Mode;
+
use super::select::ui::Window;

type Selection = tui::Selection<IssueId>;

@@ -38,70 +37,44 @@ 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<IssueItem>,
+
    selected: Option<usize>,
+
    filter: IssueItemFilter,
+
    search: store::StateValue<String>,
    page_size: usize,
    show_search: bool,
}

-
impl Default for UIState {
-
    fn default() -> Self {
-
        Self {
-
            page_size: 1,
-
            show_search: false,
-
        }
+
impl BrowserState {
+
    pub fn issues(&self) -> Vec<IssueItem> {
+
        self.items
+
            .iter()
+
            .filter(|patch| self.filter.matches(patch))
+
            .cloned()
+
            .collect()
    }
}

#[derive(Clone, Debug)]
-
pub struct IssuesState {
-
    items: Vec<IssueItem>,
-
    selected: Option<usize>,
-
}
-

-
#[derive(Clone, Debug)]
pub struct HelpState {
-
    show: bool,
    progress: usize,
+
    page_size: usize,
}

#[derive(Clone, Debug)]
pub struct State {
-
    issues: IssuesState,
-
    help: HelpState,
    mode: Mode,
-
    filter: IssueItemFilter,
-
    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 {
-
                Mode::Id => vec![("enter", "select"), ("/", "search")],
-
                Mode::Operation => vec![
-
                    ("enter", "show"),
-
                    ("e", "edit"),
-
                    ("/", "search"),
-
                    ("?", "help"),
-
                ],
-
            }
-
        }
-
    }
-

-
    pub fn issues(&self) -> Vec<IssueItem> {
-
        self.issues
-
            .items
-
            .iter()
-
            .filter(|issue| self.filter.matches(issue))
-
            .cloned()
-
            .collect()
-
    }
+
    pages: PageStack<Page>,
+
    browser: BrowserState,
+
    help: HelpState,
}

impl TryFrom<&Context> for State {
@@ -122,18 +95,20 @@ impl TryFrom<&Context> for State {
        items.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));

        Ok(Self {
-
            issues: IssuesState {
+
            mode: context.mode.clone(),
+
            pages: PageStack::new(vec![Page::Browse]),
+
            browser: BrowserState {
                items,
                selected: Some(0),
+
                filter,
+
                search,
+
                show_search: false,
+
                page_size: 1,
            },
            help: HelpState {
-
                show: false,
                progress: 0,
+
                page_size: 1,
            },
-
            mode: context.mode.clone(),
-
            filter,
-
            search,
-
            ui: UIState::default(),
        })
    }
}
@@ -141,13 +116,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 },
}

@@ -158,41 +134,47 @@ impl store::State<Action, Selection> for State {
        match action {
            Action::Exit { selection } => Some(Exit { value: selection }),
            Action::Select { selected } => {
-
                self.issues.selected = selected;
+
                self.browser.selected = selected;
+
                None
+
            }
+
            Action::BrowserPageSize(size) => {
+
                self.browser.page_size = size;
                None
            }
-
            Action::PageSize(size) => {
-
                self.ui.page_size = size;
+
            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 = IssueItemFilter::from_str(&self.search.read()).unwrap_or_default();
+
                self.browser.search.write(value);
+
                self.browser.filter =
+
                    IssueItemFilter::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 = IssueItemFilter::from_str(&self.search.read()).unwrap_or_default();
+
                self.browser.search.reset();
+
                self.browser.show_search = false;
+
                self.browser.filter =
+
                    IssueItemFilter::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 } => {
@@ -216,7 +198,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()
            ),
modified bin/commands/issue/select/ui.rs
@@ -19,7 +19,9 @@ use radicle_tui as tui;
use tui::ui::items::{IssueItem, IssueItemFilter};
use tui::ui::span;
use tui::ui::widget;
-
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::{
@@ -31,50 +33,39 @@ use tui::Selection;
use crate::tui_issue::common::IssueOperation;
use crate::tui_issue::common::Mode;

-
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
-
    issues: 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> View<State, Action> for ListPage<B>
+
impl<'a: 'static, B> View<State, Action> for Window<B>
where
    B: Backend + 'a,
{
@@ -83,159 +74,67 @@ where
        Self: Sized,
    {
        Self {
-
            action_tx: action_tx.clone(),
-
            props: ListPageProps::from(state),
-
            issues: Issues::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);
+
    fn on_update(mut self, callback: UpdateCallback<State>) -> Self {
+
        self.on_update = Some(callback);
        self
    }

-
    fn on_update(mut self, callback: UpdateCallback<State>) -> Self {
-
        self.on_update = Some(callback);
+
    fn on_change(mut self, callback: EventCallback<Action>) -> Self {
+
        self.on_change = Some(callback);
        self
    }

    fn update(&mut self, state: &State) {
-
        self.props = ListPageProps::from(state);
+
        self.props = WindowProps::from(state);

-
        self.issues.update(state);
-
        self.search.update(state);
-
        self.help.update(state);
-
        self.shortcuts.update(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.issues.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>) {
-
        let area = frame.size();
-
        let layout = tui::ui::layout::default_page(area, 0u16, 1u16);
+
    fn render(&self, frame: &mut ratatui::Frame, _area: Rect, props: Option<Box<dyn Any>>) {
+
        let props = props
+
            .and_then(|props| WindowProps::from_boxed_any(props))
+
            .unwrap_or(self.props.clone());

-
        if self.props.show_search {
-
            let component_layout = Layout::vertical([Constraint::Min(1), Constraint::Length(2)])
-
                .split(layout.component);
+
        let area = frame.size();

-
            self.issues.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.issues.render(frame, layout.component, None);
+
        if let Some(page) = self.pages.get(&props.page) {
+
            page.render(frame, area, None);
        }
-

-
        self.shortcuts.render(frame, layout.shortcuts, None);
    }
}

#[derive(Clone)]
-
struct IssuesProps<'a> {
+
struct BrowsePageProps<'a> {
    mode: Mode,
    issues: Vec<IssueItem>,
    selected: Option<usize>,
@@ -247,13 +146,14 @@ struct IssuesProps<'a> {
    focus: bool,
    page_size: usize,
    show_search: bool,
+
    shortcuts: Vec<(&'a str, &'a str)>,
}

-
impl<'a> From<&State> for IssuesProps<'a> {
+
impl<'a> From<&State> for BrowsePageProps<'a> {
    fn from(state: &State) -> Self {
        use radicle::issue::State;

-
        let issues = state.issues();
+
        let issues = state.browser.issues();

        let mut open = 0;
        let mut other = 0;
@@ -283,7 +183,7 @@ impl<'a> From<&State> for IssuesProps<'a> {
        Self {
            mode: state.mode.clone(),
            issues,
-
            search: state.search.read(),
+
            search: state.browser.search.read(),
            columns: [
                Column::new(" ● ", Constraint::Length(3)),
                Column::new("ID", Constraint::Length(8)),
@@ -299,69 +199,96 @@ impl<'a> From<&State> for IssuesProps<'a> {
            cutoff_after: 5,
            focus: false,
            stats,
-
            page_size: state.ui.page_size,
-
            show_search: state.ui.show_search,
-
            selected: state.issues.selected,
+
            page_size: state.browser.page_size,
+
            show_search: state.browser.show_search,
+
            selected: state.browser.selected,
+
            shortcuts: match state.mode {
+
                Mode::Id => vec![("enter", "select"), ("/", "search")],
+
                Mode::Operation => vec![
+
                    ("enter", "show"),
+
                    ("e", "edit"),
+
                    ("/", "search"),
+
                    ("?", "help"),
+
                ],
+
            },
        }
    }
}

-
struct Issues<'a, B> {
+
impl<'a> Properties for BrowsePageProps<'a> {}
+

+
struct BrowsePage<'a, B> {
    /// Internal properties
-
    props: IssuesProps<'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>,
+
    /// Patches widget
+
    issues: BoxedWidget<B>,
+
    /// Search widget
+
    search: BoxedWidget<B>,
+
    /// Shortcut widget
+
    shortcuts: BoxedWidget<B>,
}

-
impl<'a: 'static, B> View<State, Action> for Issues<'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 = IssuesProps::from(state);
+
        let props = BrowsePageProps::from(state);

        Self {
            action_tx: action_tx.clone(),
            props: props.clone(),
-
            table: Box::<Table<B, State, Action, IssueItem>>::new(
-
                Table::new(state, action_tx.clone())
-
                    .header(
-
                        Header::new(state, action_tx.clone())
-
                            .columns(props.columns.clone())
-
                            .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 = IssuesProps::from(state);
-

-
                        TableProps::default()
-
                            .columns(props.columns)
-
                            .items(state.issues())
-
                            .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(),
+
            issues: Container::new(state, action_tx.clone())
+
                .header(
+
                    Header::new(state, action_tx.clone())
+
                        .columns(props.columns.clone())
+
                        .cutoff(props.cutoff, props.cutoff_after)
+
                        .focus(props.focus)
+
                        .to_boxed(),
+
                )
+
                .content(Box::<Table<State, Action, IssueItem>>::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.browser.issues())
+
                                .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,
        }
@@ -379,60 +306,74 @@ where

    fn update(&mut self, state: &State) {
        // TODO call mapper here instead?
-
        self.props = IssuesProps::from(state);
+
        self.props = BrowsePageProps::from(state);

-
        self.table.update(state);
-
        self.footer.update(state);
+
        self.issues.update(state);
+
        self.search.update(state);
+
        self.shortcuts.update(state);
    }

    fn handle_key_event(&mut self, key: Key) {
-
        match key {
-
            Key::Char('\n') => {
-
                let operation = match self.props.mode {
-
                    Mode::Operation => Some(IssueOperation::Show.to_string()),
-
                    Mode::Id => None,
-
                };
-

-
                self.props
-
                    .selected
-
                    .and_then(|selected| self.props.issues.get(selected))
-
                    .and_then(|issue| {
-
                        self.action_tx
-
                            .send(Action::Exit {
-
                                selection: Some(Selection {
-
                                    operation,
-
                                    ids: vec![issue.id],
-
                                    args: vec![],
-
                                }),
-
                            })
-
                            .ok()
-
                    });
-
            }
-
            Key::Char('e') => {
-
                self.props
-
                    .selected
-
                    .and_then(|selected| self.props.issues.get(selected))
-
                    .and_then(|issue| {
-
                        self.action_tx
-
                            .send(Action::Exit {
-
                                selection: Some(Selection {
-
                                    operation: Some(IssueOperation::Edit.to_string()),
-
                                    ids: vec![issue.id],
-
                                    args: vec![],
-
                                }),
-
                            })
-
                            .ok()
-
                    });
-
            }
-
            _ => {
-
                self.table.handle_key_event(key);
+
        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') => {
+
                    let operation = match self.props.mode {
+
                        Mode::Operation => Some(IssueOperation::Show.to_string()),
+
                        Mode::Id => None,
+
                    };
+

+
                    self.props
+
                        .selected
+
                        .and_then(|selected| self.props.issues.get(selected))
+
                        .and_then(|issue| {
+
                            self.action_tx
+
                                .send(Action::Exit {
+
                                    selection: Some(Selection {
+
                                        operation,
+
                                        ids: vec![issue.id],
+
                                        args: vec![],
+
                                    }),
+
                                })
+
                                .ok()
+
                        });
+
                }
+
                Key::Char('e') => {
+
                    self.props
+
                        .selected
+
                        .and_then(|selected| self.props.issues.get(selected))
+
                        .and_then(|issue| {
+
                            self.action_tx
+
                                .send(Action::Exit {
+
                                    selection: Some(Selection {
+
                                        operation: Some(IssueOperation::Edit.to_string()),
+
                                        ids: vec![issue.id],
+
                                        args: vec![],
+
                                    }),
+
                                })
+
                                .ok()
+
                        });
+
                }
+
                _ => {
+
                    self.issues.handle_key_event(key);
+
                }
            }
        }
    }
}

-
impl<'a, B: Backend> Issues<'a, B> {
-
    fn build_footer(props: &IssuesProps<'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())
@@ -526,36 +467,58 @@ impl<'a, B: Backend> Issues<'a, B> {
    }
}

-
impl<'a: 'static, B> Widget<B, State, Action> for Issues<'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::<IssuesProps>())
-
            .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.issues.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.issues.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));
        }
    }
}
@@ -581,14 +544,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()
@@ -635,7 +598,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);
@@ -644,6 +607,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(
        [
@@ -769,6 +908,8 @@ fn help_text() -> Text<'static> {
                ]
                .to_vec(),
            ),
+
            Line::raw(""),
+
            Line::raw(""),
        ]
        .to_vec())
}