Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
Introduce window and make widget code more concise
Merged did:key:z6MkgFq6...nBGz opened 2 years ago

This introduces a new window widget and allows to pass it as root to the frontend. It also improves the properties and callback interfaces. Last but not least, the span interface is simplified.

13 files changed +651 -878 e2346edc cf32da9a
modified bin/commands/inbox/select.rs
@@ -18,14 +18,16 @@ use tui::cob::inbox::{self};
use tui::store;
use tui::store::StateValue;
use tui::task::{self, Interrupted};
-
use tui::terminal;
+
use tui::terminal::Backend;
use tui::ui::items::{Filter, NotificationItem, NotificationItemFilter};
+
use tui::ui::widget::{Properties, View, Window, WindowProps};
use tui::ui::Frontend;
use tui::Exit;

use tui::PageStack;

-
use crate::tui_inbox::select::ui::Window;
+
use self::ui::BrowsePage;
+
use self::ui::HelpPage;

use super::common::{Mode, RepositoryMode};

@@ -274,15 +276,27 @@ impl App {
    pub async fn run(&self) -> Result<Option<Selection>> {
        let (terminator, mut interrupt_rx) = task::create_termination();
        let (store, state_rx) = store::Store::<Action, State, Selection>::new();
-
        let (frontend, action_rx) = Frontend::<Action>::new();
+
        let (frontend, action_tx, action_rx) = Frontend::new();
        let state = State::try_from(&self.context)?;

+
        let window: Window<Backend, State, Action, Page> = Window::new(&state, action_tx.clone())
+
            .page(
+
                Page::Browse,
+
                BrowsePage::new(&state, action_tx.clone()).to_boxed(),
+
            )
+
            .page(
+
                Page::Help,
+
                HelpPage::new(&state, action_tx.clone()).to_boxed(),
+
            )
+
            .on_update(|state| {
+
                WindowProps::default()
+
                    .current_page(state.pages.peek().unwrap_or(&Page::Browse).clone())
+
                    .to_boxed()
+
            });
+

        tokio::try_join!(
            store.main_loop(state, terminator, action_rx, interrupt_rx.resubscribe()),
-
            frontend.main_loop::<State, Window<terminal::Backend>, Selection>(
-
                state_rx,
-
                interrupt_rx.resubscribe()
-
            ),
+
            frontend.main_loop(Some(window), state_rx, interrupt_rx.resubscribe()),
        )?;

        if let Ok(reason) = interrupt_rx.recv().await {
modified bin/commands/inbox/select/ui.rs
@@ -30,107 +30,11 @@ use tui::Selection;

use crate::tui_inbox::common::{InboxOperation, Mode, RepositoryMode, SelectionMode};

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

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

#[derive(Clone)]
-
pub struct WindowProps {
-
    page: Page,
-
}
-

-
impl From<&State> for WindowProps {
-
    fn from(state: &State) -> Self {
-
        Self {
-
            page: state.pages.peek().unwrap_or(&Page::Browse).clone(),
-
        }
-
    }
-
}
-

-
impl Properties for WindowProps {}
-

-
pub struct Window<B: Backend> {
-
    /// Internal properties
-
    props: WindowProps,
-
    /// Message sender
-
    _action_tx: UnboundedSender<Action>,
-
    /// Custom update handler
-
    on_update: Option<UpdateCallback<State>>,
-
    /// Additional custom event handler
-
    on_change: Option<EventCallback<Action>>,
-
    /// All pages known
-
    pages: HashMap<Page, BoxedWidget<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: 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_update(mut self, callback: UpdateCallback<State>) -> Self {
-
        self.on_update = Some(callback);
-
        self
-
    }
-

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

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

-
        if let Some(page) = self.pages.get_mut(&self.props.page) {
-
            page.update(state);
-
        }
-
    }
-

-
    fn handle_key_event(&mut self, key: termion::event::Key) {
-
        if let Some(page) = self.pages.get_mut(&self.props.page) {
-
            page.handle_key_event(key);
-
        }
-
    }
-
}
-

-
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());
-

-
        let area = frame.size();
-

-
        if let Some(page) = self.pages.get(&props.page) {
-
            page.render(frame, area, None);
-
        }
-
    }
-
}
-

-
#[derive(Clone)]
struct BrowsePageProps<'a> {
    notifications: Vec<NotificationItem>,
    selected: Option<usize>,
@@ -203,7 +107,7 @@ impl<'a> From<&State> for BrowsePageProps<'a> {

impl<'a> Properties for BrowsePageProps<'a> {}

-
struct BrowsePage<'a, B> {
+
pub struct BrowsePage<'a, B> {
    /// Internal properties
    props: BrowsePageProps<'a>,
    /// Message sender
@@ -211,7 +115,7 @@ struct BrowsePage<'a, B> {
    /// Custom update handler
    on_update: Option<UpdateCallback<State>>,
    /// Additional custom event handler
-
    on_change: Option<EventCallback<Action>>,
+
    on_event: Option<EventCallback<Action>>,
    /// Notifications widget
    notifications: BoxedWidget<B>,
    /// Search widget
@@ -251,7 +155,7 @@ where
                )
                .content(Box::<Table<State, Action, NotificationItem>>::new(
                    Table::new(state, action_tx.clone())
-
                        .on_change(|state, action_tx| {
+
                        .on_event(|state, action_tx| {
                            state.downcast_ref::<TableState>().and_then(|state| {
                                action_tx
                                    .send(Action::Select {
@@ -287,12 +191,12 @@ where
            search: Search::new(state, action_tx.clone()).to_boxed(),
            shortcuts: Shortcuts::new(state, action_tx.clone()).to_boxed(),
            on_update: None,
-
            on_change: None,
+
            on_event: None,
        }
    }

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

@@ -302,8 +206,8 @@ where
    }

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

        self.notifications.update(state);
        self.search.update(state);
@@ -369,41 +273,29 @@ where

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())
-
                    .cyan()
-
                    .dim()
-
                    .reversed(),
-
                span::default(" ".into()),
-
                span::default(props.search.to_string()).gray().dim(),
-
            ]
-
            .to_vec(),
-
        );
-

-
        let seen = Line::from(
-
            [
-
                span::positive(props.stats.get("Seen").unwrap_or(&0).to_string()).dim(),
-
                span::default(" Seen".to_string()).dim(),
-
            ]
-
            .to_vec(),
-
        );
-
        let unseen = Line::from(
-
            [
-
                span::positive(props.stats.get("Unseen").unwrap_or(&0).to_string())
-
                    .magenta()
-
                    .dim(),
-
                span::default(" Unseen".to_string()).dim(),
-
            ]
-
            .to_vec(),
-
        );
+
        let search = Line::from(vec![
+
            span::default(" Search ").cyan().dim().reversed(),
+
            span::default(" "),
+
            span::default(&props.search.to_string()).gray().dim(),
+
        ]);
+

+
        let seen = Line::from(vec![
+
            span::positive(&props.stats.get("Seen").unwrap_or(&0).to_string()).dim(),
+
            span::default(" Seen").dim(),
+
        ]);
+
        let unseen = Line::from(vec![
+
            span::positive(&props.stats.get("Unseen").unwrap_or(&0).to_string())
+
                .magenta()
+
                .dim(),
+
            span::default(" Unseen").dim(),
+
        ]);

        let progress = selected
            .map(|selected| {
                TableUtils::progress(selected, props.notifications.len(), props.page_size)
            })
            .unwrap_or_default();
-
        let progress = span::default(format!("{}%", progress)).dim();
+
        let progress = span::default(&format!("{}%", progress)).dim();

        match NotificationItemFilter::from_str(&props.search)
            .unwrap_or_default()
@@ -448,7 +340,7 @@ where
{
    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))
+
            .and_then(BrowsePageProps::from_boxed_any)
            .unwrap_or(self.props.clone());

        let page_size = area.height.saturating_sub(6) as usize;
@@ -504,7 +396,7 @@ pub struct Search<B: Backend> {
    /// Custom update handler
    on_update: Option<UpdateCallback<State>>,
    /// Additional custom event handler
-
    on_change: Option<EventCallback<Action>>,
+
    on_event: Option<EventCallback<Action>>,
    /// Search input field
    input: BoxedWidget<B>,
}
@@ -515,7 +407,7 @@ impl<B: Backend> View<State, Action> for Search<B> {
        Self: Sized,
    {
        let input = TextField::new(state, action_tx.clone())
-
            .on_change(|state, action_tx| {
+
            .on_event(|state, action_tx| {
                state.downcast_ref::<TextFieldState>().and_then(|state| {
                    action_tx
                        .send(Action::UpdateSearch {
@@ -536,7 +428,7 @@ impl<B: Backend> View<State, Action> for Search<B> {
            action_tx,
            input,
            on_update: None,
-
            on_change: None,
+
            on_event: None,
        }
    }

@@ -545,8 +437,8 @@ impl<B: Backend> View<State, Action> for Search<B> {
        self
    }

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

@@ -614,7 +506,7 @@ where
    /// Custom update handler
    on_update: Option<UpdateCallback<State>>,
    /// Additional custom event handler
-
    on_change: Option<EventCallback<Action>>,
+
    on_event: Option<EventCallback<Action>>,
    /// Content widget
    content: BoxedWidget<B>,
    /// Shortcut widget
@@ -656,7 +548,7 @@ where
                                .focus(props.focus)
                                .to_boxed()
                        })
-
                        .on_change(|state, action_tx| {
+
                        .on_event(|state, action_tx| {
                            state.downcast_ref::<ParagraphState>().and_then(|state| {
                                action_tx
                                    .send(Action::ScrollHelp {
@@ -677,7 +569,7 @@ where
                                    [
                                        Column::new(Text::raw(""), Constraint::Fill(1)),
                                        Column::new(
-
                                            span::default(format!("{}%", props.help_progress))
+
                                            span::default(&format!("{}%", props.help_progress))
                                                .dim(),
                                            Constraint::Min(4),
                                        ),
@@ -692,7 +584,7 @@ where
                .to_boxed(),
            shortcuts: Shortcuts::new(state, action_tx.clone()).to_boxed(),
            on_update: None,
-
            on_change: None,
+
            on_event: None,
        }
    }

@@ -701,13 +593,14 @@ where
        self
    }

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

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

        self.content.update(state);
    }
@@ -733,7 +626,7 @@ where
{
    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))
+
            .and_then(HelpPageProps::from_boxed_any)
            .unwrap_or(self.props.clone());

        let page_size = area.height.saturating_sub(6) as usize;
@@ -763,126 +656,84 @@ fn help_text() -> Text<'static> {
        [
            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::from(vec![
+
                Span::raw(format!("{key:>10}", key = "↑,k")).gray(),
+
                Span::raw(" "),
+
                Span::raw("move cursor one line up").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "↓,j")).gray(),
+
                Span::raw(" "),
+
                Span::raw("move cursor one line down").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "PageUp")).gray(),
+
                Span::raw(" "),
+
                Span::raw("move cursor one page up").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "PageDown")).gray(),
+
                Span::raw(" "),
+
                Span::raw("move cursor one page down").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "Home")).gray(),
+
                Span::raw(" "),
+
                Span::raw("move cursor to the first line").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "End")).gray(),
+
                Span::raw(" "),
+
                Span::raw("move cursor to the last line").gray().dim(),
+
            ]),
            Line::raw(""),
            Line::from(Span::raw("Specific keybindings").cyan()),
            Line::raw(""),
-
            Line::from(
-
                [
-
                    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::from(vec![
+
                Span::raw(format!("{key:>10}", key = "enter")).gray(),
+
                Span::raw(" "),
+
                Span::raw("Select notification (if --mode id)").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "enter")).gray(),
+
                Span::raw(" "),
+
                Span::raw("Show notification").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "c")).gray(),
+
                Span::raw(" "),
+
                Span::raw("Clear notifications").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "/")).gray(),
+
                Span::raw(" "),
+
                Span::raw("Search").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "?")).gray(),
+
                Span::raw(" "),
+
                Span::raw("Show help").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "Esc")).gray(),
+
                Span::raw(" "),
+
                Span::raw("Quit / cancel").gray().dim(),
+
            ]),
            Line::raw(""),
            Line::from(Span::raw("Searching").cyan()),
            Line::raw(""),
-
            Line::from(
-
                [
-
                    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::from(vec![
+
                Span::raw(format!("{key:>10}", key = "Pattern")).gray(),
+
                Span::raw(" "),
+
                Span::raw("is:<state> | is:patch | is:issue | <search>")
+
                    .gray()
+
                    .dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "Example")).gray(),
+
                Span::raw(" "),
+
                Span::raw("is:unseen is:patch Print").gray().dim(),
+
            ]),
            Line::raw(""),
            Line::raw(""),
        ]
modified bin/commands/issue/select.rs
@@ -15,14 +15,16 @@ use tui::cob::issue;
use tui::store::StateValue;
use tui::task;
use tui::task::Interrupted;
-
use tui::terminal;
+
use tui::terminal::Backend;
use tui::ui::items::{Filter, IssueItem, IssueItemFilter};
+
use tui::ui::widget::{Properties, View, Window, WindowProps};
use tui::ui::Frontend;
use tui::Exit;
use tui::{store, PageStack};

+
use self::ui::{BrowsePage, HelpPage};
+

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

type Selection = tui::Selection<IssueId>;

@@ -193,15 +195,27 @@ impl App {
    pub async fn run(&self) -> Result<Option<Selection>> {
        let (terminator, mut interrupt_rx) = task::create_termination();
        let (store, state_rx) = store::Store::<Action, State, Selection>::new();
-
        let (frontend, action_rx) = Frontend::<Action>::new();
+
        let (frontend, action_tx, action_rx) = Frontend::new();
        let state = State::try_from(&self.context)?;

+
        let window: Window<Backend, State, Action, Page> = Window::new(&state, action_tx.clone())
+
            .page(
+
                Page::Browse,
+
                BrowsePage::new(&state, action_tx.clone()).to_boxed(),
+
            )
+
            .page(
+
                Page::Help,
+
                HelpPage::new(&state, action_tx.clone()).to_boxed(),
+
            )
+
            .on_update(|state| {
+
                WindowProps::default()
+
                    .current_page(state.pages.peek().unwrap_or(&Page::Browse).clone())
+
                    .to_boxed()
+
            });
+

        tokio::try_join!(
            store.main_loop(state, terminator, action_rx, interrupt_rx.resubscribe()),
-
            frontend.main_loop::<State, Window<terminal::Backend>, Selection>(
-
                state_rx,
-
                interrupt_rx.resubscribe()
-
            ),
+
            frontend.main_loop(Some(window), state_rx, interrupt_rx.resubscribe()),
        )?;

        if let Ok(reason) = interrupt_rx.recv().await {
modified bin/commands/issue/select/ui.rs
@@ -33,107 +33,11 @@ use tui::Selection;
use crate::tui_issue::common::IssueOperation;
use crate::tui_issue::common::Mode;

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

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

#[derive(Clone)]
-
pub struct WindowProps {
-
    page: Page,
-
}
-

-
impl From<&State> for WindowProps {
-
    fn from(state: &State) -> Self {
-
        Self {
-
            page: state.pages.peek().unwrap_or(&Page::Browse).clone(),
-
        }
-
    }
-
}
-

-
impl Properties for WindowProps {}
-

-
pub struct Window<B: Backend> {
-
    /// Internal properties
-
    props: WindowProps,
-
    /// Message sender
-
    _action_tx: UnboundedSender<Action>,
-
    /// Custom update handler
-
    on_update: Option<UpdateCallback<State>>,
-
    /// Additional custom event handler
-
    on_change: Option<EventCallback<Action>>,
-
    /// All pages known
-
    pages: HashMap<Page, BoxedWidget<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: 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_update(mut self, callback: UpdateCallback<State>) -> Self {
-
        self.on_update = Some(callback);
-
        self
-
    }
-

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

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

-
        if let Some(page) = self.pages.get_mut(&self.props.page) {
-
            page.update(state);
-
        }
-
    }
-

-
    fn handle_key_event(&mut self, key: termion::event::Key) {
-
        if let Some(page) = self.pages.get_mut(&self.props.page) {
-
            page.handle_key_event(key);
-
        }
-
    }
-
}
-

-
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());
-

-
        let area = frame.size();
-

-
        if let Some(page) = self.pages.get(&props.page) {
-
            page.render(frame, area, None);
-
        }
-
    }
-
}
-

-
#[derive(Clone)]
struct BrowsePageProps<'a> {
    mode: Mode,
    issues: Vec<IssueItem>,
@@ -217,7 +121,7 @@ impl<'a> From<&State> for BrowsePageProps<'a> {

impl<'a> Properties for BrowsePageProps<'a> {}

-
struct BrowsePage<'a, B> {
+
pub struct BrowsePage<'a, B> {
    /// Internal properties
    props: BrowsePageProps<'a>,
    /// Message sender
@@ -225,7 +129,7 @@ struct BrowsePage<'a, B> {
    /// Custom update handler
    on_update: Option<UpdateCallback<State>>,
    /// Additional custom event handler
-
    on_change: Option<EventCallback<Action>>,
+
    on_event: Option<EventCallback<Action>>,
    /// Patches widget
    issues: BoxedWidget<B>,
    /// Search widget
@@ -254,7 +158,7 @@ where
                )
                .content(Box::<Table<State, Action, IssueItem>>::new(
                    Table::new(state, action_tx.clone())
-
                        .on_change(|state, action_tx| {
+
                        .on_event(|state, action_tx| {
                            state.downcast_ref::<TableState>().and_then(|state| {
                                action_tx
                                    .send(Action::Select {
@@ -290,7 +194,7 @@ where
            search: Search::new(state, action_tx.clone()).to_boxed(),
            shortcuts: Shortcuts::new(state, action_tx.clone()).to_boxed(),
            on_update: None,
-
            on_change: None,
+
            on_event: None,
        }
    }

@@ -299,14 +203,14 @@ where
        self
    }

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

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

        self.issues.update(state);
        self.search.update(state);
@@ -374,55 +278,37 @@ where

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())
-
                    .cyan()
-
                    .dim()
-
                    .reversed(),
-
                span::default(" ".into()),
-
                span::default(props.search.to_string()).gray().dim(),
-
            ]
-
            .to_vec(),
-
        );
+
        let search = Line::from(vec![
+
            span::default(" Search ").cyan().dim().reversed(),
+
            span::default(" ".into()),
+
            span::default(&props.search).gray().dim(),
+
        ]);

-
        let open = Line::from(
-
            [
-
                span::positive(props.stats.get("Open").unwrap_or(&0).to_string()).dim(),
-
                span::default(" Open".to_string()).dim(),
-
            ]
-
            .to_vec(),
-
        );
-
        let solved = Line::from(
-
            [
-
                span::default(props.stats.get("Solved").unwrap_or(&0).to_string())
-
                    .magenta()
-
                    .dim(),
-
                span::default(" Solved".to_string()).dim(),
-
            ]
-
            .to_vec(),
-
        );
-
        let closed = Line::from(
-
            [
-
                span::default(props.stats.get("Closed").unwrap_or(&0).to_string())
-
                    .magenta()
-
                    .dim(),
-
                span::default(" Closed".to_string()).dim(),
-
            ]
-
            .to_vec(),
-
        );
-
        let sum = Line::from(
-
            [
-
                span::default("Σ ".to_string()).dim(),
-
                span::default(props.issues.len().to_string()).dim(),
-
            ]
-
            .to_vec(),
-
        );
+
        let open = Line::from(vec![
+
            span::positive(&props.stats.get("Open").unwrap_or(&0).to_string()).dim(),
+
            span::default(" Open").dim(),
+
        ]);
+
        let solved = Line::from(vec![
+
            span::default(&props.stats.get("Solved").unwrap_or(&0).to_string())
+
                .magenta()
+
                .dim(),
+
            span::default(" Solved").dim(),
+
        ]);
+
        let closed = Line::from(vec![
+
            span::default(&props.stats.get("Closed").unwrap_or(&0).to_string())
+
                .magenta()
+
                .dim(),
+
            span::default(" Closed").dim(),
+
        ]);
+
        let sum = Line::from(vec![
+
            span::default("Σ ").dim(),
+
            span::default(&props.issues.len().to_string()).dim(),
+
        ]);

        let progress = selected
            .map(|selected| TableUtils::progress(selected, props.issues.len(), props.page_size))
            .unwrap_or_default();
-
        let progress = span::default(format!("{}%", progress)).dim();
+
        let progress = span::default(&format!("{}%", progress)).dim();

        match IssueItemFilter::from_str(&props.search)
            .unwrap_or_default()
@@ -473,7 +359,7 @@ where
{
    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))
+
            .and_then(BrowsePageProps::from_boxed_any)
            .unwrap_or(self.props.clone());

        let page_size = area.height.saturating_sub(6) as usize;
@@ -529,7 +415,7 @@ pub struct Search<B: Backend> {
    /// Custom update handler
    on_update: Option<UpdateCallback<State>>,
    /// Additional custom event handler
-
    on_change: Option<EventCallback<Action>>,
+
    on_event: Option<EventCallback<Action>>,
    /// Search input field
    input: BoxedWidget<B>,
}
@@ -540,7 +426,7 @@ impl<B: Backend> View<State, Action> for Search<B> {
        Self: Sized,
    {
        let input = TextField::new(state, action_tx.clone())
-
            .on_change(|state, action_tx| {
+
            .on_event(|state, action_tx| {
                state.downcast_ref::<TextFieldState>().and_then(|state| {
                    action_tx
                        .send(Action::UpdateSearch {
@@ -561,7 +447,7 @@ impl<B: Backend> View<State, Action> for Search<B> {
            action_tx,
            input,
            on_update: None,
-
            on_change: None,
+
            on_event: None,
        }
    }

@@ -570,8 +456,8 @@ impl<B: Backend> View<State, Action> for Search<B> {
        self
    }

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

@@ -639,7 +525,7 @@ where
    /// Custom update handler
    on_update: Option<UpdateCallback<State>>,
    /// Additional custom event handler
-
    on_change: Option<EventCallback<Action>>,
+
    on_event: Option<EventCallback<Action>>,
    /// Content widget
    content: BoxedWidget<B>,
    /// Shortcut widget
@@ -681,7 +567,7 @@ where
                                .focus(props.focus)
                                .to_boxed()
                        })
-
                        .on_change(|state, action_tx| {
+
                        .on_event(|state, action_tx| {
                            state.downcast_ref::<ParagraphState>().and_then(|state| {
                                action_tx
                                    .send(Action::ScrollHelp {
@@ -702,7 +588,7 @@ where
                                    [
                                        Column::new(Text::raw(""), Constraint::Fill(1)),
                                        Column::new(
-
                                            span::default(format!("{}%", props.help_progress))
+
                                            span::default(&format!("{}%", props.help_progress))
                                                .dim(),
                                            Constraint::Min(4),
                                        ),
@@ -717,7 +603,7 @@ where
                .to_boxed(),
            shortcuts: Shortcuts::new(state, action_tx.clone()).to_boxed(),
            on_update: None,
-
            on_change: None,
+
            on_event: None,
        }
    }

@@ -726,13 +612,14 @@ where
        self
    }

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

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

        self.content.update(state);
    }
@@ -758,7 +645,7 @@ where
{
    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))
+
            .and_then(HelpPageProps::from_boxed_any)
            .unwrap_or(self.props.clone());

        let page_size = area.height.saturating_sub(6) as usize;
modified bin/commands/patch/select.rs
@@ -15,13 +15,19 @@ use tui::cob::patch;
use tui::store;
use tui::task;
use tui::task::Interrupted;
-
use tui::terminal;
+
use tui::terminal::Backend;
use tui::ui::items::{Filter, PatchItem, PatchItemFilter};
+
use tui::ui::widget::Properties;
+
use tui::ui::widget::View;
+
use tui::ui::widget::Window;
+
use tui::ui::widget::WindowProps;
use tui::ui::Frontend;
use tui::Exit;

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

+
use self::ui::BrowsePage;
+
use self::ui::HelpPage;

use super::common::Mode;

@@ -194,15 +200,27 @@ impl App {
    pub async fn run(&self) -> Result<Option<Selection>> {
        let (terminator, mut interrupt_rx) = task::create_termination();
        let (store, state_rx) = store::Store::<Action, State, Selection>::new();
-
        let (frontend, action_rx) = Frontend::<Action>::new();
+
        let (frontend, action_tx, action_rx) = Frontend::<Action>::new();
        let state = State::try_from(&self.context)?;

+
        let window: Window<Backend, State, Action, Page> = Window::new(&state, action_tx.clone())
+
            .page(
+
                Page::Browse,
+
                BrowsePage::new(&state, action_tx.clone()).to_boxed(),
+
            )
+
            .page(
+
                Page::Help,
+
                HelpPage::new(&state, action_tx.clone()).to_boxed(),
+
            )
+
            .on_update(|state| {
+
                WindowProps::default()
+
                    .current_page(state.pages.peek().unwrap_or(&Page::Browse).clone())
+
                    .to_boxed()
+
            });
+

        tokio::try_join!(
            store.main_loop(state, terminator, action_rx, interrupt_rx.resubscribe()),
-
            frontend.main_loop::<State, Window<terminal::Backend>, Selection>(
-
                state_rx,
-
                interrupt_rx.resubscribe()
-
            ),
+
            frontend.main_loop(Some(window), state_rx, interrupt_rx.resubscribe()),
        )?;

        if let Ok(reason) = interrupt_rx.recv().await {
modified bin/commands/patch/select/ui.rs
@@ -35,108 +35,12 @@ use tui::Selection;
use crate::tui_patch::common::Mode;
use crate::tui_patch::common::PatchOperation;

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

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

#[derive(Clone)]
-
pub struct WindowProps {
-
    page: Page,
-
}
-

-
impl From<&State> for WindowProps {
-
    fn from(state: &State) -> Self {
-
        Self {
-
            page: state.pages.peek().unwrap_or(&Page::Browse).clone(),
-
        }
-
    }
-
}
-

-
impl Properties for WindowProps {}
-

-
pub struct Window<B: Backend> {
-
    /// Internal properties
-
    props: WindowProps,
-
    /// Message sender
-
    _action_tx: UnboundedSender<Action>,
-
    /// Custom update handler
-
    on_update: Option<UpdateCallback<State>>,
-
    /// Additional custom event handler
-
    on_change: Option<EventCallback<Action>>,
-
    /// All pages known
-
    pages: HashMap<Page, BoxedWidget<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: 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_update(mut self, callback: UpdateCallback<State>) -> Self {
-
        self.on_update = Some(callback);
-
        self
-
    }
-

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

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

-
        if let Some(page) = self.pages.get_mut(&self.props.page) {
-
            page.update(state);
-
        }
-
    }
-

-
    fn handle_key_event(&mut self, key: termion::event::Key) {
-
        if let Some(page) = self.pages.get_mut(&self.props.page) {
-
            page.handle_key_event(key);
-
        }
-
    }
-
}
-

-
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());
-

-
        let area = frame.size();
-

-
        if let Some(page) = self.pages.get(&props.page) {
-
            page.render(frame, area, None);
-
        }
-
    }
-
}
-

-
#[derive(Clone)]
-
struct BrowsePageProps<'a> {
+
pub struct BrowsePageProps<'a> {
    mode: Mode,
    patches: Vec<PatchItem>,
    selected: Option<usize>,
@@ -218,7 +122,7 @@ impl<'a> From<&State> for BrowsePageProps<'a> {

impl<'a: 'static> Properties for BrowsePageProps<'a> {}

-
struct BrowsePage<'a, B> {
+
pub struct BrowsePage<'a, B> {
    /// Internal properties
    props: BrowsePageProps<'a>,
    /// Message sender
@@ -226,7 +130,7 @@ struct BrowsePage<'a, B> {
    /// Custom update handler
    on_update: Option<UpdateCallback<State>>,
    /// Additional custom event handler
-
    on_change: Option<EventCallback<Action>>,
+
    on_event: Option<EventCallback<Action>>,
    /// Patches widget
    patches: BoxedWidget<B>,
    /// Search widget
@@ -255,7 +159,7 @@ where
                )
                .content(Box::<Table<State, Action, PatchItem>>::new(
                    Table::new(state, action_tx.clone())
-
                        .on_change(|state, action_tx| {
+
                        .on_event(|state, action_tx| {
                            state.downcast_ref::<TableState>().and_then(|state| {
                                action_tx
                                    .send(Action::Select {
@@ -291,7 +195,7 @@ where
            search: Search::new(state, action_tx.clone()).to_boxed(),
            shortcuts: Shortcuts::new(state, action_tx.clone()).to_boxed(),
            on_update: None,
-
            on_change: None,
+
            on_event: None,
        }
    }

@@ -300,14 +204,14 @@ where
        self
    }

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

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

        self.patches.update(state);
        self.search.update(state);
@@ -393,66 +297,45 @@ 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(
-
            [
-
                span::default(" Search ".to_string())
-
                    .cyan()
-
                    .dim()
-
                    .reversed(),
-
                span::default(" ".into()),
-
                span::default(props.search.to_string()).gray().dim(),
-
            ]
-
            .to_vec(),
-
        );
+
        let search = Line::from(vec![
+
            span::default(" Search ").cyan().dim().reversed(),
+
            span::default(" "),
+
            span::default(&props.search.to_string()).gray().dim(),
+
        ]);

-
        let draft = Line::from(
-
            [
-
                span::default(props.stats.get("Draft").unwrap_or(&0).to_string()).dim(),
-
                span::default(" Draft".to_string()).dim(),
-
            ]
-
            .to_vec(),
-
        );
+
        let draft = Line::from(vec![
+
            span::default(&props.stats.get("Draft").unwrap_or(&0).to_string()).dim(),
+
            span::default(" Draft").dim(),
+
        ]);

-
        let open = Line::from(
-
            [
-
                span::positive(props.stats.get("Open").unwrap_or(&0).to_string()).dim(),
-
                span::default(" Open".to_string()).dim(),
-
            ]
-
            .to_vec(),
-
        );
+
        let open = Line::from(vec![
+
            span::positive(&props.stats.get("Open").unwrap_or(&0).to_string()).dim(),
+
            span::default(" Open").dim(),
+
        ]);

-
        let merged = Line::from(
-
            [
-
                span::default(props.stats.get("Merged").unwrap_or(&0).to_string())
-
                    .magenta()
-
                    .dim(),
-
                span::default(" Merged".to_string()).dim(),
-
            ]
-
            .to_vec(),
-
        );
+
        let merged = Line::from(vec![
+
            span::default(&props.stats.get("Merged").unwrap_or(&0).to_string())
+
                .magenta()
+
                .dim(),
+
            span::default(" Merged").dim(),
+
        ]);

-
        let archived = Line::from(
-
            [
-
                span::default(props.stats.get("Archived").unwrap_or(&0).to_string())
-
                    .yellow()
-
                    .dim(),
-
                span::default(" Archived".to_string()).dim(),
-
            ]
-
            .to_vec(),
-
        );
+
        let archived = Line::from(vec![
+
            span::default(&props.stats.get("Archived").unwrap_or(&0).to_string())
+
                .yellow()
+
                .dim(),
+
            span::default(" Archived").dim(),
+
        ]);

-
        let sum = Line::from(
-
            [
-
                span::default("Σ ".to_string()).dim(),
-
                span::default(props.patches.len().to_string()).dim(),
-
            ]
-
            .to_vec(),
-
        );
+
        let sum = Line::from(vec![
+
            span::default("Σ ").dim(),
+
            span::default(&props.patches.len().to_string()).dim(),
+
        ]);

        let progress = selected
            .map(|selected| TableUtils::progress(selected, props.patches.len(), props.page_size))
            .unwrap_or_default();
-
        let progress = span::default(format!("{}%", progress)).dim();
+
        let progress = span::default(&format!("{}%", progress)).dim();

        match filter.status() {
            Some(state) => {
@@ -463,7 +346,7 @@ impl<'a, B: Backend> BrowsePage<'a, B> {
                    Status::Archived => archived,
                };

-
                [
+
                vec![
                    Column::new(Text::from(search), Constraint::Fill(1)),
                    Column::new(
                        Text::from(block.clone()),
@@ -471,9 +354,8 @@ impl<'a, B: Backend> BrowsePage<'a, B> {
                    ),
                    Column::new(Text::from(progress), Constraint::Min(4)),
                ]
-
                .to_vec()
            }
-
            None => [
+
            None => vec![
                Column::new(Text::from(search), Constraint::Fill(1)),
                Column::new(
                    Text::from(draft.clone()),
@@ -493,8 +375,7 @@ impl<'a, B: Backend> BrowsePage<'a, B> {
                ),
                Column::new(Text::from(sum.clone()), Constraint::Min(sum.width() as u16)),
                Column::new(Text::from(progress), Constraint::Min(4)),
-
            ]
-
            .to_vec(),
+
            ],
        }
    }
}
@@ -505,7 +386,7 @@ where
{
    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))
+
            .and_then(BrowsePageProps::from_boxed_any)
            .unwrap_or(self.props.clone());

        let page_size = area.height.saturating_sub(6) as usize;
@@ -561,7 +442,7 @@ pub struct Search<B: Backend> {
    /// Custom update handler
    on_update: Option<UpdateCallback<State>>,
    /// Additional custom event handler
-
    on_change: Option<EventCallback<Action>>,
+
    on_event: Option<EventCallback<Action>>,
    /// Search input field
    input: BoxedWidget<B>,
}
@@ -572,7 +453,7 @@ impl<B: Backend> View<State, Action> for Search<B> {
        Self: Sized,
    {
        let input = TextField::new(state, action_tx.clone())
-
            .on_change(|state, action_tx| {
+
            .on_event(|state, action_tx| {
                state.downcast_ref::<TextFieldState>().and_then(|state| {
                    action_tx
                        .send(Action::UpdateSearch {
@@ -593,7 +474,7 @@ impl<B: Backend> View<State, Action> for Search<B> {
            action_tx,
            input,
            on_update: None,
-
            on_change: None,
+
            on_event: None,
        }
    }

@@ -602,8 +483,8 @@ impl<B: Backend> View<State, Action> for Search<B> {
        self
    }

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

@@ -640,7 +521,7 @@ where
}

#[derive(Clone)]
-
struct HelpPageProps<'a> {
+
pub struct HelpPageProps<'a> {
    focus: bool,
    page_size: usize,
    help_progress: usize,
@@ -671,7 +552,7 @@ where
    /// Custom update handler
    on_update: Option<UpdateCallback<State>>,
    /// Additional custom event handler
-
    on_change: Option<EventCallback<Action>>,
+
    on_event: Option<EventCallback<Action>>,
    /// Content widget
    content: BoxedWidget<B>,
    /// Shortcut widget
@@ -713,7 +594,7 @@ where
                                .focus(props.focus)
                                .to_boxed()
                        })
-
                        .on_change(|state, action_tx| {
+
                        .on_event(|state, action_tx| {
                            state.downcast_ref::<ParagraphState>().and_then(|state| {
                                action_tx
                                    .send(Action::ScrollHelp {
@@ -734,7 +615,7 @@ where
                                    [
                                        Column::new(Text::raw(""), Constraint::Fill(1)),
                                        Column::new(
-
                                            span::default(format!("{}%", props.help_progress))
+
                                            span::default(&format!("{}%", props.help_progress))
                                                .dim(),
                                            Constraint::Min(4),
                                        ),
@@ -749,7 +630,7 @@ where
                .to_boxed(),
            shortcuts: Shortcuts::new(state, action_tx.clone()).to_boxed(),
            on_update: None,
-
            on_change: None,
+
            on_event: None,
        }
    }

@@ -758,13 +639,14 @@ where
        self
    }

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

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

        self.content.update(state);
    }
@@ -790,7 +672,7 @@ where
{
    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))
+
            .and_then(HelpPageProps::from_boxed_any)
            .unwrap_or(self.props.clone());

        let page_size = area.height.saturating_sub(6) as usize;
@@ -820,134 +702,89 @@ fn help_text() -> Text<'static> {
        [
            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::from(vec![
+
                Span::raw(format!("{key:>10}", key = "↑,k")).gray(),
+
                Span::raw(" "),
+
                Span::raw("move cursor one line up").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "↓,j")).gray(),
+
                Span::raw(" "),
+
                Span::raw("move cursor one line down").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "PageUp")).gray(),
+
                Span::raw(" "),
+
                Span::raw("move cursor one page up").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "PageDown")).gray(),
+
                Span::raw(" "),
+
                Span::raw("move cursor one page down").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "Home")).gray(),
+
                Span::raw(" "),
+
                Span::raw("move cursor to the first line").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "End")).gray(),
+
                Span::raw(" "),
+
                Span::raw("move cursor to the last line").gray().dim(),
+
            ]),
            Line::raw(""),
            Line::from(Span::raw("Specific keybindings").cyan()),
            Line::raw(""),
-
            Line::from(
-
                [
-
                    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::from(vec![
+
                Span::raw(format!("{key:>10}", key = "enter")).gray(),
+
                Span::raw(" "),
+
                Span::raw("Select patch (if --mode id)").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "enter")).gray(),
+
                Span::raw(" "),
+
                Span::raw("Show patch").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "c")).gray(),
+
                Span::raw(" "),
+
                Span::raw("Checkout patch").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "d")).gray(),
+
                Span::raw(" "),
+
                Span::raw("Show patch diff").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "/")).gray(),
+
                Span::raw(" "),
+
                Span::raw("Search").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "?")).gray(),
+
                Span::raw(" "),
+
                Span::raw("Show help").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "Esc")).gray(),
+
                Span::raw(" "),
+
                Span::raw("Quit / cancel").gray().dim(),
+
            ]),
            Line::raw(""),
            Line::from(Span::raw("Searching").cyan()),
            Line::raw(""),
-
            Line::from(
-
                [
-
                    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::from(vec![
+
                Span::raw(format!("{key:>10}", key = "Pattern")).gray(),
+
                Span::raw(" "),
+
                Span::raw("is:<state> | is:authored | authors:[<did>, <did>] | <search>")
+
                    .gray()
+
                    .dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "Example")).gray(),
+
                Span::raw(" "),
+
                Span::raw("is:open is:authored improve").gray().dim(),
+
            ]),
            Line::raw(""),
            Line::raw(""),
        ]
modified src/ui.rs
@@ -13,7 +13,7 @@ use std::time::Duration;
use termion::raw::RawTerminal;

use tokio::sync::broadcast;
-
use tokio::sync::mpsc::{self, UnboundedReceiver};
+
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};

use super::event::Event;
use super::store::State;
@@ -32,14 +32,21 @@ pub struct Frontend<A> {
}

impl<A> Frontend<A> {
-
    pub fn new() -> (Self, UnboundedReceiver<A>) {
+
    pub fn new() -> (Self, UnboundedSender<A>, UnboundedReceiver<A>) {
        let (action_tx, action_rx) = mpsc::unbounded_channel();

-
        (Self { action_tx }, action_rx)
+
        (
+
            Self {
+
                action_tx: action_tx.clone(),
+
            },
+
            action_tx,
+
            action_rx,
+
        )
    }

    pub async fn main_loop<S, W, P>(
        self,
+
        root: Option<W>,
        mut state_rx: UnboundedReceiver<S>,
        mut interrupt_rx: broadcast::Receiver<Interrupted<P>>,
    ) -> anyhow::Result<Interrupted<P>>
@@ -56,7 +63,13 @@ impl<A> Frontend<A> {
        let mut root = {
            let state = state_rx.recv().await.unwrap();

-
            W::new(&state, self.action_tx.clone())
+
            match root {
+
                Some(mut root) => {
+
                    root.update(&state);
+
                    root
+
                }
+
                None => W::new(&state, self.action_tx.clone()),
+
            }
        };

        let result: anyhow::Result<Interrupted<P>> = loop {
modified src/ui/items.rs
@@ -249,39 +249,39 @@ impl ToRow for NotificationItem {
            ),
        };

-
        let id = span::notification_id(format!(" {:-03}", &self.id));
+
        let id = span::notification_id(&format!(" {:-03}", &self.id));
        let seen = if self.seen {
            span::blank()
        } else {
-
            span::primary(" ● ".into())
+
            span::primary(" ● ")
        };
-
        let kind_id = span::primary(kind_id);
-
        let summary = span::default(summary.to_string());
-
        let type_name = span::notification_type(type_name);
-
        let name = span::default(self.project.clone()).style(style::gray().dim());
+
        let kind_id = span::primary(&kind_id);
+
        let summary = span::default(&summary);
+
        let type_name = span::notification_type(&type_name);
+
        let name = span::default(&self.project.clone()).style(style::gray().dim());

        let status = match status.as_str() {
-
            "archived" => span::default(status.to_string()).yellow(),
-
            "draft" => span::default(status.to_string()).gray().dim(),
-
            "updated" => span::primary(status.to_string()),
-
            "open" | "created" => span::positive(status.to_string()),
-
            "closed" | "merged" => span::ternary(status.to_string()),
-
            _ => span::default(status.to_string()),
+
            "archived" => span::default(&status).yellow(),
+
            "draft" => span::default(&status).gray().dim(),
+
            "updated" => span::primary(&status),
+
            "open" | "created" => span::positive(&status),
+
            "closed" | "merged" => span::ternary(&status),
+
            _ => span::default(&status),
        };
        let author = match &self.author.alias {
            Some(alias) => {
                if self.author.you {
-
                    span::alias(format!("{} (you)", alias))
+
                    span::alias(&format!("{} (you)", alias))
                } else {
-
                    span::alias(alias.to_string())
+
                    span::alias(alias)
                }
            }
            None => match self.author.nid {
-
                Some(nid) => span::alias(format::did(&Did::from(nid))).dim(),
-
                None => span::alias("".to_string()),
+
                Some(nid) => span::alias(&format::did(&Did::from(nid))).dim(),
+
                None => span::blank(),
            },
        };
-
        let timestamp = span::timestamp(format::timestamp(&self.timestamp));
+
        let timestamp = span::timestamp(&format::timestamp(&self.timestamp));

        [
            id.into(),
@@ -492,35 +492,35 @@ impl ToRow for IssueItem {
    fn to_row(&self) -> Vec<Cell> {
        let (state, state_color) = format::issue_state(&self.state);

-
        let state = span::default(state).style(Style::default().fg(state_color));
-
        let id = span::primary(format::cob(&self.id));
-
        let title = span::default(self.title.clone());
+
        let state = span::default(&state).style(Style::default().fg(state_color));
+
        let id = span::primary(&format::cob(&self.id));
+
        let title = span::default(&self.title.clone());

        let author = match &self.author.alias {
            Some(alias) => {
                if self.author.you {
-
                    span::alias(format!("{} (you)", alias))
+
                    span::alias(&format!("{} (you)", alias))
                } else {
-
                    span::alias(alias.to_string())
+
                    span::alias(alias)
                }
            }
            None => match self.author.nid {
-
                Some(nid) => span::alias(format::did(&Did::from(nid))).dim(),
-
                None => span::alias("".to_string()),
+
                Some(nid) => span::alias(&format::did(&Did::from(nid))).dim(),
+
                None => span::alias(""),
            },
        };
        let did = match self.author.nid {
-
            Some(nid) => span::alias(format::did(&Did::from(nid))).dim(),
-
            None => span::alias("".to_string()),
+
            Some(nid) => span::alias(&format::did(&Did::from(nid))).dim(),
+
            None => span::alias(""),
        };
-
        let labels = span::labels(format::labels(&self.labels));
+
        let labels = span::labels(&format::labels(&self.labels));
        let assignees = self
            .assignees
            .iter()
            .map(|author| (author.nid, author.alias.clone(), author.you))
            .collect::<Vec<_>>();
-
        let assignees = span::alias(format::assignees(&assignees));
-
        let opened = span::timestamp(format::timestamp(&self.timestamp));
+
        let assignees = span::alias(&format::assignees(&assignees));
+
        let opened = span::timestamp(&format::timestamp(&self.timestamp));

        [
            state.into(),
@@ -744,32 +744,32 @@ impl ToRow for PatchItem {
    fn to_row(&self) -> Vec<Cell> {
        let (state, color) = format::patch_state(&self.state);

-
        let state = span::default(state).style(Style::default().fg(color));
-
        let id = span::primary(format::cob(&self.id));
-
        let title = span::default(self.title.clone());
+
        let state = span::default(&state).style(Style::default().fg(color));
+
        let id = span::primary(&format::cob(&self.id));
+
        let title = span::default(&self.title.clone());

        let author = match &self.author.alias {
            Some(alias) => {
                if self.author.you {
-
                    span::alias(format!("{} (you)", alias))
+
                    span::alias(&format!("{} (you)", alias))
                } else {
-
                    span::alias(alias.to_string())
+
                    span::alias(alias)
                }
            }
            None => match self.author.nid {
-
                Some(nid) => span::alias(format::did(&Did::from(nid))).dim(),
-
                None => span::alias("".to_string()),
+
                Some(nid) => span::alias(&format::did(&Did::from(nid))).dim(),
+
                None => span::blank(),
            },
        };
        let did = match self.author.nid {
-
            Some(nid) => span::alias(format::did(&Did::from(nid))).dim(),
-
            None => span::alias("".to_string()),
+
            Some(nid) => span::alias(&format::did(&Did::from(nid))).dim(),
+
            None => span::blank(),
        };

-
        let head = span::ternary(format::oid(self.head));
-
        let added = span::positive(format!("+{}", self.added));
-
        let removed = span::negative(format!("-{}", self.removed));
-
        let updated = span::timestamp(format::timestamp(&self.timestamp));
+
        let head = span::ternary(&format::oid(self.head));
+
        let added = span::positive(&format!("+{}", self.added));
+
        let removed = span::negative(&format!("-{}", self.removed));
+
        let updated = span::timestamp(&format::timestamp(&self.timestamp));

        [
            state.into(),
modified src/ui/span.rs
@@ -7,75 +7,75 @@ pub fn blank() -> Span<'static> {
    Span::styled("", Style::default())
}

-
pub fn default(content: String) -> Span<'static> {
-
    Span::styled(content, Style::default())
+
pub fn default(content: &str) -> Span<'static> {
+
    Span::styled(content.to_string(), Style::default())
}

-
pub fn primary(content: String) -> Span<'static> {
+
pub fn primary(content: &str) -> Span<'static> {
    default(content).style(style::cyan())
}

-
pub fn secondary(content: String) -> Span<'static> {
+
pub fn secondary(content: &str) -> Span<'static> {
    default(content).style(style::magenta())
}

-
pub fn ternary(content: String) -> Span<'static> {
+
pub fn ternary(content: &str) -> Span<'static> {
    default(content).style(style::blue())
}

-
pub fn positive(content: String) -> Span<'static> {
+
pub fn positive(content: &str) -> Span<'static> {
    default(content).style(style::green())
}

-
pub fn negative(content: String) -> Span<'static> {
+
pub fn negative(content: &str) -> Span<'static> {
    default(content).style(style::red())
}

-
pub fn badge(content: String) -> Span<'static> {
+
pub fn badge(content: &str) -> Span<'static> {
    let content = &format!(" {content} ");
-
    default(content.to_string()).magenta().reversed()
+
    default(content).magenta().reversed()
}

-
pub fn alias(content: String) -> Span<'static> {
+
pub fn alias(content: &str) -> Span<'static> {
    secondary(content)
}

-
pub fn labels(content: String) -> Span<'static> {
+
pub fn labels(content: &str) -> Span<'static> {
    ternary(content)
}

-
pub fn timestamp(content: String) -> Span<'static> {
+
pub fn timestamp(content: &str) -> Span<'static> {
    default(content).style(style::gray().dim())
}

-
pub fn notification_id(content: String) -> Span<'static> {
+
pub fn notification_id(content: &str) -> Span<'static> {
    default(content).style(style::gray().dim())
}

-
pub fn notification_type(content: String) -> Span<'static> {
+
pub fn notification_type(content: &str) -> Span<'static> {
    default(content).style(style::gray().dim())
}

pub fn step(step: usize, len: usize, fill_zeros: bool) -> Span<'static> {
    if fill_zeros {
        if len > 10 {
-
            badge(format!("{:-02}/{:-02}", step, len))
+
            badge(&format!("{:-02}/{:-02}", step, len))
        } else if len > 100 {
-
            badge(format!("{:-03}/{:-03}", step, len))
+
            badge(&format!("{:-03}/{:-03}", step, len))
        } else if len > 1000 {
-
            badge(format!("{:-04}/{:-04}", step, len))
+
            badge(&format!("{:-04}/{:-04}", step, len))
        } else if len > 10000 {
-
            badge(format!("{:-05}/{:-05}", step, len))
+
            badge(&format!("{:-05}/{:-05}", step, len))
        } else {
-
            badge(format!("{}/{}", step, len))
+
            badge(&format!("{}/{}", step, len))
        }
    } else {
-
        badge(format!("{}/{}", step, len))
+
        badge(&format!("{}/{}", step, len))
    }
}

pub fn progress(step: usize, len: usize) -> Span<'static> {
    let progress = step as f32 / len as f32 * 100_f32;
    let progress = progress as usize;
-
    default(format!("{}%", progress)).dim()
+
    default(&format!("{}%", progress)).dim()
}
modified src/ui/widget.rs
@@ -4,7 +4,9 @@ pub mod text;

use std::any::Any;
use std::cmp;
+
use std::collections::HashMap;
use std::fmt::Debug;
+
use std::hash::Hash;

use tokio::sync::mpsc::UnboundedSender;

@@ -32,7 +34,7 @@ pub trait View<S, A> {
        Self: Sized;

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

@@ -51,12 +53,20 @@ pub trait View<S, A> {

    /// 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
+
    /// After key events have been handled, the custom event handler `on_event` 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.
+
    /// Should update the internal props of this and all children.
+
    ///
+
    /// Applications are usually defined by app-specific widgets that do know
+
    /// the type of `state`. These can use widgets from the library that do not know the
+
    /// type of `state`.
+
    ///
+
    /// If `on_update` is set, implementators of this function should call it to
+
    /// construct and update the internal props. If it is not set, app widgets can construct
+
    /// prosp directly via their state converters, whereas library widgets can just fallback
+
    /// to their current props.
    fn update(&mut self, state: &S);
}

@@ -93,6 +103,143 @@ pub trait Properties {
    {
        any.downcast_ref::<Self>().cloned()
    }
+

+
    fn from_callback<S>(callback: Option<UpdateCallback<S>>, state: &S) -> Option<Self>
+
    where
+
        Self: Sized + Clone + 'static,
+
    {
+
        callback
+
            .map(|callback| (callback)(state))
+
            .and_then(|props| Self::from_boxed_any(props))
+
    }
+
}
+

+
#[derive(Clone)]
+
pub struct WindowProps<Id> {
+
    current_page: Option<Id>,
+
}
+

+
impl<Id> WindowProps<Id> {
+
    pub fn current_page(mut self, page: Id) -> Self {
+
        self.current_page = Some(page);
+
        self
+
    }
+
}
+

+
impl<Id> Default for WindowProps<Id> {
+
    fn default() -> Self {
+
        Self { current_page: None }
+
    }
+
}
+

+
impl<Id> Properties for WindowProps<Id> {}
+

+
pub struct Window<B, S, A, Id>
+
where
+
    B: Backend,
+
{
+
    /// Internal properties
+
    props: WindowProps<Id>,
+
    /// Message sender
+
    _action_tx: UnboundedSender<A>,
+
    /// Custom update handler
+
    on_update: Option<UpdateCallback<S>>,
+
    /// Additional custom event handler
+
    on_event: Option<EventCallback<A>>,
+
    /// All pages known
+
    pages: HashMap<Id, BoxedWidget<B, S, A>>,
+
}
+

+
impl<B, S, A, Id> Window<B, S, A, Id>
+
where
+
    B: Backend,
+
    Id: Clone + Hash + Eq + PartialEq,
+
{
+
    pub fn page(mut self, id: Id, page: BoxedWidget<B, S, A>) -> Self {
+
        // self.pages.inse
+
        self.pages.insert(id, page);
+
        self
+
    }
+
}
+

+
impl<'a: 'static, B, S, A, Id> View<S, A> for Window<B, S, A, Id>
+
where
+
    B: Backend + 'a,
+
    Id: Clone + Hash + Eq + PartialEq + 'a,
+
{
+
    fn new(_state: &S, action_tx: UnboundedSender<A>) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self {
+
            _action_tx: action_tx.clone(),
+
            props: WindowProps::default(),
+
            pages: HashMap::new(),
+
            on_update: None,
+
            on_event: None,
+
        }
+
    }
+

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

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

+
    fn update(&mut self, state: &S) {
+
        self.props =
+
            WindowProps::from_callback(self.on_update, state).unwrap_or(self.props.clone());
+

+
        let page = self
+
            .props
+
            .current_page
+
            .as_ref()
+
            .and_then(|id| self.pages.get_mut(id));
+

+
        if let Some(page) = page {
+
            page.update(state);
+
        }
+
    }
+

+
    fn handle_key_event(&mut self, key: termion::event::Key) {
+
        let page = self
+
            .props
+
            .current_page
+
            .as_ref()
+
            .and_then(|id| self.pages.get_mut(id));
+

+
        if let Some(page) = page {
+
            page.handle_key_event(key);
+
        }
+
    }
+
}
+

+
impl<'a: 'static, B, S, A, Id> Widget<B, S, A> for Window<B, S, A, Id>
+
where
+
    B: Backend + 'a,
+
    Id: Clone + Hash + Eq + PartialEq + 'a,
+
{
+
    fn render(&self, frame: &mut ratatui::Frame, _area: Rect, props: Option<Box<dyn Any>>) {
+
        let _props = props
+
            .and_then(WindowProps::from_boxed_any)
+
            .unwrap_or(self.props.clone());
+

+
        let area = frame.size();
+

+
        let page = self
+
            .props
+
            .current_page
+
            .as_ref()
+
            .and_then(|id| self.pages.get(id));
+

+
        if let Some(page) = page {
+
            page.render(frame, area, None);
+
        }
+
    }
}

#[derive(Clone)]
@@ -135,7 +282,7 @@ pub struct Shortcuts<S, A> {
    /// Custom update handler
    on_update: Option<UpdateCallback<S>>,
    /// Additional custom event handler
-
    on_change: Option<EventCallback<A>>,
+
    on_event: Option<EventCallback<A>>,
}

impl<S, A> Shortcuts<S, A> {
@@ -161,12 +308,12 @@ impl<S, A> View<S, A> for Shortcuts<S, A> {
            _action_tx: action_tx.clone(),
            props: ShortcutsProps::default(),
            on_update: None,
-
            on_change: None,
+
            on_event: None,
        }
    }

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

@@ -178,10 +325,8 @@ impl<S, A> View<S, A> for Shortcuts<S, A> {
    fn handle_key_event(&mut self, _key: Key) {}

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

@@ -193,7 +338,7 @@ where
        use ratatui::widgets::Table;

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

        let mut shortcuts = props.shortcuts.iter().peekable();
@@ -335,7 +480,7 @@ where
    /// Custom update handler
    on_update: Option<UpdateCallback<S>>,
    /// Additional custom event handler
-
    on_change: Option<EventCallback<A>>,
+
    on_event: Option<EventCallback<A>>,
    /// Internal selection and offset state
    state: TableState,
}
@@ -405,7 +550,7 @@ where
            props: TableProps::default(),
            state: TableState::default().with_selected(Some(0)),
            on_update: None,
-
            on_change: None,
+
            on_event: None,
        }
    }

@@ -414,16 +559,14 @@ where
        self
    }

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

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

        // TODO: Move to state reducer
        if let Some(selected) = self.state.selected() {
@@ -458,8 +601,8 @@ where

        self.props.selected = self.state.selected();

-
        if let Some(on_change) = self.on_change {
-
            (on_change)(&self.state, self.action_tx.clone());
+
        if let Some(on_event) = self.on_event {
+
            (on_event)(&self.state, self.action_tx.clone());
        }
    }
}
@@ -471,7 +614,7 @@ where
{
    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))
+
            .and_then(TableProps::<'_, R>::from_boxed_any)
            .unwrap_or(self.props.clone());

        let widths: Vec<Constraint> = self
@@ -517,7 +660,7 @@ where
            frame.render_stateful_widget(rows, area, &mut self.state.clone());
        } else {
            let center = layout::centered_rect(area, 50, 10);
-
            let hint = Text::from(span::default("Nothing to show".to_string()))
+
            let hint = Text::from(span::default("Nothing to show"))
                .centered()
                .light_magenta()
                .dim();
modified src/ui/widget/container.rs
@@ -60,7 +60,7 @@ pub struct Header<'a, S, A> {
    /// Custom update handler
    on_update: Option<UpdateCallback<S>>,
    /// Additional custom event handler
-
    on_change: Option<EventCallback<A>>,
+
    on_event: Option<EventCallback<A>>,
}

impl<'a, S, A> Header<'a, S, A> {
@@ -87,7 +87,7 @@ impl<'a: 'static, S, A> View<S, A> for Header<'a, S, A> {
            action_tx: action_tx.clone(),
            props: HeaderProps::default(),
            on_update: None,
-
            on_change: None,
+
            on_event: None,
        }
    }

@@ -96,8 +96,8 @@ impl<'a: 'static, S, A> View<S, A> for Header<'a, S, A> {
        self
    }

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

@@ -109,8 +109,8 @@ impl<'a: 'static, S, A> View<S, A> for Header<'a, S, A> {
    }

    fn handle_key_event(&mut self, _key: Key) {
-
        if let Some(on_change) = self.on_change {
-
            (on_change)(&self.props, self.action_tx.clone());
+
        if let Some(on_event) = self.on_event {
+
            (on_event)(&self.props, self.action_tx.clone());
        }
    }
}
@@ -121,7 +121,7 @@ where
{
    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))
+
            .and_then(HeaderProps::from_boxed_any)
            .unwrap_or(self.props.clone());

        let widths: Vec<Constraint> = props
@@ -224,7 +224,7 @@ pub struct Footer<'a, S, A> {
    /// Custom update handler
    on_update: Option<UpdateCallback<S>>,
    /// Additional custom event handler
-
    on_change: Option<EventCallback<A>>,
+
    on_event: Option<EventCallback<A>>,
}

impl<'a, S, A> Footer<'a, S, A> {
@@ -251,7 +251,7 @@ impl<'a: 'static, S, A> View<S, A> for Footer<'a, S, A> {
            action_tx: action_tx.clone(),
            props: FooterProps::default(),
            on_update: None,
-
            on_change: None,
+
            on_event: None,
        }
    }

@@ -260,8 +260,8 @@ impl<'a: 'static, S, A> View<S, A> for Footer<'a, S, A> {
        self
    }

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

@@ -273,8 +273,8 @@ impl<'a: 'static, S, A> View<S, A> for Footer<'a, S, A> {
    }

    fn handle_key_event(&mut self, _key: Key) {
-
        if let Some(on_change) = self.on_change {
-
            (on_change)(&self.props, self.action_tx.clone());
+
        if let Some(on_event) = self.on_event {
+
            (on_event)(&self.props, self.action_tx.clone());
        }
    }
}
@@ -309,7 +309,7 @@ where
{
    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))
+
            .and_then(FooterProps::from_boxed_any)
            .unwrap_or(self.props.clone());

        let widths = props
@@ -375,7 +375,7 @@ where
    /// Custom update handler
    on_update: Option<UpdateCallback<S>>,
    /// Additional custom event handler
-
    on_change: Option<EventCallback<A>>,
+
    on_event: Option<EventCallback<A>>,
    /// Container header
    header: Option<BoxedWidget<B, S, A>>,
    /// Content widget
@@ -419,7 +419,7 @@ where
            content: None,
            footer: None,
            on_update: None,
-
            on_change: None,
+
            on_event: None,
        }
    }

@@ -428,16 +428,14 @@ where
        self
    }

-
    fn on_change(mut self, callback: EventCallback<A>) -> Self {
-
        self.on_change = Some(callback);
+
    fn on_event(mut self, callback: EventCallback<A>) -> Self {
+
        self.on_event = 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());
+
        self.props =
+
            ContainerProps::from_callback(self.on_update, state).unwrap_or(self.props.clone());

        if let Some(header) = &mut self.header {
            header.update(state);
@@ -465,7 +463,7 @@ where
{
    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))
+
            .and_then(ContainerProps::from_boxed_any)
            .unwrap_or(self.props.clone());

        let header_h = if self.header.is_some() { 3 } else { 0 };
modified src/ui/widget/input.rs
@@ -63,7 +63,7 @@ pub struct TextField<S, A> {
    /// Custom update handler
    on_update: Option<UpdateCallback<S>>,
    /// Additional custom event handler
-
    on_change: Option<EventCallback<A>>,
+
    on_event: Option<EventCallback<A>>,
    /// Internal state
    state: TextFieldState,
}
@@ -136,7 +136,7 @@ impl<S, A> View<S, A> for TextField<S, A> {
            action_tx,
            props: TextFieldProps::default(),
            on_update: None,
-
            on_change: None,
+
            on_event: None,
            state: TextFieldState {
                text: None,
                cursor_position: 0,
@@ -149,8 +149,8 @@ impl<S, A> View<S, A> for TextField<S, A> {
        self
    }

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

@@ -188,8 +188,8 @@ impl<S, A> View<S, A> for TextField<S, A> {
            _ => {}
        }

-
        if let Some(on_change) = self.on_change {
-
            (on_change)(&self.state, self.action_tx.clone());
+
        if let Some(on_event) = self.on_event {
+
            (on_event)(&self.state, self.action_tx.clone());
        }
    }
}
@@ -200,7 +200,7 @@ where
{
    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))
+
            .and_then(TextFieldProps::from_boxed_any)
            .unwrap_or(self.props.clone());

        let layout = Layout::vertical(Constraint::from_lengths([1, 1])).split(area);
modified src/ui/widget/text.rs
@@ -67,7 +67,7 @@ pub struct Paragraph<'a, S, A> {
    /// Custom update handler
    on_update: Option<UpdateCallback<S>>,
    /// Additional custom event handler
-
    on_change: Option<EventCallback<A>>,
+
    on_event: Option<EventCallback<A>>,
    /// Internal state
    state: ParagraphState,
}
@@ -155,7 +155,7 @@ impl<'a: 'static, S, A> View<S, A> for Paragraph<'a, S, A> {
            action_tx: action_tx.clone(),
            props: ParagraphProps::default(),
            on_update: None,
-
            on_change: None,
+
            on_event: None,
            state: ParagraphState {
                offset: 0,
                progress: 0,
@@ -163,8 +163,8 @@ impl<'a: 'static, S, A> View<S, A> for Paragraph<'a, S, A> {
        }
    }

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

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

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

    fn handle_key_event(&mut self, key: Key) {
@@ -206,8 +204,8 @@ impl<'a: 'static, S, A> View<S, A> for Paragraph<'a, S, A> {
            _ => {}
        }

-
        if let Some(on_change) = self.on_change {
-
            (on_change)(&self.state, self.action_tx.clone());
+
        if let Some(on_event) = self.on_event {
+
            (on_event)(&self.state, self.action_tx.clone());
        }
    }
}
@@ -218,7 +216,7 @@ where
{
    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))
+
            .and_then(ParagraphProps::from_boxed_any)
            .unwrap_or(self.props.clone());

        let [content_area] = Layout::horizontal([Constraint::Min(1)])