Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
Rework render properties
Merged did:key:z6MkswQE...2C1V opened 1 year ago
  • lib: introduce general render properties
  • lib: replace specific widget properties with render properties
  • lib: fix focus in library widgets
  • bin: fix focus across all commands
9 files changed +229 -300 921ebc00 b4d543ed
modified bin/commands/inbox/select/ui.rs
@@ -1,4 +1,3 @@
-
use std::any::Any;
use std::collections::HashMap;
use std::str::FromStr;

@@ -7,7 +6,7 @@ use tokio::sync::mpsc::UnboundedSender;

use termion::event::Key;

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

@@ -20,7 +19,7 @@ use tui::ui::widget::container::{
};
use tui::ui::widget::input::{TextField, TextFieldProps, TextFieldState};
use tui::ui::widget::text::{Paragraph, ParagraphProps, ParagraphState};
-
use tui::ui::widget::{self, BaseView, TableUtils, WidgetState};
+
use tui::ui::widget::{self, BaseView, RenderProps, TableUtils, WidgetState};
use tui::ui::widget::{Column, Properties, Shortcuts, ShortcutsProps, Table, TableProps, Widget};
use tui::Selection;

@@ -39,7 +38,6 @@ struct BrowsePageProps<'a> {
    columns: Vec<Column<'a>>,
    cutoff: usize,
    cutoff_after: usize,
-
    focus: bool,
    page_size: usize,
    search: String,
    show_search: bool,
@@ -83,7 +81,6 @@ impl<'a> From<&State> for BrowsePageProps<'a> {
            .to_vec(),
            cutoff: 200,
            cutoff_after: 5,
-
            focus: false,
            search: state.browser.search.read(),
            page_size: state.browser.page_size,
            show_search: state.browser.show_search,
@@ -146,7 +143,6 @@ impl<'a: 'static> Widget for BrowsePage<'a> {
                            .to_vec(),
                        )
                        .cutoff(props.cutoff, props.cutoff_after)
-
                        .focus(props.focus)
                        .to_boxed(),
                )
                .content(Box::<Table<State, Action, NotificationItem, 9>>::new(
@@ -183,9 +179,20 @@ impl<'a: 'static> Widget for BrowsePage<'a> {
                        })
                        .to_boxed(),
                )
+
                .on_update(|state| {
+
                    ContainerProps::default()
+
                        .hide_footer(BrowsePageProps::from(state).show_search)
+
                        .to_boxed()
+
                })
                .to_boxed(),
            search: Search::new(state, action_tx.clone()).to_boxed(),
-
            shortcuts: Shortcuts::new(state, action_tx.clone()).to_boxed(),
+
            shortcuts: Shortcuts::new(state, action_tx.clone())
+
                .on_update(|state| {
+
                    ShortcutsProps::default()
+
                        .shortcuts(&BrowsePageProps::from(state).shortcuts)
+
                        .to_boxed()
+
                })
+
                .to_boxed(),
        }
    }

@@ -256,41 +263,29 @@ impl<'a: 'static> Widget for BrowsePage<'a> {
        self.shortcuts.update(state);
    }

-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, props: Option<&dyn Any>) {
-
        let props = props
-
            .and_then(|props| props.downcast_ref::<BrowsePageProps>())
-
            .unwrap_or(&self.props);
-

-
        let page_size = area.height.saturating_sub(6) as usize;
+
    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(area);
+
            Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(props.area);

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

-
            self.notifications.render(
-
                frame,
-
                table_area,
-
                Some(&ContainerProps::default().hide_footer(props.show_search)),
-
            );
-
            self.search.render(frame, search_area, None);
+
            self.notifications
+
                .render(frame, RenderProps::from(table_area));
+
            self.search
+
                .render(frame, RenderProps::from(search_area).focus(true));
        } else {
-
            self.notifications.render(
-
                frame,
-
                content_area,
-
                Some(&ContainerProps::default().hide_footer(props.show_search)),
-
            );
+
            self.notifications
+
                .render(frame, RenderProps::from(content_area).focus(true));
        }

-
        self.shortcuts.render(
-
            frame,
-
            shortcuts_area,
-
            Some(&ShortcutsProps::default().shortcuts(&props.shortcuts)),
-
        );
+
        self.shortcuts
+
            .render(frame, RenderProps::from(shortcuts_area));

-
        if page_size != props.page_size {
+
        if page_size != self.props.page_size {
            let _ = self.base.action_tx.send(Action::BrowserPageSize(page_size));
        }
    }
@@ -368,12 +363,12 @@ impl Widget for Search {
        self.input.update(state);
    }

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

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

    fn base_mut(&mut self) -> &mut BaseView<State, Action> {
@@ -383,7 +378,6 @@ impl Widget for Search {

#[derive(Clone)]
struct HelpPageProps<'a> {
-
    focus: bool,
    page_size: usize,
    help_progress: usize,
    shortcuts: Vec<(&'a str, &'a str)>,
@@ -392,7 +386,6 @@ struct HelpPageProps<'a> {
impl<'a> From<&State> for HelpPageProps<'a> {
    fn from(state: &State) -> Self {
        Self {
-
            focus: false,
            page_size: state.help.page_size,
            help_progress: state.help.progress,
            shortcuts: vec![("?", "close")],
@@ -431,12 +424,9 @@ impl<'a: 'static> Widget for HelpPage<'a> {
            content: Container::new(state, action_tx.clone())
                .header(
                    Header::new(state, action_tx.clone())
-
                        .on_update(|state| {
-
                            let props = HelpPageProps::from(state);
-

+
                        .on_update(|_| {
                            HeaderProps::default()
                                .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec())
-
                                .focus(props.focus)
                                .to_boxed()
                        })
                        .to_boxed(),
@@ -449,7 +439,6 @@ impl<'a: 'static> Widget for HelpPage<'a> {
                            ParagraphProps::default()
                                .text(&help_text())
                                .page_size(props.page_size)
-
                                .focus(props.focus)
                                .to_boxed()
                        })
                        .on_event(|paragraph, action_tx| {
@@ -480,13 +469,18 @@ impl<'a: 'static> Widget for HelpPage<'a> {
                                    ]
                                    .to_vec(),
                                )
-
                                .focus(props.focus)
                                .to_boxed()
                        })
                        .to_boxed(),
                )
                .to_boxed(),
-
            shortcuts: Shortcuts::new(state, action_tx.clone()).to_boxed(),
+
            shortcuts: Shortcuts::new(state, action_tx.clone())
+
                .on_update(|state| {
+
                    ShortcutsProps::default()
+
                        .shortcuts(&HelpPageProps::from(state).shortcuts)
+
                        .to_boxed()
+
                })
+
                .to_boxed(),
        }
    }

@@ -509,26 +503,21 @@ impl<'a: 'static> Widget for HelpPage<'a> {
            .unwrap_or(HelpPageProps::from(state));

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

-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, props: Option<&dyn Any>) {
-
        let props = props
-
            .and_then(|props| props.downcast_ref::<HelpPageProps>())
-
            .unwrap_or(&self.props);
-

-
        let page_size = area.height.saturating_sub(6) as usize;
+
    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(area);
+
            Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(props.area);

-
        self.content.render(frame, content_area, None);
-
        self.shortcuts.render(
-
            frame,
-
            shortcuts_area,
-
            Some(&ShortcutsProps::default().shortcuts(&props.shortcuts)),
-
        );
+
        self.content
+
            .render(frame, RenderProps::from(content_area).focus(true));
+
        self.shortcuts
+
            .render(frame, RenderProps::from(shortcuts_area));

-
        if page_size != props.page_size {
+
        if page_size != self.props.page_size {
            let _ = self.base.action_tx.send(Action::HelpPageSize(page_size));
        }
    }
modified bin/commands/issue/select/ui.rs
@@ -1,4 +1,3 @@
-
use std::any::Any;
use std::collections::HashMap;
use std::str::FromStr;
use std::vec;
@@ -9,7 +8,7 @@ use tokio::sync::mpsc::UnboundedSender;

use termion::event::Key;

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

@@ -22,7 +21,7 @@ use tui::ui::widget::container::{
};
use tui::ui::widget::input::{TextField, TextFieldProps, TextFieldState};
use tui::ui::widget::text::{Paragraph, ParagraphProps, ParagraphState};
-
use tui::ui::widget::{self, BaseView, WidgetState};
+
use tui::ui::widget::{self, BaseView, RenderProps, WidgetState};
use tui::ui::widget::{
    Column, Properties, Shortcuts, ShortcutsProps, Table, TableProps, TableUtils, Widget,
};
@@ -45,7 +44,6 @@ struct BrowsePageProps<'a> {
    columns: Vec<Column<'a>>,
    cutoff: usize,
    cutoff_after: usize,
-
    focus: bool,
    page_size: usize,
    show_search: bool,
    shortcuts: Vec<(&'a str, &'a str)>,
@@ -99,7 +97,6 @@ impl<'a> From<&State> for BrowsePageProps<'a> {
            .to_vec(),
            cutoff: 200,
            cutoff_after: 5,
-
            focus: false,
            stats,
            page_size: state.browser.page_size,
            show_search: state.browser.show_search,
@@ -151,7 +148,6 @@ impl<'a: 'static> Widget for BrowsePage<'a> {
                    Header::new(state, action_tx.clone())
                        .columns(props.columns.clone())
                        .cutoff(props.cutoff, props.cutoff_after)
-
                        .focus(props.focus)
                        .to_boxed(),
                )
                .content(Box::<Table<State, Action, IssueItem, 8>>::new(
@@ -188,9 +184,20 @@ impl<'a: 'static> Widget for BrowsePage<'a> {
                        })
                        .to_boxed(),
                )
+
                .on_update(|state| {
+
                    ContainerProps::default()
+
                        .hide_footer(BrowsePageProps::from(state).show_search)
+
                        .to_boxed()
+
                })
                .to_boxed(),
            search: Search::new(state, action_tx.clone()).to_boxed(),
-
            shortcuts: Shortcuts::new(state, action_tx.clone()).to_boxed(),
+
            shortcuts: Shortcuts::new(state, action_tx.clone())
+
                .on_update(|state| {
+
                    ShortcutsProps::default()
+
                        .shortcuts(&BrowsePageProps::from(state).shortcuts)
+
                        .to_boxed()
+
                })
+
                .to_boxed(),
        }
    }

@@ -263,41 +270,28 @@ impl<'a: 'static> Widget for BrowsePage<'a> {
        self.shortcuts.update(state);
    }

-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, props: Option<&dyn Any>) {
-
        let props = props
-
            .and_then(|props| props.downcast_ref::<BrowsePageProps>())
-
            .unwrap_or(&self.props);
-

-
        let page_size = area.height.saturating_sub(6) as usize;
+
    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(area);
+
            Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(props.area);

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

-
            self.issues.render(
-
                frame,
-
                table_area,
-
                Some(&ContainerProps::default().hide_footer(props.show_search)),
-
            );
-
            self.search.render(frame, search_area, None);
+
            self.issues.render(frame, RenderProps::from(table_area));
+
            self.search
+
                .render(frame, RenderProps::from(search_area).focus(true));
        } else {
-
            self.issues.render(
-
                frame,
-
                content_area,
-
                Some(&ContainerProps::default().hide_footer(props.show_search)),
-
            );
+
            self.issues
+
                .render(frame, RenderProps::from(content_area).focus(true));
        }

-
        self.shortcuts.render(
-
            frame,
-
            shortcuts_area,
-
            Some(&ShortcutsProps::default().shortcuts(&props.shortcuts)),
-
        );
+
        self.shortcuts
+
            .render(frame, RenderProps::from(shortcuts_area));

-
        if page_size != props.page_size {
+
        if page_size != self.props.page_size {
            let _ = self.base.action_tx.send(Action::BrowserPageSize(page_size));
        }
    }
@@ -375,12 +369,12 @@ impl Widget for Search {
        self.input.update(state);
    }

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

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

    fn base_mut(&mut self) -> &mut BaseView<State, Action> {
@@ -390,7 +384,6 @@ impl Widget for Search {

#[derive(Clone)]
struct HelpPageProps<'a> {
-
    focus: bool,
    page_size: usize,
    help_progress: usize,
    shortcuts: Vec<(&'a str, &'a str)>,
@@ -399,7 +392,6 @@ struct HelpPageProps<'a> {
impl<'a> From<&State> for HelpPageProps<'a> {
    fn from(state: &State) -> Self {
        Self {
-
            focus: false,
            page_size: state.help.page_size,
            help_progress: state.help.progress,
            shortcuts: vec![("?", "close")],
@@ -438,12 +430,9 @@ impl<'a: 'static> Widget for HelpPage<'a> {
            content: Container::new(state, action_tx.clone())
                .header(
                    Header::new(state, action_tx.clone())
-
                        .on_update(|state| {
-
                            let props = HelpPageProps::from(state);
-

+
                        .on_update(|_| {
                            HeaderProps::default()
                                .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec())
-
                                .focus(props.focus)
                                .to_boxed()
                        })
                        .to_boxed(),
@@ -456,7 +445,6 @@ impl<'a: 'static> Widget for HelpPage<'a> {
                            ParagraphProps::default()
                                .text(&help_text())
                                .page_size(props.page_size)
-
                                .focus(props.focus)
                                .to_boxed()
                        })
                        .on_event(|paragraph, action_tx| {
@@ -487,13 +475,18 @@ impl<'a: 'static> Widget for HelpPage<'a> {
                                    ]
                                    .to_vec(),
                                )
-
                                .focus(props.focus)
                                .to_boxed()
                        })
                        .to_boxed(),
                )
                .to_boxed(),
-
            shortcuts: Shortcuts::new(state, action_tx.clone()).to_boxed(),
+
            shortcuts: Shortcuts::new(state, action_tx.clone())
+
                .on_update(|state| {
+
                    ShortcutsProps::default()
+
                        .shortcuts(&HelpPageProps::from(state).shortcuts)
+
                        .to_boxed()
+
                })
+
                .to_boxed(),
        }
    }

@@ -516,26 +509,21 @@ impl<'a: 'static> Widget for HelpPage<'a> {
            .unwrap_or(HelpPageProps::from(state));

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

-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, props: Option<&dyn Any>) {
-
        let props = props
-
            .and_then(|props| props.downcast_ref::<HelpPageProps>())
-
            .unwrap_or(&self.props);
-

-
        let page_size = area.height.saturating_sub(6) as usize;
+
    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(area);
+
            Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(props.area);

-
        self.content.render(frame, content_area, None);
-
        self.shortcuts.render(
-
            frame,
-
            shortcuts_area,
-
            Some(&ShortcutsProps::default().shortcuts(&props.shortcuts)),
-
        );
+
        self.content
+
            .render(frame, RenderProps::from(content_area).focus(true));
+
        self.shortcuts
+
            .render(frame, RenderProps::from(shortcuts_area));

-
        if page_size != props.page_size {
+
        if page_size != self.props.page_size {
            let _ = self.base.action_tx.send(Action::HelpPageSize(page_size));
        }
    }
modified bin/commands/patch/select/ui.rs
@@ -1,4 +1,3 @@
-
use std::any::Any;
use std::collections::HashMap;
use std::str::FromStr;
use std::vec;
@@ -8,7 +7,7 @@ use tokio::sync::mpsc::UnboundedSender;

use termion::event::Key;

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

@@ -23,7 +22,7 @@ use tui::ui::widget::container::{
};
use tui::ui::widget::input::{TextField, TextFieldProps, TextFieldState};
use tui::ui::widget::text::{Paragraph, ParagraphProps, ParagraphState};
-
use tui::ui::widget::{self, BaseView};
+
use tui::ui::widget::{self, BaseView, RenderProps};
use tui::ui::widget::{Column, Properties, Shortcuts, ShortcutsProps, Table, TableProps, Widget};
use tui::ui::widget::{TableUtils, WidgetState};
use tui::Selection;
@@ -45,7 +44,6 @@ pub struct BrowsePageProps<'a> {
    columns: Vec<Column<'a>>,
    cutoff: usize,
    cutoff_after: usize,
-
    focus: bool,
    page_size: usize,
    show_search: bool,
    shortcuts: Vec<(&'a str, &'a str)>,
@@ -97,7 +95,6 @@ impl<'a> From<&State> for BrowsePageProps<'a> {
            .to_vec(),
            cutoff: 150,
            cutoff_after: 5,
-
            focus: false,
            stats,
            page_size: state.browser.page_size,
            show_search: state.browser.show_search,
@@ -150,7 +147,6 @@ impl<'a: 'static> Widget for BrowsePage<'a> {
                    Header::new(state, action_tx.clone())
                        .columns(props.columns.clone())
                        .cutoff(props.cutoff, props.cutoff_after)
-
                        .focus(props.focus)
                        .to_boxed(),
                )
                .content(Box::<Table<State, Action, PatchItem, 9>>::new(
@@ -187,9 +183,20 @@ impl<'a: 'static> Widget for BrowsePage<'a> {
                        })
                        .to_boxed(),
                )
+
                .on_update(|state| {
+
                    ContainerProps::default()
+
                        .hide_footer(BrowsePageProps::from(state).show_search)
+
                        .to_boxed()
+
                })
                .to_boxed(),
            search: Search::new(state, action_tx.clone()).to_boxed(),
-
            shortcuts: Shortcuts::new(state, action_tx.clone()).to_boxed(),
+
            shortcuts: Shortcuts::new(state, action_tx.clone())
+
                .on_update(|state| {
+
                    ShortcutsProps::default()
+
                        .shortcuts(&BrowsePageProps::from(state).shortcuts)
+
                        .to_boxed()
+
                })
+
                .to_boxed(),
        }
    }

@@ -279,41 +286,28 @@ impl<'a: 'static> Widget for BrowsePage<'a> {
        self.shortcuts.update(state);
    }

-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, props: Option<&dyn Any>) {
-
        let props = props
-
            .and_then(|props| props.downcast_ref::<BrowsePageProps>())
-
            .unwrap_or(&self.props);
-

-
        let page_size = area.height.saturating_sub(6) as usize;
+
    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(area);
+
            Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(props.area);

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

-
            self.patches.render(
-
                frame,
-
                table_area,
-
                Some(&ContainerProps::default().hide_footer(props.show_search)),
-
            );
-
            self.search.render(frame, search_area, None);
+
            self.patches.render(frame, RenderProps::from(table_area));
+
            self.search
+
                .render(frame, RenderProps::from(search_area).focus(true));
        } else {
-
            self.patches.render(
-
                frame,
-
                content_area,
-
                Some(&ContainerProps::default().hide_footer(props.show_search)),
-
            );
+
            self.patches
+
                .render(frame, RenderProps::from(content_area).focus(true));
        }

-
        self.shortcuts.render(
-
            frame,
-
            shortcuts_area,
-
            Some(&ShortcutsProps::default().shortcuts(&props.shortcuts)),
-
        );
+
        self.shortcuts
+
            .render(frame, RenderProps::from(shortcuts_area));

-
        if page_size != props.page_size {
+
        if page_size != self.props.page_size {
            let _ = self.base.action_tx.send(Action::BrowserPageSize(page_size));
        }
    }
@@ -391,12 +385,12 @@ impl Widget for Search {
        self.input.update(state);
    }

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

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

    fn base_mut(&mut self) -> &mut BaseView<State, Action> {
@@ -406,7 +400,6 @@ impl Widget for Search {

#[derive(Clone)]
pub struct HelpPageProps<'a> {
-
    focus: bool,
    page_size: usize,
    help_progress: usize,
    shortcuts: Vec<(&'a str, &'a str)>,
@@ -415,7 +408,6 @@ pub struct HelpPageProps<'a> {
impl<'a> From<&State> for HelpPageProps<'a> {
    fn from(state: &State) -> Self {
        Self {
-
            focus: false,
            page_size: state.help.page_size,
            help_progress: state.help.progress,
            shortcuts: vec![("?", "close")],
@@ -454,12 +446,9 @@ impl<'a: 'static> Widget for HelpPage<'a> {
            content: Container::new(state, action_tx.clone())
                .header(
                    Header::new(state, action_tx.clone())
-
                        .on_update(|state| {
-
                            let props = HelpPageProps::from(state);
-

+
                        .on_update(|_| {
                            HeaderProps::default()
                                .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec())
-
                                .focus(props.focus)
                                .to_boxed()
                        })
                        .to_boxed(),
@@ -472,7 +461,6 @@ impl<'a: 'static> Widget for HelpPage<'a> {
                            ParagraphProps::default()
                                .text(&help_text())
                                .page_size(props.page_size)
-
                                .focus(props.focus)
                                .to_boxed()
                        })
                        .on_event(|paragraph, action_tx| {
@@ -503,13 +491,18 @@ impl<'a: 'static> Widget for HelpPage<'a> {
                                    ]
                                    .to_vec(),
                                )
-
                                .focus(props.focus)
                                .to_boxed()
                        })
                        .to_boxed(),
                )
                .to_boxed(),
-
            shortcuts: Shortcuts::new(state, action_tx.clone()).to_boxed(),
+
            shortcuts: Shortcuts::new(state, action_tx.clone())
+
                .on_update(|state| {
+
                    ShortcutsProps::default()
+
                        .shortcuts(&HelpPageProps::from(state).shortcuts)
+
                        .to_boxed()
+
                })
+
                .to_boxed(),
        }
    }

@@ -534,24 +527,18 @@ impl<'a: 'static> Widget for HelpPage<'a> {
        self.content.update(state);
    }

-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, props: Option<&dyn Any>) {
-
        let props = props
-
            .and_then(|props| props.downcast_ref::<HelpPageProps>())
-
            .unwrap_or(&self.props);
-

-
        let page_size = area.height.saturating_sub(6) as usize;
+
    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(area);
+
            Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(props.area);

-
        self.content.render(frame, content_area, None);
-
        self.shortcuts.render(
-
            frame,
-
            shortcuts_area,
-
            Some(&ShortcutsProps::default().shortcuts(&props.shortcuts)),
-
        );
+
        self.content
+
            .render(frame, RenderProps::from(content_area).focus(true));
+
        self.shortcuts
+
            .render(frame, RenderProps::from(shortcuts_area));

-
        if page_size != props.page_size {
+
        if page_size != self.props.page_size {
            let _ = self.base.action_tx.send(Action::HelpPageSize(page_size));
        }
    }
modified src/ui.rs
@@ -12,6 +12,8 @@ use std::time::Duration;
use tokio::sync::broadcast;
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};

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

use super::event::Event;
use super::store::State;
use super::task::Interrupted;
@@ -106,7 +108,7 @@ impl<A> Frontend<A> {
                    break Ok(interrupted);
                }
            }
-
            terminal.draw(|frame| root.render(frame, frame.size(), None))?;
+
            terminal.draw(|frame| root.render(frame, RenderProps::from(frame.size())))?;
        };

        terminal::restore(&mut terminal)?;
modified src/ui/theme.rs
@@ -43,7 +43,7 @@ pub mod style {

    pub fn border(focus: bool) -> Style {
        if focus {
-
            Style::default().fg(Color::Indexed(239))
+
            Style::default().fg(Color::Indexed(238))
        } else {
            Style::default().fg(Color::Indexed(236))
        }
modified src/ui/widget.rs
@@ -33,6 +33,30 @@ pub struct BaseView<S, A> {
    pub on_event: Option<EventCallback<A>>,
}

+
/// General properties that specify how a `Widget` is rendered.
+
/// They can be passed to a widgets' `render` function.
+
#[derive(Clone, Default)]
+
pub struct RenderProps {
+
    /// Area of the render props
+
    pub area: Rect,
+
    /// Focus of the render props.
+
    pub focus: bool,
+
}
+

+
impl RenderProps {
+
    /// Sets the focus of these render props.
+
    pub fn focus(mut self, focus: bool) -> Self {
+
        self.focus = focus;
+
        self
+
    }
+
}
+

+
impl From<Rect> for RenderProps {
+
    fn from(area: Rect) -> Self {
+
        Self { area, focus: false }
+
    }
+
}
+

/// Main trait defining a `Widget` behaviour.
///
/// This is the trait that you should implement to define a custom `Widget`.
@@ -66,8 +90,8 @@ pub trait Widget {

    /// Renders a widget to the given frame in the given area.
    ///
-
    /// Optional props take precedence over the internal ones.
-
    fn render(&self, frame: &mut Frame, area: Rect, props: Option<&dyn Any>);
+
    /// Optional render props can be given.
+
    fn render(&self, frame: &mut Frame, props: RenderProps);

    /// Return a mutable reference to this widgets' base view.
    fn base_mut(&mut self) -> &mut BaseView<Self::State, Self::Action>;
@@ -104,7 +128,7 @@ pub trait ToRow<const W: usize> {
    fn to_row(&self) -> [Cell; W];
}

-
/// Common trait for view properties.
+
/// Common trait for widget properties.
pub trait Properties {
    fn to_boxed(self) -> Box<Self>
    where
@@ -234,11 +258,7 @@ where
        }
    }

-
    fn render(&self, frame: &mut ratatui::Frame, _area: Rect, props: Option<&dyn Any>) {
-
        let _props = props
-
            .and_then(|props| props.downcast_ref::<WindowProps<Id>>())
-
            .unwrap_or(&self.props);
-

+
    fn render(&self, frame: &mut ratatui::Frame, _props: RenderProps) {
        let area = frame.size();

        let page = self
@@ -248,7 +268,7 @@ where
            .and_then(|id| self.pages.get(id));

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

@@ -335,21 +355,18 @@ impl<S, A> Widget for Shortcuts<S, A> {
            ShortcutsProps::from_callback(self.base.on_update, state).unwrap_or(self.props.clone());
    }

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

-
        let props = props
-
            .and_then(|props| props.downcast_ref::<ShortcutsProps>())
-
            .unwrap_or(&self.props);
-

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

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

            row.push((shortcut.0.chars().count(), short));
            row.push((1, spacer));
@@ -373,7 +390,7 @@ impl<S, A> Widget for Shortcuts<S, A> {
            .collect();

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

    fn base_mut(&mut self) -> &mut BaseView<S, A> {
@@ -410,7 +427,6 @@ where
{
    pub items: Vec<R>,
    pub selected: Option<usize>,
-
    pub focus: bool,
    pub columns: Vec<Column<'a>>,
    pub has_footer: bool,
    pub cutoff: usize,
@@ -425,7 +441,6 @@ where
    fn default() -> Self {
        Self {
            items: vec![],
-
            focus: false,
            columns: vec![],
            has_footer: false,
            cutoff: usize::MAX,
@@ -606,11 +621,7 @@ where
        }
    }

-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, props: Option<&dyn Any>) {
-
        let props = props
-
            .and_then(|props| props.downcast_ref::<TableProps<R, W>>())
-
            .unwrap_or(&self.props);
-

+
    fn render(&self, frame: &mut ratatui::Frame, props: RenderProps) {
        let widths: Vec<Constraint> = self
            .props
            .columns
@@ -618,19 +629,23 @@ where
            .filter_map(|c| if !c.skip { Some(c.width) } else { None })
            .collect();

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

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

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

-
            frame.render_stateful_widget(rows, area, &mut self.state.clone());
+
            frame.render_stateful_widget(rows, props.area, &mut self.state.clone());
        } else {
-
            let center = layout::centered_rect(area, 50, 10);
+
            let center = layout::centered_rect(props.area, 50, 10);
            let hint = Text::from(span::default("Nothing to show"))
                .centered()
                .light_magenta()
modified src/ui/widget/container.rs
@@ -1,4 +1,3 @@
-
use std::any::Any;
use std::fmt::Debug;

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

-
use super::{BaseView, BoxedWidget, Column, Properties, Widget};
+
use super::{BaseView, BoxedWidget, Column, Properties, RenderProps, Widget};

#[derive(Clone, Debug)]
pub struct HeaderProps<'a> {
    pub columns: Vec<Column<'a>>,
    pub cutoff: usize,
    pub cutoff_after: usize,
-
    pub focus: bool,
}

impl<'a> HeaderProps<'a> {
@@ -27,11 +25,6 @@ impl<'a> HeaderProps<'a> {
        self
    }

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

    pub fn cutoff(mut self, cutoff: usize, cutoff_after: usize) -> Self {
        self.cutoff = cutoff;
        self.cutoff_after = cutoff_after;
@@ -45,7 +38,6 @@ impl<'a> Default for HeaderProps<'a> {
            columns: vec![],
            cutoff: usize::MAX,
            cutoff_after: usize::MAX,
-
            focus: false,
        }
    }
}
@@ -65,11 +57,6 @@ impl<'a, S, A> Header<'a, S, A> {
        self
    }

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

    pub fn cutoff(mut self, cutoff: usize, cutoff_after: usize) -> Self {
        self.props.cutoff = cutoff;
        self.props.cutoff_after = cutoff_after;
@@ -102,12 +89,9 @@ impl<'a: 'static, S, A> Widget for Header<'a, S, A> {
            .unwrap_or(self.props.clone());
    }

-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, props: Option<&dyn Any>) {
-
        let props = props
-
            .and_then(|props| props.downcast_ref::<HeaderProps>())
-
            .unwrap_or(&self.props);
-

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

-
        let widths = if area.width < props.cutoff as u16 {
-
            widths.iter().take(props.cutoff_after).collect::<Vec<_>>()
+
        let widths = if props.area.width < self.props.cutoff as u16 {
+
            widths
+
                .iter()
+
                .take(self.props.cutoff_after)
+
                .collect::<Vec<_>>()
        } else {
            widths.iter().collect::<Vec<_>>()
        };
@@ -147,7 +135,7 @@ impl<'a: 'static, S, A> Widget for Header<'a, S, A> {
            .constraints(vec![Constraint::Min(1)])
            .vertical_margin(1)
            .horizontal_margin(1)
-
            .split(area);
+
            .split(props.area);

        let header = Row::new(cells).style(style::reset().bold());
        let header = ratatui::widgets::Table::default()
@@ -155,7 +143,7 @@ impl<'a: 'static, S, A> Widget for Header<'a, S, A> {
            .header(header)
            .widths(widths.clone());

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

@@ -169,7 +157,6 @@ pub struct FooterProps<'a> {
    pub columns: Vec<Column<'a>>,
    pub cutoff: usize,
    pub cutoff_after: usize,
-
    pub focus: bool,
}

impl<'a> FooterProps<'a> {
@@ -183,11 +170,6 @@ impl<'a> FooterProps<'a> {
        self.cutoff_after = cutoff_after;
        self
    }
-

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

impl<'a> Default for FooterProps<'a> {
@@ -196,7 +178,6 @@ impl<'a> Default for FooterProps<'a> {
            columns: vec![],
            cutoff: usize::MAX,
            cutoff_after: usize::MAX,
-
            focus: false,
        }
    }
}
@@ -222,11 +203,6 @@ impl<'a, S, A> Footer<'a, S, A> {
        self
    }

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

    fn render_cell(
        &self,
        frame: &mut ratatui::Frame,
@@ -275,12 +251,9 @@ impl<'a: 'static, S, A> Widget for Footer<'a, S, A> {
            .unwrap_or(self.props.clone());
    }

-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, props: Option<&dyn Any>) {
-
        let props = props
-
            .and_then(|props| props.downcast_ref::<FooterProps>())
-
            .unwrap_or(&self.props);
-

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

-
        let layout = Layout::horizontal(widths).split(area);
-
        let cells = props
+
        let layout = Layout::horizontal(widths).split(props.area);
+
        let cells = self
+
            .props
            .columns
            .iter()
            .map(|c| c.text.clone())
@@ -318,7 +292,6 @@ impl<'a: 'static, S, A> Widget for Footer<'a, S, A> {

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

@@ -327,11 +300,6 @@ impl ContainerProps {
        self.hide_footer = hide;
        self
    }
-

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

impl Properties for ContainerProps {}
@@ -411,13 +379,9 @@ impl<S, A> Widget for Container<S, A> {
        }
    }

-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, props: Option<&dyn Any>) {
-
        let props = props
-
            .and_then(|props| props.downcast_ref::<ContainerProps>())
-
            .unwrap_or(&self.props);
-

+
    fn render(&self, frame: &mut ratatui::Frame, props: RenderProps) {
        let header_h = if self.header.is_some() { 3 } else { 0 };
-
        let footer_h = if self.footer.is_some() && !props.hide_footer {
+
        let footer_h = if self.footer.is_some() && !self.props.hide_footer {
            3
        } else {
            0
@@ -428,11 +392,11 @@ impl<S, A> Widget for Container<S, A> {
            Constraint::Min(1),
            Constraint::Length(footer_h),
        ])
-
        .areas(area);
+
        .areas(props.area);

        let borders = match (
            self.header.is_some(),
-
            (self.footer.is_some() && !props.hide_footer),
+
            (self.footer.is_some() && !self.props.hide_footer),
        ) {
            (false, false) => Borders::ALL,
            (true, false) => Borders::BOTTOM | Borders::LEFT | Borders::RIGHT,
@@ -447,15 +411,18 @@ impl<S, A> Widget for Container<S, A> {
        frame.render_widget(block.clone(), content_area);

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

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

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

modified src/ui/widget/input.rs
@@ -1,15 +1,12 @@
-
use std::any::Any;
-

use termion::event::Key;

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

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

-
use super::{BaseView, Properties, Widget, WidgetState};
+
use super::{BaseView, Properties, RenderProps, Widget, WidgetState};

#[derive(Clone)]
pub struct TextFieldProps {
@@ -191,20 +188,17 @@ impl<S, A> Widget for TextField<S, A> {
        }
    }

-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, props: Option<&dyn Any>) {
-
        let props = props
-
            .and_then(|props| props.downcast_ref::<TextFieldProps>())
-
            .unwrap_or(&self.props);
-

+
    fn render(&self, frame: &mut ratatui::Frame, props: RenderProps) {
+
        let area = props.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!(" {} ", props.title);
+
        let label = format!(" {} ", self.props.title);
        let overline = String::from("▔").repeat(area.width as usize);
        let cursor_pos = self.state.cursor_position as u16;

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

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

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

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

use termion::event::Key;

-
use ratatui::layout::{Constraint, Layout, Rect};
+
use ratatui::layout::{Constraint, Layout};
use ratatui::text::Text;

-
use super::{BaseView, Properties, Widget, WidgetState};
+
use super::{BaseView, Properties, RenderProps, Widget, WidgetState};

#[derive(Clone)]
pub struct ParagraphProps<'a> {
    pub content: Text<'a>,
-
    pub focus: bool,
    pub has_header: bool,
    pub has_footer: bool,
    pub page_size: usize,
@@ -29,18 +26,12 @@ impl<'a> ParagraphProps<'a> {
        self.content = text.clone();
        self
    }
-

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

impl<'a> Default for ParagraphProps<'a> {
    fn default() -> Self {
        Self {
            content: Text::raw(""),
-
            focus: false,
            has_header: false,
            has_footer: false,
            page_size: 1,
@@ -205,15 +196,11 @@ impl<'a: 'static, S, A> Widget for Paragraph<'a, S, A> {
            ParagraphProps::from_callback(self.base.on_update, state).unwrap_or(self.props.clone());
    }

-
    fn render(&self, frame: &mut ratatui::Frame, area: Rect, props: Option<&dyn Any>) {
-
        let props = props
-
            .and_then(|props| props.downcast_ref::<ParagraphProps>())
-
            .unwrap_or(&self.props);
-

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

        frame.render_widget(content, content_area);