Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
Build interfaces with reusable widgets
Merged did:key:z6MkgFq6...nBGz opened 2 years ago

This reworks the library towards more reusable widgets and restructures all existing interfaces to use those.

12 files changed +2161 -1697 047e54b0 e2346edc
modified 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<NotificationId>;
@@ -42,66 +43,45 @@ 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,
-
    show_help: bool,
}

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

#[derive(Clone, Debug)]
-
pub struct NotificationsState {
-
    items: Vec<NotificationItem>,
-
    selected: Option<usize>,
+
pub struct HelpState {
+
    progress: usize,
+
    page_size: usize,
}

#[derive(Clone, Debug)]
pub struct State {
-
    notifications: NotificationsState,
    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.ui.show_help {
-
            vec![("?", "close")]
-
        } else {
-
            match self.mode.selection() {
-
                SelectionMode::Id => vec![("enter", "select"), ("/", "search")],
-
                SelectionMode::Operation => vec![
-
                    ("enter", "show"),
-
                    ("c", "clear"),
-
                    ("/", "search"),
-
                    ("?", "help"),
-
                ],
-
            }
-
        }
-
    }
-

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

impl TryFrom<&Context> for State {
@@ -195,15 +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: None,
+
                selected: Some(0),
+
                filter,
+
                search,
+
                show_search: false,
+
                page_size: 1,
+
            },
+
            help: HelpState {
+
                progress: 0,
+
                page_size: 1,
            },
-
            mode: mode.clone(),
-
            project,
-
            filter,
-
            search,
-
            ui: UIState::default(),
        })
    }
}
@@ -211,13 +197,15 @@ 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 },
}

impl store::State<Action, Selection> for State {
@@ -227,43 +215,51 @@ 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.ui.show_help = true;
+
                self.pages.push(Page::Help);
+
                None
+
            }
+
            Action::LeavePage => {
+
                self.pages.pop();
                None
            }
-
            Action::CloseHelp => {
-
                self.ui.show_help = false;
+
            Action::ScrollHelp { progress } => {
+
                self.help.progress = progress;
                None
            }
        }
@@ -283,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()
            ),
modified bin/commands/inbox/select/ui.rs
@@ -2,6 +2,7 @@ use std::any::Any;
use std::collections::HashMap;
use std::str::FromStr;

+
use ratatui::widgets::TableState;
use tokio::sync::mpsc::UnboundedSender;

use termion::event::Key;
@@ -15,141 +16,122 @@ use radicle_tui as tui;

use tui::ui::items::{NotificationItem, NotificationItemFilter, NotificationState};
use tui::ui::span;
-
use tui::ui::widget;
-
use tui::ui::widget::container::{Footer, FooterProps, Header, HeaderProps};
-
use tui::ui::widget::input::{TextField, TextFieldProps};
-
use tui::ui::widget::text::{Paragraph, ParagraphProps};
+
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};
use tui::ui::widget::{
-
    Column, EventCallback, Shortcuts, ShortcutsProps, Table, TableProps, UpdateCallback, View,
-
    Widget,
+
    Column, EventCallback, Properties, Shortcuts, ShortcutsProps, Table, TableProps,
+
    UpdateCallback, View, Widget,
};
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<State, Action, B>;
+
type BoxedWidget<B> = widget::BoxedWidget<B, State, Action>;

-
pub struct ListPageProps {
-
    show_search: bool,
-
    show_help: 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.ui.show_help,
+
            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: Help::new(state, action_tx.clone()).to_boxed(),
-
            shortcuts: Shortcuts::new(state, action_tx.clone())
-
                .on_update(|state| {
-
                    Box::new(ShortcutsProps::default().shortcuts(&state.shortcuts()))
-
                })
-
                .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.notifications.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 {
-
            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: Backend + 'a> Widget<State, Action, B> for ListPage<B> {
-
    fn render(&self, frame: &mut ratatui::Frame, _area: Rect, _props: &dyn Any) {
-
        let area = frame.size();
-
        let layout = tui::ui::layout::default_page(area, 0u16, 1u16);
+
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<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.notifications.render(frame, component_layout[0], &());
-
            self.search.render(frame, component_layout[1], &());
-
        } else if self.props.show_help {
-
            self.help.render(frame, layout.component, &());
-
        } else {
-
            self.notifications.render(frame, layout.component, &());
+
        if let Some(page) = self.pages.get(&props.page) {
+
            page.render(frame, area, None);
        }
-

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

-
struct NotificationsProps<'a> {
+
#[derive(Clone)]
+
struct BrowsePageProps<'a> {
    notifications: Vec<NotificationItem>,
    selected: Option<usize>,
    mode: Mode,
@@ -161,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 {
@@ -183,7 +166,6 @@ impl<'a> From<&State> for NotificationsProps<'a> {

        Self {
            notifications,
-
            selected: None,
            mode: state.mode.clone(),
            stats,
            columns: [
@@ -202,34 +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"),
+
                ],
+
            },
        }
    }
}

-
struct Notifications<'a, B: Backend> {
+
impl<'a> Properties for BrowsePageProps<'a> {}
+

+
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(),
@@ -238,55 +234,58 @@ where

        Self {
            action_tx: action_tx.clone(),
-
            props: NotificationsProps::from(state),
-
            table: Box::<Table<'_, State, Action, B, 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(|props, action_tx| {
-
                        props
-
                            .downcast_ref::<TableProps<'_, NotificationItem>>()
-
                            .and_then(|props| {
+
            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: props.selected,
+
                                        selected: state.selected(),
                                    })
                                    .ok()
                            });
-
                    })
-
                    .on_update(|state| {
-
                        let props = NotificationsProps::from(state);
+
                        })
+
                        .on_update(|state| {
+
                            let props = BrowsePageProps::from(state);

-
                        Box::<TableProps<'_, NotificationItem>>::new(
                            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),
-
                        )
-
                    }),
-
            ),
-
            footer: Footer::new(state, action_tx)
-
                .on_update(|state| {
-
                    let props = NotificationsProps::from(state);
-

-
                    Box::<FooterProps<'_>>::new(
-
                        FooterProps::default().columns(Self::build_footer(&props, props.selected)),
-
                    )
-
                })
+
                                .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,
        }
@@ -304,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),
-
                        };
-

-
                        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);
+
        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.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())
@@ -387,11 +400,7 @@ impl<'a, B: Backend> Notifications<'a, B> {

        let progress = selected
            .map(|selected| {
-
                Table::<State, Action, B, NotificationItem>::progress(
-
                    selected,
-
                    props.notifications.len(),
-
                    props.page_size,
-
                )
+
                TableUtils::progress(selected, props.notifications.len(), props.page_size)
            })
            .unwrap_or_default();
        let progress = span::default(format!("{}%", progress)).dim();
@@ -433,28 +442,58 @@ impl<'a, B: Backend> Notifications<'a, B> {
    }
}

-
impl<'a: 'static, B> Widget<State, Action, B> 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: &dyn Any) {
-
        let header_height = 3_usize;
-

-
        let page_size = if self.props.show_search {
-
            self.table.render(frame, area, &());
-

-
            (area.height as usize).saturating_sub(header_height)
+
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, props: Option<Box<dyn Any>>) {
+
        let props = props
+
            .and_then(|props| BrowsePageProps::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);
+

+
        if props.show_search {
+
            let [table_area, search_area] =
+
                Layout::vertical([Constraint::Min(1), Constraint::Length(2)]).areas(content_area);
+

+
            self.notifications.render(
+
                frame,
+
                table_area,
+
                Some(
+
                    ContainerProps::default()
+
                        .hide_footer(props.show_search)
+
                        .to_boxed(),
+
                ),
+
            );
+
            self.search.render(frame, search_area, None);
        } else {
-
            let layout = Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).split(area);
-

-
            self.table.render(frame, layout[0], &());
-
            self.footer.render(frame, layout[1], &());
+
            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 != self.props.page_size {
-
            let _ = self.action_tx.send(Action::PageSize(page_size));
+
        if page_size != props.page_size {
+
            let _ = self.action_tx.send(Action::BrowserPageSize(page_size));
        }
    }
}
@@ -476,22 +515,21 @@ impl<B: Backend> View<State, Action> for Search<B> {
        Self: Sized,
    {
        let input = TextField::new(state, action_tx.clone())
-
            .on_change(|props, action_tx| {
-
                props.downcast_ref::<TextFieldProps>().and_then(|props| {
+
            .on_change(|state, action_tx| {
+
                state.downcast_ref::<TextFieldState>().and_then(|state| {
                    action_tx
                        .send(Action::UpdateSearch {
-
                            value: props.text.clone(),
+
                            value: state.text.clone().unwrap_or_default(),
                        })
                        .ok()
                });
            })
            .on_update(|state| {
-
                Box::<TextFieldProps>::new(
-
                    TextFieldProps::default()
-
                        .text(&state.search.read().to_string())
-
                        .title("Search")
-
                        .inline(true),
-
                )
+
                TextFieldProps::default()
+
                    .text(&state.browser.search.read().to_string())
+
                    .title("Search")
+
                    .inline(true)
+
                    .to_boxed()
            })
            .to_boxed();
        Self {
@@ -531,227 +569,128 @@ impl<B: Backend> View<State, Action> for Search<B> {
    }
}

-
impl<B: Backend> Widget<State, Action, B> for Search<B> {
-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: &dyn Any) {
+
impl<B> Widget<B, State, Action> for Search<B>
+
where
+
    B: Backend,
+
{
+
    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);

-
        self.input.render(frame, layout[0], &());
+
        self.input.render(frame, layout[0], None);
    }
}

#[derive(Clone)]
-
pub struct HelpProps<'a> {
-
    content: Text<'a>,
+
struct HelpPageProps<'a> {
    focus: bool,
    page_size: usize,
+
    help_progress: usize,
+
    shortcuts: Vec<(&'a str, &'a str)>,
}

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

        Self {
-
            content,
            focus: false,
-
            page_size: state.ui.page_size,
+
            page_size: state.help.page_size,
+
            help_progress: state.help.progress,
+
            shortcuts: vec![("?", "close")],
        }
    }
}

-
pub struct Help<'a, B: Backend> {
+
impl<'a> Properties for HelpPageProps<'a> {}
+

+
pub struct HelpPage<'a, B>
+
where
+
    B: Backend,
+
{
    /// Internal properties
-
    props: HelpProps<'a>,
+
    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>>,
-
    /// Container header
-
    header: BoxedWidget<B>,
    /// Content widget
    content: BoxedWidget<B>,
-
    /// Container footer
-
    footer: BoxedWidget<B>,
+
    /// Shortcut widget
+
    shortcuts: BoxedWidget<B>,
}

-
impl<'a, B: Backend> View<State, Action> for Help<'a, 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: HelpProps::from(state),
-
            header: Header::new(state, action_tx.clone())
-
                .on_update(|state| {
-
                    let props = HelpProps::from(state);
-

-
                    Box::<HeaderProps<'_>>::new(
-
                        HeaderProps::default()
-
                            .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec())
-
                            .focus(props.focus),
-
                    )
-
                })
-
                .to_boxed(),
-
            content: Paragraph::new(state, action_tx.clone())
-
                .on_update(|state| {
-
                    let props = HelpProps::from(state);
-

-
                    Box::<ParagraphProps<'_>>::new(
-
                        ParagraphProps::default()
-
                            .text(&props.content)
-
                            .page_size(props.page_size)
-
                            .focus(props.focus),
-
                    )
-
                })
-
                .to_boxed(),
-
            footer: Footer::new(state, action_tx)
-
                .on_update(|state| {
-
                    let props = HelpProps::from(state);
-
                    let progress = span::default(format!("{}%", 0)).dim();
-

-
                    Box::<FooterProps<'_>>::new(
-
                        FooterProps::default()
-
                            .columns(
-
                                [
-
                                    Column::new(Text::raw(""), Constraint::Fill(1)),
-
                                    Column::new(Text::from(progress), Constraint::Min(4)),
-
                                ]
-
                                .to_vec(),
-
                            )
-
                            .focus(props.focus),
-
                    )
-
                })
+
            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,
        }
@@ -768,20 +707,18 @@ impl<'a, B: Backend> View<State, Action> for Help<'a, B> {
    }

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

-
        self.header.update(state);
        self.content.update(state);
-
        self.footer.update(state);
    }

    fn handle_key_event(&mut self, key: termion::event::Key) {
        match key {
-
            Key::Esc => {
+
            Key::Esc | Key::Ctrl('c') => {
                let _ = self.action_tx.send(Action::Exit { selection: None });
            }
            Key::Char('?') => {
-
                let _ = self.action_tx.send(Action::CloseHelp);
+
                let _ = self.action_tx.send(Action::LeavePage);
            }
            _ => {
                self.content.handle_key_event(key);
@@ -790,22 +727,165 @@ impl<'a, B: Backend> View<State, Action> for Help<'a, B> {
    }
}

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

-
        self.header.render(frame, header_area, &());
-
        self.content.render(frame, content_area, &());
-
        self.footer.render(frame, footer_area, &());
-

-
        let page_size = content_area.height as usize;
-
        if page_size != self.props.page_size {
-
            let _ = self.action_tx.send(Action::PageSize(page_size));
+
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(
+
        [
+
            Line::from(Span::raw("Generic keybindings").cyan()),
+
            Line::raw(""),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "↑,k")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("move cursor one line up").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "↓,j")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("move cursor one line down").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "PageUp")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("move cursor one page up").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "PageDown")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("move cursor one page down").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "Home")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("move cursor to the first line").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "End")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("move cursor to the last line").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::raw(""),
+
            Line::from(Span::raw("Specific keybindings").cyan()),
+
            Line::raw(""),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "enter")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("Select notification (if --mode id)").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "enter")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("Show notification").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "c")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("Clear notifications").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "/")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("Search").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "?")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("Show help").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "Esc")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("Quit / cancel").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::raw(""),
+
            Line::from(Span::raw("Searching").cyan()),
+
            Line::raw(""),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "Pattern")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("is:<state> | is:patch | is:issue | <search>")
+
                        .gray()
+
                        .dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "Example")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("is:unseen is:patch Print").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::raw(""),
+
            Line::raw(""),
+
        ]
+
        .to_vec(),
+
    )
+
}
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,65 +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,
-
    show_help: bool,
}

-
impl Default for UIState {
-
    fn default() -> Self {
-
        Self {
-
            page_size: 1,
-
            show_search: false,
-
            show_help: 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>,
+
pub struct HelpState {
+
    progress: usize,
+
    page_size: usize,
}

#[derive(Clone, Debug)]
pub struct State {
-
    issues: IssuesState,
    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.ui.show_help {
-
            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 {
@@ -117,14 +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: None,
+
                selected: Some(0),
+
                filter,
+
                search,
+
                show_search: false,
+
                page_size: 1,
+
            },
+
            help: HelpState {
+
                progress: 0,
+
                page_size: 1,
            },
-
            mode: context.mode.clone(),
-
            filter,
-
            search,
-
            ui: UIState::default(),
        })
    }
}
@@ -132,13 +116,15 @@ 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 },
}

impl store::State<Action, Selection> for State {
@@ -148,41 +134,51 @@ 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::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 = 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.ui.show_help = true;
+
                self.pages.push(Page::Help);
+
                None
+
            }
+
            Action::LeavePage => {
+
                self.pages.pop();
                None
            }
-
            Action::CloseHelp => {
-
                self.ui.show_help = false;
+
            Action::ScrollHelp { progress } => {
+
                self.help.progress = progress;
                None
            }
        }
@@ -202,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
@@ -4,6 +4,7 @@ use std::str::FromStr;
use std::vec;

use radicle::issue::{self, CloseReason};
+
use ratatui::widgets::TableState;
use tokio::sync::mpsc::UnboundedSender;

use termion::event::Key;
@@ -18,56 +19,53 @@ use radicle_tui as tui;
use tui::ui::items::{IssueItem, IssueItemFilter};
use tui::ui::span;
use tui::ui::widget;
-
use tui::ui::widget::container::{Footer, FooterProps, Header, HeaderProps};
-
use tui::ui::widget::input::{TextField, TextFieldProps};
-
use tui::ui::widget::text::{Paragraph, ParagraphProps};
+
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::{
-
    Column, EventCallback, Shortcuts, ShortcutsProps, Table, TableProps, UpdateCallback, View,
-
    Widget,
+
    Column, EventCallback, Properties, Shortcuts, ShortcutsProps, Table, TableProps, TableUtils,
+
    UpdateCallback, View, Widget,
};
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<State, Action, B>;
+
type BoxedWidget<B> = widget::BoxedWidget<B, State, Action>;

-
pub struct ListPageProps {
-
    show_search: bool,
-
    show_help: 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.ui.show_help,
+
            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,
{
@@ -76,90 +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: Help::new(state, action_tx.clone()).to_boxed(),
-
            shortcuts: Shortcuts::new(state, action_tx.clone())
-
                .on_update(|state| {
-
                    Box::new(ShortcutsProps::default().shortcuts(&state.shortcuts()))
-
                })
-
                .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 {
-
            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<State, Action, B> 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: &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], &());
-
            self.search.render(frame, component_layout[1], &());
-
        } else if self.props.show_help {
-
            self.help.render(frame, layout.component, &());
-
        } else {
-
            self.issues.render(frame, layout.component, &());
+
        if let Some(page) = self.pages.get(&props.page) {
+
            page.render(frame, area, None);
        }
-

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

#[derive(Clone)]
-
struct IssuesProps<'a> {
+
struct BrowsePageProps<'a> {
    mode: Mode,
    issues: Vec<IssueItem>,
    selected: Option<usize>,
@@ -171,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;
@@ -207,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)),
@@ -223,76 +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<'_, State, Action, B, 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(|props, action_tx| {
-
                        if let Some(props) = props.downcast_ref::<TableProps<'_, IssueItem>>() {
-
                            let _ = action_tx.send(Action::Select {
-
                                selected: props.selected,
+
            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 = IssuesProps::from(state);
+
                        })
+
                        .on_update(|state| {
+
                            let props = BrowsePageProps::from(state);

-
                        Box::<TableProps<'_, IssueItem>>::new(
                            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),
-
                        )
-
                    }),
-
            ),
-
            footer: Footer::new(state, action_tx)
-
                .on_update(|state| {
-
                    let props = IssuesProps::from(state);
-

-
                    Box::<FooterProps<'_>>::new(
-
                        FooterProps::default().columns(Self::build_footer(&props, props.selected)),
-
                    )
-
                })
+
                                .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,
        }
@@ -310,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())
@@ -410,13 +420,7 @@ impl<'a, B: Backend> Issues<'a, B> {
        );

        let progress = selected
-
            .map(|selected| {
-
                Table::<State, Action, B, IssueItem>::progress(
-
                    selected,
-
                    props.issues.len(),
-
                    props.page_size,
-
                )
-
            })
+
            .map(|selected| TableUtils::progress(selected, props.issues.len(), props.page_size))
            .unwrap_or_default();
        let progress = span::default(format!("{}%", progress)).dim();

@@ -463,28 +467,58 @@ impl<'a, B: Backend> Issues<'a, B> {
    }
}

-
impl<'a: 'static, B> Widget<State, Action, B> 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: &dyn Any) {
-
        let header_height = 3_usize;
-

-
        let page_size = if self.props.show_search {
-
            self.table.render(frame, area, &());
-

-
            (area.height as usize).saturating_sub(header_height)
+
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, props: Option<Box<dyn Any>>) {
+
        let props = props
+
            .and_then(|props| BrowsePageProps::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);
+

+
        if props.show_search {
+
            let [table_area, search_area] =
+
                Layout::vertical([Constraint::Min(1), Constraint::Length(2)]).areas(content_area);
+

+
            self.issues.render(
+
                frame,
+
                table_area,
+
                Some(
+
                    ContainerProps::default()
+
                        .hide_footer(props.show_search)
+
                        .to_boxed(),
+
                ),
+
            );
+
            self.search.render(frame, search_area, None);
        } else {
-
            let layout = Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).split(area);
-

-
            self.table.render(frame, layout[0], &());
-
            self.footer.render(frame, layout[1], &());
+
            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 != self.props.page_size {
-
            let _ = self.action_tx.send(Action::PageSize(page_size));
+
        if page_size != props.page_size {
+
            let _ = self.action_tx.send(Action::BrowserPageSize(page_size));
        }
    }
}
@@ -506,22 +540,21 @@ impl<B: Backend> View<State, Action> for Search<B> {
        Self: Sized,
    {
        let input = TextField::new(state, action_tx.clone())
-
            .on_change(|props, action_tx| {
-
                props.downcast_ref::<TextFieldProps>().and_then(|props| {
+
            .on_change(|state, action_tx| {
+
                state.downcast_ref::<TextFieldState>().and_then(|state| {
                    action_tx
                        .send(Action::UpdateSearch {
-
                            value: props.text.clone(),
+
                            value: state.text.clone().unwrap_or_default(),
                        })
                        .ok()
                });
            })
            .on_update(|state| {
-
                Box::<TextFieldProps>::new(
-
                    TextFieldProps::default()
-
                        .text(&state.search.read().to_string())
-
                        .title("Search")
-
                        .inline(true),
-
                )
+
                TextFieldProps::default()
+
                    .text(&state.browser.search.read().to_string())
+
                    .title("Search")
+
                    .inline(true)
+
                    .to_boxed()
            })
            .to_boxed();
        Self {
@@ -561,227 +594,128 @@ impl<B: Backend> View<State, Action> for Search<B> {
    }
}

-
impl<B: Backend> Widget<State, Action, B> for Search<B> {
-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: &dyn Any) {
+
impl<B> Widget<B, State, Action> for Search<B>
+
where
+
    B: Backend,
+
{
+
    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);

-
        self.input.render(frame, layout[0], &());
+
        self.input.render(frame, layout[0], None);
    }
}

#[derive(Clone)]
-
pub struct HelpProps<'a> {
-
    content: Text<'a>,
+
struct HelpPageProps<'a> {
    focus: bool,
    page_size: usize,
+
    help_progress: usize,
+
    shortcuts: Vec<(&'a str, &'a str)>,
}

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

        Self {
-
            content,
            focus: false,
-
            page_size: state.ui.page_size,
+
            page_size: state.help.page_size,
+
            help_progress: state.help.progress,
+
            shortcuts: vec![("?", "close")],
        }
    }
}

-
pub struct Help<'a, B: Backend> {
+
impl<'a> Properties for HelpPageProps<'a> {}
+

+
pub struct HelpPage<'a, B>
+
where
+
    B: Backend,
+
{
    /// Internal properties
-
    props: HelpProps<'a>,
+
    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>>,
-
    /// Container header
-
    header: BoxedWidget<B>,
    /// Content widget
    content: BoxedWidget<B>,
-
    /// Container footer
-
    footer: BoxedWidget<B>,
+
    /// Shortcut widget
+
    shortcuts: BoxedWidget<B>,
}

-
impl<'a, B: Backend> View<State, Action> for Help<'a, 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: HelpProps::from(state),
-
            header: Header::new(state, action_tx.clone())
-
                .on_update(|state| {
-
                    let props = HelpProps::from(state);
-

-
                    Box::<HeaderProps<'_>>::new(
-
                        HeaderProps::default()
-
                            .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec())
-
                            .focus(props.focus),
-
                    )
-
                })
-
                .to_boxed(),
-
            content: Paragraph::new(state, action_tx.clone())
-
                .on_update(|state| {
-
                    let props = HelpProps::from(state);
-

-
                    Box::<ParagraphProps<'_>>::new(
-
                        ParagraphProps::default()
-
                            .text(&props.content)
-
                            .page_size(props.page_size)
-
                            .focus(props.focus),
-
                    )
-
                })
-
                .to_boxed(),
-
            footer: Footer::new(state, action_tx)
-
                .on_update(|state| {
-
                    let props = HelpProps::from(state);
-
                    let progress = span::default(format!("{}%", 0)).dim();
-

-
                    Box::<FooterProps<'_>>::new(
-
                        FooterProps::default()
-
                            .columns(
-
                                [
-
                                    Column::new(Text::raw(""), Constraint::Fill(1)),
-
                                    Column::new(Text::from(progress), Constraint::Min(4)),
-
                                ]
-
                                .to_vec(),
-
                            )
-
                            .focus(props.focus),
-
                    )
-
                })
+
            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,
        }
@@ -798,20 +732,18 @@ impl<'a, B: Backend> View<State, Action> for Help<'a, B> {
    }

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

-
        self.header.update(state);
        self.content.update(state);
-
        self.footer.update(state);
    }

    fn handle_key_event(&mut self, key: termion::event::Key) {
        match key {
-
            Key::Esc => {
+
            Key::Esc | Key::Ctrl('c') => {
                let _ = self.action_tx.send(Action::Exit { selection: None });
            }
            Key::Char('?') => {
-
                let _ = self.action_tx.send(Action::CloseHelp);
+
                let _ = self.action_tx.send(Action::LeavePage);
            }
            _ => {
                self.content.handle_key_event(key);
@@ -820,22 +752,164 @@ impl<'a, B: Backend> View<State, Action> for Help<'a, B> {
    }
}

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

-
        self.header.render(frame, header_area, &());
-
        self.content.render(frame, content_area, &());
-
        self.footer.render(frame, footer_area, &());
-

-
        let page_size = content_area.height as usize;
-
        if page_size != self.props.page_size {
-
            let _ = self.action_tx.send(Action::PageSize(page_size));
+
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(
+
        [
+
            Line::from(Span::raw("Generic keybindings").cyan()),
+
            Line::raw(""),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "↑,k")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("move cursor one line up").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "↓,j")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("move cursor one line down").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "PageUp")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("move cursor one page up").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "PageDown")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("move cursor one page down").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "Home")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("move cursor to the first line").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "End")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("move cursor to the last line").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::raw(""),
+
            Line::from(Span::raw("Specific keybindings").cyan()),
+
            Line::raw(""),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "enter")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("Select issue (if --mode id)").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "enter")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("Show issue").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "e")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("Edit patch").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "/")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("Search").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "?")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("Show help").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "Esc")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("Quit / cancel").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::raw(""),
+
            Line::from(Span::raw("Searching").cyan()),
+
            Line::raw(""),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "Pattern")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("is:<state> | is:authored | is:assigned | authors:[<did>, ...] | assignees:[<did>, ...] | <search>")
+
                        .gray()
+
                        .dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "Example")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("is:solved is:authored alias").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::raw(""),
+
            Line::raw(""),
+
        ]
+
        .to_vec())
+
}
modified bin/commands/patch/select.rs
@@ -20,7 +20,8 @@ use tui::ui::items::{Filter, PatchItem, PatchItemFilter};
use tui::ui::Frontend;
use tui::Exit;

-
use ui::ListPage;
+
use tui::PageStack;
+
use ui::Window;

use super::common::Mode;

@@ -37,61 +38,25 @@ pub struct App {
    context: Context,
}

-
#[derive(Clone, Debug)]
-
pub struct UIState {
-
    page_size: usize,
-
    show_search: bool,
-
    show_help: bool,
-
}
-

-
impl Default for UIState {
-
    fn default() -> Self {
-
        Self {
-
            page_size: 1,
-
            show_search: false,
-
            show_help: false,
-
        }
-
    }
+
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
+
pub enum Page {
+
    Browse,
+
    Help,
}

#[derive(Clone, Debug)]
-
pub struct PatchesState {
+
pub struct BrowserState {
    items: Vec<PatchItem>,
    selected: Option<usize>,
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct State {
-
    patches: PatchesState,
-
    mode: Mode,
    filter: PatchItemFilter,
    search: store::StateValue<String>,
-
    ui: UIState,
+
    page_size: usize,
+
    show_search: bool,
}

-
impl State {
-
    pub fn shortcuts(&self) -> Vec<(&str, &str)> {
-
        if self.ui.show_search {
-
            vec![("esc", "cancel"), ("enter", "apply")]
-
        } else if self.ui.show_help {
-
            vec![("?", "close")]
-
        } else {
-
            match self.mode {
-
                Mode::Id => vec![("enter", "select"), ("/", "search")],
-
                Mode::Operation => vec![
-
                    ("enter", "show"),
-
                    ("c", "checkout"),
-
                    ("d", "diff"),
-
                    ("/", "search"),
-
                    ("?", "help"),
-
                ],
-
            }
-
        }
-
    }
-

+
impl BrowserState {
    pub fn patches(&self) -> Vec<PatchItem> {
-
        self.patches
-
            .items
+
        self.items
            .iter()
            .filter(|patch| self.filter.matches(patch))
            .cloned()
@@ -99,6 +64,20 @@ impl State {
    }
}

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

+
#[derive(Clone, Debug)]
+
pub struct State {
+
    mode: Mode,
+
    pages: PageStack<Page>,
+
    browser: BrowserState,
+
    help: HelpState,
+
}
+

impl TryFrom<&Context> for State {
    type Error = anyhow::Error;

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

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

impl store::State<Action, Selection> for State {
@@ -148,41 +135,51 @@ impl store::State<Action, Selection> for State {
        match action {
            Action::Exit { selection } => Some(Exit { value: selection }),
            Action::Select { selected } => {
-
                self.patches.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 = PatchItemFilter::from_str(&self.search.read()).unwrap_or_default();
+
                self.browser.search.write(value);
+
                self.browser.filter =
+
                    PatchItemFilter::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 = PatchItemFilter::from_str(&self.search.read()).unwrap_or_default();
+
                self.browser.search.reset();
+
                self.browser.show_search = false;
+
                self.browser.filter =
+
                    PatchItemFilter::from_str(&self.browser.search.read()).unwrap_or_default();

                None
            }
            Action::OpenHelp => {
-
                self.ui.show_help = true;
+
                self.pages.push(Page::Help);
+
                None
+
            }
+
            Action::LeavePage => {
+
                self.pages.pop();
                None
            }
-
            Action::CloseHelp => {
-
                self.ui.show_help = false;
+
            Action::ScrollHelp { progress } => {
+
                self.help.progress = progress;
                None
            }
        }
@@ -202,7 +199,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/patch/select/ui.rs
@@ -3,6 +3,7 @@ use std::collections::HashMap;
use std::str::FromStr;
use std::vec;

+
use ratatui::widgets::TableState;
use tokio::sync::mpsc::UnboundedSender;

use termion::event::Key;
@@ -19,71 +20,74 @@ use radicle_tui as tui;
use tui::ui::items::{PatchItem, PatchItemFilter};
use tui::ui::span;
use tui::ui::widget;
-
use tui::ui::widget::container::{Footer, FooterProps, Header, HeaderProps};
-
use tui::ui::widget::input::{TextField, TextFieldProps};
-
use tui::ui::widget::text::{Paragraph, ParagraphProps};
+
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::TableUtils;
use tui::ui::widget::{
-
    Column, EventCallback, Shortcuts, ShortcutsProps, Table, TableProps, UpdateCallback, View,
-
    Widget,
+
    Column, EventCallback, Properties, Shortcuts, ShortcutsProps, Table, TableProps,
+
    UpdateCallback, View, Widget,
};
use tui::Selection;

use crate::tui_patch::common::Mode;
use crate::tui_patch::common::PatchOperation;

-
use super::{Action, State};
+
use super::{Action, Page, State};

-
type BoxedWidget<B> = widget::BoxedWidget<State, Action, B>;
+
type BoxedWidget<B> = widget::BoxedWidget<B, State, Action>;

-
pub struct ListPageProps {
-
    show_search: bool,
-
    show_help: 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.ui.show_help,
+
            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
-
    patches: 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),
-
            patches: Patches::new(state, action_tx.clone()).to_boxed(),
-
            search: Search::new(state, action_tx.clone()).to_boxed(),
-
            help: Help::new(state, action_tx.clone()).to_boxed(),
-
            shortcuts: Shortcuts::new(state, action_tx.clone())
-
                .on_update(|state| {
-
                    Box::new(ShortcutsProps::default().shortcuts(&state.shortcuts()))
-
                })
-
                .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,
        }
@@ -100,61 +104,39 @@ impl<'a: 'static, B: Backend + 'a> View<State, Action> for ListPage<B> {
    }

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

-
        self.patches.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 {
-
            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.patches.handle_key_event(key);
-
                }
-
            }
+
        if let Some(page) = self.pages.get_mut(&self.props.page) {
+
            page.handle_key_event(key);
        }
    }
}

-
impl<'a: 'static, B: Backend + 'a> Widget<State, Action, B> for ListPage<B> {
-
    fn render(&self, frame: &mut ratatui::Frame, _area: Rect, __props: &dyn Any) {
-
        let area = frame.size();
-
        let layout = tui::ui::layout::default_page(area, 0u16, 1u16);
+
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<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.patches.render(frame, component_layout[0], &());
-
            self.search.render(frame, component_layout[1], &());
-
        } else if self.props.show_help {
-
            self.help.render(frame, layout.component, &());
-
        } else {
-
            self.patches.render(frame, layout.component, &());
+
        if let Some(page) = self.pages.get(&props.page) {
+
            page.render(frame, area, None);
        }
-

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

#[derive(Clone)]
-
struct PatchesProps<'a> {
+
struct BrowsePageProps<'a> {
    mode: Mode,
    patches: Vec<PatchItem>,
    selected: Option<usize>,
@@ -166,16 +148,17 @@ struct PatchesProps<'a> {
    focus: bool,
    page_size: usize,
    show_search: bool,
+
    shortcuts: Vec<(&'a str, &'a str)>,
}

-
impl<'a> From<&State> for PatchesProps<'a> {
+
impl<'a> From<&State> for BrowsePageProps<'a> {
    fn from(state: &State) -> Self {
        let mut draft = 0;
        let mut open = 0;
        let mut archived = 0;
        let mut merged = 0;

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

        for patch in &patches {
            match patch.state {
@@ -199,7 +182,7 @@ impl<'a> From<&State> for PatchesProps<'a> {
        Self {
            mode: state.mode.clone(),
            patches,
-
            search: state.search.read(),
+
            search: state.browser.search.read(),
            columns: [
                Column::new(" ● ", Constraint::Length(3)),
                Column::new("ID", Constraint::Length(8)),
@@ -216,80 +199,97 @@ impl<'a> From<&State> for PatchesProps<'a> {
            cutoff_after: 5,
            focus: false,
            stats,
-
            page_size: state.ui.page_size,
-
            show_search: state.ui.show_search,
-
            selected: state.patches.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"),
+
                    ("c", "checkout"),
+
                    ("d", "diff"),
+
                    ("/", "search"),
+
                    ("?", "help"),
+
                ],
+
            },
        }
    }
}

-
struct Patches<'a, B> {
+
impl<'a: 'static> Properties for BrowsePageProps<'a> {}
+

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

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

        Self {
            action_tx: action_tx.clone(),
            props: props.clone(),
-
            table: Box::<Table<'_, State, Action, B, PatchItem>>::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(|props, action_tx| {
-
                        props
-
                            .downcast_ref::<TableProps<'_, PatchItem>>()
-
                            .and_then(|props| {
+
            patches: 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, PatchItem>>::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: props.selected,
+
                                        selected: state.selected(),
                                    })
                                    .ok()
                            });
-
                    })
-
                    .on_update(|state| {
-
                        let props = PatchesProps::from(state);
+
                        })
+
                        .on_update(|state| {
+
                            let props = BrowsePageProps::from(state);

-
                        Box::<TableProps<'_, PatchItem>>::new(
                            TableProps::default()
                                .columns(props.columns)
-
                                .items(state.patches())
-
                                .footer(!state.ui.show_search)
-
                                .page_size(state.ui.page_size)
-
                                .cutoff(props.cutoff, props.cutoff_after),
-
                        )
-
                    }),
-
            ),
-
            footer: Footer::new(state, action_tx)
-
                .on_update(|state| {
-
                    let props = PatchesProps::from(state);
-

-
                    Box::<FooterProps<'_>>::new(
-
                        FooterProps::default().columns(Self::build_footer(&props, props.selected)),
-
                    )
-
                })
+
                                .items(state.browser.patches())
+
                                .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,
        }
@@ -307,76 +307,90 @@ where

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

-
        self.table.update(state);
-
        self.footer.update(state);
+
        self.patches.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(PatchOperation::Show.to_string()),
-
                    Mode::Id => None,
-
                };
-

-
                self.props
-
                    .selected
-
                    .and_then(|selected| self.props.patches.get(selected))
-
                    .and_then(|patch| {
-
                        self.action_tx
-
                            .send(Action::Exit {
-
                                selection: Some(Selection {
-
                                    operation,
-
                                    ids: vec![patch.id],
-
                                    args: vec![],
-
                                }),
-
                            })
-
                            .ok()
-
                    });
-
            }
-
            Key::Char('c') => {
-
                self.props
-
                    .selected
-
                    .and_then(|selected| self.props.patches.get(selected))
-
                    .and_then(|patch| {
-
                        self.action_tx
-
                            .send(Action::Exit {
-
                                selection: Some(Selection {
-
                                    operation: Some(PatchOperation::Checkout.to_string()),
-
                                    ids: vec![patch.id],
-
                                    args: vec![],
-
                                }),
-
                            })
-
                            .ok()
-
                    });
-
            }
-
            Key::Char('d') => {
-
                self.props
-
                    .selected
-
                    .and_then(|selected| self.props.patches.get(selected))
-
                    .and_then(|patch| {
-
                        self.action_tx
-
                            .send(Action::Exit {
-
                                selection: Some(Selection {
-
                                    operation: Some(PatchOperation::Diff.to_string()),
-
                                    ids: vec![patch.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(PatchOperation::Show.to_string()),
+
                        Mode::Id => None,
+
                    };
+

+
                    self.props
+
                        .selected
+
                        .and_then(|selected| self.props.patches.get(selected))
+
                        .and_then(|patch| {
+
                            self.action_tx
+
                                .send(Action::Exit {
+
                                    selection: Some(Selection {
+
                                        operation,
+
                                        ids: vec![patch.id],
+
                                        args: vec![],
+
                                    }),
+
                                })
+
                                .ok()
+
                        });
+
                }
+
                Key::Char('c') => {
+
                    self.props
+
                        .selected
+
                        .and_then(|selected| self.props.patches.get(selected))
+
                        .and_then(|patch| {
+
                            self.action_tx
+
                                .send(Action::Exit {
+
                                    selection: Some(Selection {
+
                                        operation: Some(PatchOperation::Checkout.to_string()),
+
                                        ids: vec![patch.id],
+
                                        args: vec![],
+
                                    }),
+
                                })
+
                                .ok()
+
                        });
+
                }
+
                Key::Char('d') => {
+
                    self.props
+
                        .selected
+
                        .and_then(|selected| self.props.patches.get(selected))
+
                        .and_then(|patch| {
+
                            self.action_tx
+
                                .send(Action::Exit {
+
                                    selection: Some(Selection {
+
                                        operation: Some(PatchOperation::Diff.to_string()),
+
                                        ids: vec![patch.id],
+
                                        args: vec![],
+
                                    }),
+
                                })
+
                                .ok()
+
                        });
+
                }
+
                _ => {
+
                    self.patches.handle_key_event(key);
+
                }
            }
        }
    }
}

-
impl<'a, B: Backend> Patches<'a, B> {
-
    fn build_footer(props: &PatchesProps<'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 filter = PatchItemFilter::from_str(&props.search).unwrap_or_default();

        let search = Line::from(
@@ -436,13 +450,7 @@ impl<'a, B: Backend> Patches<'a, B> {
        );

        let progress = selected
-
            .map(|selected| {
-
                Table::<State, Action, B, PatchItem>::progress(
-
                    selected,
-
                    props.patches.len(),
-
                    props.page_size,
-
                )
-
            })
+
            .map(|selected| TableUtils::progress(selected, props.patches.len(), props.page_size))
            .unwrap_or_default();
        let progress = span::default(format!("{}%", progress)).dim();

@@ -491,28 +499,58 @@ impl<'a, B: Backend> Patches<'a, B> {
    }
}

-
impl<'a: 'static, B> Widget<State, Action, B> for Patches<'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: &dyn Any) {
-
        let header_height = 3_usize;
-

-
        let page_size = if self.props.show_search {
-
            self.table.render(frame, area, &());
-

-
            (area.height as usize).saturating_sub(header_height)
+
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, props: Option<Box<dyn Any>>) {
+
        let props = props
+
            .and_then(|props| BrowsePageProps::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);
+

+
        if props.show_search {
+
            let [table_area, search_area] =
+
                Layout::vertical([Constraint::Min(1), Constraint::Length(2)]).areas(content_area);
+

+
            self.patches.render(
+
                frame,
+
                table_area,
+
                Some(
+
                    ContainerProps::default()
+
                        .hide_footer(props.show_search)
+
                        .to_boxed(),
+
                ),
+
            );
+
            self.search.render(frame, search_area, None);
        } else {
-
            let layout = Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).split(area);
-

-
            self.table.render(frame, layout[0], &());
-
            self.footer.render(frame, layout[1], &());
+
            self.patches.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 != self.props.page_size {
-
            let _ = self.action_tx.send(Action::PageSize(page_size));
+
        if page_size != props.page_size {
+
            let _ = self.action_tx.send(Action::BrowserPageSize(page_size));
        }
    }
}
@@ -534,22 +572,21 @@ impl<B: Backend> View<State, Action> for Search<B> {
        Self: Sized,
    {
        let input = TextField::new(state, action_tx.clone())
-
            .on_change(|props, action_tx| {
-
                props.downcast_ref::<TextFieldProps>().and_then(|props| {
+
            .on_change(|state, action_tx| {
+
                state.downcast_ref::<TextFieldState>().and_then(|state| {
                    action_tx
                        .send(Action::UpdateSearch {
-
                            value: props.text.clone(),
+
                            value: state.text.clone().unwrap_or_default(),
                        })
                        .ok()
                });
            })
            .on_update(|state| {
-
                Box::<TextFieldProps>::new(
-
                    TextFieldProps::default()
-
                        .text(&state.search.read().to_string())
-
                        .title("Search")
-
                        .inline(true),
-
                )
+
                TextFieldProps::default()
+
                    .text(&state.browser.search.read().to_string())
+
                    .title("Search")
+
                    .inline(true)
+
                    .to_boxed()
            })
            .to_boxed();
        Self {
@@ -589,235 +626,128 @@ impl<B: Backend> View<State, Action> for Search<B> {
    }
}

-
impl<B: Backend> Widget<State, Action, B> for Search<B> {
-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: &dyn Any) {
+
impl<B> Widget<B, State, Action> for Search<B>
+
where
+
    B: Backend,
+
{
+
    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);

-
        self.input.render(frame, layout[0], &());
+
        self.input.render(frame, layout[0], None);
    }
}

#[derive(Clone)]
-
pub struct HelpProps<'a> {
-
    content: Text<'a>,
+
struct HelpPageProps<'a> {
    focus: bool,
    page_size: usize,
+
    help_progress: usize,
+
    shortcuts: Vec<(&'a str, &'a str)>,
}

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

        Self {
-
            content,
            focus: false,
-
            page_size: state.ui.page_size,
+
            page_size: state.help.page_size,
+
            help_progress: state.help.progress,
+
            shortcuts: vec![("?", "close")],
        }
    }
}

-
pub struct Help<'a, B: Backend> {
+
impl<'a> Properties for HelpPageProps<'a> {}
+

+
pub struct HelpPage<'a, B>
+
where
+
    B: Backend,
+
{
    /// Internal properties
-
    props: HelpProps<'a>,
+
    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>>,
-
    /// Container header
-
    header: BoxedWidget<B>,
    /// Content widget
    content: BoxedWidget<B>,
-
    /// Container footer
-
    footer: BoxedWidget<B>,
+
    /// Shortcut widget
+
    shortcuts: BoxedWidget<B>,
}

-
impl<'a, B: Backend> View<State, Action> for Help<'a, 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: HelpProps::from(state),
-
            header: Header::new(state, action_tx.clone())
-
                .on_update(|state| {
-
                    let props = HelpProps::from(state);
-

-
                    Box::<HeaderProps<'_>>::new(
-
                        HeaderProps::default()
-
                            .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec())
-
                            .focus(props.focus),
-
                    )
-
                })
-
                .to_boxed(),
-
            content: Paragraph::new(state, action_tx.clone())
-
                .on_update(|state| {
-
                    let props = HelpProps::from(state);
-

-
                    Box::<ParagraphProps<'_>>::new(
-
                        ParagraphProps::default()
-
                            .text(&props.content)
-
                            .page_size(props.page_size)
-
                            .focus(props.focus),
-
                    )
-
                })
-
                .to_boxed(),
-
            footer: Footer::new(state, action_tx)
-
                .on_update(|state| {
-
                    let props = HelpProps::from(state);
-
                    let progress = span::default(format!("{}%", 0)).dim();
-

-
                    Box::<FooterProps<'_>>::new(
-
                        FooterProps::default()
-
                            .columns(
-
                                [
-
                                    Column::new(Text::raw(""), Constraint::Fill(1)),
-
                                    Column::new(Text::from(progress), Constraint::Min(4)),
-
                                ]
-
                                .to_vec(),
-
                            )
-
                            .focus(props.focus),
-
                    )
-
                })
+
            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,
        }
@@ -834,20 +764,18 @@ impl<'a, B: Backend> View<State, Action> for Help<'a, B> {
    }

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

-
        self.header.update(state);
        self.content.update(state);
-
        self.footer.update(state);
    }

    fn handle_key_event(&mut self, key: termion::event::Key) {
        match key {
-
            Key::Esc => {
+
            Key::Esc | Key::Ctrl('c') => {
                let _ = self.action_tx.send(Action::Exit { selection: None });
            }
            Key::Char('?') => {
-
                let _ = self.action_tx.send(Action::CloseHelp);
+
                let _ = self.action_tx.send(Action::LeavePage);
            }
            _ => {
                self.content.handle_key_event(key);
@@ -856,22 +784,173 @@ impl<'a, B: Backend> View<State, Action> for Help<'a, B> {
    }
}

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

-
        self.header.render(frame, header_area, &());
-
        self.content.render(frame, content_area, &());
-
        self.footer.render(frame, footer_area, &());
-

-
        let page_size = content_area.height as usize;
-
        if page_size != self.props.page_size {
-
            let _ = self.action_tx.send(Action::PageSize(page_size));
+
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(
+
        [
+
            Line::from(Span::raw("Generic keybindings").cyan()),
+
            Line::raw(""),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "↑,k")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("move cursor one line up").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "↓,j")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("move cursor one line down").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "PageUp")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("move cursor one page up").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "PageDown")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("move cursor one page down").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "Home")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("move cursor to the first line").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "End")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("move cursor to the last line").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::raw(""),
+
            Line::from(Span::raw("Specific keybindings").cyan()),
+
            Line::raw(""),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "enter")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("Select patch (if --mode id)").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "enter")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("Show patch").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "c")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("Checkout patch").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "d")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("Show patch diff").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "/")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("Search").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "?")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("Show help").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "Esc")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("Quit / cancel").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::raw(""),
+
            Line::from(Span::raw("Searching").cyan()),
+
            Line::raw(""),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "Pattern")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("is:<state> | is:authored | authors:[<did>, <did>] | <search>")
+
                        .gray()
+
                        .dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "Example")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("is:open is:authored improve").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::raw(""),
+
            Line::raw(""),
+
        ]
+
        .to_vec(),
+
    )
+
}
modified src/lib.rs
@@ -67,3 +67,33 @@ where
        state.end()
    }
}
+

+
/// A 'PageStack' for applications. Page identifier can be pushed to and
+
/// popped from the stack.
+
#[derive(Clone, Default, Debug)]
+
pub struct PageStack<T> {
+
    pages: Vec<T>,
+
}
+

+
impl<T> PageStack<T> {
+
    pub fn new(pages: Vec<T>) -> Self {
+
        Self { pages }
+
    }
+

+
    pub fn push(&mut self, page: T) {
+
        self.pages.push(page);
+
    }
+

+
    pub fn pop(&mut self) -> Option<T> {
+
        self.pages.pop()
+
    }
+

+
    pub fn peek(&self) -> Result<&T> {
+
        match self.pages.last() {
+
            Some(page) => Ok(page),
+
            None => Err(anyhow::anyhow!(
+
                "Could not peek active page. Page stack is empty."
+
            )),
+
        }
+
    }
+
}
modified src/ui.rs
@@ -45,7 +45,7 @@ impl<A> Frontend<A> {
    ) -> anyhow::Result<Interrupted<P>>
    where
        S: State<A, P>,
-
        W: Widget<S, A, Backend>,
+
        W: Widget<Backend, S, A>,
        P: Clone + Send + Sync + Debug,
    {
        let mut ticker = tokio::time::interval(RENDERING_TICK_RATE);
@@ -79,7 +79,7 @@ impl<A> Frontend<A> {
                    break Ok(interrupted);
                }
            }
-
            terminal.draw(|frame| root.render(frame, frame.size(), &()))?;
+
            terminal.draw(|frame| root.render(frame, frame.size(), None))?;
        };

        terminal::restore(&mut terminal)?;
modified src/ui/widget.rs
@@ -11,64 +11,96 @@ use tokio::sync::mpsc::UnboundedSender;
use termion::event::Key;

use ratatui::prelude::*;
-
use ratatui::widgets::{Block, BorderType, Borders, Cell, Row, TableState};
+
use ratatui::widgets::{Cell, Row, TableState};

use super::theme::style;
use super::{layout, span};

-
pub type BoxedWidget<S, A, B> = Box<dyn Widget<S, A, B>>;
+
pub type BoxedWidget<B, S, A> = Box<dyn Widget<B, S, A>>;

pub type UpdateCallback<S> = fn(&S) -> Box<dyn Any>;
pub type EventCallback<A> = fn(&dyn Any, UnboundedSender<A>);

+
/// Main trait defining a `View` behaviour.
+
///
+
/// This is the first trait that you should implement to define a custom `Widget`.
pub trait View<S, A> {
+
    /// Should return a new view with props build from state (if type is known) and a
+
    /// message sender set.
    fn new(state: &S, action_tx: UnboundedSender<A>) -> Self
    where
        Self: Sized;

+
    /// Should set the optional custom event handler.
    fn on_change(self, callback: EventCallback<A>) -> Self
    where
        Self: Sized;

+
    /// Should set the optional update handler.
    fn on_update(self, callback: UpdateCallback<S>) -> Self
    where
        Self: Sized;

-
    fn handle_key_event(&mut self, key: Key);
-

-
    fn update(&mut self, state: &S);
-

+
    /// Returns a boxed `View`
    fn to_boxed(self) -> Box<Self>
    where
        Self: Sized,
    {
        Box::new(self)
    }
+

+
    /// Should handle key events and call `handle_key_event` on all children.
+
    ///
+
    /// After key events have been handled, the custom event handler `on_change` should
+
    /// be called
+
    fn handle_key_event(&mut self, key: Key);
+

+
    /// Should update internal props by calling the custom update handler `on_update`
+
    /// and call `update` on all children.
+
    fn update(&mut self, state: &S);
}

-
pub trait Widget<S, A, B: Backend>: View<S, A> {
-
    fn render(&self, frame: &mut Frame, area: Rect, _props: &dyn Any);
+
/// A `Widget` is a `View` that can be rendered using a specific backend.
+
///
+
/// This is the second trait that you should implement to define a custom `Widget`.
+
pub trait Widget<B, S, A>: View<S, A>
+
where
+
    B: Backend,
+
{
+
    /// Renders a widget to the given frame in the given area.
+
    ///
+
    /// Optional props take precedence over the internal ones.
+
    fn render(&self, frame: &mut Frame, area: Rect, props: Option<Box<dyn Any>>);
}

+
/// Needs to be implemented for items that are supposed to be rendering in tables.
pub trait ToRow {
    fn to_row(&self) -> Vec<Cell>;
}

+
/// Common trait for view properties.
+
pub trait Properties {
+
    fn to_boxed(self) -> Box<Self>
+
    where
+
        Self: Sized,
+
    {
+
        Box::new(self)
+
    }
+

+
    fn from_boxed_any(any: Box<dyn Any>) -> Option<Self>
+
    where
+
        Self: Sized + Clone + 'static,
+
    {
+
        any.downcast_ref::<Self>().cloned()
+
    }
+
}
+

#[derive(Clone)]
pub struct ShortcutsProps {
    pub shortcuts: Vec<(String, String)>,
    pub divider: char,
}

-
impl Default for ShortcutsProps {
-
    fn default() -> Self {
-
        Self {
-
            shortcuts: vec![],
-
            divider: '∙',
-
        }
-
    }
-
}
-

impl ShortcutsProps {
    pub fn divider(mut self, divider: char) -> Self {
        self.divider = divider;
@@ -84,11 +116,22 @@ impl ShortcutsProps {
    }
}

+
impl Default for ShortcutsProps {
+
    fn default() -> Self {
+
        Self {
+
            shortcuts: vec![],
+
            divider: '∙',
+
        }
+
    }
+
}
+

+
impl Properties for ShortcutsProps {}
+

pub struct Shortcuts<S, A> {
    /// Internal properties
    props: ShortcutsProps,
    /// Message sender
-
    action_tx: UnboundedSender<A>,
+
    _action_tx: UnboundedSender<A>,
    /// Custom update handler
    on_update: Option<UpdateCallback<S>>,
    /// Additional custom event handler
@@ -115,7 +158,7 @@ impl<S, A> Shortcuts<S, A> {
impl<S, A> View<S, A> for Shortcuts<S, A> {
    fn new(_state: &S, action_tx: UnboundedSender<A>) -> Self {
        Self {
-
            action_tx: action_tx.clone(),
+
            _action_tx: action_tx.clone(),
            props: ShortcutsProps::default(),
            on_update: None,
            on_change: None,
@@ -132,34 +175,35 @@ impl<S, A> View<S, A> for Shortcuts<S, A> {
        self
    }

-
    fn handle_key_event(&mut self, _key: Key) {
-
        if let Some(on_change) = self.on_change {
-
            (on_change)(&self.props, self.action_tx.clone());
-
        }
-
    }
+
    fn handle_key_event(&mut self, _key: Key) {}

    fn update(&mut self, state: &S) {
-
        if let Some(on_update) = self.on_update {
-
            if let Some(props) = (on_update)(state).downcast_ref::<ShortcutsProps>() {
-
                self.props.shortcuts = props.shortcuts.clone();
-
            }
-
        }
+
        self.props = self
+
            .on_update
+
            .and_then(|on_update| ShortcutsProps::from_boxed_any((on_update)(state)))
+
            .unwrap_or(self.props.clone())
    }
}

-
impl<S, A, B: Backend> Widget<S, A, B> for Shortcuts<S, A> {
-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: &dyn Any) {
+
impl<B, S, A> Widget<B, S, A> for Shortcuts<S, A>
+
where
+
    B: Backend,
+
{
+
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, props: Option<Box<dyn Any>>) {
        use ratatui::widgets::Table;

-
        let mut shortcuts = self.props.shortcuts.iter().peekable();
+
        let props = props
+
            .and_then(|props| ShortcutsProps::from_boxed_any(props))
+
            .unwrap_or(self.props.clone());
+

+
        let mut shortcuts = props.shortcuts.iter().peekable();
        let mut row = vec![];

        while let Some(shortcut) = shortcuts.next() {
            let short = Text::from(shortcut.0.clone()).style(style::gray());
            let long = Text::from(shortcut.1.clone()).style(style::gray().dim());
            let spacer = Text::from(String::new());
-
            let divider =
-
                Text::from(format!(" {} ", self.props.divider)).style(style::gray().dim());
+
            let divider = Text::from(format!(" {} ", props.divider)).style(style::gray().dim());

            row.push((shortcut.0.chars().count(), short));
            row.push((1, spacer));
@@ -278,9 +322,10 @@ where
    }
}

-
pub struct Table<'a, S, A, B, R>
+
impl<'a: 'static, R> Properties for TableProps<'a, R> where R: ToRow + 'static {}
+

+
pub struct Table<'a, S, A, R>
where
-
    B: Backend,
    R: ToRow,
{
    /// Internal table properties
@@ -293,20 +338,12 @@ where
    on_change: Option<EventCallback<A>>,
    /// Internal selection and offset state
    state: TableState,
-
    /// Table header widget
-
    header: Option<BoxedWidget<S, A, B>>,
}

-
impl<'a, S, A, B, R> Table<'a, S, A, B, R>
+
impl<'a, S, A, R> Table<'a, S, A, R>
where
-
    B: Backend,
    R: ToRow,
{
-
    pub fn header(mut self, header: BoxedWidget<S, A, B>) -> Self {
-
        self.header = Some(header);
-
        self
-
    }
-

    fn prev(&mut self) -> Option<usize> {
        let selected = self
            .state
@@ -356,30 +393,10 @@ where
    fn end(&mut self, len: usize) {
        self.state.select(Some(len.saturating_sub(1)));
    }
-

-
    pub fn progress(selected: usize, len: usize, page_size: usize) -> usize {
-
        let step = selected;
-
        let page_size = page_size as f64;
-
        let len = len as f64;
-

-
        let lines = page_size + step.saturating_sub(page_size as usize) as f64;
-
        let progress = (lines / len * 100.0).ceil();
-

-
        if progress > 97.0 {
-
            Self::map_range((0.0, progress), (0.0, 100.0), progress) as usize
-
        } else {
-
            progress as usize
-
        }
-
    }
-

-
    fn map_range(from: (f64, f64), to: (f64, f64), value: f64) -> f64 {
-
        to.0 + (value - from.0) * (to.1 - to.0) / (from.1 - from.0)
-
    }
}

-
impl<'a: 'static, S, A, B, R> View<S, A> for Table<'a, S, A, B, R>
+
impl<'a: 'static, S, A, R> View<S, A> for Table<'a, S, A, R>
where
-
    B: Backend,
    R: ToRow + Clone + 'static,
{
    fn new(_state: &S, action_tx: UnboundedSender<A>) -> Self {
@@ -387,7 +404,6 @@ where
            action_tx: action_tx.clone(),
            props: TableProps::default(),
            state: TableState::default().with_selected(Some(0)),
-
            header: None,
            on_update: None,
            on_change: None,
        }
@@ -404,11 +420,10 @@ where
    }

    fn update(&mut self, state: &S) {
-
        if let Some(on_update) = self.on_update {
-
            if let Some(props) = (on_update)(state).downcast_ref::<TableProps<'_, R>>() {
-
                self.props = props.clone();
-
            }
-
        }
+
        self.props = self
+
            .on_update
+
            .and_then(|on_update| TableProps::<'_, R>::from_boxed_any((on_update)(state)))
+
            .unwrap_or(self.props.clone());

        // TODO: Move to state reducer
        if let Some(selected) = self.state.selected() {
@@ -444,20 +459,21 @@ where
        self.props.selected = self.state.selected();

        if let Some(on_change) = self.on_change {
-
            (on_change)(&self.props, self.action_tx.clone());
+
            (on_change)(&self.state, self.action_tx.clone());
        }
    }
}

-
impl<'a: 'static, S, A, B, R> Widget<S, A, B> for Table<'a, S, A, B, R>
+
impl<'a: 'static, B, S, A, R> Widget<B, S, A> for Table<'a, S, A, R>
where
    B: Backend,
    R: ToRow + Clone + Debug + 'static,
{
-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: &dyn Any) {
-
        let header_height = if self.header.is_some() { 3 } else { 0 };
-
        let [header_area, table_area] =
-
            Layout::vertical([Constraint::Length(header_height), Constraint::Min(1)]).areas(area);
+
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, props: Option<Box<dyn Any>>) {
+
        let props = props
+
            .and_then(|props| TableProps::<'_, R>::from_boxed_any(props))
+
            .unwrap_or(self.props.clone());
+

        let widths: Vec<Constraint> = self
            .props
            .columns
@@ -465,30 +481,19 @@ where
            .filter_map(|c| if !c.skip { Some(c.width) } else { None })
            .collect();

-
        let widths = if area.width < self.props.cutoff as u16 {
-
            widths
-
                .iter()
-
                .take(self.props.cutoff_after)
-
                .collect::<Vec<_>>()
+
        let widths = if area.width < props.cutoff as u16 {
+
            widths.iter().take(props.cutoff_after).collect::<Vec<_>>()
        } else {
            widths.iter().collect::<Vec<_>>()
        };

-
        let borders = match (self.header.is_some(), self.props.has_footer) {
-
            (false, false) => Borders::ALL,
-
            (true, false) => Borders::BOTTOM | Borders::LEFT | Borders::RIGHT,
-
            (false, true) => Borders::TOP | Borders::LEFT | Borders::RIGHT,
-
            (true, true) => Borders::LEFT | Borders::RIGHT,
-
        };
-

-
        if !self.props.items.is_empty() {
-
            let rows = self
-
                .props
+
        if !props.items.is_empty() {
+
            let rows = props
                .items
                .iter()
                .map(|item| {
                    let mut cells = vec![];
-
                    let mut it = self.props.columns.iter();
+
                    let mut it = props.columns.iter();

                    for cell in item.to_row() {
                        if let Some(col) = it.next() {
@@ -507,31 +512,11 @@ where
                .rows(rows)
                .widths(widths)
                .column_spacing(1)
-
                .block(
-
                    Block::default()
-
                        .border_style(style::border(self.props.focus))
-
                        .border_type(BorderType::Rounded)
-
                        .borders(borders),
-
                )
                .highlight_style(style::highlight());

-
            if let Some(header) = &self.header {
-
                header.render(frame, header_area, &());
-
            }
-

-
            frame.render_stateful_widget(rows, table_area, &mut self.state.clone());
+
            frame.render_stateful_widget(rows, area, &mut self.state.clone());
        } else {
-
            let block = Block::default()
-
                .border_style(style::border(self.props.focus))
-
                .border_type(BorderType::Rounded)
-
                .borders(borders);
-

-
            if let Some(header) = &self.header {
-
                header.render(frame, header_area, &());
-
            }
-
            frame.render_widget(block, table_area);
-

-
            let center = layout::centered_rect(table_area, 50, 10);
+
            let center = layout::centered_rect(area, 50, 10);
            let hint = Text::from(span::default("Nothing to show".to_string()))
                .centered()
                .light_magenta()
@@ -541,3 +526,26 @@ where
        }
    }
}
+

+
pub struct TableUtils {}
+

+
impl TableUtils {
+
    pub fn progress(selected: usize, len: usize, page_size: usize) -> usize {
+
        let step = selected;
+
        let page_size = page_size as f64;
+
        let len = len as f64;
+

+
        let lines = page_size + step.saturating_sub(page_size as usize) as f64;
+
        let progress = (lines / len * 100.0).ceil();
+

+
        if progress > 97.0 {
+
            Self::map_range((0.0, progress), (0.0, 100.0), progress) as usize
+
        } else {
+
            progress as usize
+
        }
+
    }
+

+
    fn map_range(from: (f64, f64), to: (f64, f64), value: f64) -> f64 {
+
        to.0 + (value - from.0) * (to.1 - to.0) / (from.1 - from.0)
+
    }
+
}
modified src/ui/widget/container.rs
@@ -6,12 +6,12 @@ use tokio::sync::mpsc::UnboundedSender;
use termion::event::Key;

use ratatui::prelude::*;
-
use ratatui::widgets::{BorderType, Borders, Row};
+
use ratatui::widgets::{Block, BorderType, Borders, Row};

use crate::ui::ext::{FooterBlock, FooterBlockType, HeaderBlock};
use crate::ui::theme::style;

-
use super::{Column, EventCallback, UpdateCallback, View, Widget};
+
use super::{BoxedWidget, Column, EventCallback, Properties, UpdateCallback, View, Widget};

#[derive(Clone, Debug)]
pub struct HeaderProps<'a> {
@@ -50,6 +50,8 @@ impl<'a> Default for HeaderProps<'a> {
    }
}

+
impl<'a: 'static> Properties for HeaderProps<'a> {}
+

pub struct Header<'a, S, A> {
    /// Internal props
    props: HeaderProps<'a>,
@@ -100,11 +102,10 @@ impl<'a: 'static, S, A> View<S, A> for Header<'a, S, A> {
    }

    fn update(&mut self, state: &S) {
-
        if let Some(on_update) = self.on_update {
-
            if let Some(props) = (on_update)(state).downcast_ref::<HeaderProps<'_>>() {
-
                self.props = props.clone();
-
            }
-
        }
+
        self.props = self
+
            .on_update
+
            .and_then(|on_update| HeaderProps::from_boxed_any((on_update)(state)))
+
            .unwrap_or(self.props.clone());
    }

    fn handle_key_event(&mut self, _key: Key) {
@@ -114,10 +115,16 @@ impl<'a: 'static, S, A> View<S, A> for Header<'a, S, A> {
    }
}

-
impl<'a: 'static, S, A, B: Backend> Widget<S, A, B> for Header<'a, S, A> {
-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: &dyn Any) {
-
        let widths: Vec<Constraint> = self
-
            .props
+
impl<'a: 'static, B, S, A> Widget<B, S, A> for Header<'a, S, A>
+
where
+
    B: Backend,
+
{
+
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, props: Option<Box<dyn Any>>) {
+
        let props = props
+
            .and_then(|props| HeaderProps::from_boxed_any(props))
+
            .unwrap_or(self.props.clone());
+

+
        let widths: Vec<Constraint> = props
            .columns
            .iter()
            .filter_map(|column| {
@@ -128,8 +135,7 @@ impl<'a: 'static, S, A, B: Backend> Widget<S, A, B> for Header<'a, S, A> {
                }
            })
            .collect();
-
        let cells = self
-
            .props
+
        let cells = props
            .columns
            .iter()
            .filter_map(|column| {
@@ -141,11 +147,8 @@ impl<'a: 'static, S, A, B: Backend> Widget<S, A, B> for Header<'a, S, A> {
            })
            .collect::<Vec<_>>();

-
        let widths = if area.width < self.props.cutoff as u16 {
-
            widths
-
                .iter()
-
                .take(self.props.cutoff_after)
-
                .collect::<Vec<_>>()
+
        let widths = if area.width < props.cutoff as u16 {
+
            widths.iter().take(props.cutoff_after).collect::<Vec<_>>()
        } else {
            widths.iter().collect::<Vec<_>>()
        };
@@ -153,7 +156,7 @@ impl<'a: 'static, S, A, B: Backend> Widget<S, A, B> for Header<'a, S, A> {
        // Render header
        let block = HeaderBlock::default()
            .borders(Borders::ALL)
-
            .border_style(style::border(self.props.focus))
+
            .border_style(style::border(props.focus))
            .border_type(BorderType::Rounded);

        let header_layout = Layout::default()
@@ -211,6 +214,8 @@ impl<'a> Default for FooterProps<'a> {
    }
}

+
impl<'a: 'static> Properties for FooterProps<'a> {}
+

pub struct Footer<'a, S, A> {
    /// Internal properties
    props: FooterProps<'a>,
@@ -261,11 +266,10 @@ impl<'a: 'static, S, A> View<S, A> for Footer<'a, S, A> {
    }

    fn update(&mut self, state: &S) {
-
        if let Some(on_update) = self.on_update {
-
            if let Some(props) = (on_update)(state).downcast_ref::<FooterProps<'_>>() {
-
                self.props = props.clone();
-
            }
-
        }
+
        self.props = self
+
            .on_update
+
            .and_then(|on_update| FooterProps::from_boxed_any((on_update)(state)))
+
            .unwrap_or(self.props.clone());
    }

    fn handle_key_event(&mut self, _key: Key) {
@@ -299,10 +303,16 @@ impl<'a, S, A> Footer<'a, S, A> {
    }
}

-
impl<'a: 'static, S, A, B: Backend> Widget<S, A, B> for Footer<'a, S, A> {
-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: &dyn Any) {
-
        let widths = self
-
            .props
+
impl<'a: 'static, B, S, A> Widget<B, S, A> for Footer<'a, S, A>
+
where
+
    B: Backend,
+
{
+
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, props: Option<Box<dyn Any>>) {
+
        let props = props
+
            .and_then(|props| FooterProps::from_boxed_any(props))
+
            .unwrap_or(self.props.clone());
+

+
        let widths = props
            .columns
            .iter()
            .map(|c| match c.width {
@@ -312,8 +322,7 @@ impl<'a: 'static, S, A, B: Backend> Widget<S, A, B> for Footer<'a, S, A> {
            .collect::<Vec<_>>();

        let layout = Layout::horizontal(widths).split(area);
-
        let cells = self
-
            .props
+
        let cells = props
            .columns
            .iter()
            .map(|c| c.text.clone())
@@ -330,7 +339,175 @@ impl<'a: 'static, S, A, B: Backend> Widget<S, A, B> for Footer<'a, S, A> {
                _ if i == last => FooterBlockType::End,
                _ => FooterBlockType::Repeat,
            };
-
            self.render_cell(frame, *area, block_type, cell.clone(), self.props.focus);
+
            self.render_cell(frame, *area, block_type, cell.clone(), props.focus);
+
        }
+
    }
+
}
+

+
#[derive(Clone, Default)]
+
pub struct ContainerProps {
+
    focus: bool,
+
    hide_footer: bool,
+
}
+

+
impl ContainerProps {
+
    pub fn hide_footer(mut self, hide: bool) -> Self {
+
        self.hide_footer = hide;
+
        self
+
    }
+

+
    pub fn focus(mut self, focus: bool) -> Self {
+
        self.focus = focus;
+
        self
+
    }
+
}
+

+
impl Properties for ContainerProps {}
+

+
pub struct Container<B, S, A>
+
where
+
    B: Backend,
+
{
+
    /// Internal properties
+
    props: ContainerProps,
+
    /// Message sender
+
    _action_tx: UnboundedSender<A>,
+
    /// Custom update handler
+
    on_update: Option<UpdateCallback<S>>,
+
    /// Additional custom event handler
+
    on_change: Option<EventCallback<A>>,
+
    /// Container header
+
    header: Option<BoxedWidget<B, S, A>>,
+
    /// Content widget
+
    content: Option<BoxedWidget<B, S, A>>,
+
    /// Container footer
+
    footer: Option<BoxedWidget<B, S, A>>,
+
}
+

+
impl<B, S, A> Container<B, S, A>
+
where
+
    B: Backend,
+
{
+
    pub fn header(mut self, header: BoxedWidget<B, S, A>) -> Self {
+
        self.header = Some(header);
+
        self
+
    }
+

+
    pub fn content(mut self, content: BoxedWidget<B, S, A>) -> Self {
+
        self.content = Some(content);
+
        self
+
    }
+

+
    pub fn footer(mut self, footer: BoxedWidget<B, S, A>) -> Self {
+
        self.footer = Some(footer);
+
        self
+
    }
+
}
+

+
impl<B, S, A> View<S, A> for Container<B, S, A>
+
where
+
    B: Backend,
+
{
+
    fn new(_state: &S, action_tx: UnboundedSender<A>) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self {
+
            _action_tx: action_tx.clone(),
+
            props: ContainerProps::default(),
+
            header: None,
+
            content: None,
+
            footer: None,
+
            on_update: None,
+
            on_change: None,
+
        }
+
    }
+

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

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

+
    fn update(&mut self, state: &S) {
+
        self.props = self
+
            .on_update
+
            .and_then(|on_update| ContainerProps::from_boxed_any((on_update)(state)))
+
            .unwrap_or(self.props.clone());
+

+
        if let Some(header) = &mut self.header {
+
            header.update(state);
+
        }
+

+
        if let Some(content) = &mut self.content {
+
            content.update(state);
+
        }
+

+
        if let Some(footer) = &mut self.footer {
+
            footer.update(state);
+
        }
+
    }
+

+
    fn handle_key_event(&mut self, key: termion::event::Key) {
+
        if let Some(content) = &mut self.content {
+
            content.handle_key_event(key);
+
        }
+
    }
+
}
+

+
impl<'a: 'static, B, S, A> Widget<B, S, A> for Container<B, S, A>
+
where
+
    B: Backend,
+
{
+
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, props: Option<Box<dyn Any>>) {
+
        let props = props
+
            .and_then(|props| ContainerProps::from_boxed_any(props))
+
            .unwrap_or(self.props.clone());
+

+
        let header_h = if self.header.is_some() { 3 } else { 0 };
+
        let footer_h = if self.footer.is_some() && !props.hide_footer {
+
            3
+
        } else {
+
            0
+
        };
+

+
        let [header_area, content_area, footer_area] = Layout::vertical([
+
            Constraint::Length(header_h),
+
            Constraint::Min(1),
+
            Constraint::Length(footer_h),
+
        ])
+
        .areas(area);
+

+
        let borders = match (
+
            self.header.is_some(),
+
            (self.footer.is_some() && !props.hide_footer),
+
        ) {
+
            (false, false) => Borders::ALL,
+
            (true, false) => Borders::BOTTOM | Borders::LEFT | Borders::RIGHT,
+
            (false, true) => Borders::TOP | Borders::LEFT | Borders::RIGHT,
+
            (true, true) => Borders::LEFT | Borders::RIGHT,
+
        };
+

+
        let block = Block::default()
+
            .border_style(style::border(props.focus))
+
            .border_type(BorderType::Rounded)
+
            .borders(borders);
+
        frame.render_widget(block.clone(), content_area);
+

+
        if let Some(header) = &self.header {
+
            header.render(frame, header_area, None);
+
        }
+

+
        if let Some(content) = &self.content {
+
            content.render(frame, block.inner(content_area), None);
+
        }
+

+
        if let Some(footer) = &self.footer {
+
            footer.render(frame, footer_area, None);
        }
    }
}
modified src/ui/widget/input.rs
@@ -9,7 +9,7 @@ use ratatui::prelude::{Backend, Rect};
use ratatui::style::Stylize;
use ratatui::text::{Line, Span};

-
use super::{EventCallback, UpdateCallback, View, Widget};
+
use super::{EventCallback, Properties, UpdateCallback, View, Widget};

#[derive(Clone)]
pub struct TextFieldProps {
@@ -17,14 +17,12 @@ pub struct TextFieldProps {
    pub inline_label: bool,
    pub show_cursor: bool,
    pub text: String,
-
    pub cursor_position: usize,
}

impl TextFieldProps {
    pub fn text(mut self, new_text: &str) -> Self {
        if self.text != new_text {
            self.text = String::from(new_text);
-
            self.cursor_position = self.text.len();
        }
        self
    }
@@ -47,11 +45,16 @@ impl Default for TextFieldProps {
            inline_label: false,
            show_cursor: true,
            text: String::new(),
-
            cursor_position: 0,
        }
    }
}

+
impl Properties for TextFieldProps {}
+
pub struct TextFieldState {
+
    pub text: Option<String>,
+
    pub cursor_position: usize,
+
}
+

pub struct TextField<S, A> {
    /// Internal props
    props: TextFieldProps,
@@ -61,70 +64,69 @@ pub struct TextField<S, A> {
    on_update: Option<UpdateCallback<S>>,
    /// Additional custom event handler
    on_change: Option<EventCallback<A>>,
+
    /// Internal state
+
    state: TextFieldState,
}

impl<S, A> TextField<S, A> {
-
    pub fn read(&self) -> &str {
-
        &self.props.text
-
    }
-

-
    pub fn text(mut self, new_text: &str) -> Self {
-
        if self.props.text != new_text {
-
            self.props.text = String::from(new_text);
-
            self.props.cursor_position = self.props.text.len();
-
        }
-
        self
-
    }
-

-
    pub fn title(mut self, title: &str) -> Self {
-
        self.props.title = title.to_string();
-
        self
-
    }
-

-
    pub fn inline(mut self, inline: bool) -> Self {
-
        self.props.inline_label = inline;
-
        self
-
    }
-

    fn move_cursor_left(&mut self) {
-
        let cursor_moved_left = self.props.cursor_position.saturating_sub(1);
-
        self.props.cursor_position = self.clamp_cursor(cursor_moved_left);
+
        let cursor_moved_left = self.state.cursor_position.saturating_sub(1);
+
        self.state.cursor_position = self.clamp_cursor(cursor_moved_left);
    }

    fn move_cursor_right(&mut self) {
-
        let cursor_moved_right = self.props.cursor_position.saturating_add(1);
-
        self.props.cursor_position = self.clamp_cursor(cursor_moved_right);
+
        let cursor_moved_right = self.state.cursor_position.saturating_add(1);
+
        self.state.cursor_position = self.clamp_cursor(cursor_moved_right);
    }

    fn enter_char(&mut self, new_char: char) {
-
        self.props.text.insert(self.props.cursor_position, new_char);
+
        self.state.text = Some(self.state.text.clone().unwrap_or_default());
+
        self.state
+
            .text
+
            .as_mut()
+
            .unwrap()
+
            .insert(self.state.cursor_position, new_char);
        self.move_cursor_right();
    }

    fn delete_char(&mut self) {
-
        let is_not_cursor_leftmost = self.props.cursor_position != 0;
+
        self.state.text = Some(self.state.text.clone().unwrap_or_default());
+

+
        let is_not_cursor_leftmost = self.state.cursor_position != 0;
        if is_not_cursor_leftmost {
            // Method "remove" is not used on the saved text for deleting the selected char.
            // Reason: Using remove on String works on bytes instead of the chars.
            // Using remove would require special care because of char boundaries.

-
            let current_index = self.props.cursor_position;
+
            let current_index = self.state.cursor_position;
            let from_left_to_current_index = current_index - 1;

            // Getting all characters before the selected character.
-
            let before_char_to_delete = self.props.text.chars().take(from_left_to_current_index);
+
            let before_char_to_delete = self
+
                .state
+
                .text
+
                .as_ref()
+
                .unwrap()
+
                .chars()
+
                .take(from_left_to_current_index);
            // Getting all characters after selected character.
-
            let after_char_to_delete = self.props.text.chars().skip(current_index);
+
            let after_char_to_delete = self
+
                .state
+
                .text
+
                .as_ref()
+
                .unwrap()
+
                .chars()
+
                .skip(current_index);

            // Put all characters together except the selected one.
            // By leaving the selected one out, it is forgotten and therefore deleted.
-
            self.props.text = before_char_to_delete.chain(after_char_to_delete).collect();
+
            self.state.text = Some(before_char_to_delete.chain(after_char_to_delete).collect());
            self.move_cursor_left();
        }
    }

    fn clamp_cursor(&self, new_cursor_pos: usize) -> usize {
-
        new_cursor_pos.clamp(0, self.props.text.len())
+
        new_cursor_pos.clamp(0, self.state.text.clone().unwrap_or_default().len())
    }
}

@@ -135,6 +137,10 @@ impl<S, A> View<S, A> for TextField<S, A> {
            props: TextFieldProps::default(),
            on_update: None,
            on_change: None,
+
            state: TextFieldState {
+
                text: None,
+
                cursor_position: 0,
+
            },
        }
    }

@@ -152,6 +158,11 @@ impl<S, A> View<S, A> for TextField<S, A> {
        if let Some(on_update) = self.on_update {
            if let Some(props) = (on_update)(state).downcast_ref::<TextFieldProps>() {
                self.props = props.clone();
+

+
                if self.state.text.is_none() {
+
                    self.state.cursor_position = props.text.len().saturating_sub(1);
+
                }
+
                self.state.text = Some(props.text.clone());
            }
        }
    }
@@ -178,21 +189,29 @@ impl<S, A> View<S, A> for TextField<S, A> {
        }

        if let Some(on_change) = self.on_change {
-
            (on_change)(&self.props, self.action_tx.clone());
+
            (on_change)(&self.state, self.action_tx.clone());
        }
    }
}

-
impl<S, A, B: Backend> Widget<S, A, B> for TextField<S, A> {
-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: &dyn Any) {
+
impl<B, S, A> Widget<B, S, A> for TextField<S, A>
+
where
+
    B: Backend,
+
{
+
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, props: Option<Box<dyn Any>>) {
+
        let props = props
+
            .and_then(|props| TextFieldProps::from_boxed_any(props))
+
            .unwrap_or(self.props.clone());
+

        let layout = Layout::vertical(Constraint::from_lengths([1, 1])).split(area);

-
        let input = self.props.text.as_str();
-
        let label = format!(" {} ", self.props.title);
+
        let text = self.state.text.clone().unwrap_or_default();
+
        let input = text.as_str();
+
        let label = format!(" {} ", props.title);
        let overline = String::from("▔").repeat(area.width as usize);
-
        let cursor_pos = self.props.cursor_position as u16;
+
        let cursor_pos = self.state.cursor_position as u16;

-
        if self.props.inline_label {
+
        if props.inline_label {
            let top_layout = Layout::horizontal([
                Constraint::Length(label.chars().count() as u16),
                Constraint::Length(1),
@@ -209,7 +228,7 @@ impl<S, A, B: Backend> Widget<S, A, B> for TextField<S, A> {
            frame.render_widget(input, top_layout[2]);
            frame.render_widget(overline, layout[1]);

-
            if self.props.show_cursor {
+
            if props.show_cursor {
                frame.set_cursor(top_layout[2].x + cursor_pos, top_layout[2].y)
            }
        } else {
@@ -225,7 +244,7 @@ impl<S, A, B: Backend> Widget<S, A, B> for TextField<S, A> {
            frame.render_widget(top, layout[0]);
            frame.render_widget(bottom, layout[1]);

-
            if self.props.show_cursor {
+
            if props.show_cursor {
                frame.set_cursor(area.x + cursor_pos, area.y)
            }
        }
modified src/ui/widget/text.rs
@@ -7,11 +7,8 @@ use termion::event::Key;
use ratatui::backend::Backend;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::text::Text;
-
use ratatui::widgets::{Block, BorderType, Borders};

-
use crate::ui::theme::style;
-

-
use super::{EventCallback, UpdateCallback, View, Widget};
+
use super::{EventCallback, Properties, UpdateCallback, View, Widget};

#[derive(Clone)]
pub struct ParagraphProps<'a> {
@@ -20,6 +17,7 @@ pub struct ParagraphProps<'a> {
    pub has_header: bool,
    pub has_footer: bool,
    pub page_size: usize,
+
    pub progress: usize,
}

impl<'a> ParagraphProps<'a> {
@@ -47,10 +45,20 @@ impl<'a> Default for ParagraphProps<'a> {
            has_header: false,
            has_footer: false,
            page_size: 1,
+
            progress: 0,
        }
    }
}

+
impl<'a: 'static> Properties for ParagraphProps<'a> {}
+

+
pub struct ParagraphState {
+
    /// Internal offset
+
    pub offset: usize,
+
    /// Internal progress
+
    pub progress: usize,
+
}
+

pub struct Paragraph<'a, S, A> {
    /// Internal properties
    props: ParagraphProps<'a>,
@@ -60,15 +68,13 @@ pub struct Paragraph<'a, S, A> {
    on_update: Option<UpdateCallback<S>>,
    /// Additional custom event handler
    on_change: Option<EventCallback<A>>,
-
    /// Internal offset
-
    offset: usize,
-
    /// Internal progress
-
    progress: usize,
+
    /// Internal state
+
    state: ParagraphState,
}

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

    pub fn page_size(mut self, page_size: usize) -> Self {
@@ -82,48 +88,48 @@ impl<'a, S, A> Paragraph<'a, S, A> {
    }

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

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

        self.scroll()
    }

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

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

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

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

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

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

    fn scroll_percent(offset: usize, len: usize, height: usize) -> usize {
@@ -147,11 +153,13 @@ impl<'a: 'static, S, A> View<S, A> for Paragraph<'a, S, A> {
    {
        Self {
            action_tx: action_tx.clone(),
-
            offset: 0,
-
            progress: 0,
            props: ParagraphProps::default(),
            on_update: None,
            on_change: None,
+
            state: ParagraphState {
+
                offset: 0,
+
                progress: 0,
+
            },
        }
    }

@@ -166,11 +174,10 @@ impl<'a: 'static, S, A> View<S, A> for Paragraph<'a, S, A> {
    }

    fn update(&mut self, state: &S) {
-
        if let Some(on_update) = self.on_update {
-
            if let Some(props) = (on_update)(state).downcast_ref::<ParagraphProps<'_>>() {
-
                self.props = props.clone();
-
            }
-
        }
+
        self.props = self
+
            .on_update
+
            .and_then(|on_update| ParagraphProps::from_boxed_any((on_update)(state)))
+
            .unwrap_or(self.props.clone());
    }

    fn handle_key_event(&mut self, key: Key) {
@@ -200,24 +207,25 @@ impl<'a: 'static, S, A> View<S, A> for Paragraph<'a, S, A> {
        }

        if let Some(on_change) = self.on_change {
-
            (on_change)(&self.props, self.action_tx.clone());
+
            (on_change)(&self.state, self.action_tx.clone());
        }
    }
}

-
impl<'a: 'static, S, A, B: Backend> Widget<S, A, B> for Paragraph<'a, S, A> {
-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: &dyn Any) {
-
        let block = Block::default()
-
            .borders(Borders::LEFT | Borders::RIGHT)
-
            .border_type(BorderType::Rounded)
-
            .border_style(style::border(self.props.focus));
-
        frame.render_widget(block, area);
+
impl<'a: 'static, B, S, A> Widget<B, S, A> for Paragraph<'a, S, A>
+
where
+
    B: Backend,
+
{
+
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, props: Option<Box<dyn Any>>) {
+
        let props = props
+
            .and_then(|props| ParagraphProps::from_boxed_any(props))
+
            .unwrap_or(self.props.clone());

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

        frame.render_widget(content, content_area);
    }