Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
lib v3
Merged did:key:z6MkswQE...2C1V opened 1 year ago
19 files changed +2027 -2504 56116f38 25752aaf
modified Cargo.toml
@@ -1,6 +1,7 @@
[package]
name = "radicle-tui"
description = "Radicle terminal user interfaces"
+
keywords = ["radicle", "tui", "heartwood", "cli"]
repository = "https://app.radicle.xyz/nodes/seed.radicle.xyz/rad:z39mP9rQAaGmERfUMPULfPUi473tY"
homepage = "https://radicle.xyz"
license = "MIT OR Apache-2.0"
@@ -9,9 +10,14 @@ authors = ["Erik Kundt <erik@zirkular.io>"]
edition = "2021"
build = "build.rs"

+
[features]
+
default = ["bin"]
+
bin = []
+

[[bin]]
name = "rad-tui"
path = "bin/main.rs"
+
required-features = ["bin"]

[dependencies]
anyhow = { version = "1" }
modified bin/commands/inbox/select.rs
@@ -5,29 +5,38 @@ use std::str::FromStr;

use anyhow::Result;

+
use termion::event::Key;
+

+
use ratatui::layout::Constraint;
+
use ratatui::style::Stylize;
+
use ratatui::text::Line;
+
use ratatui::text::Span;
+
use ratatui::text::Text;
+

use radicle::identity::Project;
use radicle::node::notifications::NotificationId;
+
use radicle::storage::git::Repository;
use radicle::storage::ReadRepository;
use radicle::storage::ReadStorage;
-

-
use radicle::storage::git::Repository;
use radicle::Profile;
+

use radicle_tui as tui;

use tui::cob::inbox;
use tui::store;
use tui::store::StateValue;
use tui::ui::items::{Filter, NotificationItem, NotificationItemFilter};
-
use tui::ui::widget::window::{Window, WindowProps};
-
use tui::ui::widget::{Properties, Widget};
-
use tui::Channel;
-
use tui::Exit;
-

-
use tui::PageStack;
+
use tui::ui::span;
+
use tui::ui::widget::container::{Column, Container, Footer, FooterProps, Header, HeaderProps};
+
use tui::ui::widget::text::{TextArea, TextAreaProps};
+
use tui::ui::widget::window::{Page, PageProps, Shortcuts, ShortcutsProps, Window, WindowProps};
+
use tui::ui::widget::{ToWidget, Widget};
+
use tui::{BoxedAny, Channel, Exit, PageStack};

-
use self::ui::BrowserPage;
-
use self::ui::HelpPage;
+
use self::ui::Browser;
+
use self::ui::BrowserProps;

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

type Selection = tui::Selection<NotificationId>;
@@ -45,7 +54,7 @@ pub struct App {
}

#[derive(Clone, Debug, Eq, PartialEq, Hash)]
-
pub enum Page {
+
pub enum AppPage {
    Browse,
    Help,
}
@@ -80,7 +89,7 @@ pub struct HelpState {
pub struct State {
    mode: Mode,
    project: Project,
-
    pages: PageStack<Page>,
+
    pages: PageStack<AppPage>,
    browser: BrowserState,
    help: HelpState,
}
@@ -178,7 +187,7 @@ impl TryFrom<&Context> for State {
        Ok(Self {
            mode: context.mode.clone(),
            project,
-
            pages: PageStack::new(vec![Page::Browse]),
+
            pages: PageStack::new(vec![AppPage::Browse]),
            browser: BrowserState {
                items: notifications,
                selected: Some(0),
@@ -212,7 +221,7 @@ pub enum Message {
impl store::State<Selection> for State {
    type Message = Message;

-
    fn tick(&self) {}
+
    fn tick(&mut self) {}

    fn update(&mut self, message: Message) -> Option<Exit<Selection>> {
        match message {
@@ -260,7 +269,7 @@ impl store::State<Selection> for State {
                None
            }
            Message::OpenHelp => {
-
                self.pages.push(Page::Help);
+
                self.pages.push(AppPage::Help);
                None
            }
            Message::LeavePage => {
@@ -283,21 +292,264 @@ impl App {
    pub async fn run(&self) -> Result<Option<Selection>> {
        let channel = Channel::default();
        let state = State::try_from(&self.context)?;
-
        let window: Window<State, Message, Page> = Window::new(&state, channel.tx.clone())
-
            .page(
-
                Page::Browse,
-
                BrowserPage::new(&state, channel.tx.clone()).to_boxed(),
-
            )
-
            .page(
-
                Page::Help,
-
                HelpPage::new(&state, channel.tx.clone()).to_boxed(),
-
            )
-
            .on_update(|state| {
+
        let tx = channel.tx.clone();
+

+
        let window = Window::default()
+
            .page(AppPage::Browse, browser_page(&state, &channel))
+
            .page(AppPage::Help, help_page(&state, &channel))
+
            .to_widget(tx.clone())
+
            .on_update(|state: &State| {
                WindowProps::default()
-
                    .current_page(state.pages.peek().unwrap_or(&Page::Browse).clone())
-
                    .to_boxed()
+
                    .current_page(state.pages.peek().unwrap_or(&AppPage::Browse).clone())
+
                    .to_boxed_any()
+
                    .into()
            });

-
        tui::run(channel, state, window.to_boxed()).await
+
        tui::run(channel, state, window).await
    }
}
+

+
fn browser_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Message> {
+
    let tx = channel.tx.clone();
+

+
    let content = Browser::new(tx.clone())
+
        .to_widget(tx.clone())
+
        .on_update(|state| BrowserProps::from(state).to_boxed_any().into());
+

+
    let shortcuts = Shortcuts::default()
+
        .to_widget(tx.clone())
+
        .on_update(|state: &State| {
+
            let shortcuts = if state.browser.show_search {
+
                vec![("esc", "cancel"), ("enter", "apply")]
+
            } else {
+
                match state.mode.selection() {
+
                    SelectionMode::Id => vec![("enter", "select"), ("/", "search")],
+
                    SelectionMode::Operation => vec![
+
                        ("enter", "show"),
+
                        ("c", "clear"),
+
                        ("/", "search"),
+
                        ("?", "help"),
+
                    ],
+
                }
+
            };
+

+
            ShortcutsProps::default()
+
                .shortcuts(&shortcuts)
+
                .to_boxed_any()
+
                .into()
+
        });
+

+
    Page::default()
+
        .content(content)
+
        .shortcuts(shortcuts)
+
        .to_widget(tx.clone())
+
        .on_event(|key, _, props| {
+
            let default = PageProps::default();
+
            let props = props
+
                .and_then(|props| props.inner_ref::<PageProps>())
+
                .unwrap_or(&default);
+

+
            if props.handle_keys {
+
                match key {
+
                    Key::Esc | Key::Ctrl('c') => Some(Message::Exit { selection: None }),
+
                    Key::Char('?') => Some(Message::OpenHelp),
+
                    _ => None,
+
                }
+
            } else {
+
                None
+
            }
+
        })
+
        .on_update(|state: &State| {
+
            PageProps::default()
+
                .page_size(state.browser.page_size)
+
                .handle_keys(!state.browser.show_search)
+
                .to_boxed_any()
+
                .into()
+
        })
+
        .on_render(|props, render| {
+
            let default = PageProps::default();
+
            let props = props
+
                .and_then(|props| props.inner_ref::<PageProps>())
+
                .unwrap_or(&default);
+
            let page_size = render.area.height.saturating_sub(6) as usize;
+

+
            if page_size != props.page_size {
+
                return Some(Message::BrowserPageSize(page_size));
+
            }
+
            None
+
        })
+
}
+

+
fn help_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Message> {
+
    let tx = channel.tx.clone();
+

+
    let content = Container::default()
+
        .header(Header::default().to_widget(tx.clone()).on_update(|_| {
+
            HeaderProps::default()
+
                .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec())
+
                .to_boxed_any()
+
                .into()
+
        }))
+
        .content(
+
            TextArea::default()
+
                .to_widget(tx.clone())
+
                .on_event(|_, s, _| {
+
                    Some(Message::ScrollHelp {
+
                        progress: s.and_then(|p| p.unwrap_usize()).unwrap_or_default(),
+
                    })
+
                })
+
                .on_update(|state: &State| {
+
                    TextAreaProps::default()
+
                        .text(&help_text())
+
                        .page_size(state.help.page_size)
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
        )
+
        .footer(
+
            Footer::default()
+
                .to_widget(tx.clone())
+
                .on_update(|state: &State| {
+
                    FooterProps::default()
+
                        .columns(
+
                            [
+
                                Column::new(Text::raw(""), Constraint::Fill(1)),
+
                                Column::new(
+
                                    span::default(&format!("{}%", state.help.progress)).dim(),
+
                                    Constraint::Min(4),
+
                                ),
+
                            ]
+
                            .to_vec(),
+
                        )
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
        )
+
        .to_widget(tx.clone());
+

+
    let shortcuts = Shortcuts::default().to_widget(tx.clone()).on_update(|_| {
+
        ShortcutsProps::default()
+
            .shortcuts(&[("?", "close")])
+
            .to_boxed_any()
+
            .into()
+
    });
+

+
    Page::default()
+
        .content(content)
+
        .shortcuts(shortcuts)
+
        .to_widget(tx.clone())
+
        .on_event(|key, _, _| match key {
+
            Key::Esc | Key::Ctrl('c') => Some(Message::Exit { selection: None }),
+
            Key::Char('?') => Some(Message::LeavePage),
+
            _ => None,
+
        })
+
        .on_update(|state: &State| {
+
            PageProps::default()
+
                .page_size(state.help.page_size)
+
                .handle_keys(true)
+
                .to_boxed_any()
+
                .into()
+
        })
+
        .on_render(|props, render| {
+
            let default = PageProps::default();
+
            let props = props
+
                .and_then(|props| props.inner_ref::<PageProps>())
+
                .unwrap_or(&default);
+
            let page_size = render.area.height.saturating_sub(6) as usize;
+

+
            if page_size != props.page_size {
+
                return Some(Message::HelpPageSize(page_size));
+
            }
+
            None
+
        })
+
}
+

+
fn help_text() -> Text<'static> {
+
    Text::from(
+
        [
+
            Line::from(Span::raw("Generic keybindings").cyan()),
+
            Line::raw(""),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "↑,k")).gray(),
+
                Span::raw(" "),
+
                Span::raw("move cursor one line up").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "↓,j")).gray(),
+
                Span::raw(" "),
+
                Span::raw("move cursor one line down").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "PageUp")).gray(),
+
                Span::raw(" "),
+
                Span::raw("move cursor one page up").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "PageDown")).gray(),
+
                Span::raw(" "),
+
                Span::raw("move cursor one page down").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "Home")).gray(),
+
                Span::raw(" "),
+
                Span::raw("move cursor to the first line").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "End")).gray(),
+
                Span::raw(" "),
+
                Span::raw("move cursor to the last line").gray().dim(),
+
            ]),
+
            Line::raw(""),
+
            Line::from(Span::raw("Specific keybindings").cyan()),
+
            Line::raw(""),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "enter")).gray(),
+
                Span::raw(" "),
+
                Span::raw("Select notification (if --mode id)").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "enter")).gray(),
+
                Span::raw(" "),
+
                Span::raw("Show notification").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "c")).gray(),
+
                Span::raw(" "),
+
                Span::raw("Clear notifications").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "/")).gray(),
+
                Span::raw(" "),
+
                Span::raw("Search").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "?")).gray(),
+
                Span::raw(" "),
+
                Span::raw("Show help").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "Esc")).gray(),
+
                Span::raw(" "),
+
                Span::raw("Quit / cancel").gray().dim(),
+
            ]),
+
            Line::raw(""),
+
            Line::from(Span::raw("Searching").cyan()),
+
            Line::raw(""),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "Pattern")).gray(),
+
                Span::raw(" "),
+
                Span::raw("is:<state> | is:patch | is:issue | <search>")
+
                    .gray()
+
                    .dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "Example")).gray(),
+
                Span::raw(" "),
+
                Span::raw("is:unseen is:patch Print").gray().dim(),
+
            ]),
+
            Line::raw(""),
+
            Line::raw(""),
+
        ]
+
        .to_vec(),
+
    )
+
}
modified bin/commands/inbox/select/ui.rs
@@ -1,38 +1,36 @@
use std::collections::HashMap;
use std::str::FromStr;

+
use ratatui::Frame;
use tokio::sync::mpsc::UnboundedSender;

use termion::event::Key;

use ratatui::layout::{Constraint, Layout};
use ratatui::style::Stylize;
-
use ratatui::text::{Line, Span, Text};
+
use ratatui::text::{Line, Text};

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::{
-
    Column, Container, ContainerProps, Footer, FooterProps, Header, HeaderProps, SectionGroup,
-
    SectionGroupProps,
+
    Column, Container, ContainerProps, Footer, FooterProps, Header, HeaderProps,
};
use tui::ui::widget::input::{TextField, TextFieldProps};
use tui::ui::widget::list::{Table, TableProps, TableUtils};
-
use tui::ui::widget::text::{Paragraph, ParagraphProps};
-
use tui::ui::widget::window::{Shortcuts, ShortcutsProps};
-
use tui::ui::widget::{BoxedAny, Properties, RenderProps, Widget, WidgetBase};
+
use tui::ui::widget::{self, ViewProps};
+
use tui::ui::widget::{RenderProps, ToWidget, View};

-
use tui::Selection;
+
use tui::{BoxedAny, Selection};

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

use super::{Message, State};

-
type BoxedWidget = widget::BoxedWidget<State, Message>;
+
type Widget = widget::Widget<State, Message>;

-
#[derive(Clone)]
+
#[derive(Clone, Default)]
pub struct BrowserProps<'a> {
    /// Application mode: openation and id or id only.
    mode: Mode,
@@ -108,33 +106,21 @@ impl<'a> From<&State> for BrowserProps<'a> {
    }
}

-
impl<'a> Properties for BrowserProps<'a> {}
-
impl<'a> BoxedAny for BrowserProps<'a> {}
-

-
pub struct Browser<'a> {
-
    /// Internal base
-
    base: WidgetBase<State, Message>,
-
    /// Internal props
-
    props: BrowserProps<'a>,
+
pub struct Browser {
    /// Notification widget
-
    notifications: BoxedWidget,
+
    notifications: Widget,
    /// Search widget
-
    search: BoxedWidget,
+
    search: Widget,
}

-
impl<'a: 'static> Widget for Browser<'a> {
-
    type Message = Message;
-
    type State = State;
-

-
    fn new(state: &State, tx: UnboundedSender<Message>) -> Self {
-
        let props = BrowserProps::from(state);
-

+
impl Browser {
+
    pub fn new(tx: UnboundedSender<Message>) -> Self {
        Self {
-
            base: WidgetBase::new(tx.clone()),
-
            props: props.clone(),
-
            notifications: Container::new(state, tx.clone())
-
                .header(
-
                    Header::new(state, tx.clone())
+
            notifications: Container::default()
+
                .header(Header::default().to_widget(tx.clone()).on_update(|state| {
+
                    // TODO: remove and use state directly
+
                    let props = BrowserProps::from(state);
+
                    HeaderProps::default()
                        .columns(
                            [
                                Column::new("", Constraint::Length(0)),
@@ -143,20 +129,16 @@ impl<'a: 'static> Widget for Browser<'a> {
                            .to_vec(),
                        )
                        .cutoff(props.cutoff, props.cutoff_after)
-
                        .to_boxed(),
-
                )
-
                .content(Box::<Table<State, Message, NotificationItem, 9>>::new(
-
                    Table::new(state, tx.clone())
-
                        .on_event(|table, _| {
-
                            table
-
                                .downcast_mut::<Table<State, Message, NotificationItem, 9>>()
-
                                .and_then(|table| {
-
                                    table
-
                                        .send(Message::Select {
-
                                            selected: table.selected(),
-
                                        })
-
                                        .ok()
-
                                });
+
                        .to_boxed_any()
+
                        .into()
+
                }))
+
                .content(
+
                    Table::<State, Message, NotificationItem, 9>::default()
+
                        .to_widget(tx.clone())
+
                        .on_event(|_, s, _| {
+
                            Some(Message::Select {
+
                                selected: s.and_then(|s| s.unwrap_usize()),
+
                            })
                        })
                        .on_update(|state| {
                            let props = BrowserProps::from(state);
@@ -168,472 +150,124 @@ impl<'a: 'static> Widget for Browser<'a> {
                                .footer(!state.browser.show_search)
                                .page_size(state.browser.page_size)
                                .cutoff(props.cutoff, props.cutoff_after)
-
                                .to_boxed()
+
                                .to_boxed_any()
+
                                .into()
                        }),
-
                ))
-
                .footer(
-
                    Footer::new(state, tx.clone())
-
                        .on_update(|state| {
-
                            FooterProps::default()
-
                                .columns(browse_footer(&BrowserProps::from(state)))
-
                                .to_boxed()
-
                        })
-
                        .to_boxed(),
                )
+
                .footer(Footer::default().to_widget(tx.clone()).on_update(|state| {
+
                    let props = BrowserProps::from(state);
+

+
                    FooterProps::default()
+
                        .columns(browse_footer(&props))
+
                        .to_boxed_any()
+
                        .into()
+
                }))
+
                .to_widget(tx.clone())
                .on_update(|state| {
                    ContainerProps::default()
                        .hide_footer(BrowserProps::from(state).show_search)
-
                        .to_boxed()
-
                })
-
                .to_boxed(),
-
            search: Search::new(state, tx.clone()).to_boxed(),
-
        }
-
    }
-

-
    fn handle_event(&mut self, key: Key) {
-
        if self.props.show_search {
-
            self.search.handle_event(key);
-
        } else {
-
            match key {
-
                Key::Char('/') => {
-
                    let _ = self.send(Message::OpenSearch);
-
                }
-
                Key::Char('\n') => {
-
                    self.props
-
                        .selected
-
                        .and_then(|selected| self.props.notifications.get(selected))
-
                        .and_then(|notif| {
-
                            let selection = match self.props.mode.selection() {
-
                                SelectionMode::Operation => Selection::default()
-
                                    .with_operation(InboxOperation::Show.to_string())
-
                                    .with_id(notif.id),
-
                                SelectionMode::Id => Selection::default().with_id(notif.id),
-
                            };
-

-
                            self.base
-
                                .send(Message::Exit {
-
                                    selection: Some(selection),
-
                                })
-
                                .ok()
-
                        });
-
                }
-
                Key::Char('c') => {
-
                    self.props
-
                        .selected
-
                        .and_then(|selected| self.props.notifications.get(selected))
-
                        .and_then(|notif| {
-
                            self.base
-
                                .send(Message::Exit {
-
                                    selection: Some(
-
                                        Selection::default()
-
                                            .with_operation(InboxOperation::Clear.to_string())
-
                                            .with_id(notif.id),
-
                                    ),
-
                                })
-
                                .ok()
-
                        });
-
                }
-
                _ => self.notifications.handle_event(key),
-
            }
-
        }
-
    }
-

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

-
        self.notifications.update(state);
-
        self.search.update(state);
-
    }
-

-
    fn render(&self, frame: &mut ratatui::Frame, props: RenderProps) {
-
        if self.props.show_search {
-
            let [table_area, search_area] =
-
                Layout::vertical([Constraint::Min(1), Constraint::Length(2)]).areas(props.area);
-

-
            self.notifications
-
                .render(frame, RenderProps::from(table_area));
-
            self.search
-
                .render(frame, RenderProps::from(search_area).focus(props.focus));
-
        } else {
-
            self.notifications.render(frame, props);
-
        }
-
    }
-

-
    fn base(&self) -> &WidgetBase<State, Message> {
-
        &self.base
-
    }
-

-
    fn base_mut(&mut self) -> &mut WidgetBase<State, Message> {
-
        &mut self.base
-
    }
-
}
-

-
#[derive(Clone)]
-
struct BrowserPageProps<'a> {
-
    /// Current page size (height of table content).
-
    page_size: usize,
-
    /// If this pages' keys should be handled (`false` if search is shown).
-
    handle_keys: bool,
-
    /// This pages' shortcuts.
-
    shortcuts: Vec<(&'a str, &'a str)>,
-
}
-

-
impl<'a> From<&State> for BrowserPageProps<'a> {
-
    fn from(state: &State) -> Self {
-
        Self {
-
            page_size: state.browser.page_size,
-
            handle_keys: !state.browser.show_search,
-
            shortcuts: if state.browser.show_search {
-
                vec![("esc", "cancel"), ("enter", "apply")]
-
            } else {
-
                match state.mode.selection() {
-
                    SelectionMode::Id => vec![("enter", "select"), ("/", "search")],
-
                    SelectionMode::Operation => vec![
-
                        ("enter", "show"),
-
                        ("c", "clear"),
-
                        ("/", "search"),
-
                        ("?", "help"),
-
                    ],
-
                }
-
            },
-
        }
-
    }
-
}
-

-
impl<'a> Properties for BrowserPageProps<'a> {}
-
impl<'a> BoxedAny for BrowserPageProps<'a> {}
-

-
pub struct BrowserPage<'a> {
-
    /// Internal base
-
    base: WidgetBase<State, Message>,
-
    /// Internal props
-
    props: BrowserPageProps<'a>,
-
    /// Sections widget
-
    sections: BoxedWidget,
-
    /// Shortcut widget
-
    shortcuts: BoxedWidget,
-
}
-

-
impl<'a: 'static> Widget for BrowserPage<'a> {
-
    type Message = Message;
-
    type State = State;
-

-
    fn new(state: &State, tx: UnboundedSender<Message>) -> Self {
-
        let props = BrowserPageProps::from(state);
-

-
        Self {
-
            base: WidgetBase::new(tx.clone()),
-
            props: props.clone(),
-
            sections: SectionGroup::new(state, tx.clone())
-
                .section(Browser::new(state, tx.clone()).to_boxed())
-
                .on_update(|state| {
-
                    let props = BrowserPageProps::from(state);
-
                    SectionGroupProps::default()
-
                        .handle_keys(props.handle_keys)
-
                        .to_boxed()
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
            search: TextField::default()
+
                .to_widget(tx.clone())
+
                .on_event(|key, s, _| match key {
+
                    Key::Esc => Some(Message::CloseSearch),
+
                    Key::Char('\n') => Some(Message::ApplySearch),
+
                    _ => Some(Message::UpdateSearch {
+
                        value: s.and_then(|i| i.unwrap_string()).unwrap_or_default(),
+
                    }),
                })
-
                .to_boxed(),
-
            shortcuts: Shortcuts::new(state, tx.clone())
-
                .on_update(|state| {
-
                    ShortcutsProps::default()
-
                        .shortcuts(&BrowserPageProps::from(state).shortcuts)
-
                        .to_boxed()
-
                })
-
                .to_boxed(),
-
        }
-
    }
-

-
    fn handle_event(&mut self, key: Key) {
-
        self.sections.handle_event(key);
-

-
        if self.props.handle_keys {
-
            match key {
-
                Key::Esc | Key::Ctrl('c') => {
-
                    let _ = self.send(Message::Exit { selection: None });
-
                }
-
                Key::Char('?') => {
-
                    let _ = self.send(Message::OpenHelp);
-
                }
-
                _ => {}
-
            }
-
        }
-
    }
-

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

-
        self.sections.update(state);
-
        self.shortcuts.update(state);
-
    }
-

-
    fn render(&self, frame: &mut ratatui::Frame, props: RenderProps) {
-
        let page_size = props.area.height.saturating_sub(6) as usize;
-

-
        let [content_area, shortcuts_area] =
-
            Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(props.area);
-

-
        self.sections.render(
-
            frame,
-
            RenderProps::from(content_area)
-
                .layout(Layout::horizontal([Constraint::Min(1)]))
-
                .focus(true),
-
        );
-
        self.shortcuts
-
            .render(frame, RenderProps::from(shortcuts_area));
-

-
        if page_size != self.props.page_size {
-
            let _ = self.send(Message::BrowserPageSize(page_size));
-
        }
-
    }
-

-
    fn base(&self) -> &WidgetBase<State, Message> {
-
        &self.base
-
    }
-

-
    fn base_mut(&mut self) -> &mut WidgetBase<State, Message> {
-
        &mut self.base
-
    }
-
}
-

-
pub struct SearchProps {}
-

-
impl Properties for SearchProps {}
-

-
pub struct Search {
-
    /// Internal base
-
    base: WidgetBase<State, Message>,
-
    /// Internal props
-
    _props: SearchProps,
-
    /// Search input field
-
    input: BoxedWidget,
-
}
-

-
impl Widget for Search {
-
    type Message = Message;
-
    type State = State;
-

-
    fn new(state: &State, tx: UnboundedSender<Message>) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        Self {
-
            base: WidgetBase::new(tx.clone()),
-
            _props: SearchProps {},
-
            input: TextField::new(state, tx.clone())
-
                .on_event(|widget, _| {
-
                    widget
-
                        .downcast_mut::<TextField<State, Message>>()
-
                        .and_then(|field| {
-
                            field
-
                                .send(Message::UpdateSearch {
-
                                    value: field.text().unwrap_or(&String::new()).to_string(),
-
                                })
-
                                .ok()
-
                        });
-
                })
-
                .on_update(|state| {
+
                .on_update(|state: &State| {
                    TextFieldProps::default()
                        .text(&state.browser.search.read().to_string())
                        .title("Search")
                        .inline(true)
-
                        .to_boxed()
-
                })
-
                .to_boxed(),
+
                        .to_boxed_any()
+
                        .into()
+
                }),
        }
    }
-

-
    fn handle_event(&mut self, key: termion::event::Key) {
-
        match key {
-
            Key::Esc => {
-
                let _ = self.send(Message::CloseSearch);
-
            }
-
            Key::Char('\n') => {
-
                let _ = self.send(Message::ApplySearch);
-
            }
-
            _ => {
-
                self.input.handle_event(key);
-
            }
-
        }
-
    }
-

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

-
    fn render(&self, frame: &mut ratatui::Frame, props: RenderProps) {
-
        let layout = Layout::horizontal(Constraint::from_mins([0]))
-
            .horizontal_margin(1)
-
            .split(props.area);
-

-
        self.input.render(frame, RenderProps::from(layout[0]));
-
    }
-

-
    fn base(&self) -> &WidgetBase<State, Message> {
-
        &self.base
-
    }
-

-
    fn base_mut(&mut self) -> &mut WidgetBase<State, Message> {
-
        &mut self.base
-
    }
}

-
#[derive(Clone)]
-
struct HelpPageProps<'a> {
-
    /// Current page size (height of table content).
-
    page_size: usize,
-
    /// Scroll progress of help paragraph.
-
    help_progress: usize,
-
    /// This pages' shortcuts.
-
    shortcuts: Vec<(&'a str, &'a str)>,
-
}
-

-
impl<'a> From<&State> for HelpPageProps<'a> {
-
    fn from(state: &State) -> Self {
-
        Self {
-
            page_size: state.help.page_size,
-
            help_progress: state.help.progress,
-
            shortcuts: vec![("?", "close")],
-
        }
-
    }
-
}
-

-
impl<'a> Properties for HelpPageProps<'a> {}
-
impl<'a> BoxedAny for HelpPageProps<'a> {}
-

-
pub struct HelpPage<'a> {
-
    /// Internal base
-
    base: WidgetBase<State, Message>,
-
    /// Internal props
-
    props: HelpPageProps<'a>,
-
    /// Content widget
-
    content: BoxedWidget,
-
    /// Shortcut widget
-
    shortcuts: BoxedWidget,
-
}
-

-
impl<'a: 'static> Widget for HelpPage<'a> {
+
impl View for Browser {
    type Message = Message;
    type State = State;

-
    fn new(state: &State, tx: UnboundedSender<Message>) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        Self {
-
            base: WidgetBase::new(tx.clone()),
-
            props: HelpPageProps::from(state),
-
            content: Container::new(state, tx.clone())
-
                .header(
-
                    Header::new(state, tx.clone())
-
                        .on_update(|_| {
-
                            HeaderProps::default()
-
                                .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec())
-
                                .to_boxed()
-
                        })
-
                        .to_boxed(),
-
                )
-
                .content(
-
                    Paragraph::new(state, tx.clone())
-
                        .on_event(|paragraph, _| {
-
                            paragraph
-
                                .downcast_mut::<Paragraph<'_, State, Message>>()
-
                                .and_then(|paragraph| {
-
                                    paragraph
-
                                        .send(Message::ScrollHelp {
-
                                            progress: paragraph.progress(),
-
                                        })
-
                                        .ok()
-
                                });
-
                        })
-
                        .on_update(|state| {
-
                            let props = HelpPageProps::from(state);
+
    fn handle_event(&mut self, props: Option<&ViewProps>, key: Key) -> Option<Message> {
+
        let default = BrowserProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<BrowserProps>())
+
            .unwrap_or(&default);

-
                            ParagraphProps::default()
-
                                .text(&help_text())
-
                                .page_size(props.page_size)
-
                                .to_boxed()
-
                        })
-
                        .to_boxed(),
-
                )
-
                .footer(
-
                    Footer::new(state, tx.clone())
-
                        .on_update(|state| {
-
                            let props = HelpPageProps::from(state);
-

-
                            FooterProps::default()
-
                                .columns(
-
                                    [
-
                                        Column::new(Text::raw(""), Constraint::Fill(1)),
-
                                        Column::new(
-
                                            span::default(&format!("{}%", props.help_progress))
-
                                                .dim(),
-
                                            Constraint::Min(4),
-
                                        ),
-
                                    ]
-
                                    .to_vec(),
-
                                )
-
                                .to_boxed()
-
                        })
-
                        .to_boxed(),
-
                )
-
                .to_boxed(),
-
            shortcuts: Shortcuts::new(state, tx.clone())
-
                .on_update(|state| {
-
                    ShortcutsProps::default()
-
                        .shortcuts(&HelpPageProps::from(state).shortcuts)
-
                        .to_boxed()
-
                })
-
                .to_boxed(),
-
        }
-
    }
-

-
    fn handle_event(&mut self, key: termion::event::Key) {
-
        match key {
-
            Key::Esc | Key::Ctrl('c') => {
-
                let _ = self.send(Message::Exit { selection: None });
-
            }
-
            Key::Char('?') => {
-
                let _ = self.send(Message::LeavePage);
-
            }
-
            _ => {
-
                self.content.handle_event(key);
+
        if props.show_search {
+
            self.search.handle_event(key);
+
            None
+
        } else {
+
            match key {
+
                Key::Char('/') => Some(Message::OpenSearch),
+
                Key::Char('\n') => props
+
                    .selected
+
                    .and_then(|selected| props.notifications.get(selected))
+
                    .map(|notif| {
+
                        let selection = match props.mode.selection() {
+
                            SelectionMode::Operation => Selection::default()
+
                                .with_operation(InboxOperation::Show.to_string())
+
                                .with_id(notif.id),
+
                            SelectionMode::Id => Selection::default().with_id(notif.id),
+
                        };
+

+
                        Message::Exit {
+
                            selection: Some(selection),
+
                        }
+
                    }),
+
                Key::Char('c') => props
+
                    .selected
+
                    .and_then(|selected| props.notifications.get(selected))
+
                    .map(|notif| Message::Exit {
+
                        selection: Some(
+
                            Selection::default()
+
                                .with_operation(InboxOperation::Clear.to_string())
+
                                .with_id(notif.id),
+
                        ),
+
                    }),
+
                _ => {
+
                    self.notifications.handle_event(key);
+
                    None
+
                }
            }
        }
    }

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

-
        self.content.update(state);
-
        self.shortcuts.update(state);
+
    fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) {
+
        self.notifications.update(state);
+
        self.search.update(state);
    }

-
    fn render(&self, frame: &mut ratatui::Frame, props: RenderProps) {
-
        let page_size = props.area.height.saturating_sub(6) as usize;
-

-
        let [content_area, shortcuts_area] =
-
            Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(props.area);
+
    fn render(&self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
+
        let default = BrowserProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<BrowserProps>())
+
            .unwrap_or(&default);

-
        self.content
-
            .render(frame, RenderProps::from(content_area).focus(true));
-
        self.shortcuts
-
            .render(frame, RenderProps::from(shortcuts_area));
+
        if props.show_search {
+
            let [table_area, search_area] =
+
                Layout::vertical([Constraint::Min(1), Constraint::Length(2)]).areas(render.area);
+
            let [_, search_area, _] = Layout::horizontal([
+
                Constraint::Length(1),
+
                Constraint::Min(1),
+
                Constraint::Length(1),
+
            ])
+
            .areas(search_area);

-
        if page_size != self.props.page_size {
-
            let _ = self.send(Message::HelpPageSize(page_size));
+
            self.notifications
+
                .render(RenderProps::from(table_area), frame);
+
            self.search
+
                .render(RenderProps::from(search_area).focus(render.focus), frame);
+
        } else {
+
            self.notifications.render(render, frame);
        }
    }
-

-
    fn base(&self) -> &WidgetBase<State, Message> {
-
        &self.base
-
    }
-

-
    fn base_mut(&mut self) -> &mut WidgetBase<State, Message> {
-
        &mut self.base
-
    }
}

fn browse_footer<'a>(props: &BrowserProps<'a>) -> Vec<Column<'a>> {
@@ -695,93 +329,3 @@ fn browse_footer<'a>(props: &BrowserProps<'a>) -> Vec<Column<'a>> {
        .to_vec(),
    }
}
-

-
fn help_text() -> Text<'static> {
-
    Text::from(
-
        [
-
            Line::from(Span::raw("Generic keybindings").cyan()),
-
            Line::raw(""),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "↑,k")).gray(),
-
                Span::raw(" "),
-
                Span::raw("move cursor one line up").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "↓,j")).gray(),
-
                Span::raw(" "),
-
                Span::raw("move cursor one line down").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "PageUp")).gray(),
-
                Span::raw(" "),
-
                Span::raw("move cursor one page up").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "PageDown")).gray(),
-
                Span::raw(" "),
-
                Span::raw("move cursor one page down").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "Home")).gray(),
-
                Span::raw(" "),
-
                Span::raw("move cursor to the first line").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "End")).gray(),
-
                Span::raw(" "),
-
                Span::raw("move cursor to the last line").gray().dim(),
-
            ]),
-
            Line::raw(""),
-
            Line::from(Span::raw("Specific keybindings").cyan()),
-
            Line::raw(""),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "enter")).gray(),
-
                Span::raw(" "),
-
                Span::raw("Select notification (if --mode id)").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "enter")).gray(),
-
                Span::raw(" "),
-
                Span::raw("Show notification").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "c")).gray(),
-
                Span::raw(" "),
-
                Span::raw("Clear notifications").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "/")).gray(),
-
                Span::raw(" "),
-
                Span::raw("Search").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "?")).gray(),
-
                Span::raw(" "),
-
                Span::raw("Show help").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "Esc")).gray(),
-
                Span::raw(" "),
-
                Span::raw("Quit / cancel").gray().dim(),
-
            ]),
-
            Line::raw(""),
-
            Line::from(Span::raw("Searching").cyan()),
-
            Line::raw(""),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "Pattern")).gray(),
-
                Span::raw(" "),
-
                Span::raw("is:<state> | is:patch | is:issue | <search>")
-
                    .gray()
-
                    .dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "Example")).gray(),
-
                Span::raw(" "),
-
                Span::raw("is:unseen is:patch Print").gray().dim(),
-
            ]),
-
            Line::raw(""),
-
            Line::raw(""),
-
        ]
-
        .to_vec(),
-
    )
-
}
modified bin/commands/issue/select.rs
@@ -5,6 +5,12 @@ use std::str::FromStr;

use anyhow::Result;

+
use termion::event::Key;
+

+
use ratatui::layout::Constraint;
+
use ratatui::style::Stylize;
+
use ratatui::text::{Line, Span, Text};
+

use radicle::issue::IssueId;
use radicle::storage::git::Repository;
use radicle::Profile;
@@ -12,16 +18,18 @@ use radicle::Profile;
use radicle_tui as tui;

use tui::cob::issue;
+
use tui::store;
use tui::store::StateValue;
use tui::ui::items::{Filter, IssueItem, IssueItemFilter};
-
use tui::ui::widget::window::{Window, WindowProps};
-
use tui::ui::widget::{Properties, Widget};
-
use tui::Channel;
+
use tui::ui::span;
+
use tui::ui::widget::container::{Column, Container, Footer, FooterProps, Header, HeaderProps};
+
use tui::ui::widget::text::{TextArea, TextAreaProps};
+
use tui::ui::widget::window::{Page, PageProps, Shortcuts, ShortcutsProps, Window, WindowProps};
+
use tui::ui::widget::{ToWidget, Widget};

-
use tui::Exit;
-
use tui::{store, PageStack};
+
use tui::{BoxedAny, Channel, Exit, PageStack};

-
use self::ui::{BrowserPage, HelpPage};
+
use self::ui::{Browser, BrowserProps};

use super::common::Mode;

@@ -39,8 +47,8 @@ pub struct App {
}

#[derive(Clone, Debug, Eq, PartialEq, Hash)]
-
pub enum Page {
-
    Browse,
+
pub enum AppPage {
+
    Browser,
    Help,
}

@@ -73,7 +81,7 @@ pub struct HelpState {
#[derive(Clone, Debug)]
pub struct State {
    mode: Mode,
-
    pages: PageStack<Page>,
+
    pages: PageStack<AppPage>,
    browser: BrowserState,
    help: HelpState,
}
@@ -97,7 +105,7 @@ impl TryFrom<&Context> for State {

        Ok(Self {
            mode: context.mode.clone(),
-
            pages: PageStack::new(vec![Page::Browse]),
+
            pages: PageStack::new(vec![AppPage::Browser]),
            browser: BrowserState {
                items,
                selected: Some(0),
@@ -177,7 +185,7 @@ impl store::State<Selection> for State {
                None
            }
            Message::OpenHelp => {
-
                self.pages.push(Page::Help);
+
                self.pages.push(AppPage::Help);
                None
            }
            Message::LeavePage => {
@@ -191,7 +199,7 @@ impl store::State<Selection> for State {
        }
    }

-
    fn tick(&self) {}
+
    fn tick(&mut self) {}
}

impl App {
@@ -202,21 +210,305 @@ impl App {
    pub async fn run(&self) -> Result<Option<Selection>> {
        let channel = Channel::default();
        let state = State::try_from(&self.context)?;
-
        let window: Window<State, Message, Page> = Window::new(&state, channel.tx.clone())
-
            .page(
-
                Page::Browse,
-
                BrowserPage::new(&state, channel.tx.clone()).to_boxed(),
-
            )
-
            .page(
-
                Page::Help,
-
                HelpPage::new(&state, channel.tx.clone()).to_boxed(),
-
            )
+
        let tx = channel.tx.clone();
+

+
        let window = Window::default()
+
            .page(AppPage::Browser, browser_page(&state, &channel))
+
            .page(AppPage::Help, help_page(&state, &channel))
+
            .to_widget(tx.clone())
            .on_update(|state| {
                WindowProps::default()
-
                    .current_page(state.pages.peek().unwrap_or(&Page::Browse).clone())
-
                    .to_boxed()
+
                    .current_page(state.pages.peek().unwrap_or(&AppPage::Browser).clone())
+
                    .to_boxed_any()
+
                    .into()
            });

-
        tui::run(channel, state, window.to_boxed()).await
+
        tui::run(channel, state, window).await
    }
}
+

+
fn browser_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Message> {
+
    let tx = channel.tx.clone();
+

+
    let content = Browser::new(tx.clone())
+
        .to_widget(tx.clone())
+
        .on_update(|state| BrowserProps::from(state).to_boxed_any().into());
+

+
    let shortcuts = Shortcuts::default()
+
        .to_widget(tx.clone())
+
        .on_update(|state: &State| {
+
            let shortcuts = if state.browser.show_search {
+
                vec![("esc", "cancel"), ("enter", "apply")]
+
            } else {
+
                match state.mode {
+
                    Mode::Id => vec![("enter", "select"), ("/", "search")],
+
                    Mode::Operation => vec![
+
                        ("enter", "show"),
+
                        ("e", "edit"),
+
                        ("/", "search"),
+
                        ("?", "help"),
+
                    ],
+
                }
+
            };
+

+
            ShortcutsProps::default()
+
                .shortcuts(&shortcuts)
+
                .to_boxed_any()
+
                .into()
+
        });
+

+
    Page::default()
+
        .content(content)
+
        .shortcuts(shortcuts)
+
        .to_widget(tx.clone())
+
        .on_event(|key, _, props| {
+
            let default = PageProps::default();
+
            let props = props
+
                .and_then(|props| props.inner_ref::<PageProps>())
+
                .unwrap_or(&default);
+

+
            if props.handle_keys {
+
                match key {
+
                    Key::Esc | Key::Ctrl('c') => Some(Message::Exit { selection: None }),
+
                    Key::Char('?') => Some(Message::OpenHelp),
+
                    _ => None,
+
                }
+
            } else {
+
                None
+
            }
+
        })
+
        .on_update(|state: &State| {
+
            PageProps::default()
+
                .page_size(state.browser.page_size)
+
                .handle_keys(!state.browser.show_search)
+
                .to_boxed_any()
+
                .into()
+
        })
+
        .on_render(|props, render| {
+
            let default = PageProps::default();
+
            let props = props
+
                .and_then(|props| props.inner_ref::<PageProps>())
+
                .unwrap_or(&default);
+
            let page_size = render.area.height.saturating_sub(6) as usize;
+

+
            if page_size != props.page_size {
+
                return Some(Message::BrowserPageSize(page_size));
+
            }
+
            None
+
        })
+
}
+

+
fn help_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Message> {
+
    let tx = channel.tx.clone();
+

+
    let content = Container::default()
+
        .header(Header::default().to_widget(tx.clone()).on_update(|_| {
+
            HeaderProps::default()
+
                .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec())
+
                .to_boxed_any()
+
                .into()
+
        }))
+
        .content(
+
            TextArea::default()
+
                .to_widget(tx.clone())
+
                .on_event(|_, s, _| {
+
                    Some(Message::ScrollHelp {
+
                        progress: s.and_then(|p| p.unwrap_usize()).unwrap_or_default(),
+
                    })
+
                })
+
                .on_update(|state: &State| {
+
                    TextAreaProps::default()
+
                        .text(&help_text())
+
                        .page_size(state.help.page_size)
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
        )
+
        .footer(
+
            Footer::default()
+
                .to_widget(tx.clone())
+
                .on_update(|state: &State| {
+
                    FooterProps::default()
+
                        .columns(
+
                            [
+
                                Column::new(Text::raw(""), Constraint::Fill(1)),
+
                                Column::new(
+
                                    span::default(&format!("{}%", state.help.progress)).dim(),
+
                                    Constraint::Min(4),
+
                                ),
+
                            ]
+
                            .to_vec(),
+
                        )
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
        )
+
        .to_widget(tx.clone());
+

+
    let shortcuts = Shortcuts::default().to_widget(tx.clone()).on_update(|_| {
+
        ShortcutsProps::default()
+
            .shortcuts(&[("?", "close")])
+
            .to_boxed_any()
+
            .into()
+
    });
+

+
    Page::default()
+
        .content(content)
+
        .shortcuts(shortcuts)
+
        .to_widget(tx.clone())
+
        .on_event(|key, _, _| match key {
+
            Key::Esc | Key::Ctrl('c') => Some(Message::Exit { selection: None }),
+
            Key::Char('?') => Some(Message::LeavePage),
+
            _ => None,
+
        })
+
        .on_update(|state: &State| {
+
            PageProps::default()
+
                .page_size(state.help.page_size)
+
                .handle_keys(true)
+
                .to_boxed_any()
+
                .into()
+
        })
+
        .on_render(|props, render| {
+
            let default = PageProps::default();
+
            let props = props
+
                .and_then(|props| props.inner_ref::<PageProps>())
+
                .unwrap_or(&default);
+
            let page_size = render.area.height.saturating_sub(6) as usize;
+

+
            if page_size != props.page_size {
+
                return Some(Message::HelpPageSize(page_size));
+
            }
+
            None
+
        })
+
}
+

+
fn help_text() -> Text<'static> {
+
    Text::from(
+
        [
+
            Line::from(Span::raw("Generic keybindings").cyan()),
+
            Line::raw(""),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "↑,k")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("move cursor one line up").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "↓,j")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("move cursor one line down").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "PageUp")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("move cursor one page up").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "PageDown")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("move cursor one page down").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "Home")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("move cursor to the first line").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "End")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("move cursor to the last line").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::raw(""),
+
            Line::from(Span::raw("Specific keybindings").cyan()),
+
            Line::raw(""),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "enter")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("Select issue (if --mode id)").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "enter")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("Show issue").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "e")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("Edit patch").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "/")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("Search").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "?")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("Show help").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "Esc")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("Quit / cancel").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::raw(""),
+
            Line::from(Span::raw("Searching").cyan()),
+
            Line::raw(""),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "Pattern")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("is:<state> | is:authored | is:assigned | authors:[<did>, ...] | assignees:[<did>, ...] | <search>")
+
                        .gray()
+
                        .dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::from(
+
                [
+
                    Span::raw(format!("{key:>10}", key = "Example")).gray(),
+
                    Span::raw(" "),
+
                    Span::raw("is:solved is:authored alias").gray().dim(),
+
                ]
+
                .to_vec(),
+
            ),
+
            Line::raw(""),
+
            Line::raw(""),
+
        ]
+
        .to_vec())
+
}
modified bin/commands/issue/select/ui.rs
@@ -3,13 +3,14 @@ use std::str::FromStr;
use std::vec;

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

use termion::event::Key;

use ratatui::layout::{Constraint, Layout};
use ratatui::style::Stylize;
-
use ratatui::text::{Line, Span, Text};
+
use ratatui::text::{Line, Text};

use radicle_tui as tui;

@@ -17,26 +18,24 @@ use tui::ui::items::{IssueItem, IssueItemFilter};
use tui::ui::span;
use tui::ui::widget;
use tui::ui::widget::container::{
-
    Column, Container, ContainerProps, Footer, FooterProps, Header, HeaderProps, SectionGroup,
-
    SectionGroupProps,
+
    Column, Container, ContainerProps, Footer, FooterProps, Header, HeaderProps,
};
use tui::ui::widget::input::{TextField, TextFieldProps};
use tui::ui::widget::list::{Table, TableProps, TableUtils};
-
use tui::ui::widget::text::{Paragraph, ParagraphProps};
-
use tui::ui::widget::window::{Shortcuts, ShortcutsProps};
-
use tui::ui::widget::{BoxedAny, Properties, RenderProps, Widget, WidgetBase};
+
use tui::ui::widget::ViewProps;
+
use tui::ui::widget::{RenderProps, ToWidget, View};

-
use tui::Selection;
+
use tui::{BoxedAny, Selection};

use crate::tui_issue::common::IssueOperation;
use crate::tui_issue::common::Mode;

use super::{Message, State};

-
type BoxedWidget = widget::BoxedWidget<State, Message>;
+
type Widget = widget::Widget<State, Message>;

-
#[derive(Clone)]
-
struct BrowserProps<'a> {
+
#[derive(Clone, Default)]
+
pub struct BrowserProps<'a> {
    /// Application mode: openation and id or id only.
    mode: Mode,
    /// Filtered issues.
@@ -128,49 +127,33 @@ impl<'a> From<&State> for BrowserProps<'a> {
    }
}

-
impl<'a> Properties for BrowserProps<'a> {}
-
impl<'a> BoxedAny for BrowserProps<'a> {}
-

-
pub struct Browser<'a> {
-
    /// Internal base
-
    base: WidgetBase<State, Message>,
-
    /// Internal props
-
    props: BrowserProps<'a>,
+
pub struct Browser {
    /// Notifications widget
-
    issues: BoxedWidget,
+
    issues: Widget,
    /// Search widget
-
    search: BoxedWidget,
+
    search: Widget,
}

-
impl<'a: 'static> Widget for Browser<'a> {
-
    type Message = Message;
-
    type State = State;
-

-
    fn new(state: &State, tx: UnboundedSender<Message>) -> Self {
-
        let props = BrowserProps::from(state);
-

+
impl Browser {
+
    pub fn new(tx: UnboundedSender<Message>) -> Self {
        Self {
-
            base: WidgetBase::new(tx.clone()),
-
            props: BrowserProps::from(state),
-
            issues: Container::new(state, tx.clone())
-
                .header(
-
                    Header::new(state, tx.clone())
+
            issues: Container::default()
+
                .header(Header::default().to_widget(tx.clone()).on_update(|state| {
+
                    // TODO: remove and use state directly
+
                    let props = BrowserProps::from(state);
+
                    HeaderProps::default()
                        .columns(props.header.clone())
                        .cutoff(props.cutoff, props.cutoff_after)
-
                        .to_boxed(),
-
                )
-
                .content(Box::<Table<State, Message, IssueItem, 8>>::new(
-
                    Table::new(state, tx.clone())
-
                        .on_event(|table, _| {
-
                            table
-
                                .downcast_mut::<Table<State, Message, IssueItem, 8>>()
-
                                .and_then(|table| {
-
                                    table
-
                                        .send(Message::Select {
-
                                            selected: table.selected(),
-
                                        })
-
                                        .ok()
-
                                });
+
                        .to_boxed_any()
+
                        .into()
+
                }))
+
                .content(
+
                    Table::<State, Message, IssueItem, 8>::default()
+
                        .to_widget(tx.clone())
+
                        .on_event(|_, s, _| {
+
                            Some(Message::Select {
+
                                selected: s.and_then(|s| s.unwrap_usize()),
+
                            })
                        })
                        .on_update(|state| {
                            let props = BrowserProps::from(state);
@@ -182,474 +165,125 @@ impl<'a: 'static> Widget for Browser<'a> {
                                .footer(!state.browser.show_search)
                                .page_size(state.browser.page_size)
                                .cutoff(props.cutoff, props.cutoff_after)
-
                                .to_boxed()
+
                                .to_boxed_any()
+
                                .into()
                        }),
-
                ))
-
                .footer(
-
                    Footer::new(state, tx.clone())
-
                        .on_update(|state| {
-
                            let props = BrowserProps::from(state);
-

-
                            FooterProps::default()
-
                                .columns(browse_footer(&props, props.selected))
-
                                .to_boxed()
-
                        })
-
                        .to_boxed(),
                )
+
                .footer(Footer::default().to_widget(tx.clone()).on_update(|state| {
+
                    let props = BrowserProps::from(state);
+

+
                    FooterProps::default()
+
                        .columns(browse_footer(&props, props.selected))
+
                        .to_boxed_any()
+
                        .into()
+
                }))
+
                .to_widget(tx.clone())
                .on_update(|state| {
                    ContainerProps::default()
                        .hide_footer(BrowserProps::from(state).show_search)
-
                        .to_boxed()
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
            search: TextField::default()
+
                .to_widget(tx.clone())
+
                .on_event(|key, s, _| match key {
+
                    Key::Esc => Some(Message::CloseSearch),
+
                    Key::Char('\n') => Some(Message::ApplySearch),
+
                    _ => Some(Message::UpdateSearch {
+
                        value: s.and_then(|i| i.unwrap_string()).unwrap_or_default(),
+
                    }),
                })
-
                .to_boxed(),
-
            search: Search::new(state, tx.clone()).to_boxed(),
+
                .on_update(|state: &State| {
+
                    TextFieldProps::default()
+
                        .text(&state.browser.search.read().to_string())
+
                        .title("Search")
+
                        .inline(true)
+
                        .to_boxed_any()
+
                        .into()
+
                }),
        }
    }
+
}

-
    fn handle_event(&mut self, key: Key) {
-
        if self.props.show_search {
+
impl View for Browser {
+
    type Message = Message;
+
    type State = State;
+

+
    fn handle_event(&mut self, props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
+
        let default = BrowserProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<BrowserProps>())
+
            .unwrap_or(&default);
+

+
        if props.show_search {
            self.search.handle_event(key);
+
            None
        } else {
            match key {
-
                Key::Char('/') => {
-
                    let _ = self.send(Message::OpenSearch);
-
                }
+
                Key::Char('/') => Some(Message::OpenSearch),
                Key::Char('\n') => {
-
                    let operation = match self.props.mode {
+
                    let operation = match props.mode {
                        Mode::Operation => Some(IssueOperation::Show.to_string()),
                        Mode::Id => None,
                    };

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

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

+
    fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) {
        self.issues.update(state);
        self.search.update(state);
    }

-
    fn render(&self, frame: &mut ratatui::Frame, props: RenderProps) {
-
        if self.props.show_search {
-
            let [table_area, search_area] =
-
                Layout::vertical([Constraint::Min(1), Constraint::Length(2)]).areas(props.area);
+
    fn render(&self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
+
        let default = BrowserProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<BrowserProps>())
+
            .unwrap_or(&default);

-
            self.issues.render(frame, RenderProps::from(table_area));
+
        if props.show_search {
+
            let [table_area, search_area] =
+
                Layout::vertical([Constraint::Min(1), Constraint::Length(2)]).areas(render.area);
+
            let [_, search_area, _] = Layout::horizontal([
+
                Constraint::Length(1),
+
                Constraint::Min(1),
+
                Constraint::Length(1),
+
            ])
+
            .areas(search_area);
+

+
            self.issues.render(RenderProps::from(table_area), frame);
            self.search
-
                .render(frame, RenderProps::from(search_area).focus(props.focus));
+
                .render(RenderProps::from(search_area).focus(render.focus), frame);
        } else {
-
            self.issues.render(frame, props);
-
        }
-
    }
-

-
    fn base(&self) -> &WidgetBase<State, Message> {
-
        &self.base
-
    }
-

-
    fn base_mut(&mut self) -> &mut WidgetBase<State, Message> {
-
        &mut self.base
-
    }
-
}
-

-
#[derive(Clone)]
-
struct BrowserPageProps<'a> {
-
    /// Current page size (height of table content).
-
    page_size: usize,
-
    /// If this pages' keys should be handled (`false` if search is shown).
-
    handle_keys: bool,
-
    /// This pages' shortcuts.
-
    shortcuts: Vec<(&'a str, &'a str)>,
-
}
-

-
impl<'a> From<&State> for BrowserPageProps<'a> {
-
    fn from(state: &State) -> Self {
-
        Self {
-
            page_size: state.browser.page_size,
-
            handle_keys: !state.browser.show_search,
-
            shortcuts: if state.browser.show_search {
-
                vec![("esc", "cancel"), ("enter", "apply")]
-
            } else {
-
                match state.mode {
-
                    Mode::Id => vec![("enter", "select"), ("/", "search")],
-
                    Mode::Operation => vec![
-
                        ("enter", "show"),
-
                        ("e", "edit"),
-
                        ("/", "search"),
-
                        ("?", "help"),
-
                    ],
-
                }
-
            },
-
        }
-
    }
-
}
-

-
impl<'a> Properties for BrowserPageProps<'a> {}
-
impl<'a> BoxedAny for BrowserPageProps<'a> {}
-

-
pub struct BrowserPage<'a> {
-
    /// Internal base
-
    base: WidgetBase<State, Message>,
-
    /// Internal props
-
    props: BrowserPageProps<'a>,
-
    /// Sections widget
-
    sections: BoxedWidget,
-
    /// Shortcut widget
-
    shortcuts: BoxedWidget,
-
}
-

-
impl<'a: 'static> Widget for BrowserPage<'a> {
-
    type Message = Message;
-
    type State = State;
-

-
    fn new(state: &State, tx: UnboundedSender<Message>) -> Self {
-
        let props = BrowserPageProps::from(state);
-

-
        Self {
-
            base: WidgetBase::new(tx.clone()),
-
            props: props.clone(),
-
            sections: SectionGroup::new(state, tx.clone())
-
                .section(Browser::new(state, tx.clone()).to_boxed())
-
                .on_update(|state| {
-
                    let props = BrowserPageProps::from(state);
-
                    SectionGroupProps::default()
-
                        .handle_keys(props.handle_keys)
-
                        .to_boxed()
-
                })
-
                .to_boxed(),
-
            shortcuts: Shortcuts::new(state, tx.clone())
-
                .on_update(|state| {
-
                    ShortcutsProps::default()
-
                        .shortcuts(&BrowserPageProps::from(state).shortcuts)
-
                        .to_boxed()
-
                })
-
                .to_boxed(),
-
        }
-
    }
-

-
    fn handle_event(&mut self, key: Key) {
-
        self.sections.handle_event(key);
-

-
        if self.props.handle_keys {
-
            match key {
-
                Key::Esc | Key::Ctrl('c') => {
-
                    let _ = self.send(Message::Exit { selection: None });
-
                }
-
                Key::Char('?') => {
-
                    let _ = self.send(Message::OpenHelp);
-
                }
-
                _ => {}
-
            }
-
        }
-
    }
-

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

-
        self.sections.update(state);
-
        self.shortcuts.update(state);
-
    }
-

-
    fn render(&self, frame: &mut ratatui::Frame, props: RenderProps) {
-
        let page_size = props.area.height.saturating_sub(6) as usize;
-

-
        let [content_area, shortcuts_area] =
-
            Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(props.area);
-

-
        self.sections.render(
-
            frame,
-
            RenderProps::from(content_area)
-
                .layout(Layout::horizontal([Constraint::Min(1)]))
-
                .focus(true),
-
        );
-
        self.shortcuts
-
            .render(frame, RenderProps::from(shortcuts_area));
-

-
        if page_size != self.props.page_size {
-
            let _ = self.send(Message::BrowserPageSize(page_size));
-
        }
-
    }
-

-
    fn base(&self) -> &WidgetBase<State, Message> {
-
        &self.base
-
    }
-

-
    fn base_mut(&mut self) -> &mut WidgetBase<State, Message> {
-
        &mut self.base
-
    }
-
}
-

-
pub struct SearchProps {}
-

-
impl Properties for SearchProps {}
-

-
pub struct Search {
-
    /// Internal base
-
    base: WidgetBase<State, Message>,
-
    /// Internal props
-
    _props: SearchProps,
-
    /// Search input field
-
    input: BoxedWidget,
-
}
-

-
impl Widget for Search {
-
    type Message = Message;
-
    type State = State;
-

-
    fn new(state: &State, tx: UnboundedSender<Message>) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        Self {
-
            base: WidgetBase::new(tx.clone()),
-
            _props: SearchProps {},
-
            input: TextField::new(state, tx.clone())
-
                .on_event(|widget, _| {
-
                    widget
-
                        .downcast_mut::<TextField<State, Message>>()
-
                        .and_then(|field| {
-
                            field
-
                                .send(Message::UpdateSearch {
-
                                    value: field.text().unwrap_or(&String::new()).to_string(),
-
                                })
-
                                .ok()
-
                        });
-
                })
-
                .on_update(|state| {
-
                    TextFieldProps::default()
-
                        .text(&state.browser.search.read().to_string())
-
                        .title("Search")
-
                        .inline(true)
-
                        .to_boxed()
-
                })
-
                .to_boxed(),
-
        }
-
    }
-

-
    fn handle_event(&mut self, key: termion::event::Key) {
-
        match key {
-
            Key::Esc => {
-
                let _ = self.send(Message::CloseSearch);
-
            }
-
            Key::Char('\n') => {
-
                let _ = self.send(Message::ApplySearch);
-
            }
-
            _ => {
-
                self.input.handle_event(key);
-
            }
-
        }
-
    }
-

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

-
    fn render(&self, frame: &mut ratatui::Frame, props: RenderProps) {
-
        let layout = Layout::horizontal(Constraint::from_mins([0]))
-
            .horizontal_margin(1)
-
            .split(props.area);
-

-
        self.input.render(frame, RenderProps::from(layout[0]));
-
    }
-

-
    fn base(&self) -> &WidgetBase<State, Message> {
-
        &self.base
-
    }
-

-
    fn base_mut(&mut self) -> &mut WidgetBase<State, Message> {
-
        &mut self.base
-
    }
-
}
-

-
#[derive(Clone)]
-
struct HelpPageProps<'a> {
-
    page_size: usize,
-
    help_progress: usize,
-
    shortcuts: Vec<(&'a str, &'a str)>,
-
}
-

-
impl<'a> From<&State> for HelpPageProps<'a> {
-
    fn from(state: &State) -> Self {
-
        Self {
-
            page_size: state.help.page_size,
-
            help_progress: state.help.progress,
-
            shortcuts: vec![("?", "close")],
-
        }
-
    }
-
}
-

-
impl<'a> Properties for HelpPageProps<'a> {}
-
impl<'a> BoxedAny for HelpPageProps<'a> {}
-

-
pub struct HelpPage<'a> {
-
    /// Internal base
-
    base: WidgetBase<State, Message>,
-
    /// Internal props
-
    props: HelpPageProps<'a>,
-
    /// Content widget
-
    content: BoxedWidget,
-
    /// Shortcut widget
-
    shortcuts: BoxedWidget,
-
}
-

-
impl<'a: 'static> Widget for HelpPage<'a> {
-
    type Message = Message;
-
    type State = State;
-

-
    fn new(state: &State, tx: UnboundedSender<Message>) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        Self {
-
            base: WidgetBase::new(tx.clone()),
-
            props: HelpPageProps::from(state),
-
            content: Container::new(state, tx.clone())
-
                .header(
-
                    Header::new(state, tx.clone())
-
                        .on_update(|_| {
-
                            HeaderProps::default()
-
                                .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec())
-
                                .to_boxed()
-
                        })
-
                        .to_boxed(),
-
                )
-
                .content(
-
                    Paragraph::new(state, tx.clone())
-
                        .on_event(|paragraph, _| {
-
                            paragraph
-
                                .downcast_mut::<Paragraph<'_, State, Message>>()
-
                                .and_then(|paragraph| {
-
                                    paragraph
-
                                        .send(Message::ScrollHelp {
-
                                            progress: paragraph.progress(),
-
                                        })
-
                                        .ok()
-
                                });
-
                        })
-
                        .on_update(|state| {
-
                            let props = HelpPageProps::from(state);
-

-
                            ParagraphProps::default()
-
                                .text(&help_text())
-
                                .page_size(props.page_size)
-
                                .to_boxed()
-
                        })
-
                        .to_boxed(),
-
                )
-
                .footer(
-
                    Footer::new(state, tx.clone())
-
                        .on_update(|state| {
-
                            let props = HelpPageProps::from(state);
-

-
                            FooterProps::default()
-
                                .columns(
-
                                    [
-
                                        Column::new(Text::raw(""), Constraint::Fill(1)),
-
                                        Column::new(
-
                                            span::default(&format!("{}%", props.help_progress))
-
                                                .dim(),
-
                                            Constraint::Min(4),
-
                                        ),
-
                                    ]
-
                                    .to_vec(),
-
                                )
-
                                .to_boxed()
-
                        })
-
                        .to_boxed(),
-
                )
-
                .to_boxed(),
-
            shortcuts: Shortcuts::new(state, tx.clone())
-
                .on_update(|state| {
-
                    ShortcutsProps::default()
-
                        .shortcuts(&HelpPageProps::from(state).shortcuts)
-
                        .to_boxed()
-
                })
-
                .to_boxed(),
-
        }
-
    }
-

-
    fn handle_event(&mut self, key: termion::event::Key) {
-
        match key {
-
            Key::Esc | Key::Ctrl('c') => {
-
                let _ = self.send(Message::Exit { selection: None });
-
            }
-
            Key::Char('?') => {
-
                let _ = self.send(Message::LeavePage);
-
            }
-
            _ => {
-
                self.content.handle_event(key);
-
            }
-
        }
-
    }
-

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

-
        self.content.update(state);
-
        self.shortcuts.update(state);
-
    }
-

-
    fn render(&self, frame: &mut ratatui::Frame, props: RenderProps) {
-
        let page_size = props.area.height.saturating_sub(6) as usize;
-

-
        let [content_area, shortcuts_area] =
-
            Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(props.area);
-

-
        self.content
-
            .render(frame, RenderProps::from(content_area).focus(true));
-
        self.shortcuts
-
            .render(frame, RenderProps::from(shortcuts_area));
-

-
        if page_size != self.props.page_size {
-
            let _ = self.send(Message::HelpPageSize(page_size));
+
            self.issues.render(render, frame);
        }
    }
-

-
    fn base(&self) -> &WidgetBase<State, Message> {
-
        &self.base
-
    }
-

-
    fn base_mut(&mut self) -> &mut WidgetBase<State, Message> {
-
        &mut self.base
-
    }
}

fn browse_footer<'a>(props: &BrowserProps<'a>, selected: Option<usize>) -> Vec<Column<'a>> {
@@ -726,134 +360,3 @@ fn browse_footer<'a>(props: &BrowserProps<'a>, selected: Option<usize>) -> Vec<C
        .to_vec(),
    }
}
-

-
fn help_text() -> Text<'static> {
-
    Text::from(
-
        [
-
            Line::from(Span::raw("Generic keybindings").cyan()),
-
            Line::raw(""),
-
            Line::from(
-
                [
-
                    Span::raw(format!("{key:>10}", key = "↑,k")).gray(),
-
                    Span::raw(" "),
-
                    Span::raw("move cursor one line up").gray().dim(),
-
                ]
-
                .to_vec(),
-
            ),
-
            Line::from(
-
                [
-
                    Span::raw(format!("{key:>10}", key = "↓,j")).gray(),
-
                    Span::raw(" "),
-
                    Span::raw("move cursor one line down").gray().dim(),
-
                ]
-
                .to_vec(),
-
            ),
-
            Line::from(
-
                [
-
                    Span::raw(format!("{key:>10}", key = "PageUp")).gray(),
-
                    Span::raw(" "),
-
                    Span::raw("move cursor one page up").gray().dim(),
-
                ]
-
                .to_vec(),
-
            ),
-
            Line::from(
-
                [
-
                    Span::raw(format!("{key:>10}", key = "PageDown")).gray(),
-
                    Span::raw(" "),
-
                    Span::raw("move cursor one page down").gray().dim(),
-
                ]
-
                .to_vec(),
-
            ),
-
            Line::from(
-
                [
-
                    Span::raw(format!("{key:>10}", key = "Home")).gray(),
-
                    Span::raw(" "),
-
                    Span::raw("move cursor to the first line").gray().dim(),
-
                ]
-
                .to_vec(),
-
            ),
-
            Line::from(
-
                [
-
                    Span::raw(format!("{key:>10}", key = "End")).gray(),
-
                    Span::raw(" "),
-
                    Span::raw("move cursor to the last line").gray().dim(),
-
                ]
-
                .to_vec(),
-
            ),
-
            Line::raw(""),
-
            Line::from(Span::raw("Specific keybindings").cyan()),
-
            Line::raw(""),
-
            Line::from(
-
                [
-
                    Span::raw(format!("{key:>10}", key = "enter")).gray(),
-
                    Span::raw(" "),
-
                    Span::raw("Select issue (if --mode id)").gray().dim(),
-
                ]
-
                .to_vec(),
-
            ),
-
            Line::from(
-
                [
-
                    Span::raw(format!("{key:>10}", key = "enter")).gray(),
-
                    Span::raw(" "),
-
                    Span::raw("Show issue").gray().dim(),
-
                ]
-
                .to_vec(),
-
            ),
-
            Line::from(
-
                [
-
                    Span::raw(format!("{key:>10}", key = "e")).gray(),
-
                    Span::raw(" "),
-
                    Span::raw("Edit patch").gray().dim(),
-
                ]
-
                .to_vec(),
-
            ),
-
            Line::from(
-
                [
-
                    Span::raw(format!("{key:>10}", key = "/")).gray(),
-
                    Span::raw(" "),
-
                    Span::raw("Search").gray().dim(),
-
                ]
-
                .to_vec(),
-
            ),
-
            Line::from(
-
                [
-
                    Span::raw(format!("{key:>10}", key = "?")).gray(),
-
                    Span::raw(" "),
-
                    Span::raw("Show help").gray().dim(),
-
                ]
-
                .to_vec(),
-
            ),
-
            Line::from(
-
                [
-
                    Span::raw(format!("{key:>10}", key = "Esc")).gray(),
-
                    Span::raw(" "),
-
                    Span::raw("Quit / cancel").gray().dim(),
-
                ]
-
                .to_vec(),
-
            ),
-
            Line::raw(""),
-
            Line::from(Span::raw("Searching").cyan()),
-
            Line::raw(""),
-
            Line::from(
-
                [
-
                    Span::raw(format!("{key:>10}", key = "Pattern")).gray(),
-
                    Span::raw(" "),
-
                    Span::raw("is:<state> | is:authored | is:assigned | authors:[<did>, ...] | assignees:[<did>, ...] | <search>")
-
                        .gray()
-
                        .dim(),
-
                ]
-
                .to_vec(),
-
            ),
-
            Line::from(
-
                [
-
                    Span::raw(format!("{key:>10}", key = "Example")).gray(),
-
                    Span::raw(" "),
-
                    Span::raw("is:solved is:authored alias").gray().dim(),
-
                ]
-
                .to_vec(),
-
            ),
-
            Line::raw(""),
-
            Line::raw(""),
-
        ]
-
        .to_vec())
-
}
modified bin/commands/patch/select.rs
@@ -11,18 +11,23 @@ use radicle::Profile;

use radicle_tui as tui;

+
use ratatui::layout::Constraint;
+
use ratatui::style::Stylize;
+
use ratatui::text::{Line, Span, Text};
+

+
use termion::event::Key;
use tui::cob::patch;
use tui::store;
use tui::ui::items::{Filter, PatchItem, PatchItemFilter};
-
use tui::ui::widget::window::{Window, WindowProps};
-
use tui::ui::widget::{Properties, Widget};
-
use tui::Channel;
-
use tui::Exit;
+
use tui::ui::span;
+
use tui::ui::widget::container::{Column, Container, Footer, FooterProps, Header, HeaderProps};
+
use tui::ui::widget::text::{TextArea, TextAreaProps};
+
use tui::ui::widget::window::{Page, PageProps, Shortcuts, ShortcutsProps, Window, WindowProps};
+
use tui::ui::widget::{ToWidget, Widget};

-
use tui::PageStack;
+
use tui::{BoxedAny, Channel, Exit, PageStack};

-
use self::ui::BrowserPage;
-
use self::ui::HelpPage;
+
use self::ui::{Browser, BrowserProps};

use super::common::Mode;

@@ -40,7 +45,7 @@ pub struct App {
}

#[derive(Clone, Debug, Eq, PartialEq, Hash)]
-
pub enum Page {
+
pub enum AppPage {
    Browse,
    Help,
}
@@ -74,7 +79,7 @@ pub struct HelpState {
#[derive(Clone, Debug)]
pub struct State {
    mode: Mode,
-
    pages: PageStack<Page>,
+
    pages: PageStack<AppPage>,
    browser: BrowserState,
    help: HelpState,
}
@@ -98,7 +103,7 @@ impl TryFrom<&Context> for State {

        Ok(Self {
            mode: context.mode.clone(),
-
            pages: PageStack::new(vec![Page::Browse]),
+
            pages: PageStack::new(vec![AppPage::Browse]),
            browser: BrowserState {
                items,
                selected: Some(0),
@@ -178,7 +183,7 @@ impl store::State<Selection> for State {
                None
            }
            Message::OpenHelp => {
-
                self.pages.push(Page::Help);
+
                self.pages.push(AppPage::Help);
                None
            }
            Message::LeavePage => {
@@ -192,7 +197,7 @@ impl store::State<Selection> for State {
        }
    }

-
    fn tick(&self) {}
+
    fn tick(&mut self) {}
}

impl App {
@@ -203,21 +208,270 @@ impl App {
    pub async fn run(&self) -> Result<Option<Selection>> {
        let channel = Channel::default();
        let state = State::try_from(&self.context)?;
-
        let window: Window<State, Message, Page> = Window::new(&state, channel.tx.clone())
-
            .page(
-
                Page::Browse,
-
                BrowserPage::new(&state, channel.tx.clone()).to_boxed(),
-
            )
-
            .page(
-
                Page::Help,
-
                HelpPage::new(&state, channel.tx.clone()).to_boxed(),
-
            )
+
        let tx = channel.tx.clone();
+

+
        let window = Window::default()
+
            .page(AppPage::Browse, browser_page(&state, &channel))
+
            .page(AppPage::Help, help_page(&state, &channel))
+
            .to_widget(tx.clone())
            .on_update(|state| {
                WindowProps::default()
-
                    .current_page(state.pages.peek().unwrap_or(&Page::Browse).clone())
-
                    .to_boxed()
+
                    .current_page(state.pages.peek().unwrap_or(&AppPage::Browse).clone())
+
                    .to_boxed_any()
+
                    .into()
            });

-
        tui::run(channel, state, window.to_boxed()).await
+
        tui::run(channel, state, window).await
    }
}
+

+
fn browser_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Message> {
+
    let tx = channel.tx.clone();
+

+
    let content = Browser::new(tx.clone())
+
        .to_widget(tx.clone())
+
        .on_update(|state| BrowserProps::from(state).to_boxed_any().into());
+

+
    let shortcuts = Shortcuts::default()
+
        .to_widget(tx.clone())
+
        .on_update(|state: &State| {
+
            let shortcuts = if state.browser.show_search {
+
                vec![("esc", "cancel"), ("enter", "apply")]
+
            } else {
+
                match state.mode {
+
                    Mode::Id => vec![("enter", "select"), ("/", "search")],
+
                    Mode::Operation => vec![
+
                        ("enter", "show"),
+
                        ("c", "checkout"),
+
                        ("d", "diff"),
+
                        ("/", "search"),
+
                        ("?", "help"),
+
                    ],
+
                }
+
            };
+

+
            ShortcutsProps::default()
+
                .shortcuts(&shortcuts)
+
                .to_boxed_any()
+
                .into()
+
        });
+

+
    Page::default()
+
        .content(content)
+
        .shortcuts(shortcuts)
+
        .to_widget(tx.clone())
+
        .on_event(|key, _, props| {
+
            let default = PageProps::default();
+
            let props = props
+
                .and_then(|props| props.inner_ref::<PageProps>())
+
                .unwrap_or(&default);
+

+
            if props.handle_keys {
+
                match key {
+
                    Key::Esc | Key::Ctrl('c') => Some(Message::Exit { selection: None }),
+
                    Key::Char('?') => Some(Message::OpenHelp),
+
                    _ => None,
+
                }
+
            } else {
+
                None
+
            }
+
        })
+
        .on_update(|state: &State| {
+
            PageProps::default()
+
                .page_size(state.browser.page_size)
+
                .handle_keys(!state.browser.show_search)
+
                .to_boxed_any()
+
                .into()
+
        })
+
        .on_render(|props, render| {
+
            let default = PageProps::default();
+
            let props = props
+
                .and_then(|props| props.inner_ref::<PageProps>())
+
                .unwrap_or(&default);
+
            let page_size = render.area.height.saturating_sub(6) as usize;
+

+
            if page_size != props.page_size {
+
                return Some(Message::BrowserPageSize(page_size));
+
            }
+
            None
+
        })
+
}
+

+
fn help_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Message> {
+
    let tx = channel.tx.clone();
+

+
    let content = Container::default()
+
        .header(Header::default().to_widget(tx.clone()).on_update(|_| {
+
            HeaderProps::default()
+
                .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec())
+
                .to_boxed_any()
+
                .into()
+
        }))
+
        .content(
+
            TextArea::default()
+
                .to_widget(tx.clone())
+
                .on_event(|_, s, _| {
+
                    Some(Message::ScrollHelp {
+
                        progress: s.and_then(|p| p.unwrap_usize()).unwrap_or_default(),
+
                    })
+
                })
+
                .on_update(|state: &State| {
+
                    TextAreaProps::default()
+
                        .text(&help_text())
+
                        .page_size(state.help.page_size)
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
        )
+
        .footer(
+
            Footer::default()
+
                .to_widget(tx.clone())
+
                .on_update(|state: &State| {
+
                    FooterProps::default()
+
                        .columns(
+
                            [
+
                                Column::new(Text::raw(""), Constraint::Fill(1)),
+
                                Column::new(
+
                                    span::default(&format!("{}%", state.help.progress)).dim(),
+
                                    Constraint::Min(4),
+
                                ),
+
                            ]
+
                            .to_vec(),
+
                        )
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
        )
+
        .to_widget(tx.clone());
+

+
    let shortcuts = Shortcuts::default().to_widget(tx.clone()).on_update(|_| {
+
        ShortcutsProps::default()
+
            .shortcuts(&[("?", "close")])
+
            .to_boxed_any()
+
            .into()
+
    });
+

+
    Page::default()
+
        .content(content)
+
        .shortcuts(shortcuts)
+
        .to_widget(tx.clone())
+
        .on_event(|key, _, _| match key {
+
            Key::Esc | Key::Ctrl('c') => Some(Message::Exit { selection: None }),
+
            Key::Char('?') => Some(Message::LeavePage),
+
            _ => None,
+
        })
+
        .on_update(|state: &State| {
+
            PageProps::default()
+
                .page_size(state.help.page_size)
+
                .handle_keys(true)
+
                .to_boxed_any()
+
                .into()
+
        })
+
        .on_render(|props, render| {
+
            let default = PageProps::default();
+
            let props = props
+
                .and_then(|props| props.inner_ref::<PageProps>())
+
                .unwrap_or(&default);
+
            let page_size = render.area.height.saturating_sub(6) as usize;
+

+
            if page_size != props.page_size {
+
                return Some(Message::HelpPageSize(page_size));
+
            }
+
            None
+
        })
+
}
+

+
fn help_text() -> Text<'static> {
+
    Text::from(
+
        [
+
            Line::from(Span::raw("Generic keybindings").cyan()),
+
            Line::raw(""),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "↑,k")).gray(),
+
                Span::raw(" "),
+
                Span::raw("move cursor one line up").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "↓,j")).gray(),
+
                Span::raw(" "),
+
                Span::raw("move cursor one line down").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "PageUp")).gray(),
+
                Span::raw(" "),
+
                Span::raw("move cursor one page up").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "PageDown")).gray(),
+
                Span::raw(" "),
+
                Span::raw("move cursor one page down").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "Home")).gray(),
+
                Span::raw(" "),
+
                Span::raw("move cursor to the first line").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "End")).gray(),
+
                Span::raw(" "),
+
                Span::raw("move cursor to the last line").gray().dim(),
+
            ]),
+
            Line::raw(""),
+
            Line::from(Span::raw("Specific keybindings").cyan()),
+
            Line::raw(""),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "enter")).gray(),
+
                Span::raw(" "),
+
                Span::raw("Select patch (if --mode id)").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "enter")).gray(),
+
                Span::raw(" "),
+
                Span::raw("Show patch").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "c")).gray(),
+
                Span::raw(" "),
+
                Span::raw("Checkout patch").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "d")).gray(),
+
                Span::raw(" "),
+
                Span::raw("Show patch diff").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "/")).gray(),
+
                Span::raw(" "),
+
                Span::raw("Search").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "?")).gray(),
+
                Span::raw(" "),
+
                Span::raw("Show help").gray().dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "Esc")).gray(),
+
                Span::raw(" "),
+
                Span::raw("Quit / cancel").gray().dim(),
+
            ]),
+
            Line::raw(""),
+
            Line::from(Span::raw("Searching").cyan()),
+
            Line::raw(""),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "Pattern")).gray(),
+
                Span::raw(" "),
+
                Span::raw("is:<state> | is:authored | authors:[<did>, <did>] | <search>")
+
                    .gray()
+
                    .dim(),
+
            ]),
+
            Line::from(vec![
+
                Span::raw(format!("{key:>10}", key = "Example")).gray(),
+
                Span::raw(" "),
+
                Span::raw("is:open is:authored improve").gray().dim(),
+
            ]),
+
            Line::raw(""),
+
            Line::raw(""),
+
        ]
+
        .to_vec(),
+
    )
+
}
modified bin/commands/patch/select/ui.rs
@@ -2,15 +2,17 @@ use std::collections::HashMap;
use std::str::FromStr;
use std::vec;

+
use ratatui::Frame;
use tokio::sync::mpsc::UnboundedSender;

use termion::event::Key;

use ratatui::layout::{Constraint, Layout};
use ratatui::style::Stylize;
-
use ratatui::text::{Line, Span, Text};
+
use ratatui::text::{Line, Text};

-
use radicle::patch::{self, Status};
+
use radicle::patch;
+
use radicle::patch::Status;

use radicle_tui as tui;

@@ -18,25 +20,23 @@ use tui::ui::items::{PatchItem, PatchItemFilter};
use tui::ui::span;
use tui::ui::widget;
use tui::ui::widget::container::{
-
    Column, Container, ContainerProps, Footer, FooterProps, Header, HeaderProps, SectionGroup,
-
    SectionGroupProps,
+
    Column, Container, ContainerProps, Footer, FooterProps, Header, HeaderProps,
};
use tui::ui::widget::input::{TextField, TextFieldProps};
use tui::ui::widget::list::{Table, TableProps, TableUtils};
-
use tui::ui::widget::text::{Paragraph, ParagraphProps};
-
use tui::ui::widget::window::{Shortcuts, ShortcutsProps};
-
use tui::ui::widget::{BoxedAny, Properties, RenderProps, Widget, WidgetBase};
+
use tui::ui::widget::ViewProps;
+
use tui::ui::widget::{RenderProps, ToWidget, View};

-
use tui::Selection;
+
use tui::{BoxedAny, Selection};

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

use super::{Message, State};

-
type BoxedWidget = widget::BoxedWidget<State, Message>;
+
type Widget = widget::Widget<State, Message>;

-
#[derive(Clone)]
+
#[derive(Clone, Default)]
pub struct BrowserProps<'a> {
    /// Application mode: openation and id or id only.
    mode: Mode,
@@ -128,53 +128,37 @@ impl<'a> From<&State> for BrowserProps<'a> {
    }
}

-
impl<'a: 'static> Properties for BrowserProps<'a> {}
-
impl<'a: 'static> BoxedAny for BrowserProps<'a> {}
-

-
pub struct Browser<'a> {
-
    /// Internal base
-
    base: WidgetBase<State, Message>,
-
    /// Internal props
-
    props: BrowserProps<'a>,
+
pub struct Browser {
    /// Patches widget
-
    patches: BoxedWidget,
+
    patches: Widget,
    /// Search widget
-
    search: BoxedWidget,
+
    search: Widget,
}

-
impl<'a: 'static> Widget for Browser<'a> {
-
    type Message = Message;
-
    type State = State;
-

-
    fn new(state: &State, tx: UnboundedSender<Message>) -> Self {
-
        let props = BrowserProps::from(state);
-

+
impl Browser {
+
    pub fn new(tx: UnboundedSender<Message>) -> Self {
        Self {
-
            base: WidgetBase::new(tx.clone()),
-
            props: props.clone(),
-
            patches: Container::new(state, tx.clone())
-
                .header(
-
                    Header::new(state, tx.clone())
+
            patches: Container::default()
+
                .header(Header::default().to_widget(tx.clone()).on_update(|state| {
+
                    // TODO: remove and use state directly
+
                    let props = BrowserProps::from(state);
+
                    HeaderProps::default()
                        .columns(props.header.clone())
                        .cutoff(props.cutoff, props.cutoff_after)
-
                        .to_boxed(),
-
                )
-
                .content(Box::<Table<State, Message, PatchItem, 9>>::new(
-
                    Table::new(state, tx.clone())
-
                        .on_event(|table, _| {
-
                            table
-
                                .downcast_mut::<Table<State, Message, PatchItem, 9>>()
-
                                .and_then(|table| {
-
                                    table
-
                                        .send(Message::Select {
-
                                            selected: table.selected(),
-
                                        })
-
                                        .ok()
-
                                });
+
                        .to_boxed_any()
+
                        .into()
+
                }))
+
                .content(
+
                    Table::<State, Message, PatchItem, 9>::default()
+
                        .to_widget(tx.clone())
+
                        .on_event(|_, s, _| {
+
                            Some(Message::Select {
+
                                selected: s.and_then(|s| s.unwrap_usize()),
+
                            })
                        })
                        .on_update(|state| {
+
                            // TODO: remove and use state directly
                            let props = BrowserProps::from(state);
-

                            TableProps::default()
                                .columns(props.columns)
                                .items(state.browser.patches())
@@ -182,499 +166,141 @@ impl<'a: 'static> Widget for Browser<'a> {
                                .footer(!state.browser.show_search)
                                .page_size(state.browser.page_size)
                                .cutoff(props.cutoff, props.cutoff_after)
-
                                .to_boxed()
+
                                .to_boxed_any()
+
                                .into()
                        }),
-
                ))
-
                .footer(
-
                    Footer::new(state, tx.clone())
-
                        .on_update(|state| {
-
                            let props = BrowserProps::from(state);
-

-
                            FooterProps::default()
-
                                .columns(browse_footer(&props, props.selected))
-
                                .to_boxed()
-
                        })
-
                        .to_boxed(),
                )
+
                .footer(Footer::default().to_widget(tx.clone()).on_update(|state| {
+
                    // TODO: remove and use state directly
+
                    let props = BrowserProps::from(state);
+

+
                    FooterProps::default()
+
                        .columns(browser_footer(&props, props.selected))
+
                        .to_boxed_any()
+
                        .into()
+
                }))
+
                .to_widget(tx.clone())
                .on_update(|state| {
                    ContainerProps::default()
                        .hide_footer(BrowserProps::from(state).show_search)
-
                        .to_boxed()
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
            search: TextField::default()
+
                .to_widget(tx.clone())
+
                .on_event(|key, s, _| match key {
+
                    Key::Esc => Some(Message::CloseSearch),
+
                    Key::Char('\n') => Some(Message::ApplySearch),
+
                    _ => Some(Message::UpdateSearch {
+
                        value: s.and_then(|i| i.unwrap_string()).unwrap_or_default(),
+
                    }),
                })
-
                .to_boxed(),
-
            search: Search::new(state, tx.clone()).to_boxed(),
+
                .on_update(|state: &State| {
+
                    TextFieldProps::default()
+
                        .text(&state.browser.search.read().to_string())
+
                        .title("Search")
+
                        .inline(true)
+
                        .to_boxed_any()
+
                        .into()
+
                }),
        }
    }
+
}

-
    fn handle_event(&mut self, key: Key) {
-
        if self.props.show_search {
+
impl View for Browser {
+
    type Message = Message;
+
    type State = State;
+

+
    fn handle_event(&mut self, props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
+
        let default = BrowserProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<BrowserProps>())
+
            .unwrap_or(&default);
+

+
        if props.show_search {
            self.search.handle_event(key);
+
            None
        } else {
            match key {
-
                Key::Esc | Key::Ctrl('c') => {
-
                    let _ = self.send(Message::Exit { selection: None });
-
                }
-
                Key::Char('?') => {
-
                    let _ = self.send(Message::OpenHelp);
-
                }
-
                Key::Char('/') => {
-
                    let _ = self.send(Message::OpenSearch);
-
                }
+
                Key::Esc | Key::Ctrl('c') => Some(Message::Exit { selection: None }),
+
                Key::Char('?') => Some(Message::OpenHelp),
+
                Key::Char('/') => Some(Message::OpenSearch),
                Key::Char('\n') => {
-
                    let operation = match self.props.mode {
+
                    let operation = match props.mode {
                        Mode::Operation => Some(PatchOperation::Show.to_string()),
                        Mode::Id => None,
                    };

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

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

+
    fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) {
        self.patches.update(state);
        self.search.update(state);
    }

-
    fn render(&self, frame: &mut ratatui::Frame, props: RenderProps) {
-
        if self.props.show_search {
-
            let [table_area, search_area] =
-
                Layout::vertical([Constraint::Min(1), Constraint::Length(2)]).areas(props.area);
+
    fn render(&self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
+
        let default = BrowserProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<BrowserProps>())
+
            .unwrap_or(&default);

-
            self.patches.render(frame, RenderProps::from(table_area));
+
        if props.show_search {
+
            let [table_area, search_area] =
+
                Layout::vertical([Constraint::Min(1), Constraint::Length(2)]).areas(render.area);
+
            let [_, search_area, _] = Layout::horizontal([
+
                Constraint::Length(1),
+
                Constraint::Min(1),
+
                Constraint::Length(1),
+
            ])
+
            .areas(search_area);
+

+
            self.patches.render(RenderProps::from(table_area), frame);
            self.search
-
                .render(frame, RenderProps::from(search_area).focus(props.focus));
+
                .render(RenderProps::from(search_area).focus(render.focus), frame);
        } else {
-
            self.patches.render(frame, props);
+
            self.patches.render(render, frame);
        }
    }
-

-
    fn base(&self) -> &WidgetBase<State, Message> {
-
        &self.base
-
    }
-

-
    fn base_mut(&mut self) -> &mut WidgetBase<State, Message> {
-
        &mut self.base
-
    }
}

-
#[derive(Clone)]
-
struct BrowserPageProps<'a> {
-
    /// Current page size (height of table content).
-
    page_size: usize,
-
    /// If this pages' keys should be handled (`false` if search is shown).
-
    handle_keys: bool,
-
    /// This pages' shortcuts.
-
    shortcuts: Vec<(&'a str, &'a str)>,
-
}
-

-
impl<'a> From<&State> for BrowserPageProps<'a> {
-
    fn from(state: &State) -> Self {
-
        Self {
-
            page_size: state.browser.page_size,
-
            handle_keys: !state.browser.show_search,
-
            shortcuts: if state.browser.show_search {
-
                vec![("esc", "cancel"), ("enter", "apply")]
-
            } else {
-
                match state.mode {
-
                    Mode::Id => vec![("enter", "select"), ("/", "search")],
-
                    Mode::Operation => vec![
-
                        ("enter", "show"),
-
                        ("c", "checkout"),
-
                        ("d", "diff"),
-
                        ("/", "search"),
-
                        ("?", "help"),
-
                    ],
-
                }
-
            },
-
        }
-
    }
-
}
-

-
impl<'a> Properties for BrowserPageProps<'a> {}
-
impl<'a> BoxedAny for BrowserPageProps<'a> {}
-

-
pub struct BrowserPage<'a> {
-
    /// Internal base
-
    base: WidgetBase<State, Message>,
-
    /// Internal props
-
    props: BrowserPageProps<'a>,
-
    /// Sections widget
-
    sections: BoxedWidget,
-
    /// Shortcut widget
-
    shortcuts: BoxedWidget,
-
}
-

-
impl<'a: 'static> Widget for BrowserPage<'a> {
-
    type Message = Message;
-
    type State = State;
-

-
    fn new(state: &State, tx: UnboundedSender<Message>) -> Self {
-
        let props = BrowserPageProps::from(state);
-

-
        Self {
-
            base: WidgetBase::new(tx.clone()),
-
            props: props.clone(),
-
            sections: SectionGroup::new(state, tx.clone())
-
                .section(Browser::new(state, tx.clone()).to_boxed())
-
                .on_update(|state| {
-
                    let props = BrowserPageProps::from(state);
-
                    SectionGroupProps::default()
-
                        .handle_keys(props.handle_keys)
-
                        .to_boxed()
-
                })
-
                .to_boxed(),
-
            shortcuts: Shortcuts::new(state, tx.clone())
-
                .on_update(|state| {
-
                    ShortcutsProps::default()
-
                        .shortcuts(&BrowserPageProps::from(state).shortcuts)
-
                        .to_boxed()
-
                })
-
                .to_boxed(),
-
        }
-
    }
-

-
    fn handle_event(&mut self, key: Key) {
-
        self.sections.handle_event(key);
-

-
        if self.props.handle_keys {
-
            match key {
-
                Key::Esc | Key::Ctrl('c') => {
-
                    let _ = self.send(Message::Exit { selection: None });
-
                }
-
                Key::Char('?') => {
-
                    let _ = self.send(Message::OpenHelp);
-
                }
-
                _ => {}
-
            }
-
        }
-
    }
-

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

-
        self.sections.update(state);
-
        self.shortcuts.update(state);
-
    }
-

-
    fn render(&self, frame: &mut ratatui::Frame, props: RenderProps) {
-
        let page_size = props.area.height.saturating_sub(6) as usize;
-

-
        let [content_area, shortcuts_area] =
-
            Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(props.area);
-

-
        self.sections.render(
-
            frame,
-
            RenderProps::from(content_area)
-
                .layout(Layout::horizontal([Constraint::Min(1)]))
-
                .focus(true),
-
        );
-
        self.shortcuts
-
            .render(frame, RenderProps::from(shortcuts_area));
-

-
        if page_size != self.props.page_size {
-
            let _ = self.send(Message::BrowserPageSize(page_size));
-
        }
-
    }
-

-
    fn base(&self) -> &WidgetBase<State, Message> {
-
        &self.base
-
    }
-

-
    fn base_mut(&mut self) -> &mut WidgetBase<State, Message> {
-
        &mut self.base
-
    }
-
}
-

-
pub struct SearchProps {}
-

-
impl Properties for SearchProps {}
-

-
pub struct Search {
-
    /// Internal base
-
    base: WidgetBase<State, Message>,
-
    /// Internal props
-
    _props: SearchProps,
-
    /// Search input field
-
    input: BoxedWidget,
-
}
-

-
impl Widget for Search {
-
    type Message = Message;
-
    type State = State;
-

-
    fn new(state: &State, tx: UnboundedSender<Message>) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        Self {
-
            base: WidgetBase::new(tx.clone()),
-
            _props: SearchProps {},
-
            input: TextField::new(state, tx.clone())
-
                .on_event(|widget, _| {
-
                    widget
-
                        .downcast_mut::<TextField<State, Message>>()
-
                        .and_then(|field| {
-
                            field
-
                                .send(Message::UpdateSearch {
-
                                    value: field.text().unwrap_or(&String::new()).to_string(),
-
                                })
-
                                .ok()
-
                        });
-
                })
-
                .on_update(|state| {
-
                    TextFieldProps::default()
-
                        .text(&state.browser.search.read().to_string())
-
                        .title("Search")
-
                        .inline(true)
-
                        .to_boxed()
-
                })
-
                .to_boxed(),
-
        }
-
    }
-

-
    fn handle_event(&mut self, key: termion::event::Key) {
-
        match key {
-
            Key::Esc => {
-
                let _ = self.send(Message::CloseSearch);
-
            }
-
            Key::Char('\n') => {
-
                let _ = self.send(Message::ApplySearch);
-
            }
-
            _ => {
-
                self.input.handle_event(key);
-
            }
-
        }
-
    }
-

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

-
    fn render(&self, frame: &mut ratatui::Frame, props: RenderProps) {
-
        let layout = Layout::horizontal(Constraint::from_mins([0]))
-
            .horizontal_margin(1)
-
            .split(props.area);
-

-
        self.input.render(frame, RenderProps::from(layout[0]));
-
    }
-

-
    fn base(&self) -> &WidgetBase<State, Message> {
-
        &self.base
-
    }
-

-
    fn base_mut(&mut self) -> &mut WidgetBase<State, Message> {
-
        &mut self.base
-
    }
-
}
-

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

-
impl<'a> From<&State> for HelpPageProps<'a> {
-
    fn from(state: &State) -> Self {
-
        Self {
-
            page_size: state.help.page_size,
-
            help_progress: state.help.progress,
-
            shortcuts: vec![("?", "close")],
-
        }
-
    }
-
}
-

-
impl<'a> Properties for HelpPageProps<'a> {}
-
impl<'a> BoxedAny for HelpPageProps<'a> {}
-

-
pub struct HelpPage<'a> {
-
    /// Internal base
-
    base: WidgetBase<State, Message>,
-
    /// Internal props
-
    props: HelpPageProps<'a>,
-
    /// Content widget
-
    content: BoxedWidget,
-
    /// Shortcut widget
-
    shortcuts: BoxedWidget,
-
}
-

-
impl<'a: 'static> Widget for HelpPage<'a> {
-
    type Message = Message;
-
    type State = State;
-

-
    fn new(state: &State, tx: UnboundedSender<Message>) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        Self {
-
            base: WidgetBase::new(tx.clone()),
-
            props: HelpPageProps::from(state),
-
            content: Container::new(state, tx.clone())
-
                .header(
-
                    Header::new(state, tx.clone())
-
                        .on_update(|_| {
-
                            HeaderProps::default()
-
                                .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec())
-
                                .to_boxed()
-
                        })
-
                        .to_boxed(),
-
                )
-
                .content(
-
                    Paragraph::new(state, tx.clone())
-
                        .on_event(|paragraph, _| {
-
                            paragraph
-
                                .downcast_mut::<Paragraph<'_, State, Message>>()
-
                                .and_then(|paragraph| {
-
                                    paragraph
-
                                        .send(Message::ScrollHelp {
-
                                            progress: paragraph.progress(),
-
                                        })
-
                                        .ok()
-
                                });
-
                        })
-
                        .on_update(|state| {
-
                            let props = HelpPageProps::from(state);
-

-
                            ParagraphProps::default()
-
                                .text(&help_text())
-
                                .page_size(props.page_size)
-
                                .to_boxed()
-
                        })
-
                        .to_boxed(),
-
                )
-
                .footer(
-
                    Footer::new(state, tx.clone())
-
                        .on_update(|state| {
-
                            let props = HelpPageProps::from(state);
-

-
                            FooterProps::default()
-
                                .columns(
-
                                    [
-
                                        Column::new(Text::raw(""), Constraint::Fill(1)),
-
                                        Column::new(
-
                                            span::default(&format!("{}%", props.help_progress))
-
                                                .dim(),
-
                                            Constraint::Min(4),
-
                                        ),
-
                                    ]
-
                                    .to_vec(),
-
                                )
-
                                .to_boxed()
-
                        })
-
                        .to_boxed(),
-
                )
-
                .to_boxed(),
-
            shortcuts: Shortcuts::new(state, tx.clone())
-
                .on_update(|state| {
-
                    ShortcutsProps::default()
-
                        .shortcuts(&HelpPageProps::from(state).shortcuts)
-
                        .to_boxed()
-
                })
-
                .to_boxed(),
-
        }
-
    }
-

-
    fn handle_event(&mut self, key: termion::event::Key) {
-
        match key {
-
            Key::Esc | Key::Ctrl('c') => {
-
                let _ = self.send(Message::Exit { selection: None });
-
            }
-
            Key::Char('?') => {
-
                let _ = self.send(Message::LeavePage);
-
            }
-
            _ => {
-
                self.content.handle_event(key);
-
            }
-
        }
-
    }
-

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

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

-
    fn render(&self, frame: &mut ratatui::Frame, props: RenderProps) {
-
        let page_size = props.area.height.saturating_sub(6) as usize;
-

-
        let [content_area, shortcuts_area] =
-
            Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(props.area);
-

-
        self.content
-
            .render(frame, RenderProps::from(content_area).focus(true));
-
        self.shortcuts
-
            .render(frame, RenderProps::from(shortcuts_area));
-

-
        if page_size != self.props.page_size {
-
            let _ = self.send(Message::HelpPageSize(page_size));
-
        }
-
    }
-

-
    fn base(&self) -> &WidgetBase<State, Message> {
-
        &self.base
-
    }
-

-
    fn base_mut(&mut self) -> &mut WidgetBase<State, Message> {
-
        &mut self.base
-
    }
-
}
-

-
fn browse_footer<'a>(props: &BrowserProps<'a>, selected: Option<usize>) -> Vec<Column<'a>> {
+
fn browser_footer<'a>(props: &BrowserProps<'a>, selected: Option<usize>) -> Vec<Column<'a>> {
    let filter = PatchItemFilter::from_str(&props.search).unwrap_or_default();

    let search = Line::from(vec![
@@ -758,98 +384,3 @@ fn browse_footer<'a>(props: &BrowserProps<'a>, selected: Option<usize>) -> Vec<C
        ],
    }
}
-

-
fn help_text() -> Text<'static> {
-
    Text::from(
-
        [
-
            Line::from(Span::raw("Generic keybindings").cyan()),
-
            Line::raw(""),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "↑,k")).gray(),
-
                Span::raw(" "),
-
                Span::raw("move cursor one line up").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "↓,j")).gray(),
-
                Span::raw(" "),
-
                Span::raw("move cursor one line down").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "PageUp")).gray(),
-
                Span::raw(" "),
-
                Span::raw("move cursor one page up").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "PageDown")).gray(),
-
                Span::raw(" "),
-
                Span::raw("move cursor one page down").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "Home")).gray(),
-
                Span::raw(" "),
-
                Span::raw("move cursor to the first line").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "End")).gray(),
-
                Span::raw(" "),
-
                Span::raw("move cursor to the last line").gray().dim(),
-
            ]),
-
            Line::raw(""),
-
            Line::from(Span::raw("Specific keybindings").cyan()),
-
            Line::raw(""),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "enter")).gray(),
-
                Span::raw(" "),
-
                Span::raw("Select patch (if --mode id)").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "enter")).gray(),
-
                Span::raw(" "),
-
                Span::raw("Show patch").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "c")).gray(),
-
                Span::raw(" "),
-
                Span::raw("Checkout patch").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "d")).gray(),
-
                Span::raw(" "),
-
                Span::raw("Show patch diff").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "/")).gray(),
-
                Span::raw(" "),
-
                Span::raw("Search").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "?")).gray(),
-
                Span::raw(" "),
-
                Span::raw("Show help").gray().dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "Esc")).gray(),
-
                Span::raw(" "),
-
                Span::raw("Quit / cancel").gray().dim(),
-
            ]),
-
            Line::raw(""),
-
            Line::from(Span::raw("Searching").cyan()),
-
            Line::raw(""),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "Pattern")).gray(),
-
                Span::raw(" "),
-
                Span::raw("is:<state> | is:authored | authors:[<did>, <did>] | <search>")
-
                    .gray()
-
                    .dim(),
-
            ]),
-
            Line::from(vec![
-
                Span::raw(format!("{key:>10}", key = "Example")).gray(),
-
                Span::raw(" "),
-
                Span::raw("is:open is:authored improve").gray().dim(),
-
            ]),
-
            Line::raw(""),
-
            Line::raw(""),
-
        ]
-
        .to_vec(),
-
    )
-
}
added examples/basic.rs
@@ -0,0 +1,111 @@
+
use anyhow::Result;
+

+
use termion::event::Key;
+

+
use ratatui::layout::Constraint;
+

+
use radicle_tui as tui;
+

+
use tui::store;
+
use tui::ui::widget::container::{Column, Container, Header, HeaderProps};
+
use tui::ui::widget::text::{TextArea, TextAreaProps};
+
use tui::ui::widget::window::{Page, Shortcuts, ShortcutsProps, Window, WindowProps};
+
use tui::ui::widget::ToWidget;
+
use tui::{BoxedAny, Channel, Exit};
+

+
const CONTENT: &str = r#"
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
+
incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud 
+
exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure 
+
dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
+

+
Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt 
+
mollit anim id est laborum.
+
"#;
+

+
#[derive(Clone, Debug)]
+
struct State {
+
    content: String,
+
}
+

+
enum Message {
+
    Quit,
+
    ReverseContent,
+
}
+

+
impl store::State<()> for State {
+
    type Message = Message;
+

+
    fn update(&mut self, message: Self::Message) -> Option<tui::Exit<()>> {
+
        match message {
+
            Message::Quit => Some(Exit { value: None }),
+
            Message::ReverseContent => {
+
                self.content = self.content.chars().rev().collect::<String>();
+
                None
+
            }
+
        }
+
    }
+

+
    fn tick(&mut self) {}
+
}
+

+
#[tokio::main]
+
pub async fn main() -> Result<()> {
+
    let channel = Channel::default();
+
    let sender = channel.tx.clone();
+
    let state = State {
+
        content: CONTENT.to_string(),
+
    };
+

+
    let page = Page::default()
+
        .content(
+
            Container::default()
+
                .header(Header::default().to_widget(sender.clone()).on_update(|_| {
+
                    HeaderProps::default()
+
                        .columns(vec![
+
                            Column::new("", Constraint::Length(0)),
+
                            Column::new(
+
                                "The standard Lorem Ipsum passage, used since the 1500s",
+
                                Constraint::Fill(1),
+
                            ),
+
                        ])
+
                        .to_boxed_any()
+
                        .into()
+
                }))
+
                .content(TextArea::default().to_widget(sender.clone()).on_update(
+
                    |state: &State| {
+
                        TextAreaProps::default()
+
                            .text(&state.content.clone().into())
+
                            .can_scroll(false)
+
                            .to_boxed_any()
+
                            .into()
+
                    },
+
                ))
+
                .to_widget(sender.clone()),
+
        )
+
        .shortcuts(
+
            Shortcuts::default()
+
                .to_widget(sender.clone())
+
                .on_update(|_| {
+
                    ShortcutsProps::default()
+
                        .shortcuts(&[("q", "quit"), ("r", "reverse")])
+
                        .to_boxed_any()
+
                        .into()
+
                }),
+
        )
+
        .to_widget(sender.clone());
+

+
    let window = Window::default()
+
        .page(0, page)
+
        .to_widget(sender.clone())
+
        .on_event(|key, _, _| match key {
+
            Key::Char('r') => Some(Message::ReverseContent),
+
            Key::Char('q') => Some(Message::Quit),
+
            _ => None,
+
        })
+
        .on_update(|_| WindowProps::default().current_page(0).to_boxed_any().into());
+

+
    tui::run(channel, state, window).await?;
+

+
    Ok(())
+
}
modified examples/hello.rs
@@ -1,21 +1,39 @@
use anyhow::Result;

+
use termion::event::Key;
+

+
use ratatui::style::Color;
+
use ratatui::text::Text;
+

use radicle_tui as tui;

-
use termion::event::Key;
use tui::store;
-
use tui::ui::widget::text::{Paragraph, ParagraphProps};
-
use tui::ui::widget::{Properties, Widget};
-
use tui::{Channel, Exit};
+
use tui::ui::widget::text::{TextArea, TextAreaProps};
+
use tui::ui::widget::ToWidget;
+
use tui::{BoxedAny, Channel, Exit};
+

+
const ALIEN: &str = r#"
+
     ///             ///    ,---------------------------------. 
+
     ///             ///    | Hey there, press (q) to quit... |
+
        //         //       '---------------------------------'  
+
        //,,,///,,,//      .. 
+
     ///////////////////  .  
+
  //////@@@@@//////@@@@@///  
+
  //////@@###//////@@###///  
+
,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
+
     ,,,  ///   ///  ,,,     
+
     ,,,  ///   ///  ,,,     
+
          ///   ///          
+
        /////   /////
+
"#;

#[derive(Clone, Debug)]
struct State {
-
    welcome: String,
+
    alien: String,
}

enum Message {
    Quit,
-
    ReverseWelcome,
}

impl store::State<()> for State {
@@ -24,41 +42,34 @@ impl store::State<()> for State {
    fn update(&mut self, message: Self::Message) -> Option<tui::Exit<()>> {
        match message {
            Message::Quit => Some(Exit { value: None }),
-
            Message::ReverseWelcome => {
-
                self.welcome = self.welcome.chars().rev().collect::<String>();
-
                None
-
            }
        }
    }

-
    fn tick(&self) {}
+
    fn tick(&mut self) {}
}

#[tokio::main]
pub async fn main() -> Result<()> {
    let channel = Channel::default();
+
    let sender = channel.tx.clone();
    let state = State {
-
        welcome: "Hello TUI".to_string(),
+
        alien: ALIEN.to_string(),
    };

-
    let welcome = Paragraph::new(&state, channel.tx.clone())
-
        .on_update(|state| {
-
            ParagraphProps::default()
-
                .text(&state.welcome.clone().into())
-
                .to_boxed()
-
        })
-
        .on_event(|paragraph, key| {
-
            paragraph
-
                .downcast_mut::<Paragraph<'_, State, Message>>()
-
                .and_then(|paragraph| match key {
-
                    Key::Char('r') => paragraph.send(Message::ReverseWelcome).ok(),
-
                    Key::Char('q') => paragraph.send(Message::Quit).ok(),
-
                    _ => None,
-
                });
+
    let scene = TextArea::default()
+
        .to_widget(sender.clone())
+
        .on_event(|key, _, _| match key {
+
            Key::Char('q') => Some(Message::Quit),
+
            _ => None,
        })
-
        .to_boxed();
+
        .on_update(|state: &State| {
+
            TextAreaProps::default()
+
                .text(&Text::styled(state.alien.clone(), Color::Rgb(85, 85, 255)))
+
                .to_boxed_any()
+
                .into()
+
        });

-
    tui::run(channel, state, welcome).await?;
+
    tui::run(channel, state, scene).await?;

    Ok(())
}
modified src/lib.rs
@@ -8,15 +8,19 @@ pub mod task;
pub mod terminal;
pub mod ui;

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

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

use serde::ser::{Serialize, SerializeStruct, Serializer};
+

+
use anyhow::Result;
+

use store::State;
use task::Interrupted;
-
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
-
use ui::{widget::Widget, Frontend};
+
use ui::widget::Widget;
+
use ui::Frontend;

/// An optional return value.
#[derive(Clone, Debug)]
@@ -74,6 +78,36 @@ where
    }
}

+
/// Provide implementations for conversions to and from `Box<dyn Any>`.
+
pub trait BoxedAny {
+
    fn from_boxed_any(any: Box<dyn Any>) -> Option<Self>
+
    where
+
        Self: Sized + Clone + 'static;
+

+
    fn to_boxed_any(self) -> Box<dyn Any>
+
    where
+
        Self: Sized + Clone + 'static;
+
}
+

+
impl<T> BoxedAny for T
+
where
+
    T: Sized + Clone + 'static,
+
{
+
    fn from_boxed_any(any: Box<dyn Any>) -> Option<Self>
+
    where
+
        Self: Sized + Clone + 'static,
+
    {
+
        any.downcast::<Self>().ok().map(|b| *b)
+
    }
+

+
    fn to_boxed_any(self) -> Box<dyn Any>
+
    where
+
        Self: Sized + Clone + 'static,
+
    {
+
        Box::new(self)
+
    }
+
}
+

/// A 'PageStack' for applications. Page identifier can be pushed to and
/// popped from the stack.
#[derive(Clone, Default, Debug)]
@@ -120,20 +154,20 @@ impl<A> Default for Channel<A> {
/// Initialize a `Store` with the `State` given and a `Frontend` with the `Widget` given,
/// and run their main loops concurrently. Connect them to the `Channel` and also to
/// an interrupt broadcast channel also initialized in this function.
-
pub async fn run<S, M, W, P>(channel: Channel<M>, state: S, root: Box<W>) -> Result<Option<P>>
+
pub async fn run<S, M, P>(channel: Channel<M>, state: S, root: Widget<S, M>) -> Result<Option<P>>
where
    S: State<P, Message = M> + Clone + Debug + Send + Sync + 'static,
-
    W: Widget<State = S, Message = M>,
+
    M: 'static,
    P: Clone + Debug + Send + Sync + 'static,
{
    let (terminator, mut interrupt_rx) = task::create_termination();

    let (store, state_rx) = store::Store::<S, M, P>::new();
-
    let frontend = Frontend::<M>::new(channel.tx.clone());
+
    let frontend = Frontend::default();

    tokio::try_join!(
        store.main_loop(state, terminator, channel.rx, interrupt_rx.resubscribe()),
-
        frontend.main_loop(Some(*root), state_rx, interrupt_rx.resubscribe()),
+
        frontend.main_loop(root, state_rx, interrupt_rx.resubscribe()),
    )?;

    if let Ok(reason) = interrupt_rx.recv().await {
modified src/store.rs
@@ -24,7 +24,7 @@ where
    fn update(&mut self, message: Self::Message) -> Option<Exit<P>>;

    /// Handle recurring tick.
-
    fn tick(&self);
+
    fn tick(&mut self);
}

/// The `Store` updates the applications' state concurrently. It handles
modified src/ui.rs
@@ -10,16 +10,16 @@ use std::fmt::Debug;
use std::time::Duration;

use tokio::sync::broadcast;
-
use tokio::sync::mpsc;
use tokio::sync::mpsc::UnboundedReceiver;

use crate::ui::widget::RenderProps;

+
use self::widget::Widget;
+

use super::event::Event;
use super::store::State;
use super::task::Interrupted;
use super::terminal;
-
use super::ui::widget::Widget;

const RENDERING_TICK_RATE: Duration = Duration::from_millis(250);
const INLINE_HEIGHT: usize = 20;
@@ -29,16 +29,10 @@ const INLINE_HEIGHT: usize = 20;
///
/// Once created and run with `main_loop`, the `Frontend` will wait for new messages
/// being sent on either the terminal event, the state or the interrupt message channel.
-
pub struct Frontend<M> {
-
    tx: mpsc::UnboundedSender<M>,
-
}
-

-
impl<M> Frontend<M> {
-
    /// Create a new `Frontend` storing the sending end of a message channel.
-
    pub fn new(tx: mpsc::UnboundedSender<M>) -> Self {
-
        Self { tx: tx.clone() }
-
    }
+
#[derive(Default)]
+
pub struct Frontend {}

+
impl Frontend {
    /// By calling `main_loop`, the `Frontend` will wait for new messages being sent
    /// on either the terminal event, the state or the interrupt message channel.
    /// After all, it will draw the (potentially) updated root widget.
@@ -53,15 +47,15 @@ impl<M> Frontend<M> {
    ///
    /// Interrupt messages are being sent to broadcast channel for retrieving the
    /// application kill signal.
-
    pub async fn main_loop<S, W, P>(
+
    pub async fn main_loop<S, M, P>(
        self,
-
        root: Option<W>,
+
        mut root: Widget<S, M>,
        mut state_rx: UnboundedReceiver<S>,
        mut interrupt_rx: broadcast::Receiver<Interrupted<P>>,
    ) -> anyhow::Result<Interrupted<P>>
    where
-
        S: State<P>,
-
        W: Widget<State = S, Message = M>,
+
        S: State<P> + 'static,
+
        M: 'static,
        P: Clone + Send + Sync + Debug,
    {
        let mut ticker = tokio::time::interval(RENDERING_TICK_RATE);
@@ -72,13 +66,8 @@ impl<M> Frontend<M> {
        let mut root = {
            let state = state_rx.recv().await.unwrap();

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

        let result: anyhow::Result<Interrupted<P>> = loop {
@@ -101,7 +90,7 @@ impl<M> Frontend<M> {
                    break Ok(interrupted);
                }
            }
-
            terminal.draw(|frame| root.render(frame, RenderProps::from(frame.size())))?;
+
            terminal.draw(|frame| root.render(RenderProps::from(frame.size()), frame))?;
        };

        terminal::restore(&mut terminal)?;
modified src/ui/items.rs
@@ -22,9 +22,13 @@ use ratatui::widgets::Cell;

use super::super::git;
use super::theme::style;
-
use super::widget::ToRow;
use super::{format, span};

+
/// Needs to be implemented for items that are supposed to be rendered in tables.
+
pub trait ToRow<const W: usize> {
+
    fn to_row(&self) -> [Cell; W];
+
}
+

pub trait Filter<T> {
    fn matches(&self, item: &T) -> bool;
}
modified src/ui/widget.rs
@@ -6,48 +6,80 @@ pub mod window;

use std::any::Any;

-
use tokio::sync::mpsc::error::SendError;
use tokio::sync::mpsc::UnboundedSender;

use termion::event::Key;

use ratatui::prelude::*;
-
use ratatui::widgets::Cell;
-

-
pub type BoxedWidget<S, M> = Box<dyn Widget<State = S, Message = M>>;
-

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

-
/// A `WidgetBase` provides common functionality to a `Widget`. It's used to store
-
/// event and update callbacks as well sending messages to the UI's message channel.
-
pub struct WidgetBase<S, M> {
-
    /// Message sender
-
    pub tx: UnboundedSender<M>,
-
    /// Custom update handler
-
    pub on_update: Option<UpdateCallback<S>>,
-
    /// Additional custom event handler
-
    pub on_event: Option<EventCallback>,
+

+
pub type BoxedView<S, M> = Box<dyn View<State = S, Message = M>>;
+
pub type UpdateCallback<S> = fn(&S) -> ViewProps;
+
pub type EventCallback<M> = fn(Key, Option<&ViewState>, Option<&ViewProps>) -> Option<M>;
+
pub type RenderCallback<M> = fn(Option<&ViewProps>, &RenderProps) -> Option<M>;
+

+
/// `ViewProps` are properties of a `View`. They define a `View`s data, configuration etc.
+
/// Since the framework itself does not know the concrete type of `View`, it also does not
+
/// know the concrete type of a `View`s properties.
+
/// Hence, view properties are stored inside a `Box<dyn Any>` and downcasted to the concrete
+
/// type when needed.
+
pub struct ViewProps {
+
    inner: Box<dyn Any>,
}

-
impl<S, M> WidgetBase<S, M> {
-
    /// Create a new `WidgetBase` with no callbacks set.
-
    pub fn new(tx: UnboundedSender<M>) -> Self {
+
impl ViewProps {
+
    pub fn inner<T>(self) -> Option<T>
+
    where
+
        T: Default + Clone + 'static,
+
    {
+
        self.inner.downcast::<T>().ok().map(|inner| *inner)
+
    }
+

+
    pub fn inner_ref<T>(&self) -> Option<&T>
+
    where
+
        T: Default + Clone + 'static,
+
    {
+
        self.inner.downcast_ref::<T>()
+
    }
+
}
+

+
impl From<Box<dyn Any>> for ViewProps {
+
    fn from(props: Box<dyn Any>) -> Self {
+
        ViewProps { inner: props }
+
    }
+
}
+

+
impl From<&'static dyn Any> for ViewProps {
+
    fn from(inner: &'static dyn Any) -> Self {
        Self {
-
            tx: tx.clone(),
-
            on_update: None,
-
            on_event: None,
+
            inner: Box::new(inner),
        }
    }
+
}

-
    /// Send a message to the internal channel.
-
    pub fn send(&self, message: M) -> Result<(), SendError<M>> {
-
        self.tx.send(message)
+
/// A `ViewState` is the representation of a `View`s internal state. e.g. current
+
/// table selection or contents of a text field.
+
pub enum ViewState {
+
    USize(usize),
+
    String(String),
+
}
+

+
impl ViewState {
+
    pub fn unwrap_usize(&self) -> Option<usize> {
+
        match self {
+
            ViewState::USize(value) => Some(*value),
+
            _ => None,
+
        }
+
    }
+

+
    pub fn unwrap_string(&self) -> Option<String> {
+
        match self {
+
            ViewState::String(value) => Some(value.clone()),
+
            _ => None,
+
        }
    }
}

-
/// General properties that specify how a `Widget` is rendered.
-
/// They can be passed to a widgets' `render` function.
+
/// General properties that specify how a `View` is rendered.
#[derive(Clone, Default)]
pub struct RenderProps {
    /// Area of the render props.
@@ -82,27 +114,75 @@ impl From<Rect> for RenderProps {
    }
}

-
/// Main trait defining a `Widget` behaviour.
-
///
-
/// This is the trait that you should implement to define a custom `Widget`.
-
pub trait Widget {
+
/// Main trait defining a `View` behaviour, which needs be implemented in order to
+
/// build a custom widget. A `View` operates on an application state and can emit
+
/// application messages. It's usually is accompanied by a definition of view-specific
+
/// properties, which are being built from the application state by the framework.
+
pub trait View {
    type State;
    type Message;

-
    /// Should return a new view with props build from state (if type is known) and a
-
    /// message sender set.
-
    fn new(state: &Self::State, tx: UnboundedSender<Self::Message>) -> Self
-
    where
-
        Self: Sized;
+
    /// Should return the internal state.
+
    fn view_state(&self) -> Option<ViewState> {
+
        None
+
    }

    /// Should handle key events and call `handle_event` on all children.
-
    ///
-
    /// After key events have been handled, the custom event handler `on_event` should
-
    /// be called
-
    fn handle_event(&mut self, key: Key);
+
    fn handle_event(&mut self, _props: Option<&ViewProps>, _key: Key) -> Option<Self::Message> {
+
        None
+
    }

    /// Should update the internal props of this and all children.
-
    ///
+
    fn update(&mut self, _props: Option<&ViewProps>, _state: &Self::State) {}
+

+
    /// Should render the view using the given `RenderProps`.
+
    fn render(&self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame);
+
}
+

+
/// A `View` needs to wrapped into a `Widget` before being able to use with the
+
/// framework. A `Widget` enhances a `View` with event and update callbacks and takes
+
/// care of calling them before / after calling into the `View`.
+
pub struct Widget<S, M> {
+
    view: BoxedView<S, M>,
+
    props: Option<ViewProps>,
+
    sender: UnboundedSender<M>,
+
    on_update: Option<UpdateCallback<S>>,
+
    on_event: Option<EventCallback<M>>,
+
    on_render: Option<RenderCallback<M>>,
+
}
+

+
impl<S: 'static, M: 'static> Widget<S, M> {
+
    pub fn new<V>(view: V, sender: UnboundedSender<M>) -> Self
+
    where
+
        Self: Sized,
+
        V: View<State = S, Message = M> + 'static,
+
    {
+
        Self {
+
            view: Box::new(view),
+
            props: None,
+
            sender: sender.clone(),
+
            on_update: None,
+
            on_event: None,
+
            on_render: None,
+
        }
+
    }
+

+
    /// Calls `handle_event` on the wrapped view as well as the `on_event` callback.
+
    /// Sends any message returned by either the view or the callback.
+
    pub fn handle_event(&mut self, key: Key) {
+
        if let Some(message) = self.view.handle_event(self.props.as_ref(), key) {
+
            let _ = self.sender.send(message);
+
        }
+

+
        if let Some(on_event) = self.on_event {
+
            if let Some(message) =
+
                (on_event)(key, self.view.view_state().as_ref(), self.props.as_ref())
+
            {
+
                let _ = self.sender.send(message);
+
            }
+
        }
+
    }
+

    /// Applications are usually defined by app-specific widgets that do know
    /// the type of `state`. These can use widgets from the library that do not know the
    /// type of `state`.
@@ -111,88 +191,72 @@ pub trait Widget {
    /// construct and update the internal props. If it is not set, app widgets can construct
    /// props directly via their state converters, whereas library widgets can just fallback
    /// to their current props.
-
    fn update(&mut self, state: &Self::State);
-

-
    /// Renders a widget to the given frame in the given area.
-
    ///
-
    /// Optional render props can be given.
-
    fn render(&self, frame: &mut Frame, props: RenderProps);
-

-
    /// Return a reference to this widgets' base.
-
    fn base(&self) -> &WidgetBase<Self::State, Self::Message>;
+
    pub fn update(&mut self, state: &S) {
+
        self.props = self.on_update.map(|on_update| (on_update)(state));
+
        self.view.update(self.props.as_ref(), state);
+
    }

-
    /// Return a mutable reference to this widgets' base.
-
    fn base_mut(&mut self) -> &mut WidgetBase<Self::State, Self::Message>;
+
    /// Renders the wrapped view.
+
    pub fn render(&self, render: RenderProps, frame: &mut Frame) {
+
        self.view.render(self.props.as_ref(), render.clone(), frame);

-
    /// Send a message to the widgets' base channel.
-
    fn send(&self, message: Self::Message) -> Result<(), SendError<Self::Message>> {
-
        self.base().send(message)
+
        if let Some(on_render) = self.on_render {
+
            (on_render)(self.props.as_ref(), &render)
+
                .and_then(|message| self.sender.send(message).ok());
+
        }
    }

-
    /// Should set the optional custom event handler.
-
    fn on_event(mut self, callback: EventCallback) -> Self
+
    /// Sets the optional custom event handler.
+
    pub fn on_event(mut self, callback: EventCallback<M>) -> Self
    where
        Self: Sized,
    {
-
        self.base_mut().on_event = Some(callback);
+
        self.on_event = Some(callback);
        self
    }

-
    /// Should set the optional update handler.
-
    fn on_update(mut self, callback: UpdateCallback<Self::State>) -> Self
+
    /// Sets the optional update handler.
+
    pub fn on_update(mut self, callback: UpdateCallback<S>) -> Self
    where
        Self: Sized,
    {
-
        self.base_mut().on_update = Some(callback);
+
        self.on_update = Some(callback);
        self
    }

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

-
/// Needs to be implemented for items that are supposed to be rendered in tables.
-
pub trait ToRow<const W: usize> {
-
    fn to_row(&self) -> [Cell; W];
-
}
-

-
/// Common trait for widget properties.
-
pub trait Properties {
-
    fn to_boxed(self) -> Box<Self>
+
    /// Sets the optional update handler.
+
    pub fn on_render(mut self, callback: RenderCallback<M>) -> Self
    where
        Self: Sized,
    {
-
        Box::new(self)
+
        self.on_render = Some(callback);
+
        self
    }

-
    fn from_callback<S>(callback: Option<UpdateCallback<S>>, state: &S) -> Option<Self>
-
    where
-
        Self: Sized + Clone + 'static + BoxedAny,
-
    {
-
        callback
-
            .map(|callback| (callback)(state))
-
            .and_then(|props| Self::from_boxed_any(props))
+
    /// Sends a message to the widgets' message channel.
+
    pub fn send(&self, message: M) {
+
        let _ = self.sender.send(message);
    }
}

-
/// Provide default implementations for conversions to and from `Box<dyn Any>`.
-
pub trait BoxedAny {
-
    fn from_boxed_any(any: Box<dyn Any>) -> Option<Self>
+
/// A `View` needs to be wrapped into a `Widget` in order to be used with the framework.
+
/// `ToWidget` provides a blanket implementation for all `View`s.
+
pub trait ToWidget<S, M> {
+
    fn to_widget(self, tx: UnboundedSender<M>) -> Widget<S, M>
    where
-
        Self: Sized + Clone + 'static,
-
    {
-
        any.downcast_ref::<Self>().cloned()
-
    }
+
        Self: Sized + 'static;
+
}

-
    fn to_boxed_any(self) -> Box<dyn Any>
+
impl<T, S, M> ToWidget<S, M> for T
+
where
+
    T: View<State = S, Message = M>,
+
    S: 'static,
+
    M: 'static,
+
{
+
    fn to_widget(self, tx: UnboundedSender<M>) -> Widget<S, M>
    where
-
        Self: Sized + Clone + 'static,
+
        Self: Sized + 'static,
    {
-
        Box::new(self)
+
        Widget::new(self, tx)
    }
}
modified src/ui/widget/container.rs
@@ -1,6 +1,5 @@
use std::fmt::Debug;
-

-
use tokio::sync::mpsc::UnboundedSender;
+
use std::marker::PhantomData;

use termion::event::Key;

@@ -10,7 +9,7 @@ use ratatui::widgets::{Block, BorderType, Borders, Row};
use crate::ui::ext::{FooterBlock, FooterBlockType, HeaderBlock};
use crate::ui::theme::style;

-
use super::{BoxedAny, BoxedWidget, Properties, RenderProps, Widget, WidgetBase};
+
use super::{RenderProps, View, ViewProps, Widget};

#[derive(Clone, Debug)]
pub struct Column<'a> {
@@ -64,53 +63,30 @@ impl<'a> Default for HeaderProps<'a> {
    }
}

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

-
pub struct Header<'a: 'static, S, M> {
-
    /// Internal props
-
    props: HeaderProps<'a>,
-
    /// Internal base
-
    base: WidgetBase<S, M>,
+
pub struct Header<S, M> {
+
    /// Phantom
+
    phantom: PhantomData<(S, M)>,
}

-
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 cutoff(mut self, cutoff: usize, cutoff_after: usize) -> Self {
-
        self.props.cutoff = cutoff;
-
        self.props.cutoff_after = cutoff_after;
-
        self
+
impl<S, M> Default for Header<S, M> {
+
    fn default() -> Self {
+
        Self {
+
            phantom: PhantomData,
+
        }
    }
}

-
impl<'a: 'static, S, M> Widget for Header<'a, S, M> {
+
impl<'a: 'static, S, M> View for Header<S, M> {
    type Message = M;
    type State = S;

-
    fn new(_state: &S, tx: UnboundedSender<M>) -> Self {
-
        Self {
-
            base: WidgetBase::new(tx.clone()),
-
            props: HeaderProps::default(),
-
        }
-
    }
-

-
    fn handle_event(&mut self, _key: Key) {}
+
    fn render(&self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
+
        let default = HeaderProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<HeaderProps>())
+
            .unwrap_or(&default);

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

-
    fn render(&self, frame: &mut ratatui::Frame, props: RenderProps) {
-
        let widths: Vec<Constraint> = self
-
            .props
+
        let widths: Vec<Constraint> = props
            .columns
            .iter()
            .filter_map(|column| {
@@ -121,8 +97,7 @@ impl<'a: 'static, S, M> Widget for Header<'a, S, M> {
                }
            })
            .collect();
-
        let cells = self
-
            .props
+
        let cells = props
            .columns
            .iter()
            .filter_map(|column| {
@@ -134,11 +109,8 @@ impl<'a: 'static, S, M> Widget for Header<'a, S, M> {
            })
            .collect::<Vec<_>>();

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

        let header_layout = Layout::default()
@@ -154,7 +126,7 @@ impl<'a: 'static, S, M> Widget for Header<'a, S, M> {
            .constraints(vec![Constraint::Min(1)])
            .vertical_margin(1)
            .horizontal_margin(1)
-
            .split(props.area);
+
            .split(render.area);

        let header = Row::new(cells).style(style::reset().bold());
        let header = ratatui::widgets::Table::default()
@@ -162,17 +134,9 @@ impl<'a: 'static, S, M> Widget for Header<'a, S, M> {
            .header(header)
            .widths(widths.clone());

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

-
    fn base(&self) -> &WidgetBase<S, M> {
-
        &self.base
-
    }
-

-
    fn base_mut(&mut self) -> &mut WidgetBase<S, M> {
-
        &mut self.base
-
    }
}

#[derive(Clone, Debug)]
@@ -205,28 +169,20 @@ impl<'a> Default for FooterProps<'a> {
    }
}

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

-
pub struct Footer<'a, S, M> {
-
    /// Internal props
-
    props: FooterProps<'a>,
-
    /// Internal base
-
    base: WidgetBase<S, M>,
+
pub struct Footer<S, M> {
+
    /// Phantom
+
    phantom: PhantomData<(S, M)>,
}

-
impl<'a, S, M> Footer<'a, S, M> {
-
    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
+
impl<S, M> Default for Footer<S, M> {
+
    fn default() -> Self {
+
        Self {
+
            phantom: PhantomData,
+
        }
    }
+
}

+
impl<'a, S, M> Footer<S, M> {
    fn render_cell(
        &self,
        frame: &mut ratatui::Frame,
@@ -250,30 +206,17 @@ impl<'a, S, M> Footer<'a, S, M> {
    }
}

-
impl<'a: 'static, S, M> Widget for Footer<'a, S, M> {
+
impl<'a: 'static, S, M> View for Footer<S, M> {
    type Message = M;
    type State = S;

-
    fn new(_state: &S, tx: UnboundedSender<M>) -> Self {
-
        Self {
-
            base: WidgetBase::new(tx.clone()),
-
            props: FooterProps::default(),
-
        }
-
    }
+
    fn render(&self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
+
        let default = FooterProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<FooterProps>())
+
            .unwrap_or(&default);

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

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

-
    fn render(&self, frame: &mut ratatui::Frame, props: RenderProps) {
-
        let widths = self
-
            .props
+
        let widths = props
            .columns
            .iter()
            .map(|c| match c.width {
@@ -282,9 +225,8 @@ impl<'a: 'static, S, M> Widget for Footer<'a, S, M> {
            })
            .collect::<Vec<_>>();

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

-
    fn base(&self) -> &WidgetBase<S, M> {
-
        &self.base
-
    }
-

-
    fn base_mut(&mut self) -> &mut WidgetBase<S, M> {
-
        &mut self.base
-
    }
}

#[derive(Clone, Default)]
@@ -326,66 +260,59 @@ impl ContainerProps {
    }
}

-
impl Properties for ContainerProps {}
-
impl BoxedAny for ContainerProps {}
-

pub struct Container<S, M> {
-
    /// Internal base
-
    base: WidgetBase<S, M>,
-
    /// Internal props
-
    props: ContainerProps,
    /// Container header
-
    header: Option<BoxedWidget<S, M>>,
+
    header: Option<Widget<S, M>>,
    /// Content widget
-
    content: Option<BoxedWidget<S, M>>,
+
    content: Option<Widget<S, M>>,
    /// Container footer
-
    footer: Option<BoxedWidget<S, M>>,
+
    footer: Option<Widget<S, M>>,
+
}
+

+
impl<S, M> Default for Container<S, M> {
+
    fn default() -> Self {
+
        Self {
+
            header: None,
+
            content: None,
+
            footer: None,
+
        }
+
    }
}

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

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

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

-
impl<S, M> Widget for Container<S, M> {
+
impl<S, M> View for Container<S, M>
+
where
+
    S: 'static,
+
    M: 'static,
+
{
    type Message = M;
    type State = S;

-
    fn new(_state: &S, tx: UnboundedSender<M>) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        Self {
-
            base: WidgetBase::new(tx.clone()),
-
            props: ContainerProps::default(),
-
            header: None,
-
            content: None,
-
            footer: None,
-
        }
-
    }
-

-
    fn handle_event(&mut self, key: termion::event::Key) {
+
    fn handle_event(&mut self, _props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
        if let Some(content) = &mut self.content {
            content.handle_event(key);
        }
-
    }

-
    fn update(&mut self, state: &S) {
-
        self.props =
-
            ContainerProps::from_callback(self.base.on_update, state).unwrap_or(self.props.clone());
+
        None
+
    }

+
    fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) {
        if let Some(header) = &mut self.header {
            header.update(state);
        }
@@ -399,9 +326,14 @@ impl<S, M> Widget for Container<S, M> {
        }
    }

-
    fn render(&self, frame: &mut ratatui::Frame, props: RenderProps) {
+
    fn render(&self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
+
        let default = ContainerProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<ContainerProps>())
+
            .unwrap_or(&default);
+

        let header_h = if self.header.is_some() { 3 } else { 0 };
-
        let footer_h = if self.footer.is_some() && !self.props.hide_footer {
+
        let footer_h = if self.footer.is_some() && !props.hide_footer {
            3
        } else {
            0
@@ -412,11 +344,11 @@ impl<S, M> Widget for Container<S, M> {
            Constraint::Min(1),
            Constraint::Length(footer_h),
        ])
-
        .areas(props.area);
+
        .areas(render.area);

        let borders = match (
            self.header.is_some(),
-
            (self.footer.is_some() && !self.props.hide_footer),
+
            (self.footer.is_some() && !props.hide_footer),
        ) {
            (false, false) => Borders::ALL,
            (true, false) => Borders::BOTTOM | Borders::LEFT | Borders::RIGHT,
@@ -425,34 +357,26 @@ impl<S, M> Widget for Container<S, M> {
        };

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

        if let Some(header) = &self.header {
-
            header.render(frame, RenderProps::from(header_area).focus(props.focus));
+
            header.render(RenderProps::from(header_area).focus(render.focus), frame);
        }

        if let Some(content) = &self.content {
            content.render(
+
                RenderProps::from(block.inner(content_area)).focus(render.focus),
                frame,
-
                RenderProps::from(block.inner(content_area)).focus(props.focus),
            );
        }

        if let Some(footer) = &self.footer {
-
            footer.render(frame, RenderProps::from(footer_area).focus(props.focus));
+
            footer.render(RenderProps::from(footer_area).focus(render.focus), frame);
        }
    }
-

-
    fn base(&self) -> &WidgetBase<S, M> {
-
        &self.base
-
    }
-

-
    fn base_mut(&mut self) -> &mut WidgetBase<S, M> {
-
        &mut self.base
-
    }
}

#[derive(Clone)]
@@ -461,8 +385,6 @@ pub struct SectionGroupState {
    focus: Option<usize>,
}

-
impl BoxedAny for SectionGroupState {}
-

#[derive(Clone, Default)]
pub struct SectionGroupProps {
    /// If this pages' keys should be handled.
@@ -476,22 +398,24 @@ impl SectionGroupProps {
    }
}

-
impl Properties for SectionGroupProps {}
-
impl BoxedAny for SectionGroupProps {}
-

pub struct SectionGroup<S, M> {
-
    /// Internal base
-
    base: WidgetBase<S, M>,
-
    /// Internal table properties
-
    props: SectionGroupProps,
    /// All sections
-
    sections: Vec<BoxedWidget<S, M>>,
+
    sections: Vec<Widget<S, M>>,
    /// Internal selection and offset state
    state: SectionGroupState,
}

+
impl<S, M> Default for SectionGroup<S, M> {
+
    fn default() -> Self {
+
        Self {
+
            sections: vec![],
+
            state: SectionGroupState { focus: Some(0) },
+
        }
+
    }
+
}
+

impl<S, M> SectionGroup<S, M> {
-
    pub fn section(mut self, section: BoxedWidget<S, M>) -> Self {
+
    pub fn section(mut self, section: Widget<S, M>) -> Self {
        self.sections.push(section);
        self
    }
@@ -515,7 +439,7 @@ impl<S, M> SectionGroup<S, M> {
    }
}

-
impl<S, M> Widget for SectionGroup<S, M>
+
impl<S, M> View for SectionGroup<S, M>
where
    S: 'static,
    M: 'static,
@@ -523,16 +447,12 @@ where
    type State = S;
    type Message = M;

-
    fn new(_state: &S, tx: UnboundedSender<M>) -> Self {
-
        Self {
-
            base: WidgetBase::new(tx.clone()),
-
            props: SectionGroupProps::default(),
-
            sections: vec![],
-
            state: SectionGroupState { focus: Some(0) },
-
        }
-
    }
+
    fn handle_event(&mut self, props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
+
        let default = SectionGroupProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<SectionGroupProps>())
+
            .unwrap_or(&default);

-
    fn handle_event(&mut self, key: Key) {
        if let Some(section) = self
            .state
            .focus
@@ -541,7 +461,7 @@ where
            section.handle_event(key);
        }

-
        if self.props.handle_keys {
+
        if props.handle_keys {
            match key {
                Key::Left => {
                    self.prev();
@@ -553,22 +473,17 @@ where
            }
        }

-
        if let Some(on_event) = self.base.on_event {
-
            (on_event)(self, key);
-
        }
+
        None
    }

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

+
    fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) {
        for section in &mut self.sections {
            section.update(state);
        }
    }

-
    fn render(&self, frame: &mut ratatui::Frame, props: RenderProps) {
-
        let areas = props.layout.split(props.area);
+
    fn render(&self, _props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
+
        let areas = render.layout.split(render.area);

        for (index, area) in areas.iter().enumerate() {
            if let Some(section) = self.sections.get(index) {
@@ -578,16 +493,8 @@ where
                    .map(|focus_index| index == focus_index)
                    .unwrap_or_default();

-
                section.render(frame, RenderProps::from(*area).focus(focus));
+
                section.render(RenderProps::from(*area).focus(focus), frame);
            }
        }
    }
-

-
    fn base(&self) -> &WidgetBase<S, M> {
-
        &self.base
-
    }
-

-
    fn base_mut(&mut self) -> &mut WidgetBase<S, M> {
-
        &mut self.base
-
    }
}
modified src/ui/widget/input.rs
@@ -1,12 +1,13 @@
-
use termion::event::Key;
+
use std::marker::PhantomData;

-
use tokio::sync::mpsc::UnboundedSender;
+
use ratatui::Frame;
+
use termion::event::Key;

use ratatui::layout::{Constraint, Layout};
use ratatui::style::Stylize;
use ratatui::text::{Line, Span};

-
use super::{BoxedAny, Properties, RenderProps, Widget, WidgetBase};
+
use super::{RenderProps, View, ViewProps, ViewState};

#[derive(Clone)]
pub struct TextFieldProps {
@@ -46,30 +47,32 @@ impl Default for TextFieldProps {
    }
}

-
impl Properties for TextFieldProps {}
-

#[derive(Clone)]
struct TextFieldState {
    pub text: Option<String>,
    pub cursor_position: usize,
}

-
impl BoxedAny for TextFieldState {}
-

pub struct TextField<S, M> {
-
    /// Internal base
-
    base: WidgetBase<S, M>,
-
    /// Internal props
-
    props: TextFieldProps,
    /// Internal state
    state: TextFieldState,
+
    /// Phantom
+
    phantom: PhantomData<(S, M)>,
}

-
impl<S, M> TextField<S, M> {
-
    pub fn text(&self) -> Option<&String> {
-
        self.state.text.as_ref()
+
impl<S, M> Default for TextField<S, M> {
+
    fn default() -> Self {
+
        Self {
+
            state: TextFieldState {
+
                text: None,
+
                cursor_position: 0,
+
            },
+
            phantom: PhantomData,
+
        }
    }
+
}

+
impl<S, M> TextField<S, M> {
    fn move_cursor_left(&mut self) {
        let cursor_moved_left = self.state.cursor_position.saturating_sub(1);
        self.state.cursor_position = self.clamp_cursor(cursor_moved_left);
@@ -131,7 +134,7 @@ impl<S, M> TextField<S, M> {
    }
}

-
impl<S, M> Widget for TextField<S, M>
+
impl<S, M> View for TextField<S, M>
where
    S: 'static,
    M: 'static,
@@ -139,18 +142,14 @@ where
    type Message = M;
    type State = S;

-
    fn new(_state: &S, tx: UnboundedSender<M>) -> Self {
-
        Self {
-
            base: WidgetBase::new(tx.clone()),
-
            props: TextFieldProps::default(),
-
            state: TextFieldState {
-
                text: None,
-
                cursor_position: 0,
-
            },
-
        }
+
    fn view_state(&self) -> Option<ViewState> {
+
        self.state
+
            .text
+
            .as_ref()
+
            .map(|text| ViewState::String(text.to_string()))
    }

-
    fn handle_event(&mut self, key: Key) {
+
    fn handle_event(&mut self, _props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
        match key {
            Key::Char(to_insert)
                if (key != Key::Alt('\n'))
@@ -171,35 +170,37 @@ where
            _ => {}
        }

-
        if let Some(on_event) = self.base.on_event {
-
            (on_event)(self, key);
-
        }
+
        None
    }

-
    fn update(&mut self, state: &S) {
-
        if let Some(on_update) = self.base.on_update {
-
            if let Some(props) = (on_update)(state).downcast_ref::<TextFieldProps>() {
-
                self.props = props.clone();
+
    fn update(&mut self, props: Option<&ViewProps>, _state: &Self::State) {
+
        let default = TextFieldProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<TextFieldProps>())
+
            .unwrap_or(&default);

-
                if self.state.text.is_none() {
-
                    self.state.cursor_position = props.text.len().saturating_sub(1);
-
                }
-
                self.state.text = Some(props.text.clone());
-
            }
+
        if self.state.text.is_none() {
+
            self.state.cursor_position = props.text.len().saturating_sub(1);
        }
+
        self.state.text = Some(props.text.clone());
    }

-
    fn render(&self, frame: &mut ratatui::Frame, props: RenderProps) {
-
        let area = props.area;
+
    fn render(&self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
+
        let default = TextFieldProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<TextFieldProps>())
+
            .unwrap_or(&default);
+

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

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

-
        if self.props.inline_label {
+
        if props.inline_label {
            let top_layout = Layout::horizontal([
                Constraint::Length(label.chars().count() as u16),
                Constraint::Length(1),
@@ -216,7 +217,7 @@ where
            frame.render_widget(input, top_layout[2]);
            frame.render_widget(overline, layout[1]);

-
            if self.props.show_cursor {
+
            if props.show_cursor {
                frame.set_cursor(top_layout[2].x + cursor_pos, top_layout[2].y)
            }
        } else {
@@ -232,19 +233,9 @@ where
            frame.render_widget(top, layout[0]);
            frame.render_widget(bottom, layout[1]);

-
            if self.props.show_cursor {
+
            if props.show_cursor {
                frame.set_cursor(area.x + cursor_pos, area.y)
            }
        }
    }
-

-
    fn base(&self) -> &WidgetBase<S, M> {
-
        &self.base
-
    }
-

-
    fn base_mut(&mut self) -> &mut WidgetBase<S, M> {
-
        &mut self.base
-
    }
}
-

-
impl<S, M> BoxedAny for TextField<S, M> {}
modified src/ui/widget/list.rs
@@ -1,8 +1,8 @@
use std::cmp;
+
use std::marker::PhantomData;

use ratatui::widgets::Row;
-
use tokio::sync::mpsc::UnboundedSender;
-

+
use ratatui::Frame;
use termion::event::Key;

use ratatui::layout::Constraint;
@@ -10,11 +10,12 @@ use ratatui::style::Stylize;
use ratatui::text::Text;
use ratatui::widgets::TableState;

+
use crate::ui::items::ToRow;
use crate::ui::theme::style;
use crate::ui::{layout, span};

-
use super::BoxedAny;
-
use super::{container::Column, Properties, RenderProps, ToRow, Widget, WidgetBase};
+
use super::{container::Column, RenderProps, View};
+
use super::{ViewProps, ViewState};

#[derive(Clone, Debug)]
pub struct TableProps<'a, R, const W: usize>
@@ -83,31 +84,32 @@ where
    }
}

-
impl<'a: 'static, R, const W: usize> Properties for TableProps<'a, R, W> where R: ToRow<W> + 'static {}
-
impl<'a: 'static, R, const W: usize> BoxedAny for TableProps<'a, R, W> where R: ToRow<W> + 'static {}
-

-
impl BoxedAny for TableState {}
-

-
pub struct Table<'a, S, M, R, const W: usize>
+
pub struct Table<S, M, R, const W: usize>
where
    R: ToRow<W>,
{
-
    /// Internal base
-
    base: WidgetBase<S, M>,
-
    /// Internal table properties
-
    props: TableProps<'a, R, W>,
    /// Internal selection and offset state
    state: TableState,
+
    /// Phantom
+
    phantom: PhantomData<(S, M, R)>,
}

-
impl<'a, S, M, R, const W: usize> Table<'a, S, M, R, W>
+
impl<S, M, R, const W: usize> Default for Table<S, M, R, W>
where
    R: ToRow<W>,
{
-
    pub fn selected(&self) -> Option<usize> {
-
        self.state.selected()
+
    fn default() -> Self {
+
        Self {
+
            state: TableState::default().with_selected(Some(0)),
+
            phantom: PhantomData,
+
        }
    }
+
}

+
impl<S, M, R, const W: usize> Table<S, M, R, W>
+
where
+
    R: ToRow<W>,
+
{
    fn prev(&mut self) -> Option<usize> {
        let selected = self
            .state
@@ -159,83 +161,82 @@ where
    }
}

-
impl<'a: 'static, S: 'a, M: 'a, R, const W: usize> Widget for Table<'a, S, M, R, W>
+
impl<S, M, R, const W: usize> View for Table<S, M, R, W>
where
+
    S: 'static,
+
    M: 'static,
    R: ToRow<W> + Clone + 'static,
{
    type Message = M;
    type State = S;

-
    fn new(_state: &S, tx: UnboundedSender<M>) -> Self {
-
        Self {
-
            base: WidgetBase::new(tx.clone()),
-
            props: TableProps::default(),
-
            state: TableState::default().with_selected(Some(0)),
-
        }
-
    }
+
    fn handle_event(&mut self, props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
+
        let default = TableProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<TableProps<R, W>>())
+
            .unwrap_or(&default);

-
    fn handle_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());
+
                self.next(props.items.len());
            }
            Key::PageUp => {
-
                self.prev_page(self.props.page_size);
+
                self.prev_page(props.page_size);
            }
            Key::PageDown => {
-
                self.next_page(self.props.items.len(), self.props.page_size);
+
                self.next_page(props.items.len(), props.page_size);
            }
            Key::Home => {
                self.begin();
            }
            Key::End => {
-
                self.end(self.props.items.len());
+
                self.end(props.items.len());
            }
            _ => {}
        }

-
        if let Some(on_event) = self.base.on_event {
-
            (on_event)(self, key);
-
        }
+
        None
    }

-
    fn update(&mut self, state: &S) {
-
        self.props =
-
            TableProps::from_callback(self.base.on_update, state).unwrap_or(self.props.clone());
+
    fn update(&mut self, props: Option<&ViewProps>, _state: &Self::State) {
+
        let default = TableProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<TableProps<R, W>>())
+
            .unwrap_or(&default);

-
        if self.props.selected != self.state.selected() {
-
            self.state.select(self.props.selected);
+
        if props.selected != self.state.selected() {
+
            self.state.select(props.selected);
        }
    }

-
    fn render(&self, frame: &mut ratatui::Frame, props: RenderProps) {
-
        let widths: Vec<Constraint> = self
-
            .props
+
    fn render(&self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
+
        let default = TableProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<TableProps<R, W>>())
+
            .unwrap_or(&default);
+

+
        let widths: Vec<Constraint> = props
            .columns
            .iter()
            .filter_map(|c| if !c.skip { Some(c.width) } else { None })
            .collect();

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

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

                    for cell in item.to_row() {
                        if let Some(col) = it.next() {
@@ -256,9 +257,9 @@ where
                .column_spacing(1)
                .highlight_style(style::highlight());

-
            frame.render_stateful_widget(rows, props.area, &mut self.state.clone());
+
            frame.render_stateful_widget(rows, render.area, &mut self.state.clone());
        } else {
-
            let center = layout::centered_rect(props.area, 50, 10);
+
            let center = layout::centered_rect(render.area, 50, 10);
            let hint = Text::from(span::default("Nothing to show"))
                .centered()
                .light_magenta()
@@ -268,12 +269,8 @@ where
        }
    }

-
    fn base(&self) -> &WidgetBase<S, M> {
-
        &self.base
-
    }
-

-
    fn base_mut(&mut self) -> &mut WidgetBase<S, M> {
-
        &mut self.base
+
    fn view_state(&self) -> Option<ViewState> {
+
        self.state.selected().map(ViewState::USize)
    }
}

modified src/ui/widget/text.rs
@@ -1,22 +1,23 @@
-
use tokio::sync::mpsc::UnboundedSender;
-

-
use termion::event::Key;
+
use std::marker::PhantomData;

use ratatui::layout::{Constraint, Layout};
use ratatui::text::Text;
+
use ratatui::Frame;
+
use termion::event::Key;

-
use super::{BoxedAny, Properties, RenderProps, Widget, WidgetBase};
+
use super::{RenderProps, View, ViewProps, ViewState};

#[derive(Clone)]
-
pub struct ParagraphProps<'a> {
+
pub struct TextAreaProps<'a> {
    pub content: Text<'a>,
    pub has_header: bool,
    pub has_footer: bool,
    pub page_size: usize,
    pub progress: usize,
+
    pub can_scroll: bool,
}

-
impl<'a> ParagraphProps<'a> {
+
impl<'a> TextAreaProps<'a> {
    pub fn page_size(mut self, page_size: usize) -> Self {
        self.page_size = page_size;
        self
@@ -26,9 +27,14 @@ impl<'a> ParagraphProps<'a> {
        self.content = text.clone();
        self
    }
+

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

-
impl<'a> Default for ParagraphProps<'a> {
+
impl<'a> Default for TextAreaProps<'a> {
    fn default() -> Self {
        Self {
            content: Text::raw(""),
@@ -36,45 +42,41 @@ impl<'a> Default for ParagraphProps<'a> {
            has_footer: false,
            page_size: 1,
            progress: 0,
+
            can_scroll: true,
        }
    }
}

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

#[derive(Clone)]
-
struct ParagraphState {
+
struct TextAreaState {
    /// Internal offset
    pub offset: usize,
    /// Internal progress
    pub progress: usize,
}

-
impl BoxedAny for ParagraphState {}
-

-
pub struct Paragraph<'a, S, M> {
-
    /// Internal base
-
    base: WidgetBase<S, M>,
-
    /// Internal props
-
    props: ParagraphProps<'a>,
+
pub struct TextArea<S, M> {
    /// Internal state
-
    state: ParagraphState,
+
    state: TextAreaState,
+
    /// Phantom
+
    phantom: PhantomData<(S, M)>,
}

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

-
    pub fn page_size(mut self, page_size: usize) -> Self {
-
        self.props.page_size = page_size;
-
        self
+
impl<S, M> Default for TextArea<S, M> {
+
    fn default() -> Self {
+
        Self {
+
            state: TextAreaState {
+
                offset: 0,
+
                progress: 0,
+
            },
+
            phantom: PhantomData,
+
        }
    }
+
}

-
    pub fn text(mut self, text: &Text<'a>) -> Self {
-
        self.props.content = text.clone();
-
        self
+
impl<S, M> TextArea<S, M> {
+
    fn scroll(&self) -> (u16, u16) {
+
        (self.state.offset as u16, 0)
    }

    fn prev(&mut self, len: usize, page_size: usize) -> (u16, u16) {
@@ -118,10 +120,6 @@ impl<'a, S, M> Paragraph<'a, S, M> {
        self.scroll()
    }

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

    fn scroll_percent(offset: usize, len: usize, height: usize) -> usize {
        if height >= len {
            100
@@ -136,80 +134,67 @@ impl<'a, S, M> Paragraph<'a, S, M> {
    }
}

-
impl<'a, S, M> Widget for Paragraph<'a, S, M>
+
impl<S, M> View for TextArea<S, M>
where
-
    'a: 'static,
    S: 'static,
    M: 'static,
{
    type Message = M;
    type State = S;

-
    fn new(_state: &S, tx: UnboundedSender<M>) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        Self {
-
            base: WidgetBase::new(tx.clone()),
-
            props: ParagraphProps::default(),
-
            state: ParagraphState {
-
                offset: 0,
-
                progress: 0,
-
            },
-
        }
-
    }
-

-
    fn handle_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);
+
    fn handle_event(&mut self, props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
+
        let default = TextAreaProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<TextAreaProps>())
+
            .unwrap_or(&default);
+

+
        let len = props.content.lines.len() + 1;
+
        let page_size = props.page_size;
+

+
        if props.can_scroll {
+
            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);
+
                }
+
                _ => {}
            }
-
            Key::End => {
-
                self.end(len, page_size);
-
            }
-
            _ => {}
        }

-
        if let Some(on_event) = self.base.on_event {
-
            (on_event)(self, key);
-
        }
+
        None
    }

-
    fn update(&mut self, state: &S) {
-
        self.props =
-
            ParagraphProps::from_callback(self.base.on_update, state).unwrap_or(self.props.clone());
-
    }
+
    fn render(&self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
+
        let default = TextAreaProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<TextAreaProps>())
+
            .unwrap_or(&default);

-
    fn render(&self, frame: &mut ratatui::Frame, props: RenderProps) {
        let [content_area] = Layout::horizontal([Constraint::Min(1)])
            .horizontal_margin(1)
-
            .areas(props.area);
-
        let content = ratatui::widgets::Paragraph::new(self.props.content.clone())
+
            .areas(render.area);
+
        let content = ratatui::widgets::Paragraph::new(props.content.clone())
+
            .style(props.content.style)
            .scroll((self.state.offset as u16, 0));

        frame.render_widget(content, content_area);
    }

-
    fn base(&self) -> &WidgetBase<S, M> {
-
        &self.base
-
    }
-

-
    fn base_mut(&mut self) -> &mut WidgetBase<S, M> {
-
        &mut self.base
+
    fn view_state(&self) -> Option<ViewState> {
+
        Some(ViewState::USize(self.state.progress))
    }
}
modified src/ui/widget/window.rs
@@ -1,18 +1,17 @@
-
use std::collections::HashMap;
use std::hash::Hash;
+
use std::{collections::HashMap, marker::PhantomData};

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

+
use ratatui::Frame;
use termion::event::Key;

-
use ratatui::layout::Constraint;
+
use ratatui::layout::{Constraint, Layout};
use ratatui::style::Stylize;
use ratatui::text::Text;
use ratatui::widgets::Row;

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

-
use super::{BoxedAny, BoxedWidget, Properties, RenderProps, Widget, WidgetBase};
+
use super::{RenderProps, View, ViewProps, Widget};

#[derive(Clone)]
pub struct WindowProps<Id> {
@@ -32,29 +31,30 @@ impl<Id> Default for WindowProps<Id> {
    }
}

-
impl<Id> Properties for WindowProps<Id> {}
-
impl<Id> BoxedAny for WindowProps<Id> {}
-

pub struct Window<S, M, Id> {
-
    /// Internal base
-
    base: WidgetBase<S, M>,
-
    /// Internal properties
-
    props: WindowProps<Id>,
    /// All pages known
-
    pages: HashMap<Id, BoxedWidget<S, M>>,
+
    pages: HashMap<Id, Widget<S, M>>,
+
}
+

+
impl<S, M, Id> Default for Window<S, M, Id> {
+
    fn default() -> Self {
+
        Self {
+
            pages: HashMap::new(),
+
        }
+
    }
}

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

-
impl<'a, S, M, Id> Widget for Window<S, M, Id>
+
impl<'a, S, M, Id> View for Window<S, M, Id>
where
    'a: 'static,
    S: 'static,
@@ -64,20 +64,13 @@ where
    type Message = M;
    type State = S;

-
    fn new(_state: &S, tx: UnboundedSender<M>) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        Self {
-
            base: WidgetBase::new(tx.clone()),
-
            props: WindowProps::default(),
-
            pages: HashMap::new(),
-
        }
-
    }
+
    fn handle_event(&mut self, props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
+
        let default = WindowProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<WindowProps<Id>>())
+
            .unwrap_or(&default);

-
    fn handle_event(&mut self, key: termion::event::Key) {
-
        let page = self
-
            .props
+
        let page = props
            .current_page
            .as_ref()
            .and_then(|id| self.pages.get_mut(id));
@@ -86,17 +79,16 @@ where
            page.handle_event(key);
        }

-
        if let Some(on_event) = self.base.on_event {
-
            (on_event)(self, key);
-
        }
+
        None
    }

-
    fn update(&mut self, state: &S) {
-
        self.props =
-
            WindowProps::from_callback(self.base.on_update, state).unwrap_or(self.props.clone());
+
    fn update(&mut self, props: Option<&ViewProps>, state: &Self::State) {
+
        let default = WindowProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<WindowProps<Id>>())
+
            .unwrap_or(&default);

-
        let page = self
-
            .props
+
        let page = props
            .current_page
            .as_ref()
            .and_then(|id| self.pages.get_mut(id));
@@ -106,26 +98,114 @@ where
        }
    }

-
    fn render(&self, frame: &mut ratatui::Frame, _props: RenderProps) {
+
    fn render(&self, props: Option<&ViewProps>, _render: RenderProps, frame: &mut Frame) {
+
        let default = WindowProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<WindowProps<Id>>())
+
            .unwrap_or(&default);
+

        let area = frame.size();

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

        if let Some(page) = page {
-
            page.render(frame, RenderProps::from(area).focus(true));
+
            page.render(RenderProps::from(area).focus(true), frame);
+
        }
+
    }
+
}
+

+
#[derive(Clone, Default)]
+
pub struct PageProps {
+
    /// Current page size (height of table content etc.).
+
    pub page_size: usize,
+
    /// If this view's should handle keys
+
    pub handle_keys: bool,
+
}
+

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

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

+
pub struct Page<S, M> {
+
    /// Content widget
+
    content: Option<Widget<S, M>>,
+
    /// Shortcut widget
+
    shortcuts: Option<Widget<S, M>>,
+
}
+

+
impl<S, M> Default for Page<S, M> {
+
    fn default() -> Self {
+
        Self {
+
            content: None,
+
            shortcuts: None,
+
        }
+
    }
+
}
+

+
impl<S, M> Page<S, M> {
+
    pub fn content(mut self, content: Widget<S, M>) -> Self {
+
        self.content = Some(content);
+
        self
+
    }
+

+
    pub fn shortcuts(mut self, shortcuts: Widget<S, M>) -> Self {
+
        self.shortcuts = Some(shortcuts);
+
        self
+
    }
+
}
+

+
impl<S, M> View for Page<S, M>
+
where
+
    S: 'static,
+
    M: 'static,
+
{
+
    type State = S;
+
    type Message = M;
+

+
    fn handle_event(&mut self, _props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
+
        if let Some(content) = self.content.as_mut() {
+
            content.handle_event(key);
        }
+

+
        None
    }

-
    fn base(&self) -> &WidgetBase<S, M> {
-
        &self.base
+
    fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) {
+
        if let Some(content) = self.content.as_mut() {
+
            content.update(state);
+
        }
+
        if let Some(shortcuts) = self.shortcuts.as_mut() {
+
            shortcuts.update(state);
+
        }
    }

-
    fn base_mut(&mut self) -> &mut WidgetBase<S, M> {
-
        &mut self.base
+
    fn render(&self, _props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
+
        let [content_area, shortcuts_area] =
+
            Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(render.area);
+

+
        if let Some(content) = self.content.as_ref() {
+
            content.render(
+
                RenderProps::from(content_area)
+
                    .layout(Layout::horizontal([Constraint::Min(1)]))
+
                    .focus(true),
+
                frame,
+
            );
+
        }
+

+
        if let Some(shortcuts) = self.shortcuts.as_ref() {
+
            shortcuts.render(RenderProps::from(shortcuts_area), frame);
+
        }
    }
}

@@ -159,63 +239,39 @@ impl Default for ShortcutsProps {
    }
}

-
impl Properties for ShortcutsProps {}
-
impl BoxedAny for ShortcutsProps {}
-

pub struct Shortcuts<S, M> {
-
    /// Internal properties
-
    props: ShortcutsProps,
-
    /// Internal base
-
    base: WidgetBase<S, M>,
+
    /// Phantom
+
    phantom: PhantomData<(S, M)>,
}

-
impl<S, M> Shortcuts<S, M> {
-
    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()));
+
impl<S, M> Default for Shortcuts<S, M> {
+
    fn default() -> Self {
+
        Self {
+
            phantom: PhantomData,
        }
-
        self
    }
}

-
impl<S, M> Widget for Shortcuts<S, M> {
+
impl<S, M> View for Shortcuts<S, M> {
    type Message = M;
    type State = S;

-
    fn new(_state: &S, tx: UnboundedSender<M>) -> Self {
-
        Self {
-
            base: WidgetBase::new(tx.clone()),
-
            props: ShortcutsProps::default(),
-
        }
-
    }
-

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

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

-
    fn render(&self, frame: &mut ratatui::Frame, props: RenderProps) {
+
    fn render(&self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
        use ratatui::widgets::Table;

-
        let mut shortcuts = self.props.shortcuts.iter().peekable();
+
        let default = ShortcutsProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<ShortcutsProps>())
+
            .unwrap_or(&default);
+

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

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

            row.push((shortcut.0.chars().count(), short));
            row.push((1, spacer));
@@ -239,14 +295,6 @@ impl<S, M> Widget for Shortcuts<S, M> {
            .collect();

        let table = Table::new([Row::new(row)], widths).column_spacing(0);
-
        frame.render_widget(table, props.area);
-
    }
-

-
    fn base(&self) -> &WidgetBase<S, M> {
-
        &self.base
-
    }
-

-
    fn base_mut(&mut self) -> &mut WidgetBase<S, M> {
-
        &mut self.base
+
        frame.render_widget(table, render.area);
    }
}