Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
Support widget trait objects
Merged did:key:z6MkgFq6...nBGz opened 2 years ago

This adds support for dynamically dispatched widgets to the library and all exising apps. It allows for much more flexible and reusable widgets. The interface was designed with declarative widget building in mind.

13 files changed +2094 -1525 b3e3bb56 047e54b0
modified bin/commands/inbox/select.rs
@@ -1,6 +1,8 @@
#[path = "select/ui.rs"]
mod ui;

+
use std::str::FromStr;
+

use anyhow::Result;

use radicle::identity::Project;
@@ -16,12 +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::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>;
@@ -56,14 +60,50 @@ 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,
    search: StateValue<String>,
    ui: UIState,
}

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

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

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

@@ -71,6 +111,9 @@ impl TryFrom<&Context> for State {
        let doc = context.repository.identity_doc()?;
        let project = doc.project()?;

+
        let search = StateValue::new(String::new());
+
        let filter = NotificationItemFilter::from_str(&search.read()).unwrap_or_default();
+

        let mut notifications = match &context.mode.repository() {
            RepositoryMode::All => {
                let mut repos = context.profile.storage.repositories()?;
@@ -152,10 +195,14 @@ impl TryFrom<&Context> for State {
        }

        Ok(Self {
-
            notifications,
+
            notifications: NotificationsState {
+
                items: notifications,
+
                selected: None,
+
            },
            mode: mode.clone(),
            project,
-
            search: StateValue::new(String::new()),
+
            filter,
+
            search,
            ui: UIState::default(),
        })
    }
@@ -163,6 +210,7 @@ impl TryFrom<&Context> for State {

pub enum Action {
    Exit { selection: Option<Selection> },
+
    Select { selected: Option<usize> },
    PageSize(usize),
    OpenSearch,
    UpdateSearch { value: String },
@@ -178,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
@@ -188,6 +240,9 @@ impl store::State<Action, Selection> for State {
            }
            Action::UpdateSearch { value } => {
                self.search.write(value);
+
                self.filter =
+
                    NotificationItemFilter::from_str(&self.search.read()).unwrap_or_default();
+

                None
            }
            Action::ApplySearch => {
@@ -198,6 +253,9 @@ impl store::State<Action, Selection> for State {
            Action::CloseSearch => {
                self.search.reset();
                self.ui.show_search = false;
+
                self.filter =
+
                    NotificationItemFilter::from_str(&self.search.read()).unwrap_or_default();
+

                None
            }
            Action::OpenHelp => {
@@ -225,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;

@@ -10,24 +11,27 @@ use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::Stylize;
use ratatui::text::{Line, Span, Text};

-
use radicle::identity::Project;
-

use radicle_tui as tui;

use tui::ui::items::{NotificationItem, NotificationItemFilter, NotificationState};
use tui::ui::span;
+
use tui::ui::widget;
use tui::ui::widget::container::{Footer, FooterProps, Header, HeaderProps};
use tui::ui::widget::input::{TextField, TextFieldProps};
use tui::ui::widget::text::{Paragraph, ParagraphProps};
-
use tui::ui::widget::{Render, Shortcut, Shortcuts, ShortcutsProps, Table, TableProps, Widget};
+
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 {
-
    mode: Mode,
    show_search: bool,
    show_help: bool,
}
@@ -35,29 +39,32 @@ pub struct ListPageProps {
impl From<&State> for ListPageProps {
    fn from(state: &State) -> Self {
        Self {
-
            mode: state.mode.clone(),
            show_search: state.ui.show_search,
            show_help: state.ui.show_help,
        }
    }
}

-
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,
+
    /// 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> Widget<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,
@@ -65,36 +72,43 @@ impl<'a> Widget<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(state, 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,
-
    {
-
        ListPage {
-
            notifications: self.notifications.move_with_state(state),
-
            shortcuts: self.shortcuts.move_with_state(state),
-
            help: self.help.move_with_state(state),
-
            props: ListPageProps::from(state),
-
            ..self
-
        }
+
    fn on_change(mut self, callback: EventCallback<Action>) -> Self {
+
        self.on_change = Some(callback);
+
        self
    }

-
    fn name(&self) -> &str {
-
        "list-page"
+
    fn on_update(mut self, callback: UpdateCallback<State>) -> Self {
+
        self.on_update = Some(callback);
+
        self
+
    }
+

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

+
        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 Widget<State, Action>>::handle_key_event(&mut self.search, key)
+
            self.search.handle_key_event(key);
        } else if self.props.show_help {
-
            <Help as Widget<State, Action>>::handle_key_event(&mut self.help, key)
+
            self.help.handle_key_event(key);
        } else {
            match key {
                Key::Esc | Key::Ctrl('c') => {
@@ -107,73 +121,40 @@ impl<'a> Widget<State, Action> for ListPage<'a> {
                    let _ = self.action_tx.send(Action::OpenHelp);
                }
                _ => {
-
                    <Notifications as Widget<State, Action>>::handle_key_event(
-
                        &mut self.notifications,
-
                        key,
-
                    );
+
                    self.notifications.handle_key_event(key);
                }
            }
        }
    }
}

-
impl<'a> Render<()> for ListPage<'a> {
-
    fn render<B: Backend>(&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);

-
        let shortcuts = if self.props.show_search {
-
            vec![
-
                Shortcut::new("esc", "cancel"),
-
                Shortcut::new("enter", "apply"),
-
            ]
-
        } else if self.props.show_help {
-
            vec![Shortcut::new("?", "close")]
-
        } else {
-
            match self.props.mode.selection() {
-
                SelectionMode::Id => vec![
-
                    Shortcut::new("enter", "select"),
-
                    Shortcut::new("/", "search"),
-
                ],
-
                SelectionMode::Operation => vec![
-
                    Shortcut::new("enter", "show"),
-
                    Shortcut::new("c", "clear"),
-
                    Shortcut::new("/", "search"),
-
                    Shortcut::new("?", "help"),
-
                ],
-
            }
-
        };
-

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

-
            self.notifications
-
                .render::<B>(frame, component_layout[0], ());
-
            self.search
-
                .render::<B>(frame, component_layout[1], SearchProps {});
+
            self.notifications.render(frame, component_layout[0], &());
+
            self.search.render(frame, component_layout[1], &());
        } else if self.props.show_help {
-
            self.help.render::<B>(frame, layout.component, ());
+
            self.help.render(frame, layout.component, &());
        } else {
-
            self.notifications.render::<B>(frame, layout.component, ());
+
            self.notifications.render(frame, layout.component, &());
        }

-
        self.shortcuts.render::<B>(
-
            frame,
-
            layout.shortcuts,
-
            ShortcutsProps {
-
                shortcuts,
-
                divider: '∙',
-
            },
-
        );
+
        self.shortcuts.render(frame, layout.shortcuts, &());
    }
}

-
struct NotificationsProps {
+
struct NotificationsProps<'a> {
    notifications: Vec<NotificationItem>,
+
    selected: Option<usize>,
    mode: Mode,
-
    project: Project,
    stats: HashMap<String, usize>,
+
    columns: Vec<Column<'a>>,
    cutoff: usize,
    cutoff_after: usize,
    focus: bool,
@@ -182,22 +163,15 @@ struct NotificationsProps {
    show_search: bool,
}

-
impl From<&State> for NotificationsProps {
+
impl<'a> From<&State> for NotificationsProps<'a> {
    fn from(state: &State) -> Self {
        let mut seen = 0;
        let mut unseen = 0;

-
        // Filter by search string
-
        let filter = NotificationItemFilter::from_str(&state.search.read()).unwrap_or_default();
-
        let notifications = state
-
            .notifications
-
            .clone()
-
            .into_iter()
-
            .filter(|issue| filter.matches(issue))
-
            .collect::<Vec<_>>();
+
        let notifications = state.notifications();

        // Compute statistics
-
        for notification in &state.notifications {
+
        for notification in &notifications {
            if notification.seen {
                seen += 1;
            } else {
@@ -209,9 +183,22 @@ impl From<&State> for NotificationsProps {

        Self {
            notifications,
+
            selected: None,
            mode: state.mode.clone(),
-
            project: state.project.clone(),
            stats,
+
            columns: [
+
                Column::new("", Constraint::Length(5)),
+
                Column::new("", Constraint::Length(3)),
+
                Column::new("", Constraint::Length(15))
+
                    .skip(*state.mode.repository() != RepositoryMode::All),
+
                Column::new("", Constraint::Length(25)),
+
                Column::new("", Constraint::Fill(1)),
+
                Column::new("", Constraint::Length(8)),
+
                Column::new("", Constraint::Length(10)),
+
                Column::new("", Constraint::Length(15)),
+
                Column::new("", Constraint::Length(18)),
+
            ]
+
            .to_vec(),
            cutoff: 200,
            cutoff_after: 5,
            focus: false,
@@ -222,80 +209,112 @@ impl From<&State> for NotificationsProps {
    }
}

-
struct Notifications {
-
    /// Action sender
+
struct Notifications<'a, B: Backend> {
+
    /// Internal properties
+
    props: NotificationsProps<'a>,
+
    /// Message sender
    action_tx: UnboundedSender<Action>,
-
    /// State mapped props
-
    props: NotificationsProps,
-
    /// Table header
-
    header: Header<Action>,
-
    /// Notification table
-
    table: Table<Action>,
-
    /// Table footer
-
    footer: Footer<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 Widget<State, Action> for Notifications {
+
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() {
+
            RepositoryMode::Contextual => state.project.name().to_string(),
+
            RepositoryMode::All => "All repositories".to_string(),
+
            RepositoryMode::ByRepo((_, name)) => name.clone().unwrap_or_default(),
+
        };
+

        Self {
            action_tx: action_tx.clone(),
            props: NotificationsProps::from(state),
-
            header: Header::new(state, action_tx.clone()),
-
            table: Table::new(state, action_tx.clone()),
-
            footer: Footer::new(state, action_tx),
+
            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),
+
                        )
+
                    }),
+
            ),
+
            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 props = NotificationsProps::from(state);
-
        let mut table = self.table.move_with_state(state);
-

-
        if let Some(selected) = table.selected() {
-
            if selected > props.notifications.len() {
-
                table.begin();
-
            }
-
        }
+
    fn on_change(mut self, callback: EventCallback<Action>) -> Self {
+
        self.on_change = Some(callback);
+
        self
+
    }

-
        Self {
-
            props,
-
            table,
-
            header: self.header.move_with_state(state),
-
            footer: self.footer.move_with_state(state),
-
            ..self
-
        }
+
    fn on_update(mut self, callback: UpdateCallback<State>) -> Self {
+
        self.on_update = Some(callback);
+
        self
    }

-
    fn name(&self) -> &str {
-
        "notifications"
+
    fn update(&mut self, state: &State) {
+
        // TODO call mapper here instead?
+
        self.props = NotificationsProps::from(state);
+

+
        self.table.update(state);
+
        self.footer.update(state);
    }

    fn handle_key_event(&mut self, key: Key) {
        match key {
-
            Key::Up | Key::Char('k') => {
-
                self.table.prev();
-
            }
-
            Key::Down | Key::Char('j') => {
-
                self.table.next(self.props.notifications.len());
-
            }
-
            Key::PageUp => {
-
                self.table.prev_page(self.props.page_size);
-
            }
-
            Key::PageDown => {
-
                self.table
-
                    .next_page(self.props.notifications.len(), self.props.page_size);
-
            }
-
            Key::Home => {
-
                self.table.begin();
-
            }
-
            Key::End => {
-
                self.table.end(self.props.notifications.len());
-
            }
            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() {
@@ -313,8 +332,8 @@ impl Widget<State, Action> for Notifications {
                    });
            }
            Key::Char('c') => {
-
                self.table
-
                    .selected()
+
                self.props
+
                    .selected
                    .and_then(|selected| self.props.notifications.get(selected))
                    .and_then(|notif| {
                        self.action_tx
@@ -328,88 +347,15 @@ impl Widget<State, Action> for Notifications {
                            .ok()
                    });
            }
-
            _ => {}
+
            _ => {
+
                self.table.handle_key_event(key);
+
            }
        }
    }
}

-
impl Notifications {
-
    fn render_header<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect) {
-
        let title = match self.props.mode.repository() {
-
            RepositoryMode::Contextual => self.props.project.name().to_string(),
-
            RepositoryMode::All => "All repositories".to_string(),
-
            RepositoryMode::ByRepo((_, name)) => name.clone().unwrap_or_default(),
-
        };
-

-
        self.header.render::<B>(
-
            frame,
-
            area,
-
            HeaderProps {
-
                cells: [String::from("").into(), title.into()],
-
                widths: [Constraint::Length(0), Constraint::Fill(1)],
-
                focus: self.props.focus,
-
                cutoff: self.props.cutoff,
-
                cutoff_after: self.props.cutoff_after,
-
            },
-
        );
-
    }
-

-
    fn render_list<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect) {
-
        if let RepositoryMode::All = self.props.mode.repository() {
-
            let widths = [
-
                Constraint::Length(5),
-
                Constraint::Length(3),
-
                Constraint::Length(15),
-
                Constraint::Length(25),
-
                Constraint::Fill(1),
-
                Constraint::Length(8),
-
                Constraint::Length(10),
-
                Constraint::Length(15),
-
                Constraint::Length(18),
-
            ];
-

-
            self.table.render::<B>(
-
                frame,
-
                area,
-
                TableProps {
-
                    items: self.props.notifications.to_vec(),
-
                    has_header: true,
-
                    has_footer: !self.props.show_search,
-
                    widths,
-
                    focus: self.props.focus,
-
                    cutoff: self.props.cutoff,
-
                    cutoff_after: self.props.cutoff_after.saturating_add(1),
-
                },
-
            );
-
        } else {
-
            let widths = [
-
                Constraint::Length(5),
-
                Constraint::Length(3),
-
                Constraint::Length(25),
-
                Constraint::Fill(1),
-
                Constraint::Length(8),
-
                Constraint::Length(10),
-
                Constraint::Length(15),
-
                Constraint::Length(18),
-
            ];
-

-
            self.table.render::<B>(
-
                frame,
-
                area,
-
                TableProps {
-
                    items: self.props.notifications.to_vec(),
-
                    has_header: true,
-
                    has_footer: !self.props.show_search,
-
                    widths,
-
                    focus: self.props.focus,
-
                    cutoff: self.props.cutoff,
-
                    cutoff_after: self.props.cutoff_after,
-
                },
-
            );
-
        }
-
    }
-

-
    fn render_footer<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect) {
+
impl<'a, B: Backend> Notifications<'a, B> {
+
    fn build_footer(props: &NotificationsProps<'a>, selected: Option<usize>) -> Vec<Column<'a>> {
        let search = Line::from(
            [
                span::default(" Search ".to_string())
@@ -417,21 +363,21 @@ impl Notifications {
                    .dim()
                    .reversed(),
                span::default(" ".into()),
-
                span::default(self.props.search.to_string()).gray().dim(),
+
                span::default(props.search.to_string()).gray().dim(),
            ]
            .to_vec(),
        );

        let seen = Line::from(
            [
-
                span::positive(self.props.stats.get("Seen").unwrap_or(&0).to_string()).dim(),
+
                span::positive(props.stats.get("Seen").unwrap_or(&0).to_string()).dim(),
                span::default(" Seen".to_string()).dim(),
            ]
            .to_vec(),
        );
        let unseen = Line::from(
            [
-
                span::positive(self.props.stats.get("Unseen").unwrap_or(&0).to_string())
+
                span::positive(props.stats.get("Unseen").unwrap_or(&0).to_string())
                    .magenta()
                    .dim(),
                span::default(" Unseen".to_string()).dim(),
@@ -439,12 +385,18 @@ impl Notifications {
            .to_vec(),
        );

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

-
        match NotificationItemFilter::from_str(&self.props.search)
+
        match NotificationItemFilter::from_str(&props.search)
            .unwrap_or_default()
            .state()
        {
@@ -454,71 +406,51 @@ impl Notifications {
                    NotificationState::Unseen => unseen,
                };

-
                self.footer.render::<B>(
-
                    frame,
-
                    area,
-
                    FooterProps {
-
                        cells: [search.into(), block.clone().into(), progress.clone().into()],
-
                        widths: [
-
                            Constraint::Fill(1),
-
                            Constraint::Min(block.width() as u16),
-
                            Constraint::Min(4),
-
                        ],
-
                        focus: self.props.focus,
-
                        cutoff: self.props.cutoff,
-
                        cutoff_after: self.props.cutoff_after,
-
                    },
-
                );
-
            }
-
            None => {
-
                self.footer.render::<B>(
-
                    frame,
-
                    area,
-
                    FooterProps {
-
                        cells: [
-
                            search.into(),
-
                            seen.clone().into(),
-
                            unseen.clone().into(),
-
                            progress.clone().into(),
-
                        ],
-
                        widths: [
-
                            Constraint::Fill(1),
-
                            Constraint::Min(seen.width() as u16),
-
                            Constraint::Min(unseen.width() as u16),
-
                            Constraint::Min(4),
-
                        ],
-
                        focus: self.props.focus,
-
                        cutoff: self.props.cutoff,
-
                        cutoff_after: self.props.cutoff_after,
-
                    },
-
                );
+
                [
+
                    Column::new(Text::from(search), Constraint::Fill(1)),
+
                    Column::new(
+
                        Text::from(block.clone()),
+
                        Constraint::Min(block.width() as u16),
+
                    ),
+
                    Column::new(Text::from(progress), Constraint::Min(4)),
+
                ]
+
                .to_vec()
            }
+
            None => [
+
                Column::new(Text::from(search), Constraint::Fill(1)),
+
                Column::new(
+
                    Text::from(seen.clone()),
+
                    Constraint::Min(seen.width() as u16),
+
                ),
+
                Column::new(
+
                    Text::from(unseen.clone()),
+
                    Constraint::Min(unseen.width() as u16),
+
                ),
+
                Column::new(Text::from(progress), Constraint::Min(4)),
+
            ]
+
            .to_vec(),
        }
    }
}

-
impl Render<()> for Notifications {
-
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
-
        let page_size = if self.props.show_search {
-
            let layout = Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).split(area);
+
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;

-
            self.render_header::<B>(frame, layout[0]);
-
            self.render_list::<B>(frame, layout[1]);
+
        let page_size = if self.props.show_search {
+
            self.table.render(frame, area, &());

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

-
            self.render_header::<B>(frame, layout[0]);
-
            self.render_list::<B>(frame, layout[1]);
-
            self.render_footer::<B>(frame, layout[2]);
+
            self.table.render(frame, layout[0], &());
+
            self.footer.render(frame, layout[1], &());

-
            layout[1].height as usize
+
            (area.height as usize).saturating_sub(header_height)
        };

        if page_size != self.props.page_size {
@@ -527,36 +459,61 @@ impl Render<()> for Notifications {
    }
}

-
pub struct SearchProps {}
-

-
pub struct Search {
-
    pub action_tx: UnboundedSender<Action>,
-
    pub input: TextField,
+
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 Widget<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 mut input = TextField::new(state, action_tx.clone());
-
        input.set_text(&state.search.read().to_string());
-

-
        Self { action_tx, input }.move_with_state(state)
+
        let input = TextField::new(state, action_tx.clone())
+
            .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 mut input = <TextField as Widget<State, Action>>::move_with_state(self.input, state);
-
        input.set_text(&state.search.read().to_string());
+
    fn on_update(mut self, callback: UpdateCallback<State>) -> Self {
+
        self.on_update = Some(callback);
+
        self
+
    }

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

-
    fn name(&self) -> &str {
-
        "filter-popup"
+
    fn update(&mut self, state: &State) {
+
        self.input.update(state);
    }

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

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

-
        self.input.render::<B>(
-
            frame,
-
            layout[0],
-
            TextFieldProps {
-
                titles: ("Search".into(), "Search".into()),
-
                show_cursor: true,
-
                inline_label: true,
-
            },
-
        );
+
        self.input.render(frame, layout[0], &());
    }
}

+
#[derive(Clone)]
pub struct HelpProps<'a> {
    content: Text<'a>,
    focus: bool,
@@ -739,20 +686,24 @@ 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<Action>,
+
    header: BoxedWidget<B>,
    /// Content widget
-
    content: Paragraph<Action>,
+
    content: BoxedWidget<B>,
    /// Container footer
-
    footer: Footer<Action>,
+
    footer: BoxedWidget<B>,
}

-
impl<'a> Widget<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,
@@ -760,33 +711,71 @@ impl<'a> Widget<State, Action> for Help<'a> {
        Self {
            action_tx: action_tx.clone(),
            props: HelpProps::from(state),
-
            header: Header::new(state, action_tx.clone()),
-
            content: Paragraph::new(state, action_tx.clone()),
-
            footer: Footer::new(state, action_tx),
+
            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,
-
    {
-
        Self {
-
            props: HelpProps::from(state),
-
            header: self.header.move_with_state(state),
-
            content: self.content.move_with_state(state),
-
            footer: self.footer.move_with_state(state),
-
            ..self
-
        }
+
    fn on_update(mut self, callback: UpdateCallback<State>) -> Self {
+
        self.on_update = Some(callback);
+
        self
+
    }
+

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

-
    fn name(&self) -> &str {
-
        "help"
+
    fn update(&mut self, state: &State) {
+
        self.props = HelpProps::from(state);
+

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

    fn handle_key_event(&mut self, key: termion::event::Key) {
-
        let len = self.props.content.lines.len() + 1;
-
        let page_size = self.props.page_size;
        match key {
            Key::Esc => {
                let _ = self.action_tx.send(Action::Exit { selection: None });
@@ -794,31 +783,15 @@ impl<'a> Widget<State, Action> for Help<'a> {
            Key::Char('?') => {
                let _ = self.action_tx.send(Action::CloseHelp);
            }
-
            Key::Up | Key::Char('k') => {
-
                self.content.prev(len, page_size);
-
            }
-
            Key::Down | Key::Char('j') => {
-
                self.content.next(len, page_size);
-
            }
-
            Key::PageUp => {
-
                self.content.prev_page(len, page_size);
-
            }
-
            Key::PageDown => {
-
                self.content.next_page(len, page_size);
-
            }
-
            Key::Home => {
-
                self.content.begin(len, page_size);
-
            }
-
            Key::End => {
-
                self.content.end(len, page_size);
+
            _ => {
+
                self.content.handle_key_event(key);
            }
-
            _ => {}
        }
    }
}

-
impl<'a> Render<()> for Help<'a> {
-
    fn render<B: Backend>(&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),
@@ -826,42 +799,9 @@ impl<'a> Render<()> for Help<'a> {
        ])
        .areas(area);

-
        self.header.render::<B>(
-
            frame,
-
            header_area,
-
            HeaderProps {
-
                cells: [String::from(" Help ").into()],
-
                widths: [Constraint::Fill(1)],
-
                focus: self.props.focus,
-
                cutoff: usize::MIN,
-
                cutoff_after: usize::MAX,
-
            },
-
        );
-

-
        self.content.render::<B>(
-
            frame,
-
            content_area,
-
            ParagraphProps {
-
                content: self.props.content.clone(),
-
                focus: self.props.focus,
-
                has_footer: true,
-
                has_header: true,
-
            },
-
        );
-

-
        let progress = span::default(format!("{}%", self.content.progress())).dim();
-

-
        self.footer.render::<B>(
-
            frame,
-
            footer_area,
-
            FooterProps {
-
                cells: [String::new().into(), progress.clone().into()],
-
                widths: [Constraint::Fill(1), Constraint::Min(4)],
-
                focus: self.props.focus,
-
                cutoff: usize::MAX,
-
                cutoff_after: usize::MAX,
-
            },
-
        );
+
        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
@@ -1,6 +1,8 @@
#[path = "select/ui.rs"]
mod ui;

+
use std::str::FromStr;
+

use anyhow::Result;

use radicle::issue::IssueId;
@@ -9,10 +11,13 @@ use radicle::Profile;

use radicle_tui as tui;

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

@@ -26,7 +31,7 @@ pub struct Context {
    pub profile: Profile,
    pub repository: Repository,
    pub mode: Mode,
-
    pub filter: Filter,
+
    pub filter: issue::Filter,
}

pub struct App {
@@ -51,18 +56,56 @@ 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;

    fn try_from(context: &Context) -> Result<Self, Self::Error> {
        let issues = issue::all(&context.profile, &context.repository)?;
+
        let search = StateValue::new(context.filter.to_string());
+
        let filter = IssueItemFilter::from_str(&search.read()).unwrap_or_default();

        // Convert into UI items
        let mut items = vec![];
@@ -71,11 +114,16 @@ impl TryFrom<&Context> for State {
                items.push(item);
            }
        }
+
        items.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));

        Ok(Self {
-
            issues: items,
+
            issues: IssuesState {
+
                items,
+
                selected: None,
+
            },
            mode: context.mode.clone(),
-
            search: StateValue::new(context.filter.to_string()),
+
            filter,
+
            search,
            ui: UIState::default(),
        })
    }
@@ -83,6 +131,7 @@ impl TryFrom<&Context> for State {

pub enum Action {
    Exit { selection: Option<Selection> },
+
    Select { selected: Option<usize> },
    PageSize(usize),
    OpenSearch,
    UpdateSearch { value: String },
@@ -98,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
@@ -108,6 +161,8 @@ impl store::State<Action, Selection> for State {
            }
            Action::UpdateSearch { value } => {
                self.search.write(value);
+
                self.filter = IssueItemFilter::from_str(&self.search.read()).unwrap_or_default();
+

                None
            }
            Action::ApplySearch => {
@@ -118,6 +173,8 @@ impl store::State<Action, Selection> for State {
            Action::CloseSearch => {
                self.search.reset();
                self.ui.show_search = false;
+
                self.filter = IssueItemFilter::from_str(&self.search.read()).unwrap_or_default();
+

                None
            }
            Action::OpenHelp => {
@@ -145,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;
@@ -16,10 +17,14 @@ use radicle_tui as tui;

use tui::ui::items::{IssueItem, IssueItemFilter};
use tui::ui::span;
+
use tui::ui::widget;
use tui::ui::widget::container::{Footer, FooterProps, Header, HeaderProps};
use tui::ui::widget::input::{TextField, TextFieldProps};
use tui::ui::widget::text::{Paragraph, ParagraphProps};
-
use tui::ui::widget::{Render, Shortcut, Shortcuts, ShortcutsProps, Table, TableProps, Widget};
+
use tui::ui::widget::{
+
    Column, EventCallback, Shortcuts, ShortcutsProps, Table, TableProps, UpdateCallback, View,
+
    Widget,
+
};
use tui::Selection;

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

use super::{Action, State};

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

pub struct ListPageProps {
-
    mode: Mode,
    show_search: bool,
    show_help: bool,
}
@@ -36,29 +42,35 @@ pub struct ListPageProps {
impl From<&State> for ListPageProps {
    fn from(state: &State) -> Self {
        Self {
-
            mode: state.mode.clone(),
            show_search: state.ui.show_search,
            show_help: state.ui.show_help,
        }
    }
}

-
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,
+
    /// 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> Widget<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,
@@ -66,36 +78,43 @@ impl<'a> Widget<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(state, 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,
-
    {
-
        ListPage {
-
            issues: self.issues.move_with_state(state),
-
            shortcuts: self.shortcuts.move_with_state(state),
-
            help: self.help.move_with_state(state),
-
            props: ListPageProps::from(state),
-
            ..self
-
        }
+
    fn on_change(mut self, callback: EventCallback<Action>) -> Self {
+
        self.on_change = Some(callback);
+
        self
    }

-
    fn name(&self) -> &str {
-
        "list-page"
+
    fn on_update(mut self, callback: UpdateCallback<State>) -> Self {
+
        self.on_update = Some(callback);
+
        self
+
    }
+

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

+
        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 Widget<State, Action>>::handle_key_event(&mut self.search, key)
+
            self.search.handle_key_event(key);
        } else if self.props.show_help {
-
            <Help as Widget<State, Action>>::handle_key_event(&mut self.help, key)
+
            self.help.handle_key_event(key);
        } else {
            match key {
                Key::Esc | Key::Ctrl('c') => {
@@ -108,70 +127,45 @@ impl<'a> Widget<State, Action> for ListPage<'a> {
                    let _ = self.action_tx.send(Action::OpenHelp);
                }
                _ => {
-
                    <Issues as Widget<State, Action>>::handle_key_event(&mut self.issues, key);
+
                    self.issues.handle_key_event(key);
                }
            }
        }
    }
}

-
impl<'a> Render<()> for ListPage<'a> {
-
    fn render<B: Backend>(&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);

-
        let shortcuts = if self.props.show_search {
-
            vec![
-
                Shortcut::new("esc", "cancel"),
-
                Shortcut::new("enter", "apply"),
-
            ]
-
        } else if self.props.show_help {
-
            vec![Shortcut::new("?", "close")]
-
        } else {
-
            match self.props.mode {
-
                Mode::Id => vec![
-
                    Shortcut::new("enter", "select"),
-
                    Shortcut::new("/", "search"),
-
                ],
-
                Mode::Operation => vec![
-
                    Shortcut::new("enter", "show"),
-
                    Shortcut::new("e", "edit"),
-
                    Shortcut::new("/", "search"),
-
                    Shortcut::new("?", "help"),
-
                ],
-
            }
-
        };
-

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

-
            self.issues.render::<B>(frame, component_layout[0], ());
-
            self.search
-
                .render::<B>(frame, component_layout[1], SearchProps {});
+
            self.issues.render(frame, component_layout[0], &());
+
            self.search.render(frame, component_layout[1], &());
        } else if self.props.show_help {
-
            self.help.render::<B>(frame, layout.component, ());
+
            self.help.render(frame, layout.component, &());
        } else {
-
            self.issues.render::<B>(frame, layout.component, ());
+
            self.issues.render(frame, layout.component, &());
        }

-
        self.shortcuts.render::<B>(
-
            frame,
-
            layout.shortcuts,
-
            ShortcutsProps {
-
                shortcuts,
-
                divider: '∙',
-
            },
-
        );
+
        self.shortcuts.render(frame, layout.shortcuts, &());
    }
}

-
struct IssuesProps {
+
#[derive(Clone)]
+
struct IssuesProps<'a> {
    mode: Mode,
    issues: Vec<IssueItem>,
+
    selected: Option<usize>,
    search: String,
    stats: HashMap<String, usize>,
-
    widths: [Constraint; 8],
+
    columns: Vec<Column<'a>>,
    cutoff: usize,
    cutoff_after: usize,
    focus: bool,
@@ -179,27 +173,17 @@ struct IssuesProps {
    show_search: bool,
}

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

+
        let issues = state.issues();
+

        let mut open = 0;
        let mut other = 0;
        let mut solved = 0;

-
        // Filter by search string
-
        let filter = IssueItemFilter::from_str(&state.search.read()).unwrap_or_default();
-
        let mut issues = state
-
            .issues
-
            .clone()
-
            .into_iter()
-
            .filter(|issue| filter.matches(issue))
-
            .collect::<Vec<_>>();
-

-
        // Apply sorting
-
        issues.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
-

-
        for issue in &state.issues {
+
        for issue in &issues {
            match issue.state {
                State::Open => open += 1,
                State::Closed {
@@ -224,105 +208,124 @@ impl From<&State> for IssuesProps {
            mode: state.mode.clone(),
            issues,
            search: state.search.read(),
-
            widths: [
-
                Constraint::Length(3),
-
                Constraint::Length(8),
-
                Constraint::Fill(5),
-
                Constraint::Length(16),
-
                Constraint::Length(16),
-
                Constraint::Fill(1),
-
                Constraint::Fill(1),
-
                Constraint::Length(16),
-
            ],
+
            columns: [
+
                Column::new(" ● ", Constraint::Length(3)),
+
                Column::new("ID", Constraint::Length(8)),
+
                Column::new("Title", Constraint::Fill(5)),
+
                Column::new("Author", Constraint::Length(16)),
+
                Column::new("", Constraint::Length(16)),
+
                Column::new("Labels", Constraint::Fill(1)),
+
                Column::new("Assignees", Constraint::Fill(1)),
+
                Column::new("Opened", Constraint::Length(16)),
+
            ]
+
            .to_vec(),
            cutoff: 200,
            cutoff_after: 5,
            focus: false,
            stats,
            page_size: state.ui.page_size,
            show_search: state.ui.show_search,
+
            selected: state.issues.selected,
        }
    }
}

-
struct Issues {
-
    /// Action sender
+
struct Issues<'a, B> {
+
    /// Internal properties
+
    props: IssuesProps<'a>,
+
    /// Message sender
    action_tx: UnboundedSender<Action>,
-
    /// State mapped props
-
    props: IssuesProps,
-
    /// Header
-
    header: Header<Action>,
-
    /// Notification table
-
    table: Table<Action>,
-
    /// Footer
-
    footer: Footer<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 Widget<State, Action> for Issues {
+
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: IssuesProps::from(state),
-
            header: Header::new(state, action_tx.clone()),
-
            table: Table::new(state, action_tx.clone()),
-
            footer: Footer::new(state, action_tx),
+
            props: props.clone(),
+
            table: Box::<Table<'_, State, Action, B, IssueItem>>::new(
+
                Table::new(state, action_tx.clone())
+
                    .header(
+
                        Header::new(state, action_tx.clone())
+
                            .columns(props.columns.clone())
+
                            .cutoff(props.cutoff, props.cutoff_after)
+
                            .focus(props.focus)
+
                            .to_boxed(),
+
                    )
+
                    .on_change(|props, action_tx| {
+
                        if let Some(props) = props.downcast_ref::<TableProps<'_, IssueItem>>() {
+
                            let _ = action_tx.send(Action::Select {
+
                                selected: props.selected,
+
                            });
+
                        }
+
                    })
+
                    .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,
        }
    }

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

-
        if let Some(selected) = table.selected() {
-
            if selected > props.issues.len() {
-
                table.begin();
-
            }
-
        }
+
    fn on_update(mut self, callback: UpdateCallback<State>) -> Self {
+
        self.on_update = Some(callback);
+
        self
+
    }

-
        Self {
-
            props,
-
            table,
-
            header: self.header.move_with_state(state),
-
            footer: self.footer.move_with_state(state),
-
            ..self
-
        }
+
    fn on_change(mut self, callback: EventCallback<Action>) -> Self {
+
        self.on_change = Some(callback);
+
        self
    }

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

+
        self.table.update(state);
+
        self.footer.update(state);
    }

    fn handle_key_event(&mut self, key: Key) {
        match key {
-
            Key::Up | Key::Char('k') => {
-
                self.table.prev();
-
            }
-
            Key::Down | Key::Char('j') => {
-
                self.table.next(self.props.issues.len());
-
            }
-
            Key::PageUp => {
-
                self.table.prev_page(self.props.page_size);
-
            }
-
            Key::PageDown => {
-
                self.table
-
                    .next_page(self.props.issues.len(), self.props.page_size);
-
            }
-
            Key::Home => {
-
                self.table.begin();
-
            }
-
            Key::End => {
-
                self.table.end(self.props.issues.len());
-
            }
            Key::Char('\n') => {
                let operation = match self.props.mode {
                    Mode::Operation => Some(IssueOperation::Show.to_string()),
                    Mode::Id => None,
                };

-
                self.table
-
                    .selected()
+
                self.props
+
                    .selected
                    .and_then(|selected| self.props.issues.get(selected))
                    .and_then(|issue| {
                        self.action_tx
@@ -337,8 +340,8 @@ impl Widget<State, Action> for Issues {
                    });
            }
            Key::Char('e') => {
-
                self.table
-
                    .selected()
+
                self.props
+
                    .selected
                    .and_then(|selected| self.props.issues.get(selected))
                    .and_then(|issue| {
                        self.action_tx
@@ -352,52 +355,15 @@ impl Widget<State, Action> for Issues {
                            .ok()
                    });
            }
-
            _ => {}
+
            _ => {
+
                self.table.handle_key_event(key);
+
            }
        }
    }
}

-
impl Issues {
-
    fn render_header<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect) {
-
        self.header.render::<B>(
-
            frame,
-
            area,
-
            HeaderProps {
-
                cells: [
-
                    String::from(" ● ").into(),
-
                    String::from("ID").into(),
-
                    String::from("Title").into(),
-
                    String::from("Author").into(),
-
                    String::from("").into(),
-
                    String::from("Labels").into(),
-
                    String::from("Assignees ").into(),
-
                    String::from("Opened").into(),
-
                ],
-
                widths: self.props.widths,
-
                focus: self.props.focus,
-
                cutoff: self.props.cutoff,
-
                cutoff_after: self.props.cutoff_after,
-
            },
-
        );
-
    }
-

-
    fn render_list<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect) {
-
        self.table.render::<B>(
-
            frame,
-
            area,
-
            TableProps {
-
                items: self.props.issues.to_vec(),
-
                has_footer: !self.props.show_search,
-
                has_header: true,
-
                widths: self.props.widths,
-
                focus: self.props.focus,
-
                cutoff: self.props.cutoff,
-
                cutoff_after: self.props.cutoff_after,
-
            },
-
        );
-
    }
-

-
    fn render_footer<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect) {
+
impl<'a, B: Backend> Issues<'a, B> {
+
    fn build_footer(props: &IssuesProps<'a>, selected: Option<usize>) -> Vec<Column<'a>> {
        let search = Line::from(
            [
                span::default(" Search ".to_string())
@@ -405,21 +371,21 @@ impl Issues {
                    .dim()
                    .reversed(),
                span::default(" ".into()),
-
                span::default(self.props.search.to_string()).gray().dim(),
+
                span::default(props.search.to_string()).gray().dim(),
            ]
            .to_vec(),
        );

        let open = Line::from(
            [
-
                span::positive(self.props.stats.get("Open").unwrap_or(&0).to_string()).dim(),
+
                span::positive(props.stats.get("Open").unwrap_or(&0).to_string()).dim(),
                span::default(" Open".to_string()).dim(),
            ]
            .to_vec(),
        );
        let solved = Line::from(
            [
-
                span::default(self.props.stats.get("Solved").unwrap_or(&0).to_string())
+
                span::default(props.stats.get("Solved").unwrap_or(&0).to_string())
                    .magenta()
                    .dim(),
                span::default(" Solved".to_string()).dim(),
@@ -428,7 +394,7 @@ impl Issues {
        );
        let closed = Line::from(
            [
-
                span::default(self.props.stats.get("Closed").unwrap_or(&0).to_string())
+
                span::default(props.stats.get("Closed").unwrap_or(&0).to_string())
                    .magenta()
                    .dim(),
                span::default(" Closed".to_string()).dim(),
@@ -438,17 +404,23 @@ impl Issues {
        let sum = Line::from(
            [
                span::default("Σ ".to_string()).dim(),
-
                span::default(self.props.issues.len().to_string()).dim(),
+
                span::default(props.issues.len().to_string()).dim(),
            ]
            .to_vec(),
        );

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

-
        match IssueItemFilter::from_str(&self.props.search)
+
        match IssueItemFilter::from_str(&props.search)
            .unwrap_or_default()
            .state()
        {
@@ -463,73 +435,52 @@ impl Issues {
                    } => solved,
                };

-
                self.footer.render::<B>(
-
                    frame,
-
                    area,
-
                    FooterProps {
-
                        cells: [search.into(), block.clone().into(), progress.clone().into()],
-
                        widths: [
-
                            Constraint::Fill(1),
-
                            Constraint::Min(block.width() as u16),
-
                            Constraint::Min(4),
-
                        ],
-
                        focus: self.props.focus,
-
                        cutoff: self.props.cutoff,
-
                        cutoff_after: self.props.cutoff_after,
-
                    },
-
                );
-
            }
-
            None => {
-
                self.footer.render::<B>(
-
                    frame,
-
                    area,
-
                    FooterProps {
-
                        cells: [
-
                            search.into(),
-
                            open.clone().into(),
-
                            closed.clone().into(),
-
                            sum.clone().into(),
-
                            progress.clone().into(),
-
                        ],
-
                        widths: [
-
                            Constraint::Fill(1),
-
                            Constraint::Min(open.width() as u16),
-
                            Constraint::Min(closed.width() as u16),
-
                            Constraint::Min(sum.width() as u16),
-
                            Constraint::Min(4),
-
                        ],
-
                        focus: self.props.focus,
-
                        cutoff: self.props.cutoff,
-
                        cutoff_after: self.props.cutoff_after,
-
                    },
-
                );
+
                [
+
                    Column::new(Text::from(search), Constraint::Fill(1)),
+
                    Column::new(
+
                        Text::from(block.clone()),
+
                        Constraint::Min(block.width() as u16),
+
                    ),
+
                    Column::new(Text::from(progress), Constraint::Min(4)),
+
                ]
+
                .to_vec()
            }
+
            None => [
+
                Column::new(Text::from(search), Constraint::Fill(1)),
+
                Column::new(
+
                    Text::from(open.clone()),
+
                    Constraint::Min(open.width() as u16),
+
                ),
+
                Column::new(
+
                    Text::from(closed.clone()),
+
                    Constraint::Min(closed.width() as u16),
+
                ),
+
                Column::new(Text::from(sum.clone()), Constraint::Min(sum.width() as u16)),
+
                Column::new(Text::from(progress), Constraint::Min(4)),
+
            ]
+
            .to_vec(),
        }
    }
}

-
impl Render<()> for Issues {
-
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
-
        let page_size = if self.props.show_search {
-
            let layout = Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).split(area);
+
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;

-
            self.render_header::<B>(frame, layout[0]);
-
            self.render_list::<B>(frame, layout[1]);
+
        let page_size = if self.props.show_search {
+
            self.table.render(frame, area, &());

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

-
            self.render_header::<B>(frame, layout[0]);
-
            self.render_list::<B>(frame, layout[1]);
-
            self.render_footer::<B>(frame, layout[2]);
+
            self.table.render(frame, layout[0], &());
+
            self.footer.render(frame, layout[1], &());

-
            layout[1].height as usize
+
            (area.height as usize).saturating_sub(header_height)
        };

        if page_size != self.props.page_size {
@@ -538,36 +489,61 @@ impl Render<()> for Issues {
    }
}

-
pub struct SearchProps {}
-

-
pub struct Search {
-
    pub action_tx: UnboundedSender<Action>,
-
    pub input: TextField,
+
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 Widget<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 mut input = TextField::new(state, action_tx.clone());
-
        input.set_text(&state.search.read().to_string());
-

-
        Self { action_tx, input }.move_with_state(state)
+
        let input = TextField::new(state, action_tx.clone())
+
            .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 mut input = <TextField as Widget<State, Action>>::move_with_state(self.input, state);
-
        input.set_text(&state.search.read().to_string());
+
    fn on_update(mut self, callback: UpdateCallback<State>) -> Self {
+
        self.on_update = Some(callback);
+
        self
+
    }

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

-
    fn name(&self) -> &str {
-
        "filter-popup"
+
    fn update(&mut self, state: &State) {
+
        self.input.update(state);
    }

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

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

-
        self.input.render::<B>(
-
            frame,
-
            layout[0],
-
            TextFieldProps {
-
                titles: ("Search".into(), "Search".into()),
-
                show_cursor: true,
-
                inline_label: true,
-
            },
-
        );
+
        self.input.render(frame, layout[0], &());
    }
}

+
#[derive(Clone)]
pub struct HelpProps<'a> {
    content: Text<'a>,
    focus: bool,
@@ -750,20 +716,24 @@ 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<Action>,
+
    header: BoxedWidget<B>,
    /// Content widget
-
    content: Paragraph<Action>,
+
    content: BoxedWidget<B>,
    /// Container footer
-
    footer: Footer<Action>,
+
    footer: BoxedWidget<B>,
}

-
impl<'a> Widget<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,
@@ -771,33 +741,71 @@ impl<'a> Widget<State, Action> for Help<'a> {
        Self {
            action_tx: action_tx.clone(),
            props: HelpProps::from(state),
-
            header: Header::new(state, action_tx.clone()),
-
            content: Paragraph::new(state, action_tx.clone()),
-
            footer: Footer::new(state, action_tx),
+
            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,
-
    {
-
        Self {
-
            props: HelpProps::from(state),
-
            header: self.header.move_with_state(state),
-
            content: self.content.move_with_state(state),
-
            footer: self.footer.move_with_state(state),
-
            ..self
-
        }
+
    fn on_update(mut self, callback: UpdateCallback<State>) -> Self {
+
        self.on_update = Some(callback);
+
        self
    }

-
    fn name(&self) -> &str {
-
        "help"
+
    fn on_change(mut self, callback: EventCallback<Action>) -> Self {
+
        self.on_change = Some(callback);
+
        self
+
    }
+

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

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

    fn handle_key_event(&mut self, key: termion::event::Key) {
-
        let len = self.props.content.lines.len() + 1;
-
        let page_size = self.props.page_size;
        match key {
            Key::Esc => {
                let _ = self.action_tx.send(Action::Exit { selection: None });
@@ -805,31 +813,15 @@ impl<'a> Widget<State, Action> for Help<'a> {
            Key::Char('?') => {
                let _ = self.action_tx.send(Action::CloseHelp);
            }
-
            Key::Up | Key::Char('k') => {
-
                self.content.prev(len, page_size);
-
            }
-
            Key::Down | Key::Char('j') => {
-
                self.content.next(len, page_size);
-
            }
-
            Key::PageUp => {
-
                self.content.prev_page(len, page_size);
-
            }
-
            Key::PageDown => {
-
                self.content.next_page(len, page_size);
-
            }
-
            Key::Home => {
-
                self.content.begin(len, page_size);
-
            }
-
            Key::End => {
-
                self.content.end(len, page_size);
+
            _ => {
+
                self.content.handle_key_event(key);
            }
-
            _ => {}
        }
    }
}

-
impl<'a> Render<()> for Help<'a> {
-
    fn render<B: Backend>(&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),
@@ -837,42 +829,9 @@ impl<'a> Render<()> for Help<'a> {
        ])
        .areas(area);

-
        self.header.render::<B>(
-
            frame,
-
            header_area,
-
            HeaderProps {
-
                cells: [String::from(" Help ").into()],
-
                widths: [Constraint::Fill(1)],
-
                focus: self.props.focus,
-
                cutoff: usize::MIN,
-
                cutoff_after: usize::MAX,
-
            },
-
        );
-

-
        self.content.render::<B>(
-
            frame,
-
            content_area,
-
            ParagraphProps {
-
                content: self.props.content.clone(),
-
                focus: self.props.focus,
-
                has_footer: true,
-
                has_header: true,
-
            },
-
        );
-

-
        let progress = span::default(format!("{}%", self.content.progress())).dim();
-

-
        self.footer.render::<B>(
-
            frame,
-
            footer_area,
-
            FooterProps {
-
                cells: [String::new().into(), progress.clone().into()],
-
                widths: [Constraint::Fill(1), Constraint::Min(4)],
-
                focus: self.props.focus,
-
                cutoff: usize::MAX,
-
                cutoff_after: usize::MAX,
-
            },
-
        );
+
        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
@@ -1,6 +1,8 @@
#[path = "select/ui.rs"]
mod ui;

+
use std::str::FromStr;
+

use anyhow::Result;

use radicle::patch::PatchId;
@@ -9,10 +11,12 @@ use radicle::Profile;

use radicle_tui as tui;

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

@@ -26,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 {
@@ -51,18 +55,57 @@ 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;

    fn try_from(context: &Context) -> Result<Self, Self::Error> {
        let patches = patch::all(&context.profile, &context.repository)?;
+
        let search = store::StateValue::new(context.filter.to_string());
+
        let filter = PatchItemFilter::from_str(&context.filter.to_string()).unwrap_or_default();

        // Convert into UI items
        let mut items = vec![];
@@ -71,11 +114,16 @@ impl TryFrom<&Context> for State {
                items.push(item);
            }
        }
+
        items.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));

        Ok(Self {
-
            patches: items,
+
            patches: PatchesState {
+
                items,
+
                selected: None,
+
            },
            mode: context.mode.clone(),
-
            search: store::StateValue::new(context.filter.to_string()),
+
            filter,
+
            search,
            ui: UIState::default(),
        })
    }
@@ -83,6 +131,7 @@ impl TryFrom<&Context> for State {

pub enum Action {
    Exit { selection: Option<Selection> },
+
    Select { selected: Option<usize> },
    PageSize(usize),
    OpenSearch,
    UpdateSearch { value: String },
@@ -98,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
@@ -108,6 +161,8 @@ impl store::State<Action, Selection> for State {
            }
            Action::UpdateSearch { value } => {
                self.search.write(value);
+
                self.filter = PatchItemFilter::from_str(&self.search.read()).unwrap_or_default();
+

                None
            }
            Action::ApplySearch => {
@@ -118,6 +173,8 @@ impl store::State<Action, Selection> for State {
            Action::CloseSearch => {
                self.search.reset();
                self.ui.show_search = false;
+
                self.filter = PatchItemFilter::from_str(&self.search.read()).unwrap_or_default();
+

                None
            }
            Action::OpenHelp => {
@@ -145,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;
@@ -17,10 +18,14 @@ use radicle_tui as tui;

use tui::ui::items::{PatchItem, PatchItemFilter};
use tui::ui::span;
+
use tui::ui::widget;
use tui::ui::widget::container::{Footer, FooterProps, Header, HeaderProps};
use tui::ui::widget::input::{TextField, TextFieldProps};
use tui::ui::widget::text::{Paragraph, ParagraphProps};
-
use tui::ui::widget::{Render, Shortcut, Shortcuts, ShortcutsProps, Table, TableProps, Widget};
+
use tui::ui::widget::{
+
    Column, EventCallback, Shortcuts, ShortcutsProps, Table, TableProps, UpdateCallback, View,
+
    Widget,
+
};
use tui::Selection;

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

use super::{Action, State};

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

pub struct ListPageProps {
-
    mode: Mode,
    show_search: bool,
    show_help: bool,
}
@@ -37,29 +43,32 @@ pub struct ListPageProps {
impl From<&State> for ListPageProps {
    fn from(state: &State) -> Self {
        Self {
-
            mode: state.mode.clone(),
            show_search: state.ui.show_search,
            show_help: state.ui.show_help,
        }
    }
}

-
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,
+
    /// 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> Widget<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,
@@ -67,37 +76,43 @@ impl<'a> Widget<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(state, 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,
-
    {
-
        ListPage {
-
            patches: self.patches.move_with_state(state),
-
            search: self.search.move_with_state(state),
-
            shortcuts: self.shortcuts.move_with_state(state),
-
            help: self.help.move_with_state(state),
-
            props: ListPageProps::from(state),
-
            ..self
-
        }
+
    fn on_update(mut self, callback: UpdateCallback<State>) -> Self {
+
        self.on_update = Some(callback);
+
        self
    }

-
    fn name(&self) -> &str {
-
        "list-page"
+
    fn on_change(mut self, callback: EventCallback<Action>) -> Self {
+
        self.on_change = Some(callback);
+
        self
+
    }
+

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

+
        self.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 Widget<State, Action>>::handle_key_event(&mut self.search, key)
+
            self.search.handle_key_event(key);
        } else if self.props.show_help {
-
            <Help as Widget<State, Action>>::handle_key_event(&mut self.help, key)
+
            self.help.handle_key_event(key);
        } else {
            match key {
                Key::Esc | Key::Ctrl('c') => {
@@ -110,71 +125,42 @@ impl<'a> Widget<State, Action> for ListPage<'a> {
                    let _ = self.action_tx.send(Action::OpenHelp);
                }
                _ => {
-
                    <Patches as Widget<State, Action>>::handle_key_event(&mut self.patches, key);
+
                    self.patches.handle_key_event(key);
                }
            }
        }
    }
}

-
impl<'a> Render<()> for ListPage<'a> {
-
    fn render<B: Backend>(&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);

-
        let shortcuts = if self.props.show_search {
-
            vec![
-
                Shortcut::new("esc", "cancel"),
-
                Shortcut::new("enter", "apply"),
-
            ]
-
        } else if self.props.show_help {
-
            vec![Shortcut::new("?", "close")]
-
        } else {
-
            match self.props.mode {
-
                Mode::Id => vec![
-
                    Shortcut::new("enter", "select"),
-
                    Shortcut::new("/", "search"),
-
                ],
-
                Mode::Operation => vec![
-
                    Shortcut::new("enter", "show"),
-
                    Shortcut::new("c", "checkout"),
-
                    Shortcut::new("d", "diff"),
-
                    Shortcut::new("/", "search"),
-
                    Shortcut::new("?", "help"),
-
                ],
-
            }
-
        };
-

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

-
            self.patches.render::<B>(frame, component_layout[0], ());
-
            self.search
-
                .render::<B>(frame, component_layout[1], SearchProps {});
+
            self.patches.render(frame, component_layout[0], &());
+
            self.search.render(frame, component_layout[1], &());
        } else if self.props.show_help {
-
            self.help.render::<B>(frame, layout.component, ());
+
            self.help.render(frame, layout.component, &());
        } else {
-
            self.patches.render::<B>(frame, layout.component, ());
+
            self.patches.render(frame, layout.component, &());
        }

-
        self.shortcuts.render::<B>(
-
            frame,
-
            layout.shortcuts,
-
            ShortcutsProps {
-
                shortcuts,
-
                divider: '∙',
-
            },
-
        );
+
        self.shortcuts.render(frame, layout.shortcuts, &());
    }
}

-
struct PatchesProps {
+
#[derive(Clone)]
+
struct PatchesProps<'a> {
    mode: Mode,
    patches: Vec<PatchItem>,
+
    selected: Option<usize>,
    search: String,
    stats: HashMap<String, usize>,
-
    widths: [Constraint; 9],
+
    columns: Vec<Column<'a>>,
    cutoff: usize,
    cutoff_after: usize,
    focus: bool,
@@ -182,23 +168,14 @@ struct PatchesProps {
    show_search: bool,
}

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

-
        let filter = PatchItemFilter::from_str(&state.search.read()).unwrap_or_default();
-
        let mut patches = state
-
            .patches
-
            .clone()
-
            .into_iter()
-
            .filter(|patch| filter.matches(patch))
-
            .collect::<Vec<_>>();
-

-
        // Apply sorting
-
        patches.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
+
        let patches = state.patches();

        for patch in &patches {
            match patch.state {
@@ -223,106 +200,129 @@ impl From<&State> for PatchesProps {
            mode: state.mode.clone(),
            patches,
            search: state.search.read(),
-
            widths: [
-
                Constraint::Length(3),
-
                Constraint::Length(8),
-
                Constraint::Fill(1),
-
                Constraint::Length(16),
-
                Constraint::Length(16),
-
                Constraint::Length(8),
-
                Constraint::Length(6),
-
                Constraint::Length(6),
-
                Constraint::Length(16),
-
            ],
+
            columns: [
+
                Column::new(" ● ", Constraint::Length(3)),
+
                Column::new("ID", Constraint::Length(8)),
+
                Column::new("Title", Constraint::Fill(1)),
+
                Column::new("Author", Constraint::Length(16)),
+
                Column::new("", Constraint::Length(16)),
+
                Column::new("Head", Constraint::Length(8)),
+
                Column::new("+", Constraint::Length(6)),
+
                Column::new("-", Constraint::Length(6)),
+
                Column::new("Updated", Constraint::Length(16)),
+
            ]
+
            .to_vec(),
            cutoff: 150,
            cutoff_after: 5,
            focus: false,
            stats,
            page_size: state.ui.page_size,
            show_search: state.ui.show_search,
+
            selected: state.patches.selected,
        }
    }
}

-
struct Patches {
-
    /// Action sender
+
struct Patches<'a, B> {
+
    /// Internal properties
+
    props: PatchesProps<'a>,
+
    /// Message sender
    action_tx: UnboundedSender<Action>,
-
    /// State mapped props
-
    props: PatchesProps,
-
    /// Table header
-
    header: Header<Action>,
-
    /// Notification table
-
    table: Table<Action>,
-
    /// Table footer
-
    footer: Footer<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 Widget<State, Action> for Patches {
+
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: PatchesProps::from(state),
-
            header: Header::new(state, action_tx.clone()),
-
            table: Table::new(state, action_tx.clone()),
-
            footer: Footer::new(state, action_tx),
+
            props: props.clone(),
+
            table: Box::<Table<'_, State, Action, B, PatchItem>>::new(
+
                Table::new(state, action_tx.clone())
+
                    .header(
+
                        Header::new(state, action_tx.clone())
+
                            .columns(props.columns.clone())
+
                            .cutoff(props.cutoff, props.cutoff_after)
+
                            .focus(props.focus)
+
                            .to_boxed(),
+
                    )
+
                    .on_change(|props, action_tx| {
+
                        props
+
                            .downcast_ref::<TableProps<'_, PatchItem>>()
+
                            .and_then(|props| {
+
                                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,
        }
    }

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

-
        if let Some(selected) = table.selected() {
-
            if selected > props.patches.len() {
-
                table.begin();
-
            }
-
        }
+
    fn on_update(mut self, callback: UpdateCallback<State>) -> Self {
+
        self.on_update = Some(callback);
+
        self
+
    }

-
        Self {
-
            props,
-
            header: self.header.move_with_state(state),
-
            table,
-
            footer: self.footer.move_with_state(state),
-
            ..self
-
        }
+
    fn on_change(mut self, callback: EventCallback<Action>) -> Self {
+
        self.on_change = Some(callback);
+
        self
    }

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

+
        self.table.update(state);
+
        self.footer.update(state);
    }

    fn handle_key_event(&mut self, key: Key) {
        match key {
-
            Key::Up | Key::Char('k') => {
-
                self.table.prev();
-
            }
-
            Key::Down | Key::Char('j') => {
-
                self.table.next(self.props.patches.len());
-
            }
-
            Key::PageUp => {
-
                self.table.prev_page(self.props.page_size);
-
            }
-
            Key::PageDown => {
-
                self.table
-
                    .next_page(self.props.patches.len(), self.props.page_size);
-
            }
-
            Key::Home => {
-
                self.table.begin();
-
            }
-
            Key::End => {
-
                self.table.end(self.props.patches.len());
-
            }
            Key::Char('\n') => {
                let operation = match self.props.mode {
                    Mode::Operation => Some(PatchOperation::Show.to_string()),
                    Mode::Id => None,
                };

-
                self.table
-
                    .selected()
+
                self.props
+
                    .selected
                    .and_then(|selected| self.props.patches.get(selected))
                    .and_then(|patch| {
                        self.action_tx
@@ -337,8 +337,8 @@ impl Widget<State, Action> for Patches {
                    });
            }
            Key::Char('c') => {
-
                self.table
-
                    .selected()
+
                self.props
+
                    .selected
                    .and_then(|selected| self.props.patches.get(selected))
                    .and_then(|patch| {
                        self.action_tx
@@ -353,8 +353,8 @@ impl Widget<State, Action> for Patches {
                    });
            }
            Key::Char('d') => {
-
                self.table
-
                    .selected()
+
                self.props
+
                    .selected
                    .and_then(|selected| self.props.patches.get(selected))
                    .and_then(|patch| {
                        self.action_tx
@@ -368,54 +368,16 @@ impl Widget<State, Action> for Patches {
                            .ok()
                    });
            }
-
            _ => {}
+
            _ => {
+
                self.table.handle_key_event(key);
+
            }
        }
    }
}

-
impl Patches {
-
    fn render_header<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect) {
-
        self.header.render::<B>(
-
            frame,
-
            area,
-
            HeaderProps {
-
                cells: [
-
                    String::from(" ● ").into(),
-
                    String::from("ID").into(),
-
                    String::from("Title").into(),
-
                    String::from("Author").into(),
-
                    String::from("").into(),
-
                    String::from("Head").into(),
-
                    String::from("+").into(),
-
                    String::from("- ").into(),
-
                    String::from("Updated").into(),
-
                ],
-
                widths: self.props.widths,
-
                focus: self.props.focus,
-
                cutoff: self.props.cutoff,
-
                cutoff_after: self.props.cutoff_after,
-
            },
-
        );
-
    }
-

-
    fn render_list<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect) {
-
        self.table.render::<B>(
-
            frame,
-
            area,
-
            TableProps {
-
                items: self.props.patches.to_vec(),
-
                has_header: true,
-
                has_footer: !self.props.show_search,
-
                widths: self.props.widths,
-
                focus: self.props.focus,
-
                cutoff: self.props.cutoff,
-
                cutoff_after: self.props.cutoff_after,
-
            },
-
        );
-
    }
-

-
    fn render_footer<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect) {
-
        let filter = PatchItemFilter::from_str(&self.props.search).unwrap_or_default();
+
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();

        let search = Line::from(
            [
@@ -424,14 +386,14 @@ impl Patches {
                    .dim()
                    .reversed(),
                span::default(" ".into()),
-
                span::default(self.props.search.to_string()).gray().dim(),
+
                span::default(props.search.to_string()).gray().dim(),
            ]
            .to_vec(),
        );

        let draft = Line::from(
            [
-
                span::default(self.props.stats.get("Draft").unwrap_or(&0).to_string()).dim(),
+
                span::default(props.stats.get("Draft").unwrap_or(&0).to_string()).dim(),
                span::default(" Draft".to_string()).dim(),
            ]
            .to_vec(),
@@ -439,7 +401,7 @@ impl Patches {

        let open = Line::from(
            [
-
                span::positive(self.props.stats.get("Open").unwrap_or(&0).to_string()).dim(),
+
                span::positive(props.stats.get("Open").unwrap_or(&0).to_string()).dim(),
                span::default(" Open".to_string()).dim(),
            ]
            .to_vec(),
@@ -447,7 +409,7 @@ impl Patches {

        let merged = Line::from(
            [
-
                span::default(self.props.stats.get("Merged").unwrap_or(&0).to_string())
+
                span::default(props.stats.get("Merged").unwrap_or(&0).to_string())
                    .magenta()
                    .dim(),
                span::default(" Merged".to_string()).dim(),
@@ -457,7 +419,7 @@ impl Patches {

        let archived = Line::from(
            [
-
                span::default(self.props.stats.get("Archived").unwrap_or(&0).to_string())
+
                span::default(props.stats.get("Archived").unwrap_or(&0).to_string())
                    .yellow()
                    .dim(),
                span::default(" Archived".to_string()).dim(),
@@ -468,14 +430,20 @@ impl Patches {
        let sum = Line::from(
            [
                span::default("Σ ".to_string()).dim(),
-
                span::default(self.props.patches.len().to_string()).dim(),
+
                span::default(props.patches.len().to_string()).dim(),
            ]
            .to_vec(),
        );

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

        match filter.status() {
@@ -487,77 +455,60 @@ impl Patches {
                    Status::Archived => archived,
                };

-
                self.footer.render::<B>(
-
                    frame,
-
                    area,
-
                    FooterProps {
-
                        cells: [search.into(), block.clone().into(), progress.clone().into()],
-
                        widths: [
-
                            Constraint::Fill(1),
-
                            Constraint::Min(block.width() as u16),
-
                            Constraint::Min(4),
-
                        ],
-
                        focus: self.props.focus,
-
                        cutoff: self.props.cutoff,
-
                        cutoff_after: self.props.cutoff_after,
-
                    },
-
                );
-
            }
-
            None => {
-
                self.footer.render::<B>(
-
                    frame,
-
                    area,
-
                    FooterProps {
-
                        cells: [
-
                            search.into(),
-
                            draft.clone().into(),
-
                            open.clone().into(),
-
                            merged.clone().into(),
-
                            archived.clone().into(),
-
                            sum.clone().into(),
-
                            progress.clone().into(),
-
                        ],
-
                        widths: [
-
                            Constraint::Fill(1),
-
                            Constraint::Min(draft.width() as u16),
-
                            Constraint::Min(open.width() as u16),
-
                            Constraint::Min(merged.width() as u16),
-
                            Constraint::Min(archived.width() as u16),
-
                            Constraint::Min(sum.width() as u16),
-
                            Constraint::Min(4),
-
                        ],
-
                        focus: self.props.focus,
-
                        cutoff: self.props.cutoff,
-
                        cutoff_after: self.props.cutoff_after,
-
                    },
-
                );
+
                [
+
                    Column::new(Text::from(search), Constraint::Fill(1)),
+
                    Column::new(
+
                        Text::from(block.clone()),
+
                        Constraint::Min(block.width() as u16),
+
                    ),
+
                    Column::new(Text::from(progress), Constraint::Min(4)),
+
                ]
+
                .to_vec()
            }
-
        };
+
            None => [
+
                Column::new(Text::from(search), Constraint::Fill(1)),
+
                Column::new(
+
                    Text::from(draft.clone()),
+
                    Constraint::Min(draft.width() as u16),
+
                ),
+
                Column::new(
+
                    Text::from(open.clone()),
+
                    Constraint::Min(open.width() as u16),
+
                ),
+
                Column::new(
+
                    Text::from(merged.clone()),
+
                    Constraint::Min(merged.width() as u16),
+
                ),
+
                Column::new(
+
                    Text::from(archived.clone()),
+
                    Constraint::Min(archived.width() as u16),
+
                ),
+
                Column::new(Text::from(sum.clone()), Constraint::Min(sum.width() as u16)),
+
                Column::new(Text::from(progress), Constraint::Min(4)),
+
            ]
+
            .to_vec(),
+
        }
    }
}

-
impl Render<()> for Patches {
-
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
-
        let page_size = if self.props.show_search {
-
            let layout = Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).split(area);
+
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;

-
            self.render_header::<B>(frame, layout[0]);
-
            self.render_list::<B>(frame, layout[1]);
+
        let page_size = if self.props.show_search {
+
            self.table.render(frame, area, &());

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

-
            self.render_header::<B>(frame, layout[0]);
-
            self.render_list::<B>(frame, layout[1]);
-
            self.render_footer::<B>(frame, layout[2]);
+
            self.table.render(frame, layout[0], &());
+
            self.footer.render(frame, layout[1], &());

-
            layout[1].height as usize
+
            (area.height as usize).saturating_sub(header_height)
        };

        if page_size != self.props.page_size {
@@ -566,36 +517,61 @@ impl Render<()> for Patches {
    }
}

-
pub struct SearchProps {}
-

-
pub struct Search {
-
    pub action_tx: UnboundedSender<Action>,
-
    pub input: TextField,
+
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 Widget<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 mut input = TextField::new(state, action_tx.clone());
-
        input.set_text(&state.search.read().to_string());
-

-
        Self { action_tx, input }.move_with_state(state)
+
        let input = TextField::new(state, action_tx.clone())
+
            .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 mut input = <TextField as Widget<State, Action>>::move_with_state(self.input, state);
-
        input.set_text(&state.search.read().to_string());
+
    fn on_update(mut self, callback: UpdateCallback<State>) -> Self {
+
        self.on_update = Some(callback);
+
        self
+
    }

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

-
    fn name(&self) -> &str {
-
        "filter-popup"
+
    fn update(&mut self, state: &State) {
+
        self.input.update(state);
    }

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

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

-
        self.input.render::<B>(
-
            frame,
-
            layout[0],
-
            TextFieldProps {
-
                titles: ("Search".into(), "Search".into()),
-
                show_cursor: true,
-
                inline_label: true,
-
            },
-
        );
+
        self.input.render(frame, layout[0], &());
    }
}

+
#[derive(Clone)]
pub struct HelpProps<'a> {
    content: Text<'a>,
    focus: bool,
@@ -786,20 +752,24 @@ 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<Action>,
+
    header: BoxedWidget<B>,
    /// Content widget
-
    content: Paragraph<Action>,
+
    content: BoxedWidget<B>,
    /// Container footer
-
    footer: Footer<Action>,
+
    footer: BoxedWidget<B>,
}

-
impl<'a> Widget<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,
@@ -807,33 +777,71 @@ impl<'a> Widget<State, Action> for Help<'a> {
        Self {
            action_tx: action_tx.clone(),
            props: HelpProps::from(state),
-
            header: Header::new(state, action_tx.clone()),
-
            content: Paragraph::new(state, action_tx.clone()),
-
            footer: Footer::new(state, action_tx),
+
            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,
-
    {
-
        Self {
-
            props: HelpProps::from(state),
-
            header: self.header.move_with_state(state),
-
            content: self.content.move_with_state(state),
-
            footer: self.footer.move_with_state(state),
-
            ..self
-
        }
+
    fn on_update(mut self, callback: UpdateCallback<State>) -> Self {
+
        self.on_update = Some(callback);
+
        self
    }

-
    fn name(&self) -> &str {
-
        "help"
+
    fn on_change(mut self, callback: EventCallback<Action>) -> Self {
+
        self.on_change = Some(callback);
+
        self
+
    }
+

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

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

    fn handle_key_event(&mut self, key: termion::event::Key) {
-
        let len = self.props.content.lines.len() + 1;
-
        let page_size = self.props.page_size;
        match key {
            Key::Esc => {
                let _ = self.action_tx.send(Action::Exit { selection: None });
@@ -841,31 +849,15 @@ impl<'a> Widget<State, Action> for Help<'a> {
            Key::Char('?') => {
                let _ = self.action_tx.send(Action::CloseHelp);
            }
-
            Key::Up | Key::Char('k') => {
-
                self.content.prev(len, page_size);
-
            }
-
            Key::Down | Key::Char('j') => {
-
                self.content.next(len, page_size);
-
            }
-
            Key::PageUp => {
-
                self.content.prev_page(len, page_size);
-
            }
-
            Key::PageDown => {
-
                self.content.next_page(len, page_size);
-
            }
-
            Key::Home => {
-
                self.content.begin(len, page_size);
-
            }
-
            Key::End => {
-
                self.content.end(len, page_size);
+
            _ => {
+
                self.content.handle_key_event(key);
            }
-
            _ => {}
        }
    }
}

-
impl<'a> Render<()> for Help<'a> {
-
    fn render<B: Backend>(&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),
@@ -873,42 +865,9 @@ impl<'a> Render<()> for Help<'a> {
        ])
        .areas(area);

-
        self.header.render::<B>(
-
            frame,
-
            header_area,
-
            HeaderProps {
-
                cells: [String::from(" Help ").into()],
-
                widths: [Constraint::Fill(1)],
-
                focus: self.props.focus,
-
                cutoff: usize::MIN,
-
                cutoff_after: usize::MAX,
-
            },
-
        );
-

-
        self.content.render::<B>(
-
            frame,
-
            content_area,
-
            ParagraphProps {
-
                content: self.props.content.clone(),
-
                focus: self.props.focus,
-
                has_footer: true,
-
                has_header: true,
-
            },
-
        );
-

-
        let progress = span::default(format!("{}%", self.content.progress())).dim();
-

-
        self.footer.render::<B>(
-
            frame,
-
            footer_area,
-
            FooterProps {
-
                cells: [String::new().into(), progress.clone().into()],
-
                widths: [Constraint::Fill(1), Constraint::Min(4)],
-
                focus: self.props.focus,
-
                cutoff: usize::MAX,
-
                cutoff_after: usize::MAX,
-
            },
-
        );
+
        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, Widget};
+
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: Widget<S, A> + Render<()>,
+
        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::<Backend>(frame, frame.size(), ()))?;
+
            terminal.draw(|frame| root.render(frame, frame.size(), &()))?;
        };

        terminal::restore(&mut terminal)?;
modified src/ui/items.rs
@@ -25,6 +25,10 @@ use super::theme::style;
use super::widget::ToRow;
use super::{format, span};

+
pub trait Filter<T> {
+
    fn matches(&self, item: &T) -> bool;
+
}
+

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuthorItem {
    pub nid: Option<NodeId>,
@@ -209,8 +213,8 @@ impl NotificationItem {
    }
}

-
impl ToRow<8> for NotificationItem {
-
    fn to_row(&self) -> [Cell; 8] {
+
impl ToRow for NotificationItem {
+
    fn to_row(&self) -> Vec<Cell> {
        let (type_name, summary, status, kind_id) = match &self.kind {
            NotificationKindItem::Branch {
                name,
@@ -254,6 +258,7 @@ impl ToRow<8> for NotificationItem {
        let kind_id = span::primary(kind_id);
        let summary = span::default(summary.to_string());
        let type_name = span::notification_type(type_name);
+
        let name = span::default(self.project.clone()).style(style::gray().dim());

        let status = match status.as_str() {
            "archived" => span::default(status.to_string()).yellow(),
@@ -276,12 +281,12 @@ impl ToRow<8> for NotificationItem {
                None => span::alias("".to_string()),
            },
        };
-

        let timestamp = span::timestamp(format::timestamp(&self.timestamp));

        [
            id.into(),
            seen.into(),
+
            name.into(),
            kind_id.into(),
            summary.into(),
            type_name.into(),
@@ -289,25 +294,7 @@ impl ToRow<8> for NotificationItem {
            author.into(),
            timestamp.into(),
        ]
-
    }
-
}
-

-
impl ToRow<9> for NotificationItem {
-
    fn to_row(&self) -> [Cell; 9] {
-
        let row: [Cell; 8] = self.to_row();
-
        let name = span::default(self.project.clone()).style(style::gray().dim());
-

-
        [
-
            row[0].clone(),
-
            row[1].clone(),
-
            name.into(),
-
            row[2].clone(),
-
            row[3].clone(),
-
            row[4].clone(),
-
            row[5].clone(),
-
            row[6].clone(),
-
            row[7].clone(),
-
        ]
+
        .to_vec()
    }
}

@@ -336,8 +323,10 @@ impl NotificationItemFilter {
    pub fn state(&self) -> Option<NotificationState> {
        self.state.clone()
    }
+
}

-
    pub fn matches(&self, notif: &NotificationItem) -> bool {
+
impl Filter<NotificationItem> for NotificationItemFilter {
+
    fn matches(&self, notif: &NotificationItem) -> bool {
        use fuzzy_matcher::skim::SkimMatcherV2;
        use fuzzy_matcher::FuzzyMatcher;

@@ -499,8 +488,8 @@ impl IssueItem {
    }
}

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

        let state = span::default(state).style(Style::default().fg(state_color));
@@ -543,6 +532,7 @@ impl ToRow<8> for IssueItem {
            assignees.into(),
            opened.into(),
        ]
+
        .to_vec()
    }
}

@@ -560,8 +550,10 @@ impl IssueItemFilter {
    pub fn state(&self) -> Option<issue::State> {
        self.state
    }
+
}

-
    pub fn matches(&self, issue: &IssueItem) -> bool {
+
impl Filter<IssueItem> for IssueItemFilter {
+
    fn matches(&self, issue: &IssueItem) -> bool {
        use fuzzy_matcher::skim::SkimMatcherV2;
        use fuzzy_matcher::FuzzyMatcher;

@@ -748,8 +740,8 @@ impl PatchItem {
    }
}

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

        let state = span::default(state).style(Style::default().fg(color));
@@ -790,6 +782,7 @@ impl ToRow<9> for PatchItem {
            removed.into(),
            updated.into(),
        ]
+
        .to_vec()
    }
}

@@ -805,8 +798,10 @@ impl PatchItemFilter {
    pub fn status(&self) -> Option<patch::Status> {
        self.status
    }
+
}

-
    pub fn matches(&self, patch: &PatchItem) -> bool {
+
impl Filter<PatchItem> for PatchItemFilter {
+
    fn matches(&self, patch: &PatchItem) -> bool {
        use fuzzy_matcher::skim::SkimMatcherV2;
        use fuzzy_matcher::FuzzyMatcher;

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;

@@ -15,88 +16,154 @@ use ratatui::widgets::{Block, BorderType, Borders, Cell, Row, TableState};
use super::theme::style;
use super::{layout, span};

-
pub trait Widget<S, A> {
+
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 name(&self) -> &str;
+
    fn on_update(self, callback: UpdateCallback<S>) -> Self
+
    where
+
        Self: Sized;

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

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

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

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

-
pub struct Shortcut {
-
    pub short: String,
-
    pub long: String,
+
pub trait ToRow {
+
    fn to_row(&self) -> Vec<Cell>;
}

-
impl Shortcut {
-
    pub fn new(short: &str, long: &str) -> Self {
+
#[derive(Clone)]
+
pub struct ShortcutsProps {
+
    pub shortcuts: Vec<(String, String)>,
+
    pub divider: char,
+
}
+

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

-
pub struct ShortcutsProps {
-
    pub shortcuts: Vec<Shortcut>,
-
    pub divider: char,
+
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<A> {
-
    pub action_tx: UnboundedSender<A>,
+
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<S, A> Widget<S, A> for Shortcuts<A> {
-
    fn new(state: &S, action_tx: UnboundedSender<A>) -> Self
-
    where
-
        Self: Sized,
-
    {
+
impl<S, A> Shortcuts<S, A> {
+
    pub fn divider(mut self, divider: char) -> Self {
+
        self.props.divider = divider;
+
        self
+
    }
+

+
    pub fn shortcuts(mut self, shortcuts: &[(&str, &str)]) -> Self {
+
        self.props.shortcuts.clear();
+
        for (short, long) in shortcuts {
+
            self.props
+
                .shortcuts
+
                .push((short.to_string(), long.to_string()));
+
        }
+
        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
-
    where
-
        Self: Sized,
-
    {
-
        Self { ..self }
+
    fn on_change(mut self, callback: EventCallback<A>) -> Self {
+
        self.on_change = Some(callback);
+
        self
    }

-
    fn name(&self) -> &str {
-
        "shortcuts"
+
    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> Render<ShortcutsProps> for Shortcuts<A> {
-
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, props: ShortcutsProps) {
+
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 = props.shortcuts.iter().peekable();
+
        let mut shortcuts = self.props.shortcuts.iter().peekable();
        let mut row = vec![];

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

-
            row.push((shortcut.short.chars().count(), short));
+
            row.push((shortcut.0.chars().count(), short));
            row.push((1, spacer));
-
            row.push((shortcut.long.chars().count(), long));
+
            row.push((shortcut.1.chars().count(), long));

            if shortcuts.peek().is_some() {
                row.push((3, divider));
@@ -120,41 +187,137 @@ impl<A> Render<ShortcutsProps> for Shortcuts<A> {
    }
}

-
pub trait ToRow<const W: usize> {
-
    fn to_row(&self) -> [Cell; W];
+
#[derive(Clone, Debug)]
+
pub struct Column<'a> {
+
    pub text: Text<'a>,
+
    pub width: Constraint,
+
    pub skip: bool,
+
}
+

+
impl<'a> Column<'a> {
+
    pub fn new(text: impl Into<Text<'a>>, width: Constraint) -> Self {
+
        Self {
+
            text: text.into(),
+
            width,
+
            skip: false,
+
        }
+
    }
+

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

-
#[derive(Debug)]
-
pub struct TableProps<R: ToRow<W>, const W: usize> {
+
#[derive(Clone, Debug)]
+
pub struct TableProps<'a, R>
+
where
+
    R: ToRow,
+
{
    pub items: Vec<R>,
+
    pub selected: Option<usize>,
    pub focus: bool,
-
    pub widths: [Constraint; W],
-
    pub has_header: bool,
+
    pub columns: Vec<Column<'a>>,
    pub has_footer: bool,
    pub cutoff: usize,
    pub cutoff_after: usize,
+
    pub page_size: usize,
+
}
+

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

+
impl<'a, R> TableProps<'a, R>
+
where
+
    R: ToRow,
+
{
+
    pub fn items(mut self, items: Vec<R>) -> Self {
+
        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.columns = columns;
+
        self
+
    }
+

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

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

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

-
pub struct Table<A> {
-
    /// Sending actions to the state store
-
    pub action_tx: UnboundedSender<A>,
-
    /// Internal selection state
+
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> Table<A> {
-
    pub fn selected(&self) -> Option<usize> {
-
        self.state.selected()
+
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
    }

-
    pub fn prev(&mut self) -> Option<usize> {
-
        let selected = self.selected().map(|current| current.saturating_sub(1));
+
    fn prev(&mut self) -> Option<usize> {
+
        let selected = self
+
            .state
+
            .selected()
+
            .map(|current| current.saturating_sub(1));
        self.state.select(selected);
        selected
    }

-
    pub fn next(&mut self, len: usize) -> Option<usize> {
-
        let selected = self.selected().map(|current| {
+
    fn next(&mut self, len: usize) -> Option<usize> {
+
        let selected = self.state.selected().map(|current| {
            if current < len.saturating_sub(1) {
                current.saturating_add(1)
            } else {
@@ -165,16 +328,17 @@ impl<A> Table<A> {
        selected
    }

-
    pub fn prev_page(&mut self, page_size: usize) -> Option<usize> {
+
    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);
        selected
    }

-
    pub fn next_page(&mut self, len: usize, page_size: usize) -> Option<usize> {
-
        let selected = self.selected().map(|current| {
+
    fn next_page(&mut self, len: usize, page_size: usize) -> Option<usize> {
+
        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 {
@@ -185,27 +349,16 @@ impl<A> Table<A> {
        selected
    }

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

-
    pub 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 progress(&self, len: usize) -> (usize, usize) {
-
        let step = self
-
            .selected()
-
            .map(|selected| selected.saturating_add(1))
-
            .unwrap_or_default();
-

-
        (cmp::min(step, len), len)
    }

-
    pub fn progress_percentage(&self, len: usize, page_size: usize) -> usize {
-
        let step = self.selected().unwrap_or_default();
+
    pub fn progress(selected: usize, len: usize, page_size: usize) -> usize {
+
        let step = selected;
        let page_size = page_size as f64;
        let len = len as f64;

@@ -224,56 +377,131 @@ impl<A> Table<A> {
    }
}

-
impl<S, A> Widget<S, A> for Table<A> {
-
    fn new(state: &S, action_tx: UnboundedSender<A>) -> Self
-
    where
-
        Self: Sized,
-
    {
+
impl<'a: 'static, S, A, B, R> View<S, A> for Table<'a, S, A, B, R>
+
where
+
    B: Backend,
+
    R: ToRow + Clone + 'static,
+
{
+
    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
-
    where
-
        Self: Sized,
-
    {
-
        Self { ..self }
+
    fn on_update(mut self, callback: UpdateCallback<S>) -> Self {
+
        self.on_update = Some(callback);
+
        self
    }

-
    fn name(&self) -> &str {
-
        "shortcuts"
+
    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();
+
            }
+
        }
+

+
        // 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) {}
+
    fn handle_key_event(&mut self, key: Key) {
+
        match key {
+
            Key::Up | Key::Char('k') => {
+
                self.prev();
+
            }
+
            Key::Down | Key::Char('j') => {
+
                self.next(self.props.items.len());
+
            }
+
            Key::PageUp => {
+
                self.prev_page(self.props.page_size);
+
            }
+
            Key::PageDown => {
+
                self.next_page(self.props.items.len(), self.props.page_size);
+
            }
+
            Key::Home => {
+
                self.begin();
+
            }
+
            Key::End => {
+
                self.end(self.props.items.len());
+
            }
+
            _ => {}
+
        }
+

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

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

-
impl<A, R, const W: usize> Render<TableProps<R, W>> for Table<A>
+
impl<'a: 'static, S, A, B, R> Widget<S, A, B> for Table<'a, S, A, B, R>
where
-
    R: ToRow<W> + Debug,
+
    B: Backend,
+
    R: ToRow + Clone + Debug + 'static,
{
-
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, props: TableProps<R, W>) {
-
        let widths = props.widths.to_vec();
-
        let widths = if area.width < props.cutoff as u16 {
-
            widths.iter().take(props.cutoff_after).collect::<Vec<_>>()
+
    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);
+
        let widths: Vec<Constraint> = self
+
            .props
+
            .columns
+
            .iter()
+
            .filter_map(|c| if !c.skip { Some(c.width) } else { None })
+
            .collect();
+

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

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

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

+
                    for cell in item.to_row() {
+
                        if let Some(col) = it.next() {
+
                            if !col.skip {
+
                                cells.push(cell.clone());
+
                            }
+
                        } else {
+
                            continue;
+
                        }
+
                    }
+

+
                    Row::new(cells)
+
                })
                .collect::<Vec<_>>();
            let rows = ratatui::widgets::Table::default()
                .rows(rows)
@@ -281,22 +509,29 @@ where
                .column_spacing(1)
                .block(
                    Block::default()
-
                        .border_style(style::border(props.focus))
+
                        .border_style(style::border(self.props.focus))
                        .border_type(BorderType::Rounded)
                        .borders(borders),
                )
                .highlight_style(style::highlight());

-
            frame.render_stateful_widget(rows, area, &mut self.state.clone());
+
            if let Some(header) = &self.header {
+
                header.render(frame, header_area, &());
+
            }
+

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

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

-
            let center = layout::centered_rect(area, 50, 10);
+
            let center = layout::centered_rect(table_area, 50, 10);
            let hint = Text::from(span::default("Nothing to show".to_string()))
                .centered()
                .light_magenta()
modified src/ui/widget/container.rs
@@ -1,3 +1,4 @@
+
use std::any::Any;
use std::fmt::Debug;

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

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

-
#[derive(Debug)]
-
pub struct FooterProps<'a, const W: usize> {
-
    pub cells: [Text<'a>; W],
-
    pub widths: [Constraint; W],
+
#[derive(Clone, Debug)]
+
pub struct HeaderProps<'a> {
+
    pub columns: Vec<Column<'a>>,
    pub cutoff: usize,
    pub cutoff_after: usize,
    pub focus: bool,
}

-
pub struct Footer<A> {
-
    /// Sending actions to the state store
-
    pub action_tx: UnboundedSender<A>,
+
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 {
+
            columns: vec![],
+
            cutoff: usize::MAX,
+
            cutoff_after: usize::MAX,
+
            focus: false,
+
        }
+
    }
+
}
+

+
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, S, A> Header<'a, S, A> {
+
    pub fn columns(mut self, columns: Vec<Column<'a>>) -> Self {
+
        self.props.columns = columns;
+
        self
+
    }
+

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

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

-
impl<S, A> Widget<S, A> for Footer<A> {
-
    fn new(state: &S, action_tx: UnboundedSender<A>) -> Self
-
    where
-
        Self: Sized,
-
    {
+
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: &S) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        Self { ..self }
+
    fn on_update(mut self, callback: UpdateCallback<S>) -> Self {
+
        self.on_update = Some(callback);
+
        self
    }

-
    fn name(&self) -> &str {
-
        "footer"
+
    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::<HeaderProps<'_>>() {
+
                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: '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
+
            .iter()
+
            .filter_map(|column| {
+
                if !column.skip {
+
                    Some(column.width)
+
                } else {
+
                    None
+
                }
+
            })
+
            .collect();
+
        let cells = self
+
            .props
+
            .columns
+
            .iter()
+
            .filter_map(|column| {
+
                if !column.skip {
+
                    Some(column.text.clone())
+
                } else {
+
                    None
+
                }
+
            })
+
            .collect::<Vec<_>>();
+

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

+
        // Render header
+
        let block = HeaderBlock::default()
+
            .borders(Borders::ALL)
+
            .border_style(style::border(self.props.focus))
+
            .border_type(BorderType::Rounded);
+

+
        let header_layout = Layout::default()
+
            .direction(Direction::Vertical)
+
            .constraints(vec![Constraint::Min(1)])
+
            .vertical_margin(1)
+
            .horizontal_margin(1)
+
            .split(area);
+

+
        let header = Row::new(cells).style(style::reset().bold());
+
        let header = ratatui::widgets::Table::default()
+
            .column_spacing(1)
+
            .header(header)
+
            .widths(widths.clone());
+

+
        frame.render_widget(block, area);
+
        frame.render_widget(header, header_layout[0]);
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct FooterProps<'a> {
+
    pub columns: Vec<Column<'a>>,
+
    pub cutoff: usize,
+
    pub cutoff_after: usize,
+
    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 {
+
            columns: vec![],
+
            cutoff: usize::MAX,
+
            cutoff_after: usize::MAX,
+
            focus: false,
+
        }
+
    }
}

-
impl<A> Footer<A> {
-
    fn render_cell<'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, S, A> Footer<'a, S, A> {
+
    pub fn columns(mut self, columns: Vec<Column<'a>>) -> Self {
+
        self.props.columns = columns;
+
        self
+
    }
+

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

+
    pub fn focus(mut self, focus: bool) -> Self {
+
        self.props.focus = focus;
+
        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,
+
        }
+
    }
+

+
    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::<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, S, A> Footer<'a, S, A> {
+
    fn render_cell(
        &self,
        frame: &mut ratatui::Frame,
        area: Rect,
@@ -75,19 +299,26 @@ impl<A> Footer<A> {
    }
}

-
impl<'a, A, const W: usize> Render<FooterProps<'a, W>> for Footer<A> {
-
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, props: FooterProps<W>) {
-
        let widths = props
-
            .widths
-
            .into_iter()
-
            .map(|c| match c {
+
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
+
            .iter()
+
            .map(|c| match c.width {
                Constraint::Min(min) => Constraint::Length(min.saturating_add(3)),
-
                _ => c,
+
                _ => c.width,
            })
            .collect::<Vec<_>>();

        let layout = Layout::horizontal(widths).split(area);
-
        let cells = props.cells.iter().zip(layout.iter()).collect::<Vec<_>>();
+
        let cells = self
+
            .props
+
            .columns
+
            .iter()
+
            .map(|c| c.text.clone())
+
            .zip(layout.iter())
+
            .collect::<Vec<_>>();

        let last = cells.len().saturating_sub(1);
        let len = cells.len();
@@ -99,79 +330,7 @@ impl<'a, A, const W: usize> Render<FooterProps<'a, W>> for Footer<A> {
                _ if i == last => FooterBlockType::End,
                _ => FooterBlockType::Repeat,
            };
-
            self.render_cell(frame, *area, block_type, cell.clone(), props.focus);
-
        }
-
    }
-
}
-

-
#[derive(Debug)]
-
pub struct HeaderProps<'a, const W: usize> {
-
    pub cells: [Text<'a>; W],
-
    pub widths: [Constraint; W],
-
    pub cutoff: usize,
-
    pub cutoff_after: usize,
-
    pub focus: bool,
-
}
-

-
pub struct Header<A> {
-
    /// Sending actions to the state store
-
    pub action_tx: UnboundedSender<A>,
-
}
-

-
impl<S, A> Widget<S, A> for Header<A> {
-
    fn new(state: &S, action_tx: UnboundedSender<A>) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        Self {
-
            action_tx: action_tx.clone(),
+
            self.render_cell(frame, *area, block_type, cell.clone(), self.props.focus);
        }
-
        .move_with_state(state)
-
    }
-

-
    fn move_with_state(self, _state: &S) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        Self { ..self }
-
    }
-

-
    fn name(&self) -> &str {
-
        "footer"
-
    }
-

-
    fn handle_key_event(&mut self, _key: Key) {}
-
}
-

-
impl<'a, A, const W: usize> Render<HeaderProps<'a, W>> for Header<A> {
-
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, props: HeaderProps<W>) {
-
        let widths = props.widths.to_vec();
-
        let widths = if area.width < props.cutoff as u16 {
-
            widths.iter().take(props.cutoff_after).collect::<Vec<_>>()
-
        } else {
-
            widths.iter().collect::<Vec<_>>()
-
        };
-

-
        // Render header
-
        let block = HeaderBlock::default()
-
            .borders(Borders::ALL)
-
            .border_style(style::border(props.focus))
-
            .border_type(BorderType::Rounded);
-

-
        let header_layout = Layout::default()
-
            .direction(Direction::Vertical)
-
            .constraints(vec![Constraint::Min(1)])
-
            .vertical_margin(1)
-
            .horizontal_margin(1)
-
            .split(area);
-

-
        let header = Row::new(props.cells).style(style::reset().bold());
-
        let header = ratatui::widgets::Table::default()
-
            .column_spacing(1)
-
            .header(header)
-
            .widths(widths.clone());
-

-
        frame.render_widget(block, area);
-
        frame.render_widget(header, header_layout[0]);
    }
}
modified src/ui/widget/input.rs
@@ -1,3 +1,5 @@
+
use std::any::Any;
+

use termion::event::Key;

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

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

-
pub struct TextField {
-
    /// Current value of the input box
-
    text: String,
-
    /// Position of cursor in the editor area.
-
    cursor_position: usize,
+
#[derive(Clone)]
+
pub struct TextFieldProps {
+
    pub title: String,
+
    pub inline_label: bool,
+
    pub show_cursor: bool,
+
    pub text: String,
+
    pub cursor_position: usize,
}

-
impl TextField {
-
    pub fn text(&self) -> &str {
-
        &self.text
-
    }
-

-
    pub fn set_text(&mut self, new_text: &str) {
+
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
    }
+
}

-
    pub fn reset(&mut self) {
-
        self.cursor_position = 0;
-
        self.text.clear();
+
impl Default for TextFieldProps {
+
    fn default() -> Self {
+
        Self {
+
            title: String::new(),
+
            inline_label: false,
+
            show_cursor: true,
+
            text: String::new(),
+
            cursor_position: 0,
+
        }
    }
+
}
+

+
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>>,
+
}

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

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

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

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

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

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

    fn enter_char(&mut self, new_char: char) {
-
        self.text.insert(self.cursor_position, new_char);
-

+
        self.props.text.insert(self.props.cursor_position, new_char);
        self.move_cursor_right();
    }

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

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

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

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

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

-
impl<S, A> Widget<S, A> for TextField {
-
    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 {
-
            //
-
            text: String::new(),
-
            cursor_position: 0,
+
            action_tx,
+
            props: TextFieldProps::default(),
+
            on_update: None,
+
            on_change: None,
        }
    }

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

-
    fn name(&self) -> &str {
-
        "Input Box"
+
    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) {
@@ -120,25 +176,23 @@ impl<S, A> Widget<S, A> for TextField {
            }
            _ => {}
        }
-
    }
-
}

-
pub struct TextFieldProps {
-
    pub titles: (String, String),
-
    pub inline_label: bool,
-
    pub show_cursor: bool,
+
        if let Some(on_change) = self.on_change {
+
            (on_change)(&self.props, self.action_tx.clone());
+
        }
+
    }
}

-
impl Render<TextFieldProps> for TextField {
-
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, props: TextFieldProps) {
+
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.text.as_str();
-
        let label = format!(" {} ", props.titles.0);
+
        let input = self.props.text.as_str();
+
        let label = format!(" {} ", self.props.title);
        let overline = String::from("▔").repeat(area.width as usize);
-
        let cursor_pos = self.cursor_position as u16;
+
        let cursor_pos = self.props.cursor_position as u16;

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

-
            if props.show_cursor {
+
            if self.props.show_cursor {
                frame.set_cursor(top_layout[2].x + cursor_pos, top_layout[2].y)
            }
        } else {
@@ -171,7 +225,7 @@ impl Render<TextFieldProps> for TextField {
            frame.render_widget(top, layout[0]);
            frame.render_widget(bottom, layout[1]);

-
            if props.show_cursor {
+
            if self.props.show_cursor {
                frame.set_cursor(area.x + cursor_pos, area.y)
            }
        }
modified src/ui/widget/text.rs
@@ -1,3 +1,5 @@
+
use std::any::Any;
+

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

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

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

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

+
#[derive(Clone)]
pub struct ParagraphProps<'a> {
    pub content: Text<'a>,
    pub focus: bool,
    pub has_header: bool,
    pub has_footer: bool,
+
    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
+
    }
}

-
pub struct Paragraph<A> {
-
    /// Sending actions to the state store
-
    pub action_tx: UnboundedSender<A>,
+
impl<'a> Default for ParagraphProps<'a> {
+
    fn default() -> Self {
+
        Self {
+
            content: Text::raw(""),
+
            focus: false,
+
            has_header: false,
+
            has_footer: false,
+
            page_size: 1,
+
        }
+
    }
+
}
+

+
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,
}

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

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

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

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

-
    pub fn next(&mut self, len: usize, page_size: usize) -> (u16, u16) {
+
    fn next(&mut self, len: usize, page_size: usize) -> (u16, u16) {
        if self.progress < 100 {
            self.offset = self.offset.saturating_add(1);
            self.progress = Self::scroll_percent(self.offset, len, page_size);
@@ -47,13 +96,13 @@ impl<A> Paragraph<A> {
        self.scroll()
    }

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

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

        self.offset = std::cmp::min(self.offset.saturating_add(page_size), end);
@@ -61,13 +110,13 @@ impl<A> Paragraph<A> {
        self.scroll()
    }

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

-
    pub fn end(&mut self, len: usize, page_size: usize) -> (u16, u16) {
+
    fn end(&mut self, len: usize, page_size: usize) -> (u16, u16) {
        self.offset = len.saturating_sub(page_size);
        self.progress = Self::scroll_percent(self.offset, len, page_size);
        self.scroll()
@@ -91,8 +140,8 @@ impl<A> Paragraph<A> {
    }
}

-
impl<S, A> Widget<S, A> for Paragraph<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,
    {
@@ -100,37 +149,75 @@ impl<S, A> Widget<S, A> for Paragraph<A> {
            action_tx: action_tx.clone(),
            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 name(&self) -> &str {
-
        "paragraph"
+
    fn on_update(mut self, callback: UpdateCallback<S>) -> Self {
+
        self.on_update = 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::<ParagraphProps<'_>>() {
+
                self.props = props.clone();
+
            }
+
        }
+
    }
+

+
    fn handle_key_event(&mut self, key: Key) {
+
        let len = self.props.content.lines.len() + 1;
+
        let page_size = self.props.page_size;
+

+
        match key {
+
            Key::Up | Key::Char('k') => {
+
                self.prev(len, page_size);
+
            }
+
            Key::Down | Key::Char('j') => {
+
                self.next(len, page_size);
+
            }
+
            Key::PageUp => {
+
                self.prev_page(len, page_size);
+
            }
+
            Key::PageDown => {
+
                self.next_page(len, page_size);
+
            }
+
            Key::Home => {
+
                self.begin(len, page_size);
+
            }
+
            Key::End => {
+
                self.end(len, page_size);
+
            }
+
            _ => {}
+
        }
+

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

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

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

        frame.render_widget(content, content_area);
    }