Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
lib: Support trait objects for widgets
Erik Kundt committed 2 years ago
commit 047e54b0e38b590b663de58b38d1d7b4d43a08aa
parent 937580af7bea8f5fb6bcc0a90de5cf1576168eba
12 files changed +1412 -788
modified bin/commands/inbox/select.rs
@@ -18,13 +18,14 @@ use tui::cob::inbox::{self};
use tui::store;
use tui::store::StateValue;
use tui::task::{self, Interrupted};
-
use tui::ui::items::NotificationItem;
-
use tui::ui::items::NotificationItemFilter;
+
use tui::terminal;
+
use tui::ui::items::{Filter, NotificationItem, NotificationItemFilter};
use tui::ui::Frontend;
use tui::Exit;

use ui::ListPage;

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

type Selection = tui::Selection<NotificationId>;
@@ -59,8 +60,14 @@ impl Default for UIState {
}

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

+
#[derive(Clone, Debug)]
pub struct State {
-
    notifications: Vec<NotificationItem>,
+
    notifications: NotificationsState,
    mode: Mode,
    project: Project,
    filter: NotificationItemFilter,
@@ -68,6 +75,35 @@ pub struct State {
    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()
+
    }
+
}
+

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

@@ -159,7 +195,10 @@ impl TryFrom<&Context> for State {
        }

        Ok(Self {
-
            notifications,
+
            notifications: NotificationsState {
+
                items: notifications,
+
                selected: None,
+
            },
            mode: mode.clone(),
            project,
            filter,
@@ -171,7 +210,7 @@ impl TryFrom<&Context> for State {

pub enum Action {
    Exit { selection: Option<Selection> },
-
    Update,
+
    Select { selected: Option<usize> },
    PageSize(usize),
    OpenSearch,
    UpdateSearch { value: String },
@@ -187,6 +226,10 @@ impl store::State<Action, Selection> for State {
    fn handle_action(&mut self, action: Action) -> Option<Exit<Selection>> {
        match action {
            Action::Exit { selection } => Some(Exit { value: selection }),
+
            Action::Select { selected } => {
+
                self.notifications.selected = selected;
+
                None
+
            }
            Action::PageSize(size) => {
                self.ui.page_size = size;
                None
@@ -223,7 +266,6 @@ impl store::State<Action, Selection> for State {
                self.ui.show_help = false;
                None
            }
-
            Action::Update => None,
        }
    }
}
@@ -241,7 +283,10 @@ impl App {

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

        if let Ok(reason) = interrupt_rx.recv().await {
modified bin/commands/inbox/select/ui.rs
@@ -1,3 +1,4 @@
+
use std::any::Any;
use std::collections::HashMap;
use std::str::FromStr;

@@ -12,18 +13,24 @@ use ratatui::text::{Line, Span, Text};

use radicle_tui as tui;

-
use tui::ui::items::{Filter, NotificationItem, NotificationItemFilter, NotificationState};
+
use tui::ui::items::{NotificationItem, NotificationItemFilter, NotificationState};
use tui::ui::span;
-
use tui::ui::widget::container::{Footer, Header};
-
use tui::ui::widget::input::TextField;
-
use tui::ui::widget::text::Paragraph;
-
use tui::ui::widget::{Column, Render, Shortcuts, Table, View};
+
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::{
+
    Column, EventCallback, Shortcuts, ShortcutsProps, Table, TableProps, UpdateCallback, View,
+
    Widget,
+
};
use tui::Selection;

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

use super::{Action, State};

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

pub struct ListPageProps {
    show_search: bool,
    show_help: bool,
@@ -38,22 +45,26 @@ impl From<&State> for ListPageProps {
    }
}

-
pub struct ListPage<'a> {
-
    /// Action sender
-
    pub action_tx: UnboundedSender<Action>,
-
    /// State mapped props
+
pub struct ListPage<B: Backend> {
+
    /// Internal properties
    props: ListPageProps,
-
    /// Notification widget
-
    notifications: Notifications<'a>,
+
    /// Message sender
+
    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: Search,
+
    search: BoxedWidget<B>,
    /// Help widget
-
    help: Help<'a>,
+
    help: BoxedWidget<B>,
    /// Shortcut widget
-
    shortcuts: Shortcuts<Action>,
+
    shortcuts: BoxedWidget<B>,
}

-
impl<'a> View<State, Action> for ListPage<'a> {
+
impl<'a: 'static, B: Backend + 'a> View<State, Action> for ListPage<B> {
    fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self
    where
        Self: Sized,
@@ -61,51 +72,43 @@ impl<'a> View<State, Action> for ListPage<'a> {
        Self {
            action_tx: action_tx.clone(),
            props: ListPageProps::from(state),
-
            notifications: Notifications::new(state, action_tx.clone()),
-
            search: Search::new(state, action_tx.clone()),
-
            help: Help::new(state, action_tx.clone()),
-
            shortcuts: Shortcuts::new(&(), action_tx.clone()),
+
            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(),
+
            on_update: None,
+
            on_change: None,
        }
-
        .move_with_state(state)
    }

-
    fn move_with_state(self, state: &State) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        let shorts = if state.ui.show_search {
-
            vec![("esc", "cancel"), ("enter", "apply")]
-
        } else if state.ui.show_help {
-
            vec![("?", "close")]
-
        } else {
-
            match state.mode.selection() {
-
                SelectionMode::Id => vec![("enter", "select"), ("/", "search")],
-
                SelectionMode::Operation => vec![
-
                    ("enter", "show"),
-
                    ("c", "clear"),
-
                    ("/", "search"),
-
                    ("?", "help"),
-
                ],
-
            }
-
        };
+
    fn on_change(mut self, callback: EventCallback<Action>) -> Self {
+
        self.on_change = Some(callback);
+
        self
+
    }

-
        let shortcuts = self.shortcuts.move_with_state(&());
-
        let shortcuts = shortcuts.shortcuts(&shorts);
+
    fn on_update(mut self, callback: UpdateCallback<State>) -> Self {
+
        self.on_update = Some(callback);
+
        self
+
    }

-
        ListPage {
-
            notifications: self.notifications.move_with_state(state),
-
            shortcuts,
-
            help: self.help.move_with_state(state),
-
            props: ListPageProps::from(state),
-
            ..self
-
        }
+
    fn update(&mut self, state: &State) {
+
        self.props = ListPageProps::from(state);
+

+
        self.notifications.update(state);
+
        self.search.update(state);
+
        self.help.update(state);
+
        self.shortcuts.update(state);
    }

    fn handle_key_event(&mut self, key: termion::event::Key) {
        if self.props.show_search {
-
            <Search as View<State, Action>>::handle_key_event(&mut self.search, key)
+
            self.search.handle_key_event(key);
        } else if self.props.show_help {
-
            <Help as View<State, Action>>::handle_key_event(&mut self.help, key)
+
            self.help.handle_key_event(key);
        } else {
            match key {
                Key::Esc | Key::Ctrl('c') => {
@@ -118,19 +121,15 @@ impl<'a> View<State, Action> for ListPage<'a> {
                    let _ = self.action_tx.send(Action::OpenHelp);
                }
                _ => {
-
                    <Notifications as View<State, Action>>::handle_key_event(
-
                        &mut self.notifications,
-
                        key,
-
                    );
+
                    self.notifications.handle_key_event(key);
                }
            }
        }
-
        let _ = self.action_tx.send(Action::Update);
    }
}

-
impl<'a, B: Backend> Render<B, ()> for ListPage<'a> {
-
    fn render(&self, frame: &mut ratatui::Frame, _area: Rect, _props: ()) {
+
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);

@@ -138,30 +137,21 @@ impl<'a, B: Backend> Render<B, ()> for ListPage<'a> {
            let component_layout = Layout::vertical([Constraint::Min(1), Constraint::Length(2)])
                .split(layout.component);

-
            <Notifications<'_> as Render<B, ()>>::render(
-
                &self.notifications,
-
                frame,
-
                component_layout[0],
-
                (),
-
            );
-
            <Search as Render<B, ()>>::render(&self.search, frame, component_layout[1], ());
+
            self.notifications.render(frame, component_layout[0], &());
+
            self.search.render(frame, component_layout[1], &());
        } else if self.props.show_help {
-
            <Help<'_> as Render<B, ()>>::render(&self.help, frame, layout.component, ());
+
            self.help.render(frame, layout.component, &());
        } else {
-
            <Notifications<'_> as Render<B, ()>>::render(
-
                &self.notifications,
-
                frame,
-
                layout.component,
-
                (),
-
            );
+
            self.notifications.render(frame, layout.component, &());
        }

-
        <Shortcuts<_> as Render<B, ()>>::render(&self.shortcuts, frame, layout.shortcuts, ());
+
        self.shortcuts.render(frame, layout.shortcuts, &());
    }
}

struct NotificationsProps<'a> {
    notifications: Vec<NotificationItem>,
+
    selected: Option<usize>,
    mode: Mode,
    stats: HashMap<String, usize>,
    columns: Vec<Column<'a>>,
@@ -178,12 +168,7 @@ impl<'a> From<&State> for NotificationsProps<'a> {
        let mut seen = 0;
        let mut unseen = 0;

-
        let notifications: Vec<NotificationItem> = state
-
            .notifications
-
            .iter()
-
            .filter(|issue| state.filter.matches(issue))
-
            .cloned()
-
            .collect();
+
        let notifications = state.notifications();

        // Compute statistics
        for notification in &notifications {
@@ -198,6 +183,7 @@ impl<'a> From<&State> for NotificationsProps<'a> {

        Self {
            notifications,
+
            selected: None,
            mode: state.mode.clone(),
            stats,
            columns: [
@@ -223,18 +209,25 @@ impl<'a> From<&State> for NotificationsProps<'a> {
    }
}

-
struct Notifications<'a> {
-
    /// Action sender
-
    action_tx: UnboundedSender<Action>,
-
    /// State mapped props
+
struct Notifications<'a, B: Backend> {
+
    /// Internal properties
    props: NotificationsProps<'a>,
-
    /// Notification table
-
    table: Table<'a, Action, NotificationItem>,
-
    /// Table footer
-
    footer: Footer<'a, Action>,
+
    /// 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>,
}

-
impl<'a> View<State, Action> for Notifications<'a> {
+
impl<'a: 'static, B> View<State, Action> for Notifications<'a, B>
+
where
+
    B: Backend + 'a,
+
{
    fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self {
        let props = NotificationsProps::from(state);
        let name = match state.mode.repository() {
@@ -246,62 +239,82 @@ impl<'a> View<State, Action> for Notifications<'a> {
        Self {
            action_tx: action_tx.clone(),
            props: NotificationsProps::from(state),
-
            table: Table::new(&(), action_tx.clone())
-
                .items(props.notifications.clone())
-
                .columns(props.columns.to_vec())
-
                .header(
-
                    Header::new(&(), action_tx.clone())
-
                        .columns(
-
                            [
-
                                Column::new("", Constraint::Length(0)),
-
                                Column::new(Text::from(name), Constraint::Fill(1)),
-
                            ]
-
                            .to_vec(),
+
            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| {
+
                                action_tx
+
                                    .send(Action::Select {
+
                                        selected: props.selected,
+
                                    })
+
                                    .ok()
+
                            });
+
                    })
+
                    .on_update(|state| {
+
                        let props = NotificationsProps::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),
                        )
-
                        .cutoff(props.cutoff, props.cutoff_after)
-
                        .focus(props.focus),
-
                )
-
                .footer(!props.show_search)
-
                .cutoff(props.cutoff, props.cutoff_after),
-
            footer: Footer::new(&(), action_tx),
+
                    }),
+
            ),
+
            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)),
+
                    )
+
                })
+
                .to_boxed(),
+
            on_update: None,
+
            on_change: None,
        }
    }

-
    fn move_with_state(self, state: &State) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        let notifications: Vec<NotificationItem> = state
-
            .notifications
-
            .iter()
-
            .filter(|issue| state.filter.matches(issue))
-
            .cloned()
-
            .collect();
-

-
        let props = NotificationsProps::from(state);
+
    fn on_change(mut self, callback: EventCallback<Action>) -> Self {
+
        self.on_change = Some(callback);
+
        self
+
    }

-
        let table = self.table.move_with_state(&());
-
        let table = table
-
            .items(notifications)
-
            .footer(!state.ui.show_search)
-
            .page_size(state.ui.page_size);
+
    fn on_update(mut self, callback: UpdateCallback<State>) -> Self {
+
        self.on_update = Some(callback);
+
        self
+
    }

-
        let footer = self.footer.move_with_state(&());
-
        let footer = footer.columns(Self::build_footer(&props, table.selected()));
+
    fn update(&mut self, state: &State) {
+
        // TODO call mapper here instead?
+
        self.props = NotificationsProps::from(state);

-
        Self {
-
            props,
-
            table,
-
            footer,
-
            ..self
-
        }
+
        self.table.update(state);
+
        self.footer.update(state);
    }

    fn handle_key_event(&mut self, key: Key) {
        match key {
            Key::Char('\n') => {
-
                self.table
-
                    .selected()
+
                self.props
+
                    .selected
                    .and_then(|selected| self.props.notifications.get(selected))
                    .and_then(|notif| {
                        let selection = match self.props.mode.selection() {
@@ -319,8 +332,8 @@ impl<'a> View<State, Action> for Notifications<'a> {
                    });
            }
            Key::Char('c') => {
-
                self.table
-
                    .selected()
+
                self.props
+
                    .selected
                    .and_then(|selected| self.props.notifications.get(selected))
                    .and_then(|notif| {
                        self.action_tx
@@ -335,16 +348,13 @@ impl<'a> View<State, Action> for Notifications<'a> {
                    });
            }
            _ => {
-
                <Table<Action, NotificationItem> as View<(), Action>>::handle_key_event(
-
                    &mut self.table,
-
                    key,
-
                );
+
                self.table.handle_key_event(key);
            }
        }
    }
}

-
impl<'a> Notifications<'a> {
+
impl<'a, B: Backend> Notifications<'a, B> {
    fn build_footer(props: &NotificationsProps<'a>, selected: Option<usize>) -> Vec<Column<'a>> {
        let search = Line::from(
            [
@@ -377,7 +387,7 @@ impl<'a> Notifications<'a> {

        let progress = selected
            .map(|selected| {
-
                Table::<Action, NotificationItem>::progress(
+
                Table::<State, Action, B, NotificationItem>::progress(
                    selected,
                    props.notifications.len(),
                    props.page_size,
@@ -423,19 +433,22 @@ impl<'a> Notifications<'a> {
    }
}

-
impl<'a, B: Backend> Render<B, ()> for Notifications<'a> {
-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
+
impl<'a: 'static, B> Widget<State, Action, B> for Notifications<'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 {
-
            <Table<'_, _, _> as Render<B, ()>>::render(&self.table, frame, area, ());
+
            self.table.render(frame, area, &());

            (area.height as usize).saturating_sub(header_height)
        } else {
            let layout = Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).split(area);

-
            <Table<'_, _, _> as Render<B, ()>>::render(&self.table, frame, layout[0], ());
-
            <Footer<'_, _> as Render<B, ()>>::render(&self.footer, frame, layout[1], ());
+
            self.table.render(frame, layout[0], &());
+
            self.footer.render(frame, layout[1], &());

            (area.height as usize).saturating_sub(header_height)
        };
@@ -446,30 +459,61 @@ impl<'a, B: Backend> Render<B, ()> for Notifications<'a> {
    }
}

-
pub struct Search {
-
    pub action_tx: UnboundedSender<Action>,
-
    pub input: TextField<Action>,
+
pub struct Search<B: Backend> {
+
    /// Message sender
+
    action_tx: UnboundedSender<Action>,
+
    /// Custom update handler
+
    on_update: Option<UpdateCallback<State>>,
+
    /// Additional custom event handler
+
    on_change: Option<EventCallback<Action>>,
+
    /// Search input field
+
    input: BoxedWidget<B>,
}

-
impl View<State, Action> for Search {
+
impl<B: Backend> View<State, Action> for Search<B> {
    fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self
    where
        Self: Sized,
    {
        let input = TextField::new(state, action_tx.clone())
-
            .title("Search")
-
            .inline(true);
-
        Self { action_tx, input }.move_with_state(state)
+
            .on_change(|props, action_tx| {
+
                props.downcast_ref::<TextFieldProps>().and_then(|props| {
+
                    action_tx
+
                        .send(Action::UpdateSearch {
+
                            value: props.text.clone(),
+
                        })
+
                        .ok()
+
                });
+
            })
+
            .on_update(|state| {
+
                Box::<TextFieldProps>::new(
+
                    TextFieldProps::default()
+
                        .text(&state.search.read().to_string())
+
                        .title("Search")
+
                        .inline(true),
+
                )
+
            })
+
            .to_boxed();
+
        Self {
+
            action_tx,
+
            input,
+
            on_update: None,
+
            on_change: None,
+
        }
    }

-
    fn move_with_state(self, state: &State) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        let input = self.input.move_with_state(state);
-
        let input = input.text(&state.search.read().to_string());
+
    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
+
    }

-
        Self { input, ..self }
+
    fn update(&mut self, state: &State) {
+
        self.input.update(state);
    }

    fn handle_key_event(&mut self, key: termion::event::Key) {
@@ -481,22 +525,19 @@ impl View<State, Action> for Search {
                let _ = self.action_tx.send(Action::ApplySearch);
            }
            _ => {
-
                <TextField<Action> as View<State, Action>>::handle_key_event(&mut self.input, key);
-
                let _ = self.action_tx.send(Action::UpdateSearch {
-
                    value: self.input.read().to_string(),
-
                });
+
                self.input.handle_key_event(key);
            }
        }
    }
}

-
impl<B: Backend> Render<B, ()> for Search {
-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
+
impl<B: Backend> Widget<State, Action, B> for Search<B> {
+
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: &dyn Any) {
        let layout = Layout::horizontal(Constraint::from_mins([0]))
            .horizontal_margin(1)
            .split(area);

-
        <TextField<_> as Render<B, ()>>::render(&self.input, frame, layout[0], ());
+
        self.input.render(frame, layout[0], &());
    }
}

@@ -645,70 +686,93 @@ impl<'a> From<&State> for HelpProps<'a> {
    }
}

-
pub struct Help<'a> {
-
    /// Send messages
-
    pub action_tx: UnboundedSender<Action>,
-
    /// This widget's render properties
-
    pub props: HelpProps<'a>,
+
pub struct Help<'a, B: Backend> {
+
    /// Internal properties
+
    props: HelpProps<'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: Header<'a, Action>,
+
    header: BoxedWidget<B>,
    /// Content widget
-
    content: Paragraph<'a, Action>,
+
    content: BoxedWidget<B>,
    /// Container footer
-
    footer: Footer<'a, Action>,
+
    footer: BoxedWidget<B>,
}

-
impl<'a> View<State, Action> for Help<'a> {
+
impl<'a, B: Backend> View<State, Action> for Help<'a, B> {
    fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self
    where
        Self: Sized,
    {
-
        let props = HelpProps::from(state);
-

        Self {
            action_tx: action_tx.clone(),
-
            props: props.clone(),
-
            header: Header::new(&(), action_tx.clone()),
-
            content: Paragraph::new(state, action_tx.clone()),
-
            footer: Footer::new(&(), action_tx),
+
            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),
+
                    )
+
                })
+
                .to_boxed(),
+
            on_update: None,
+
            on_change: None,
        }
-
        .move_with_state(state)
    }

-
    fn move_with_state(self, state: &State) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        let props = HelpProps::from(state);
-

-
        let header = self.header.move_with_state(&());
-
        let header = header
-
            .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec())
-
            .focus(props.focus);
+
    fn on_update(mut self, callback: UpdateCallback<State>) -> Self {
+
        self.on_update = Some(callback);
+
        self
+
    }

-
        let content = self.content.move_with_state(state);
-
        let content = content.text(&props.content).page_size(props.page_size);
+
    fn on_change(mut self, callback: EventCallback<Action>) -> Self {
+
        self.on_change = Some(callback);
+
        self
+
    }

-
        let progress = span::default(format!("{}%", content.progress())).dim();
+
    fn update(&mut self, state: &State) {
+
        self.props = HelpProps::from(state);

-
        let footer = self.footer.move_with_state(&());
-
        let footer = footer
-
            .columns(
-
                [
-
                    Column::new(Text::raw(""), Constraint::Fill(1)),
-
                    Column::new(Text::from(progress), Constraint::Min(4)),
-
                ]
-
                .to_vec(),
-
            )
-
            .focus(props.focus);
-

-
        Self {
-
            props,
-
            header,
-
            content,
-
            footer,
-
            ..self
-
        }
+
        self.header.update(state);
+
        self.content.update(state);
+
        self.footer.update(state);
    }

    fn handle_key_event(&mut self, key: termion::event::Key) {
@@ -720,14 +784,14 @@ impl<'a> View<State, Action> for Help<'a> {
                let _ = self.action_tx.send(Action::CloseHelp);
            }
            _ => {
-
                <Paragraph<_> as View<(), _>>::handle_key_event(&mut self.content, key);
+
                self.content.handle_key_event(key);
            }
        }
    }
}

-
impl<'a, B: Backend> Render<B, ()> for Help<'a> {
-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
+
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),
@@ -735,9 +799,9 @@ impl<'a, B: Backend> Render<B, ()> for Help<'a> {
        ])
        .areas(area);

-
        <Header<'_, _> as Render<B, ()>>::render(&self.header, frame, header_area, ());
-
        <Paragraph<'_, _> as Render<B, ()>>::render(&self.content, frame, content_area, ());
-
        <Footer<'_, _> as Render<B, ()>>::render(&self.footer, frame, footer_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 {
modified bin/commands/issue/select.rs
@@ -16,7 +16,8 @@ use tui::store;
use tui::store::StateValue;
use tui::task;
use tui::task::Interrupted;
-
use tui::ui::items::{IssueItem, IssueItemFilter};
+
use tui::terminal;
+
use tui::ui::items::{Filter, IssueItem, IssueItemFilter};
use tui::ui::Frontend;
use tui::Exit;

@@ -55,14 +56,49 @@ impl Default for UIState {
}

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

+
#[derive(Clone, Debug)]
pub struct State {
-
    issues: Vec<IssueItem>,
+
    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()
+
    }
+
}
+

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

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

        Ok(Self {
-
            issues: items,
+
            issues: IssuesState {
+
                items,
+
                selected: None,
+
            },
            mode: context.mode.clone(),
            filter,
            search,
@@ -92,7 +131,7 @@ impl TryFrom<&Context> for State {

pub enum Action {
    Exit { selection: Option<Selection> },
-
    Update,
+
    Select { selected: Option<usize> },
    PageSize(usize),
    OpenSearch,
    UpdateSearch { value: String },
@@ -108,6 +147,10 @@ impl store::State<Action, Selection> for State {
    fn handle_action(&mut self, action: Action) -> Option<Exit<Selection>> {
        match action {
            Action::Exit { selection } => Some(Exit { value: selection }),
+
            Action::Select { selected } => {
+
                self.issues.selected = selected;
+
                None
+
            }
            Action::PageSize(size) => {
                self.ui.page_size = size;
                None
@@ -142,7 +185,6 @@ impl store::State<Action, Selection> for State {
                self.ui.show_help = false;
                None
            }
-
            Action::Update => None,
        }
    }
}
@@ -160,7 +202,10 @@ impl App {

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

        if let Ok(reason) = interrupt_rx.recv().await {
modified bin/commands/issue/select/ui.rs
@@ -1,3 +1,4 @@
+
use std::any::Any;
use std::collections::HashMap;
use std::str::FromStr;
use std::vec;
@@ -14,12 +15,16 @@ use ratatui::text::{Line, Span, Text};

use radicle_tui as tui;

-
use tui::ui::items::{Filter, IssueItem, IssueItemFilter};
+
use tui::ui::items::{IssueItem, IssueItemFilter};
use tui::ui::span;
-
use tui::ui::widget::container::{Footer, Header};
-
use tui::ui::widget::input::TextField;
-
use tui::ui::widget::text::Paragraph;
-
use tui::ui::widget::{Column, Render, Shortcuts, Table, View};
+
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::{
+
    Column, EventCallback, Shortcuts, ShortcutsProps, Table, TableProps, UpdateCallback, View,
+
    Widget,
+
};
use tui::Selection;

use crate::tui_issue::common::IssueOperation;
@@ -27,6 +32,8 @@ use crate::tui_issue::common::Mode;

use super::{Action, State};

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

pub struct ListPageProps {
    show_search: bool,
    show_help: bool,
@@ -41,22 +48,29 @@ impl From<&State> for ListPageProps {
    }
}

-
pub struct ListPage<'a> {
-
    /// Action sender
-
    pub action_tx: UnboundedSender<Action>,
-
    /// State mapped props
+
pub struct ListPage<B: Backend> {
+
    /// Internal properties
    props: ListPageProps,
-
    /// Notification widget
-
    issues: Issues<'a>,
+
    /// Message sender
+
    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: Search,
+
    search: BoxedWidget<B>,
    /// Help widget
-
    help: Help<'a>,
+
    help: BoxedWidget<B>,
    /// Shortcut widget
-
    shortcuts: Shortcuts<Action>,
+
    shortcuts: BoxedWidget<B>,
}

-
impl<'a> View<State, Action> for ListPage<'a> {
+
impl<'a: 'static, B> View<State, Action> for ListPage<B>
+
where
+
    B: Backend + 'a,
+
{
    fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self
    where
        Self: Sized,
@@ -64,51 +78,43 @@ impl<'a> View<State, Action> for ListPage<'a> {
        Self {
            action_tx: action_tx.clone(),
            props: ListPageProps::from(state),
-
            issues: Issues::new(state, action_tx.clone()),
-
            search: Search::new(state, action_tx.clone()),
-
            help: Help::new(state, action_tx.clone()),
-
            shortcuts: Shortcuts::new(&(), action_tx.clone()),
+
            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(),
+
            on_update: None,
+
            on_change: None,
        }
-
        .move_with_state(state)
    }

-
    fn move_with_state(self, state: &State) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        let shorts = if state.ui.show_search {
-
            vec![("esc", "cancel"), ("enter", "apply")]
-
        } else if state.ui.show_help {
-
            vec![("?", "close")]
-
        } else {
-
            match state.mode {
-
                Mode::Id => vec![("enter", "select"), ("/", "search")],
-
                Mode::Operation => vec![
-
                    ("enter", "show"),
-
                    ("e", "edit"),
-
                    ("/", "search"),
-
                    ("?", "help"),
-
                ],
-
            }
-
        };
+
    fn on_change(mut self, callback: EventCallback<Action>) -> Self {
+
        self.on_change = Some(callback);
+
        self
+
    }

-
        let shortcuts = self.shortcuts.move_with_state(&());
-
        let shortcuts = shortcuts.shortcuts(&shorts);
+
    fn on_update(mut self, callback: UpdateCallback<State>) -> Self {
+
        self.on_update = Some(callback);
+
        self
+
    }

-
        ListPage {
-
            issues: self.issues.move_with_state(state),
-
            shortcuts,
-
            help: self.help.move_with_state(state),
-
            props: ListPageProps::from(state),
-
            ..self
-
        }
+
    fn update(&mut self, state: &State) {
+
        self.props = ListPageProps::from(state);
+

+
        self.issues.update(state);
+
        self.search.update(state);
+
        self.help.update(state);
+
        self.shortcuts.update(state);
    }

    fn handle_key_event(&mut self, key: termion::event::Key) {
        if self.props.show_search {
-
            <Search as View<State, Action>>::handle_key_event(&mut self.search, key)
+
            self.search.handle_key_event(key);
        } else if self.props.show_help {
-
            <Help as View<State, Action>>::handle_key_event(&mut self.help, key)
+
            self.help.handle_key_event(key);
        } else {
            match key {
                Key::Esc | Key::Ctrl('c') => {
@@ -121,16 +127,18 @@ impl<'a> View<State, Action> for ListPage<'a> {
                    let _ = self.action_tx.send(Action::OpenHelp);
                }
                _ => {
-
                    <Issues as View<State, Action>>::handle_key_event(&mut self.issues, key);
+
                    self.issues.handle_key_event(key);
                }
            }
        }
-
        let _ = self.action_tx.send(Action::Update);
    }
}

-
impl<'a, B: Backend> Render<B, ()> for ListPage<'a> {
-
    fn render(&self, frame: &mut ratatui::Frame, _area: Rect, _props: ()) {
+
impl<'a: 'static, B> Widget<State, Action, B> for ListPage<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);

@@ -138,15 +146,15 @@ impl<'a, B: Backend> Render<B, ()> for ListPage<'a> {
            let component_layout = Layout::vertical([Constraint::Min(1), Constraint::Length(2)])
                .split(layout.component);

-
            <Issues<'_> as Render<B, ()>>::render(&self.issues, frame, component_layout[0], ());
-
            <Search as Render<B, ()>>::render(&self.search, frame, component_layout[1], ());
+
            self.issues.render(frame, component_layout[0], &());
+
            self.search.render(frame, component_layout[1], &());
        } else if self.props.show_help {
-
            <Help<'_> as Render<B, ()>>::render(&self.help, frame, layout.component, ());
+
            self.help.render(frame, layout.component, &());
        } else {
-
            <Issues<'_> as Render<B, ()>>::render(&self.issues, frame, layout.component, ());
+
            self.issues.render(frame, layout.component, &());
        }

-
        <Shortcuts<_> as Render<B, ()>>::render(&self.shortcuts, frame, layout.shortcuts, ());
+
        self.shortcuts.render(frame, layout.shortcuts, &());
    }
}

@@ -154,6 +162,7 @@ impl<'a, B: Backend> Render<B, ()> for ListPage<'a> {
struct IssuesProps<'a> {
    mode: Mode,
    issues: Vec<IssueItem>,
+
    selected: Option<usize>,
    search: String,
    stats: HashMap<String, usize>,
    columns: Vec<Column<'a>>,
@@ -168,12 +177,7 @@ impl<'a> From<&State> for IssuesProps<'a> {
    fn from(state: &State) -> Self {
        use radicle::issue::State;

-
        let issues: Vec<IssueItem> = state
-
            .issues
-
            .iter()
-
            .filter(|issue| state.filter.matches(issue))
-
            .cloned()
-
            .collect();
+
        let issues = state.issues();

        let mut open = 0;
        let mut other = 0;
@@ -221,72 +225,95 @@ impl<'a> From<&State> for IssuesProps<'a> {
            stats,
            page_size: state.ui.page_size,
            show_search: state.ui.show_search,
+
            selected: state.issues.selected,
        }
    }
}

-
struct Issues<'a> {
-
    /// Action sender
-
    action_tx: UnboundedSender<Action>,
-
    /// State mapped props
+
struct Issues<'a, B> {
+
    /// Internal properties
    props: IssuesProps<'a>,
-
    /// Notification table
-
    table: Table<'a, Action, IssueItem>,
-
    /// Footer
-
    footer: Footer<'a, Action>,
+
    /// 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>,
}

-
impl<'a> View<State, Action> for Issues<'a> {
+
impl<'a: 'static, B> View<State, Action> for Issues<'a, B>
+
where
+
    B: Backend + 'a,
+
{
    fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self {
        let props = IssuesProps::from(state);

        Self {
            action_tx: action_tx.clone(),
            props: props.clone(),
-
            table: Table::new(&(), action_tx.clone())
-
                .items(props.issues.clone())
-
                .columns(props.columns.to_vec())
-
                .header(
-
                    Header::new(&(), action_tx.clone())
-
                        .columns(props.columns.clone())
-
                        .cutoff(props.cutoff, props.cutoff_after)
-
                        .focus(props.focus),
-
                )
-
                .footer(!props.show_search)
-
                .cutoff(props.cutoff, props.cutoff_after),
-
            footer: Footer::new(&(), action_tx),
+
            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,
+
                            });
+
                        }
+
                    })
+
                    .on_update(|state| {
+
                        let props = IssuesProps::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)),
+
                    )
+
                })
+
                .to_boxed(),
+
            on_update: None,
+
            on_change: None,
        }
-
        .move_with_state(state)
    }

-
    fn move_with_state(self, state: &State) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        let issues: Vec<IssueItem> = state
-
            .issues
-
            .iter()
-
            .filter(|issue| state.filter.matches(issue))
-
            .cloned()
-
            .collect();
-

-
        let props = IssuesProps::from(state);
+
    fn on_update(mut self, callback: UpdateCallback<State>) -> Self {
+
        self.on_update = Some(callback);
+
        self
+
    }

-
        let table = self.table.move_with_state(&());
-
        let table = table
-
            .items(issues)
-
            .footer(!state.ui.show_search)
-
            .page_size(state.ui.page_size);
+
    fn on_change(mut self, callback: EventCallback<Action>) -> Self {
+
        self.on_change = Some(callback);
+
        self
+
    }

-
        let footer = self.footer.move_with_state(&());
-
        let footer = footer.columns(Self::build_footer(&props, table.selected()));
+
    fn update(&mut self, state: &State) {
+
        // TODO call mapper here instead?
+
        self.props = IssuesProps::from(state);

-
        Self {
-
            props,
-
            table,
-
            footer,
-
            ..self
-
        }
+
        self.table.update(state);
+
        self.footer.update(state);
    }

    fn handle_key_event(&mut self, key: Key) {
@@ -297,8 +324,8 @@ impl<'a> View<State, Action> for Issues<'a> {
                    Mode::Id => None,
                };

-
                self.table
-
                    .selected()
+
                self.props
+
                    .selected
                    .and_then(|selected| self.props.issues.get(selected))
                    .and_then(|issue| {
                        self.action_tx
@@ -313,8 +340,8 @@ impl<'a> View<State, Action> for Issues<'a> {
                    });
            }
            Key::Char('e') => {
-
                self.table
-
                    .selected()
+
                self.props
+
                    .selected
                    .and_then(|selected| self.props.issues.get(selected))
                    .and_then(|issue| {
                        self.action_tx
@@ -329,16 +356,13 @@ impl<'a> View<State, Action> for Issues<'a> {
                    });
            }
            _ => {
-
                <Table<Action, IssueItem> as View<(), Action>>::handle_key_event(
-
                    &mut self.table,
-
                    key,
-
                );
+
                self.table.handle_key_event(key);
            }
        }
    }
}

-
impl<'a> Issues<'a> {
+
impl<'a, B: Backend> Issues<'a, B> {
    fn build_footer(props: &IssuesProps<'a>, selected: Option<usize>) -> Vec<Column<'a>> {
        let search = Line::from(
            [
@@ -387,7 +411,11 @@ impl<'a> Issues<'a> {

        let progress = selected
            .map(|selected| {
-
                Table::<Action, IssueItem>::progress(selected, props.issues.len(), props.page_size)
+
                Table::<State, Action, B, IssueItem>::progress(
+
                    selected,
+
                    props.issues.len(),
+
                    props.page_size,
+
                )
            })
            .unwrap_or_default();
        let progress = span::default(format!("{}%", progress)).dim();
@@ -435,19 +463,22 @@ impl<'a> Issues<'a> {
    }
}

-
impl<'a, B: Backend> Render<B, ()> for Issues<'a> {
-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
+
impl<'a: 'static, B> Widget<State, Action, B> for Issues<'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 {
-
            <Table<'_, _, _> as Render<B, ()>>::render(&self.table, frame, area, ());
+
            self.table.render(frame, area, &());

            (area.height as usize).saturating_sub(header_height)
        } else {
            let layout = Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).split(area);

-
            <Table<'_, _, _> as Render<B, ()>>::render(&self.table, frame, layout[0], ());
-
            <Footer<'_, _> as Render<B, ()>>::render(&self.footer, frame, layout[1], ());
+
            self.table.render(frame, layout[0], &());
+
            self.footer.render(frame, layout[1], &());

            (area.height as usize).saturating_sub(header_height)
        };
@@ -458,30 +489,61 @@ impl<'a, B: Backend> Render<B, ()> for Issues<'a> {
    }
}

-
pub struct Search {
-
    pub action_tx: UnboundedSender<Action>,
-
    pub input: TextField<Action>,
+
pub struct Search<B: Backend> {
+
    /// Message sender
+
    action_tx: UnboundedSender<Action>,
+
    /// Custom update handler
+
    on_update: Option<UpdateCallback<State>>,
+
    /// Additional custom event handler
+
    on_change: Option<EventCallback<Action>>,
+
    /// Search input field
+
    input: BoxedWidget<B>,
}

-
impl View<State, Action> for Search {
+
impl<B: Backend> View<State, Action> for Search<B> {
    fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self
    where
        Self: Sized,
    {
        let input = TextField::new(state, action_tx.clone())
-
            .title("Search")
-
            .inline(true);
-
        Self { action_tx, input }.move_with_state(state)
+
            .on_change(|props, action_tx| {
+
                props.downcast_ref::<TextFieldProps>().and_then(|props| {
+
                    action_tx
+
                        .send(Action::UpdateSearch {
+
                            value: props.text.clone(),
+
                        })
+
                        .ok()
+
                });
+
            })
+
            .on_update(|state| {
+
                Box::<TextFieldProps>::new(
+
                    TextFieldProps::default()
+
                        .text(&state.search.read().to_string())
+
                        .title("Search")
+
                        .inline(true),
+
                )
+
            })
+
            .to_boxed();
+
        Self {
+
            action_tx,
+
            input,
+
            on_update: None,
+
            on_change: None,
+
        }
    }

-
    fn move_with_state(self, state: &State) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        let input = self.input.move_with_state(state);
-
        let input = input.text(&state.search.read().to_string());
+
    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
+
    }

-
        Self { input, ..self }
+
    fn update(&mut self, state: &State) {
+
        self.input.update(state);
    }

    fn handle_key_event(&mut self, key: termion::event::Key) {
@@ -493,25 +555,23 @@ impl View<State, Action> for Search {
                let _ = self.action_tx.send(Action::ApplySearch);
            }
            _ => {
-
                <TextField<Action> as View<State, Action>>::handle_key_event(&mut self.input, key);
-
                let _ = self.action_tx.send(Action::UpdateSearch {
-
                    value: self.input.read().to_string(),
-
                });
+
                self.input.handle_key_event(key);
            }
        }
    }
}

-
impl<B: Backend> Render<B, ()> for Search {
-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
+
impl<B: Backend> Widget<State, Action, B> for Search<B> {
+
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: &dyn Any) {
        let layout = Layout::horizontal(Constraint::from_mins([0]))
            .horizontal_margin(1)
            .split(area);

-
        <TextField<_> as Render<B, ()>>::render(&self.input, frame, layout[0], ());
+
        self.input.render(frame, layout[0], &());
    }
}

+
#[derive(Clone)]
pub struct HelpProps<'a> {
    content: Text<'a>,
    focus: bool,
@@ -656,70 +716,93 @@ impl<'a> From<&State> for HelpProps<'a> {
    }
}

-
pub struct Help<'a> {
-
    /// Send messages
-
    pub action_tx: UnboundedSender<Action>,
-
    /// This widget's render properties
-
    pub props: HelpProps<'a>,
+
pub struct Help<'a, B: Backend> {
+
    /// Internal properties
+
    props: HelpProps<'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: Header<'a, Action>,
+
    header: BoxedWidget<B>,
    /// Content widget
-
    content: Paragraph<'a, Action>,
+
    content: BoxedWidget<B>,
    /// Container footer
-
    footer: Footer<'a, Action>,
+
    footer: BoxedWidget<B>,
}

-
impl<'a> View<State, Action> for Help<'a> {
+
impl<'a, B: Backend> View<State, Action> for Help<'a, B> {
    fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self
    where
        Self: Sized,
    {
-
        let props = HelpProps::from(state);
-

        Self {
            action_tx: action_tx.clone(),
-
            props,
-
            header: Header::new(&(), action_tx.clone()),
-
            content: Paragraph::new(state, action_tx.clone()),
-
            footer: Footer::new(&(), action_tx),
+
            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),
+
                    )
+
                })
+
                .to_boxed(),
+
            on_update: None,
+
            on_change: None,
        }
-
        .move_with_state(state)
    }

-
    fn move_with_state(self, state: &State) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        let props = HelpProps::from(state);
-

-
        let header = self.header.move_with_state(&());
-
        let header = header
-
            .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec())
-
            .focus(props.focus);
-

-
        let content = self.content.move_with_state(state);
-
        let content = content.text(&props.content).page_size(props.page_size);
+
    fn on_update(mut self, callback: UpdateCallback<State>) -> Self {
+
        self.on_update = Some(callback);
+
        self
+
    }

-
        let progress = span::default(format!("{}%", content.progress())).dim();
+
    fn on_change(mut self, callback: EventCallback<Action>) -> Self {
+
        self.on_change = Some(callback);
+
        self
+
    }

-
        let footer = self.footer.move_with_state(&());
-
        let footer = footer
-
            .columns(
-
                [
-
                    Column::new(Text::raw(""), Constraint::Fill(1)),
-
                    Column::new(Text::from(progress), Constraint::Min(4)),
-
                ]
-
                .to_vec(),
-
            )
-
            .focus(props.focus);
+
    fn update(&mut self, state: &State) {
+
        self.props = HelpProps::from(state);

-
        Self {
-
            props,
-
            header,
-
            content,
-
            footer,
-
            ..self
-
        }
+
        self.header.update(state);
+
        self.content.update(state);
+
        self.footer.update(state);
    }

    fn handle_key_event(&mut self, key: termion::event::Key) {
@@ -731,14 +814,14 @@ impl<'a> View<State, Action> for Help<'a> {
                let _ = self.action_tx.send(Action::CloseHelp);
            }
            _ => {
-
                <Paragraph<_> as View<(), _>>::handle_key_event(&mut self.content, key);
+
                self.content.handle_key_event(key);
            }
        }
    }
}

-
impl<'a, B: Backend> Render<B, ()> for Help<'a> {
-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
+
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),
@@ -746,9 +829,9 @@ impl<'a, B: Backend> Render<B, ()> for Help<'a> {
        ])
        .areas(area);

-
        <Header<'_, _> as Render<B, ()>>::render(&self.header, frame, header_area, ());
-
        <Paragraph<'_, _> as Render<B, ()>>::render(&self.content, frame, content_area, ());
-
        <Footer<'_, _> as Render<B, ()>>::render(&self.footer, frame, footer_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 {
modified bin/commands/patch/select.rs
@@ -12,11 +12,11 @@ use radicle::Profile;
use radicle_tui as tui;

use tui::cob::patch;
-
use tui::cob::patch::Filter;
use tui::store;
use tui::task;
use tui::task::Interrupted;
-
use tui::ui::items::{PatchItem, PatchItemFilter};
+
use tui::terminal;
+
use tui::ui::items::{Filter, PatchItem, PatchItemFilter};
use tui::ui::Frontend;
use tui::Exit;

@@ -30,7 +30,7 @@ pub struct Context {
    pub profile: Profile,
    pub repository: Repository,
    pub mode: Mode,
-
    pub filter: Filter,
+
    pub filter: patch::Filter,
}

pub struct App {
@@ -55,14 +55,50 @@ impl Default for UIState {
}

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

+
#[derive(Clone, Debug)]
pub struct State {
-
    patches: Vec<PatchItem>,
+
    patches: PatchesState,
    mode: Mode,
    filter: PatchItemFilter,
    search: store::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"),
+
                    ("c", "checkout"),
+
                    ("d", "diff"),
+
                    ("/", "search"),
+
                    ("?", "help"),
+
                ],
+
            }
+
        }
+
    }
+

+
    pub fn patches(&self) -> Vec<PatchItem> {
+
        self.patches
+
            .items
+
            .iter()
+
            .filter(|patch| self.filter.matches(patch))
+
            .cloned()
+
            .collect()
+
    }
+
}
+

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

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

        Ok(Self {
-
            patches: items,
+
            patches: PatchesState {
+
                items,
+
                selected: None,
+
            },
            mode: context.mode.clone(),
            filter,
            search,
@@ -92,7 +131,7 @@ impl TryFrom<&Context> for State {

pub enum Action {
    Exit { selection: Option<Selection> },
-
    Update,
+
    Select { selected: Option<usize> },
    PageSize(usize),
    OpenSearch,
    UpdateSearch { value: String },
@@ -108,6 +147,10 @@ impl store::State<Action, Selection> for State {
    fn handle_action(&mut self, action: Action) -> Option<Exit<Selection>> {
        match action {
            Action::Exit { selection } => Some(Exit { value: selection }),
+
            Action::Select { selected } => {
+
                self.patches.selected = selected;
+
                None
+
            }
            Action::PageSize(size) => {
                self.ui.page_size = size;
                None
@@ -142,7 +185,6 @@ impl store::State<Action, Selection> for State {
                self.ui.show_help = false;
                None
            }
-
            Action::Update => None,
        }
    }
}
@@ -160,7 +202,10 @@ impl App {

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

        if let Ok(reason) = interrupt_rx.recv().await {
modified bin/commands/patch/select/ui.rs
@@ -1,3 +1,4 @@
+
use std::any::Any;
use std::collections::HashMap;
use std::str::FromStr;
use std::vec;
@@ -15,12 +16,16 @@ use radicle::patch::{self, Status};

use radicle_tui as tui;

-
use tui::ui::items::{Filter, PatchItem, PatchItemFilter};
+
use tui::ui::items::{PatchItem, PatchItemFilter};
use tui::ui::span;
-
use tui::ui::widget::container::{Footer, Header};
-
use tui::ui::widget::input::TextField;
-
use tui::ui::widget::text::Paragraph;
-
use tui::ui::widget::{Column, Render, Shortcuts, Table, View, Widget};
+
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::{
+
    Column, EventCallback, Shortcuts, ShortcutsProps, Table, TableProps, UpdateCallback, View,
+
    Widget,
+
};
use tui::Selection;

use crate::tui_patch::common::Mode;
@@ -28,6 +33,8 @@ use crate::tui_patch::common::PatchOperation;

use super::{Action, State};

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

pub struct ListPageProps {
    show_search: bool,
    show_help: bool,
@@ -42,25 +49,26 @@ impl From<&State> for ListPageProps {
    }
}

-
impl<'a, B: Backend> Widget<State, Action, B> for Patches<'a> {}
-
impl<B: Backend> Widget<State, Action, B> for Search {}
-

-
pub struct ListPage<'a> {
-
    /// Action sender
-
    pub action_tx: UnboundedSender<Action>,
-
    /// State mapped props
+
pub struct ListPage<B: Backend> {
+
    /// Internal properties
    props: ListPageProps,
-
    /// Notification widget
-
    patches: Patches<'a>,
+
    /// Message sender
+
    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: Search,
+
    search: BoxedWidget<B>,
    /// Help widget
-
    help: Help<'a>,
+
    help: BoxedWidget<B>,
    /// Shortcut widget
-
    shortcuts: Shortcuts<Action>,
+
    shortcuts: BoxedWidget<B>,
}

-
impl<'a> View<State, Action> for ListPage<'a> {
+
impl<'a: 'static, B: Backend + 'a> View<State, Action> for ListPage<B> {
    fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self
    where
        Self: Sized,
@@ -68,53 +76,43 @@ impl<'a> View<State, Action> for ListPage<'a> {
        Self {
            action_tx: action_tx.clone(),
            props: ListPageProps::from(state),
-
            patches: Patches::new(state, action_tx.clone()),
-
            search: Search::new(state, action_tx.clone()),
-
            help: Help::new(state, action_tx.clone()),
-
            shortcuts: Shortcuts::new(&(), action_tx),
+
            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(),
+
            on_update: None,
+
            on_change: None,
        }
-
        .move_with_state(state)
    }

-
    fn move_with_state(self, state: &State) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        let shorts = if state.ui.show_search {
-
            vec![("esc", "cancel"), ("enter", "apply")]
-
        } else if state.ui.show_help {
-
            vec![("?", "close")]
-
        } else {
-
            match state.mode {
-
                Mode::Id => vec![("enter", "select"), ("/", "search")],
-
                Mode::Operation => vec![
-
                    ("enter", "show"),
-
                    ("c", "checkout"),
-
                    ("d", "diff"),
-
                    ("/", "search"),
-
                    ("?", "help"),
-
                ],
-
            }
-
        };
+
    fn on_update(mut self, callback: UpdateCallback<State>) -> Self {
+
        self.on_update = Some(callback);
+
        self
+
    }

-
        let shortcuts = self.shortcuts.move_with_state(state);
-
        let shortcuts = shortcuts.shortcuts(&shorts);
+
    fn on_change(mut self, callback: EventCallback<Action>) -> Self {
+
        self.on_change = Some(callback);
+
        self
+
    }

-
        ListPage {
-
            patches: self.patches.move_with_state(state),
-
            search: self.search.move_with_state(state),
-
            shortcuts,
-
            help: self.help.move_with_state(state),
-
            props: ListPageProps::from(state),
-
            ..self
-
        }
+
    fn update(&mut self, state: &State) {
+
        self.props = ListPageProps::from(state);
+

+
        self.patches.update(state);
+
        self.search.update(state);
+
        self.help.update(state);
+
        self.shortcuts.update(state);
    }

    fn handle_key_event(&mut self, key: termion::event::Key) {
        if self.props.show_search {
-
            <Search as View<State, Action>>::handle_key_event(&mut self.search, key)
+
            self.search.handle_key_event(key);
        } else if self.props.show_help {
-
            <Help as View<State, Action>>::handle_key_event(&mut self.help, key);
+
            self.help.handle_key_event(key);
        } else {
            match key {
                Key::Esc | Key::Ctrl('c') => {
@@ -127,16 +125,15 @@ impl<'a> View<State, Action> for ListPage<'a> {
                    let _ = self.action_tx.send(Action::OpenHelp);
                }
                _ => {
-
                    <Patches as View<State, Action>>::handle_key_event(&mut self.patches, key);
+
                    self.patches.handle_key_event(key);
                }
            }
        }
-
        let _ = self.action_tx.send(Action::Update);
    }
}

-
impl<'a, B: Backend> Render<B, ()> for ListPage<'a> {
-
    fn render(&self, frame: &mut ratatui::Frame, _area: Rect, _props: ()) {
+
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);

@@ -144,15 +141,15 @@ impl<'a, B: Backend> Render<B, ()> for ListPage<'a> {
            let component_layout = Layout::vertical([Constraint::Min(1), Constraint::Length(2)])
                .split(layout.component);

-
            <Patches<'_> as Render<B, ()>>::render(&self.patches, frame, component_layout[0], ());
-
            <Search as Render<B, ()>>::render(&self.search, frame, component_layout[1], ());
+
            self.patches.render(frame, component_layout[0], &());
+
            self.search.render(frame, component_layout[1], &());
        } else if self.props.show_help {
-
            <Help<'_> as Render<B, ()>>::render(&self.help, frame, layout.component, ());
+
            self.help.render(frame, layout.component, &());
        } else {
-
            <Patches<'_> as Render<B, ()>>::render(&self.patches, frame, layout.component, ());
+
            self.patches.render(frame, layout.component, &());
        }

-
        <Shortcuts<_> as Render<B, ()>>::render(&self.shortcuts, frame, layout.shortcuts, ());
+
        self.shortcuts.render(frame, layout.shortcuts, &());
    }
}

@@ -160,6 +157,7 @@ impl<'a, B: Backend> Render<B, ()> for ListPage<'a> {
struct PatchesProps<'a> {
    mode: Mode,
    patches: Vec<PatchItem>,
+
    selected: Option<usize>,
    search: String,
    stats: HashMap<String, usize>,
    columns: Vec<Column<'a>>,
@@ -177,12 +175,7 @@ impl<'a> From<&State> for PatchesProps<'a> {
        let mut archived = 0;
        let mut merged = 0;

-
        let patches: Vec<PatchItem> = state
-
            .patches
-
            .iter()
-
            .filter(|patch| state.filter.matches(patch))
-
            .cloned()
-
            .collect();
+
        let patches = state.patches();

        for patch in &patches {
            match patch.state {
@@ -225,70 +218,99 @@ impl<'a> From<&State> for PatchesProps<'a> {
            stats,
            page_size: state.ui.page_size,
            show_search: state.ui.show_search,
+
            selected: state.patches.selected,
        }
    }
}

-
struct Patches<'a> {
-
    /// Action sender
-
    action_tx: UnboundedSender<Action>,
-
    /// State mapped props
+
struct Patches<'a, B> {
+
    /// Internal properties
    props: PatchesProps<'a>,
-
    /// Notification table
-
    table: Table<'a, Action, PatchItem>,
-
    /// Table footer
-
    footer: Footer<'a, Action>,
+
    /// 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>,
}

-
impl<'a> View<State, Action> for Patches<'a> {
+
impl<'a: 'static, B> View<State, Action> for Patches<'a, B>
+
where
+
    B: Backend + 'a,
+
{
    fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self {
        let props = PatchesProps::from(state);

        Self {
            action_tx: action_tx.clone(),
            props: props.clone(),
-
            table: Table::new(&(), action_tx.clone())
-
                .items(props.patches.clone())
-
                .columns(props.columns.to_vec())
-
                .header(
-
                    Header::new(&(), action_tx.clone())
-
                        .columns(props.columns.clone())
-
                        .cutoff(props.cutoff, props.cutoff_after)
-
                        .focus(props.focus),
-
                )
-
                .footer(!props.show_search)
-
                .cutoff(props.cutoff, props.cutoff_after),
-
            footer: Footer::new(&(), action_tx),
+
            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| {
+
                                action_tx
+
                                    .send(Action::Select {
+
                                        selected: props.selected,
+
                                    })
+
                                    .ok()
+
                            });
+
                    })
+
                    .on_update(|state| {
+
                        let props = PatchesProps::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)),
+
                    )
+
                })
+
                .to_boxed(),
+
            on_update: None,
+
            on_change: None,
        }
-
        .move_with_state(state)
    }

-
    fn move_with_state(self, state: &State) -> Self {
-
        let patches: Vec<PatchItem> = state
-
            .patches
-
            .iter()
-
            .filter(|patch| state.filter.matches(patch))
-
            .cloned()
-
            .collect();
-

-
        let props = PatchesProps::from(state);
+
    fn on_update(mut self, callback: UpdateCallback<State>) -> Self {
+
        self.on_update = Some(callback);
+
        self
+
    }

-
        let table = self
-
            .table
-
            .move_with_state(&())
-
            .items(patches)
-
            .footer(!state.ui.show_search)
-
            .page_size(state.ui.page_size);
+
    fn on_change(mut self, callback: EventCallback<Action>) -> Self {
+
        self.on_change = Some(callback);
+
        self
+
    }

-
        let footer = self.footer.move_with_state(&());
-
        let footer = footer.columns(Self::build_footer(&props, table.selected()));
+
    fn update(&mut self, state: &State) {
+
        // TODO call mapper here instead?
+
        self.props = PatchesProps::from(state);

-
        Self {
-
            props,
-
            table,
-
            footer,
-
            ..self
-
        }
+
        self.table.update(state);
+
        self.footer.update(state);
    }

    fn handle_key_event(&mut self, key: Key) {
@@ -299,8 +321,8 @@ impl<'a> View<State, Action> for Patches<'a> {
                    Mode::Id => None,
                };

-
                self.table
-
                    .selected()
+
                self.props
+
                    .selected
                    .and_then(|selected| self.props.patches.get(selected))
                    .and_then(|patch| {
                        self.action_tx
@@ -315,8 +337,8 @@ impl<'a> View<State, Action> for Patches<'a> {
                    });
            }
            Key::Char('c') => {
-
                self.table
-
                    .selected()
+
                self.props
+
                    .selected
                    .and_then(|selected| self.props.patches.get(selected))
                    .and_then(|patch| {
                        self.action_tx
@@ -331,8 +353,8 @@ impl<'a> View<State, Action> for Patches<'a> {
                    });
            }
            Key::Char('d') => {
-
                self.table
-
                    .selected()
+
                self.props
+
                    .selected
                    .and_then(|selected| self.props.patches.get(selected))
                    .and_then(|patch| {
                        self.action_tx
@@ -347,16 +369,13 @@ impl<'a> View<State, Action> for Patches<'a> {
                    });
            }
            _ => {
-
                <Table<Action, PatchItem> as View<(), Action>>::handle_key_event(
-
                    &mut self.table,
-
                    key,
-
                );
+
                self.table.handle_key_event(key);
            }
        }
    }
}

-
impl<'a> Patches<'a> {
+
impl<'a, B: Backend> Patches<'a, B> {
    fn build_footer(props: &PatchesProps<'a>, selected: Option<usize>) -> Vec<Column<'a>> {
        let filter = PatchItemFilter::from_str(&props.search).unwrap_or_default();

@@ -418,7 +437,11 @@ impl<'a> Patches<'a> {

        let progress = selected
            .map(|selected| {
-
                Table::<Action, PatchItem>::progress(selected, props.patches.len(), props.page_size)
+
                Table::<State, Action, B, PatchItem>::progress(
+
                    selected,
+
                    props.patches.len(),
+
                    props.page_size,
+
                )
            })
            .unwrap_or_default();
        let progress = span::default(format!("{}%", progress)).dim();
@@ -468,19 +491,22 @@ impl<'a> Patches<'a> {
    }
}

-
impl<'a, B: Backend> Render<B, ()> for Patches<'a> {
-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
+
impl<'a: 'static, B> Widget<State, Action, B> for Patches<'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 {
-
            <Table<'_, _, _> as Render<B, ()>>::render(&self.table, frame, area, ());
+
            self.table.render(frame, area, &());

            (area.height as usize).saturating_sub(header_height)
        } else {
            let layout = Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).split(area);

-
            <Table<'_, _, _> as Render<B, ()>>::render(&self.table, frame, layout[0], ());
-
            <Footer<'_, _> as Render<B, ()>>::render(&self.footer, frame, layout[1], ());
+
            self.table.render(frame, layout[0], &());
+
            self.footer.render(frame, layout[1], &());

            (area.height as usize).saturating_sub(header_height)
        };
@@ -491,30 +517,61 @@ impl<'a, B: Backend> Render<B, ()> for Patches<'a> {
    }
}

-
pub struct Search {
-
    pub action_tx: UnboundedSender<Action>,
-
    pub input: TextField<Action>,
+
pub struct Search<B: Backend> {
+
    /// Message sender
+
    action_tx: UnboundedSender<Action>,
+
    /// Custom update handler
+
    on_update: Option<UpdateCallback<State>>,
+
    /// Additional custom event handler
+
    on_change: Option<EventCallback<Action>>,
+
    /// Search input field
+
    input: BoxedWidget<B>,
}

-
impl View<State, Action> for Search {
+
impl<B: Backend> View<State, Action> for Search<B> {
    fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self
    where
        Self: Sized,
    {
        let input = TextField::new(state, action_tx.clone())
-
            .title("Search")
-
            .inline(true);
-
        Self { action_tx, input }.move_with_state(state)
+
            .on_change(|props, action_tx| {
+
                props.downcast_ref::<TextFieldProps>().and_then(|props| {
+
                    action_tx
+
                        .send(Action::UpdateSearch {
+
                            value: props.text.clone(),
+
                        })
+
                        .ok()
+
                });
+
            })
+
            .on_update(|state| {
+
                Box::<TextFieldProps>::new(
+
                    TextFieldProps::default()
+
                        .text(&state.search.read().to_string())
+
                        .title("Search")
+
                        .inline(true),
+
                )
+
            })
+
            .to_boxed();
+
        Self {
+
            action_tx,
+
            input,
+
            on_update: None,
+
            on_change: None,
+
        }
    }

-
    fn move_with_state(self, state: &State) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        let input = self.input.move_with_state(state);
-
        let input = input.text(&state.search.read().to_string());
+
    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
+
    }

-
        Self { input, ..self }
+
    fn update(&mut self, state: &State) {
+
        self.input.update(state);
    }

    fn handle_key_event(&mut self, key: termion::event::Key) {
@@ -526,22 +583,19 @@ impl View<State, Action> for Search {
                let _ = self.action_tx.send(Action::ApplySearch);
            }
            _ => {
-
                <TextField<Action> as View<State, Action>>::handle_key_event(&mut self.input, key);
-
                let _ = self.action_tx.send(Action::UpdateSearch {
-
                    value: self.input.read().to_string(),
-
                });
+
                self.input.handle_key_event(key);
            }
        }
    }
}

-
impl<B: Backend> Render<B, ()> for Search {
-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
+
impl<B: Backend> Widget<State, Action, B> for Search<B> {
+
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: &dyn Any) {
        let layout = Layout::horizontal(Constraint::from_mins([0]))
            .horizontal_margin(1)
            .split(area);

-
        <TextField<_> as Render<B, ()>>::render(&self.input, frame, layout[0], ());
+
        self.input.render(frame, layout[0], &());
    }
}

@@ -698,70 +752,93 @@ impl<'a> From<&State> for HelpProps<'a> {
    }
}

-
pub struct Help<'a> {
-
    /// Send messages
-
    pub action_tx: UnboundedSender<Action>,
-
    /// This widget's render properties
-
    pub props: HelpProps<'a>,
+
pub struct Help<'a, B: Backend> {
+
    /// Internal properties
+
    props: HelpProps<'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: Header<'a, Action>,
+
    header: BoxedWidget<B>,
    /// Content widget
-
    content: Paragraph<'a, Action>,
+
    content: BoxedWidget<B>,
    /// Container footer
-
    footer: Footer<'a, Action>,
+
    footer: BoxedWidget<B>,
}

-
impl<'a> View<State, Action> for Help<'a> {
+
impl<'a, B: Backend> View<State, Action> for Help<'a, B> {
    fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self
    where
        Self: Sized,
    {
-
        let props = HelpProps::from(state);
-

        Self {
            action_tx: action_tx.clone(),
-
            props: props.clone(),
-
            header: Header::new(&(), action_tx.clone()),
-
            content: Paragraph::new(state, action_tx.clone()),
-
            footer: Footer::new(&(), action_tx),
+
            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),
+
                    )
+
                })
+
                .to_boxed(),
+
            on_update: None,
+
            on_change: None,
        }
-
        .move_with_state(state)
    }

-
    fn move_with_state(self, state: &State) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        let props = HelpProps::from(state);
-

-
        let header = self.header.move_with_state(&());
-
        let header = header
-
            .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec())
-
            .focus(props.focus);
+
    fn on_update(mut self, callback: UpdateCallback<State>) -> Self {
+
        self.on_update = Some(callback);
+
        self
+
    }

-
        let content = self.content.move_with_state(state);
-
        let content = content.text(&props.content).page_size(props.page_size);
+
    fn on_change(mut self, callback: EventCallback<Action>) -> Self {
+
        self.on_change = Some(callback);
+
        self
+
    }

-
        let progress = span::default(format!("{}%", content.progress())).dim();
+
    fn update(&mut self, state: &State) {
+
        self.props = HelpProps::from(state);

-
        let footer = self.footer.move_with_state(&());
-
        let footer = footer
-
            .columns(
-
                [
-
                    Column::new(Text::raw(""), Constraint::Fill(1)),
-
                    Column::new(Text::from(progress), Constraint::Min(4)),
-
                ]
-
                .to_vec(),
-
            )
-
            .focus(props.focus);
-

-
        Self {
-
            props,
-
            header,
-
            content,
-
            footer,
-
            ..self
-
        }
+
        self.header.update(state);
+
        self.content.update(state);
+
        self.footer.update(state);
    }

    fn handle_key_event(&mut self, key: termion::event::Key) {
@@ -773,14 +850,14 @@ impl<'a> View<State, Action> for Help<'a> {
                let _ = self.action_tx.send(Action::CloseHelp);
            }
            _ => {
-
                <Paragraph<_> as View<(), _>>::handle_key_event(&mut self.content, key);
+
                self.content.handle_key_event(key);
            }
        }
    }
}

-
impl<'a, B: Backend> Render<B, ()> for Help<'a> {
-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
+
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),
@@ -788,9 +865,9 @@ impl<'a, B: Backend> Render<B, ()> for Help<'a> {
        ])
        .areas(area);

-
        <Header<'_, _> as Render<B, ()>>::render(&self.header, frame, header_area, ());
-
        <Paragraph<'_, _> as Render<B, ()>>::render(&self.content, frame, content_area, ());
-
        <Footer<'_, _> as Render<B, ()>>::render(&self.footer, frame, footer_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 {
modified src/terminal.rs
@@ -10,7 +10,7 @@ use tokio::sync::mpsc::{self};

use super::event::Event;

-
type Backend = TermionBackendExt<RawTerminal<io::Stdout>>;
+
pub type Backend = TermionBackendExt<RawTerminal<io::Stdout>>;

/// FIXME Remove workaround after a new `ratatui` version with
/// https://github.com/ratatui-org/ratatui/pull/981/ included was released.
modified src/ui.rs
@@ -20,7 +20,7 @@ use super::store::State;
use super::task::Interrupted;
use super::terminal;
use super::terminal::TermionBackendExt;
-
use super::ui::widget::{Render, View};
+
use super::ui::widget::Widget;

type Backend = TermionBackendExt<RawTerminal<io::Stdout>>;

@@ -45,7 +45,7 @@ impl<A> Frontend<A> {
    ) -> anyhow::Result<Interrupted<P>>
    where
        S: State<A, P>,
-
        W: View<S, A> + Render<Backend, ()>,
+
        W: Widget<S, A, Backend>,
        P: Clone + Send + Sync + Debug,
    {
        let mut ticker = tokio::time::interval(RENDERING_TICK_RATE);
@@ -69,7 +69,7 @@ impl<A> Frontend<A> {
                },
                // Handle state updates
                Some(state) = state_rx.recv() => {
-
                    root = root.move_with_state(&state);
+
                    root.update(&state);
                },
                // Catch and handle interrupt signal to gracefully shutdown
                Ok(interrupted) = interrupt_rx.recv() => {
@@ -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(), &()))?;
        };

        terminal::restore(&mut terminal)?;
modified src/ui/widget.rs
@@ -2,6 +2,7 @@ pub mod container;
pub mod input;
pub mod text;

+
use std::any::Any;
use std::cmp;
use std::fmt::Debug;

@@ -12,33 +13,48 @@ use termion::event::Key;
use ratatui::prelude::*;
use ratatui::widgets::{Block, BorderType, Borders, Cell, Row, TableState};

-
use self::container::Header;
-

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

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

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

pub trait View<S, A> {
    fn new(state: &S, action_tx: UnboundedSender<A>) -> Self
    where
        Self: Sized;

-
    fn move_with_state(self, state: &S) -> Self
+
    fn on_change(self, callback: EventCallback<A>) -> Self
+
    where
+
        Self: Sized;
+

+
    fn on_update(self, callback: UpdateCallback<S>) -> Self
    where
        Self: Sized;

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

-
pub trait Render<B: Backend, P> {
-
    fn render(&self, frame: &mut Frame, area: Rect, props: P);
+
    fn update(&mut self, state: &S);
+

+
    fn to_boxed(self) -> Box<Self>
+
    where
+
        Self: Sized,
+
    {
+
        Box::new(self)
+
    }
}

-
pub trait Widget<S, A, B: Backend>: View<S, A> + Render<B, ()> {}
+
pub trait Widget<S, A, B: Backend>: View<S, A> {
+
    fn render(&self, frame: &mut Frame, area: Rect, _props: &dyn Any);
+
}

pub trait ToRow {
    fn to_row(&self) -> Vec<Cell>;
}

+
#[derive(Clone)]
pub struct ShortcutsProps {
    pub shortcuts: Vec<(String, String)>,
    pub divider: char,
@@ -53,14 +69,33 @@ impl Default for ShortcutsProps {
    }
}

-
pub struct Shortcuts<A> {
-
    /// Message sender
-
    pub action_tx: UnboundedSender<A>,
+
impl ShortcutsProps {
+
    pub fn divider(mut self, divider: char) -> Self {
+
        self.divider = divider;
+
        self
+
    }
+

+
    pub fn shortcuts(mut self, shortcuts: &[(&str, &str)]) -> Self {
+
        self.shortcuts.clear();
+
        for (short, long) in shortcuts {
+
            self.shortcuts.push((short.to_string(), long.to_string()));
+
        }
+
        self
+
    }
+
}
+

+
pub struct Shortcuts<S, A> {
    /// Internal properties
    props: ShortcutsProps,
+
    /// Message sender
+
    action_tx: UnboundedSender<A>,
+
    /// Custom update handler
+
    on_update: Option<UpdateCallback<S>>,
+
    /// Additional custom event handler
+
    on_change: Option<EventCallback<A>>,
}

-
impl<A> Shortcuts<A> {
+
impl<S, A> Shortcuts<S, A> {
    pub fn divider(mut self, divider: char) -> Self {
        self.props.divider = divider;
        self
@@ -77,24 +112,43 @@ impl<A> Shortcuts<A> {
    }
}

-
impl<S, A> View<S, A> for Shortcuts<A> {
-
    fn new(state: &S, action_tx: UnboundedSender<A>) -> Self {
+
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(),
            props: ShortcutsProps::default(),
+
            on_update: None,
+
            on_change: None,
        }
-
        .move_with_state(state)
    }

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

+
    fn on_update(mut self, callback: UpdateCallback<S>) -> Self {
+
        self.on_update = Some(callback);
+
        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: termion::event::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();
+
            }
+
        }
+
    }
}

-
impl<A, B: Backend> Render<B, ()> for Shortcuts<A> {
-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
+
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) {
        use ratatui::widgets::Table;

        let mut shortcuts = self.props.shortcuts.iter().peekable();
@@ -133,8 +187,6 @@ impl<A, B: Backend> Render<B, ()> for Shortcuts<A> {
    }
}

-
impl<S, A, B: Backend> Widget<S, A, B> for Shortcuts<A> {}
-

#[derive(Clone, Debug)]
pub struct Column<'a> {
    pub text: Text<'a>,
@@ -157,9 +209,13 @@ impl<'a> Column<'a> {
    }
}

-
#[derive(Debug)]
-
pub struct TableProps<'a, R: ToRow> {
+
#[derive(Clone, Debug)]
+
pub struct TableProps<'a, R>
+
where
+
    R: ToRow,
+
{
    pub items: Vec<R>,
+
    pub selected: Option<usize>,
    pub focus: bool,
    pub columns: Vec<Column<'a>>,
    pub has_footer: bool,
@@ -168,7 +224,10 @@ pub struct TableProps<'a, R: ToRow> {
    pub page_size: usize,
}

-
impl<'a, R: ToRow> Default for TableProps<'a, R> {
+
impl<'a, R> Default for TableProps<'a, R>
+
where
+
    R: ToRow,
+
{
    fn default() -> Self {
        Self {
            items: vec![],
@@ -178,61 +237,87 @@ impl<'a, R: ToRow> Default for TableProps<'a, R> {
            cutoff: usize::MAX,
            cutoff_after: usize::MAX,
            page_size: 1,
+
            selected: Some(0),
        }
    }
}

-
pub struct Table<'a, A, R: ToRow> {
-
    /// Sending actions to the state store
-
    pub action_tx: UnboundedSender<A>,
-
    /// Internal table properties
-
    pub props: TableProps<'a, R>,
-
    /// Internal selection state
-
    state: TableState,
-
    /// Table header widget
-
    header: Option<Header<'a, A>>,
-
}
-

-
impl<'a, A, R: ToRow> Table<'a, A, R> {
+
impl<'a, R> TableProps<'a, R>
+
where
+
    R: ToRow,
+
{
    pub fn items(mut self, items: Vec<R>) -> Self {
-
        self.props.items = items;
+
        self.items = items;
+
        self
+
    }
+

+
    pub fn selected(mut self, selected: Option<usize>) -> Self {
+
        self.selected = selected;
        self
    }

    pub fn columns(mut self, columns: Vec<Column<'a>>) -> Self {
-
        self.props.columns = columns;
+
        self.columns = columns;
        self
    }

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

    pub fn cutoff(mut self, cutoff: usize, cutoff_after: usize) -> Self {
-
        self.props.cutoff = cutoff;
-
        self.props.cutoff_after = cutoff_after;
+
        self.cutoff = cutoff;
+
        self.cutoff_after = cutoff_after;
        self
    }

    pub fn page_size(mut self, page_size: usize) -> Self {
-
        self.props.page_size = page_size;
+
        self.page_size = page_size;
        self
    }
+
}

-
    pub fn header(mut self, header: Header<'a, A>) -> Self {
+
pub struct Table<'a, S, A, B, R>
+
where
+
    B: Backend,
+
    R: ToRow,
+
{
+
    /// Internal table properties
+
    props: TableProps<'a, R>,
+
    /// Message sender
+
    action_tx: UnboundedSender<A>,
+
    /// Custom update handler
+
    on_update: Option<UpdateCallback<S>>,
+
    /// Additional custom event handler
+
    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>
+
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.selected().map(|current| current.saturating_sub(1));
+
        let selected = self
+
            .state
+
            .selected()
+
            .map(|current| current.saturating_sub(1));
        self.state.select(selected);
        selected
    }

    fn next(&mut self, len: usize) -> Option<usize> {
-
        let selected = self.selected().map(|current| {
+
        let selected = self.state.selected().map(|current| {
            if current < len.saturating_sub(1) {
                current.saturating_add(1)
            } else {
@@ -245,6 +330,7 @@ impl<'a, A, R: ToRow> Table<'a, A, R> {

    fn prev_page(&mut self, page_size: usize) -> Option<usize> {
        let selected = self
+
            .state
            .selected()
            .map(|current| current.saturating_sub(page_size));
        self.state.select(selected);
@@ -252,7 +338,7 @@ impl<'a, A, R: ToRow> Table<'a, A, R> {
    }

    fn next_page(&mut self, len: usize, page_size: usize) -> Option<usize> {
-
        let selected = self.selected().map(|current| {
+
        let selected = self.state.selected().map(|current| {
            if current < len.saturating_sub(1) {
                cmp::min(current.saturating_add(page_size), len.saturating_sub(1))
            } else {
@@ -263,18 +349,12 @@ impl<'a, A, R: ToRow> Table<'a, A, R> {
        selected
    }

-
    fn begin(&mut self) -> Option<usize> {
+
    fn begin(&mut self) {
        self.state.select(Some(0));
-
        self.state.selected()
    }

-
    fn end(&mut self, len: usize) -> Option<usize> {
+
    fn end(&mut self, len: usize) {
        self.state.select(Some(len.saturating_sub(1)));
-
        self.state.selected()
-
    }
-

-
    pub fn selected(&self) -> Option<usize> {
-
        self.state.selected()
    }

    pub fn progress(selected: usize, len: usize, page_size: usize) -> usize {
@@ -297,29 +377,45 @@ impl<'a, A, R: ToRow> Table<'a, A, R> {
    }
}

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

-
    fn move_with_state(self, _state: &S) -> Self {
-
        let mut me = self;
-
        if let Some(selected) = me.selected() {
-
            if selected > me.props.items.len() {
-
                me.begin();
+
    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) {
+
        if let Some(on_update) = self.on_update {
+
            if let Some(props) = (on_update)(state).downcast_ref::<TableProps<'_, R>>() {
+
                self.props = props.clone();
            }
        }

-
        me
+
        // TODO: Move to state reducer
+
        if let Some(selected) = self.state.selected() {
+
            if selected > self.props.items.len() {
+
                self.begin();
+
            }
+
        }
    }

    fn handle_key_event(&mut self, key: Key) {
@@ -344,15 +440,21 @@ where
            }
            _ => {}
        }
+

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

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

-
impl<'a, A, B, R> Render<B, ()> for Table<'a, A, R>
+
impl<'a: 'static, S, A, B, R> Widget<S, A, B> for Table<'a, S, A, B, R>
where
    B: Backend,
-
    R: ToRow + Debug,
+
    R: ToRow + Clone + Debug + 'static,
{
-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
+
    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);
@@ -414,8 +516,9 @@ where
                .highlight_style(style::highlight());

            if let Some(header) = &self.header {
-
                <Header<'_, _> as Render<B, ()>>::render(header, frame, header_area, ());
+
                header.render(frame, header_area, &());
            }
+

            frame.render_stateful_widget(rows, table_area, &mut self.state.clone());
        } else {
            let block = Block::default()
@@ -424,7 +527,7 @@ where
                .borders(borders);

            if let Some(header) = &self.header {
-
                <Header<'_, _> as Render<B, ()>>::render(header, frame, header_area, ());
+
                header.render(frame, header_area, &());
            }
            frame.render_widget(block, table_area);

@@ -438,10 +541,3 @@ where
        }
    }
}
-

-
impl<'a, S, A, B, R> Widget<S, A, B> for Table<'a, A, R>
-
where
-
    B: Backend,
-
    R: ToRow + Debug,
-
{
-
}
modified src/ui/widget/container.rs
@@ -1,3 +1,4 @@
+
use std::any::Any;
use std::fmt::Debug;

use tokio::sync::mpsc::UnboundedSender;
@@ -10,9 +11,9 @@ use ratatui::widgets::{BorderType, Borders, Row};
use crate::ui::ext::{FooterBlock, FooterBlockType, HeaderBlock};
use crate::ui::theme::style;

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

-
#[derive(Debug)]
+
#[derive(Clone, Debug)]
pub struct HeaderProps<'a> {
    pub columns: Vec<Column<'a>>,
    pub cutoff: usize,
@@ -20,6 +21,24 @@ pub struct HeaderProps<'a> {
    pub focus: bool,
}

+
impl<'a> HeaderProps<'a> {
+
    pub fn columns(mut self, columns: Vec<Column<'a>>) -> Self {
+
        self.columns = columns;
+
        self
+
    }
+

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

+
    pub fn cutoff(mut self, cutoff: usize, cutoff_after: usize) -> Self {
+
        self.cutoff = cutoff;
+
        self.cutoff_after = cutoff_after;
+
        self
+
    }
+
}
+

impl<'a> Default for HeaderProps<'a> {
    fn default() -> Self {
        Self {
@@ -31,14 +50,18 @@ impl<'a> Default for HeaderProps<'a> {
    }
}

-
pub struct Header<'a, A> {
-
    /// Sending actions to the state store
-
    pub action_tx: UnboundedSender<A>,
+
pub struct Header<'a, S, A> {
    /// Internal props
    props: HeaderProps<'a>,
+
    /// Message sender
+
    action_tx: UnboundedSender<A>,
+
    /// Custom update handler
+
    on_update: Option<UpdateCallback<S>>,
+
    /// Additional custom event handler
+
    on_change: Option<EventCallback<A>>,
}

-
impl<'a, A> Header<'a, A> {
+
impl<'a, S, A> Header<'a, S, A> {
    pub fn columns(mut self, columns: Vec<Column<'a>>) -> Self {
        self.props.columns = columns;
        self
@@ -56,24 +79,43 @@ impl<'a, A> Header<'a, A> {
    }
}

-
impl<'a, A> View<(), A> for Header<'a, A> {
-
    fn new(state: &(), action_tx: UnboundedSender<A>) -> Self {
+
impl<'a: 'static, S, A> View<S, A> for Header<'a, S, A> {
+
    fn new(_state: &S, action_tx: UnboundedSender<A>) -> Self {
        Self {
            action_tx: action_tx.clone(),
            props: HeaderProps::default(),
+
            on_update: None,
+
            on_change: None,
        }
-
        .move_with_state(state)
    }

-
    fn move_with_state(self, _state: &()) -> Self {
-
        Self { ..self }
+
    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) {
+
        if let Some(on_update) = self.on_update {
+
            if let Some(props) = (on_update)(state).downcast_ref::<HeaderProps<'_>>() {
+
                self.props = props.clone();
+
            }
+
        }
    }

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

-
impl<'a, A, B: Backend> Render<B, ()> for Header<'a, A> {
-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
+
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
            .columns
@@ -132,7 +174,7 @@ impl<'a, A, B: Backend> Render<B, ()> for Header<'a, A> {
    }
}

-
#[derive(Debug)]
+
#[derive(Clone, Debug)]
pub struct FooterProps<'a> {
    pub columns: Vec<Column<'a>>,
    pub cutoff: usize,
@@ -140,6 +182,24 @@ pub struct FooterProps<'a> {
    pub focus: bool,
}

+
impl<'a> FooterProps<'a> {
+
    pub fn columns(mut self, columns: Vec<Column<'a>>) -> Self {
+
        self.columns = columns;
+
        self
+
    }
+

+
    pub fn cutoff(mut self, cutoff: usize, cutoff_after: usize) -> Self {
+
        self.cutoff = cutoff;
+
        self.cutoff_after = cutoff_after;
+
        self
+
    }
+

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

impl<'a> Default for FooterProps<'a> {
    fn default() -> Self {
        Self {
@@ -151,14 +211,18 @@ impl<'a> Default for FooterProps<'a> {
    }
}

-
pub struct Footer<'a, A> {
-
    /// Message sender
-
    pub action_tx: UnboundedSender<A>,
+
pub struct Footer<'a, S, A> {
    /// Internal properties
    props: FooterProps<'a>,
+
    /// Message sender
+
    action_tx: UnboundedSender<A>,
+
    /// Custom update handler
+
    on_update: Option<UpdateCallback<S>>,
+
    /// Additional custom event handler
+
    on_change: Option<EventCallback<A>>,
}

-
impl<'a, A> Footer<'a, A> {
+
impl<'a, S, A> Footer<'a, S, A> {
    pub fn columns(mut self, columns: Vec<Column<'a>>) -> Self {
        self.props.columns = columns;
        self
@@ -176,23 +240,42 @@ impl<'a, A> Footer<'a, A> {
    }
}

-
impl<'a, A> View<(), A> for Footer<'a, A> {
-
    fn new(_state: &(), action_tx: UnboundedSender<A>) -> Self {
+
impl<'a: 'static, S, A> View<S, A> for Footer<'a, S, A> {
+
    fn new(_state: &S, action_tx: UnboundedSender<A>) -> Self {
        Self {
            action_tx: action_tx.clone(),
            props: FooterProps::default(),
+
            on_update: None,
+
            on_change: None,
        }
-
        .move_with_state(&())
    }

-
    fn move_with_state(self, _state: &()) -> Self {
-
        Self { ..self }
+
    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 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::<FooterProps<'_>>() {
+
                self.props = props.clone();
+
            }
+
        }
+
    }
+

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

-
impl<'a, A> Footer<'a, A> {
+
impl<'a, S, A> Footer<'a, S, A> {
    fn render_cell(
        &self,
        frame: &mut ratatui::Frame,
@@ -216,8 +299,8 @@ impl<'a, A> Footer<'a, A> {
    }
}

-
impl<'a, A, B: Backend> Render<B, ()> for Footer<'a, A> {
-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
+
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
            .columns
modified src/ui/widget/input.rs
@@ -1,3 +1,5 @@
+
use std::any::Any;
+

use termion::event::Key;

use tokio::sync::mpsc::UnboundedSender;
@@ -7,14 +9,35 @@ use ratatui::prelude::{Backend, Rect};
use ratatui::style::Stylize;
use ratatui::text::{Line, Span};

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

+
#[derive(Clone)]
pub struct TextFieldProps {
-
    title: String,
-
    inline_label: bool,
-
    show_cursor: bool,
-
    text: String,
-
    cursor_position: usize,
+
    pub title: String,
+
    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
+
    }
+

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

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

impl Default for TextFieldProps {
@@ -29,14 +52,18 @@ impl Default for TextFieldProps {
    }
}

-
pub struct TextField<A> {
-
    /// Message sender
-
    pub action_tx: UnboundedSender<A>,
+
pub struct TextField<S, A> {
    /// Internal props
    props: TextFieldProps,
+
    /// Message sender
+
    action_tx: UnboundedSender<A>,
+
    /// Custom update handler
+
    on_update: Option<UpdateCallback<S>>,
+
    /// Additional custom event handler
+
    on_change: Option<EventCallback<A>>,
}

-
impl<A> TextField<A> {
+
impl<S, A> TextField<S, A> {
    pub fn read(&self) -> &str {
        &self.props.text
    }
@@ -101,19 +128,34 @@ impl<A> TextField<A> {
    }
}

-
impl<S, A> View<S, A> for TextField<A> {
-
    fn new(state: &S, action_tx: UnboundedSender<A>) -> Self {
+
impl<S, A> View<S, A> for TextField<S, A> {
+
    fn new(_state: &S, action_tx: UnboundedSender<A>) -> Self {
        Self {
            action_tx,
            props: TextFieldProps::default(),
+
            on_update: None,
+
            on_change: None,
        }
-
        .move_with_state(state)
    }

-
    fn move_with_state(self, _state: &S) -> Self {
+
    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) {
+
        if let Some(on_update) = self.on_update {
+
            if let Some(props) = (on_update)(state).downcast_ref::<TextFieldProps>() {
+
                self.props = props.clone();
+
            }
+
        }
+
    }
+

    fn handle_key_event(&mut self, key: Key) {
        match key {
            Key::Char(to_insert)
@@ -134,11 +176,15 @@ impl<S, A> View<S, A> for TextField<A> {
            }
            _ => {}
        }
+

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

-
impl<A, B: Backend> Render<B, ()> for TextField<A> {
-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
+
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) {
        let layout = Layout::vertical(Constraint::from_lengths([1, 1])).split(area);

        let input = self.props.text.as_str();
modified src/ui/widget/text.rs
@@ -1,3 +1,5 @@
+
use std::any::Any;
+

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

use termion::event::Key;
@@ -9,8 +11,9 @@ use ratatui::widgets::{Block, BorderType, Borders};

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

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

+
#[derive(Clone)]
pub struct ParagraphProps<'a> {
    pub content: Text<'a>,
    pub focus: bool,
@@ -19,6 +22,23 @@ pub struct ParagraphProps<'a> {
    pub page_size: usize,
}

+
impl<'a> ParagraphProps<'a> {
+
    pub fn page_size(mut self, page_size: usize) -> Self {
+
        self.page_size = page_size;
+
        self
+
    }
+

+
    pub fn text(mut self, text: &Text<'a>) -> Self {
+
        self.content = text.clone();
+
        self
+
    }
+

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

impl<'a> Default for ParagraphProps<'a> {
    fn default() -> Self {
        Self {
@@ -31,18 +51,22 @@ impl<'a> Default for ParagraphProps<'a> {
    }
}

-
pub struct Paragraph<'a, A> {
-
    /// Sending actions to the state store
-
    pub action_tx: UnboundedSender<A>,
+
pub struct Paragraph<'a, S, A> {
+
    /// Internal properties
+
    props: ParagraphProps<'a>,
+
    /// Message sender
+
    action_tx: UnboundedSender<A>,
+
    /// Custom update handler
+
    on_update: Option<UpdateCallback<S>>,
+
    /// Additional custom event handler
+
    on_change: Option<EventCallback<A>>,
    /// Internal offset
    offset: usize,
    /// Internal progress
    progress: usize,
-
    /// Internal properties
-
    props: ParagraphProps<'a>,
}

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

-
impl<'a, S, A> View<S, A> for Paragraph<'a, A> {
-
    fn new(state: &S, action_tx: UnboundedSender<A>) -> Self
+
impl<'a: 'static, S, A> View<S, A> for Paragraph<'a, S, A> {
+
    fn new(_state: &S, action_tx: UnboundedSender<A>) -> Self
    where
        Self: Sized,
    {
@@ -126,15 +150,27 @@ impl<'a, S, A> View<S, A> for Paragraph<'a, A> {
            offset: 0,
            progress: 0,
            props: ParagraphProps::default(),
+
            on_update: None,
+
            on_change: None,
        }
-
        .move_with_state(state)
    }

-
    fn move_with_state(self, _state: &S) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        Self { ..self }
+
    fn on_change(mut self, callback: EventCallback<A>) -> Self {
+
        self.on_change = Some(callback);
+
        self
+
    }
+

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

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

    fn handle_key_event(&mut self, key: Key) {
@@ -162,11 +198,15 @@ impl<'a, S, A> View<S, A> for Paragraph<'a, A> {
            }
            _ => {}
        }
+

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

-
impl<'a, A, B: Backend> Render<B, ()> for Paragraph<'a, A> {
-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
+
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)