Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
lib: Rework footer widget
Erik Kundt committed 2 years ago
commit e4fe6af72c878781ddf2d71a9521af52f72774e5
parent aee8b37caaa578b9d1f6c854928343ba4f645563
6 files changed +447 -417
modified bin/commands/inbox/select/ui.rs
@@ -14,7 +14,7 @@ use radicle_tui as tui;

use tui::ui::items::{Filter, NotificationItem, NotificationItemFilter, NotificationState};
use tui::ui::span;
-
use tui::ui::widget::container::{Footer, FooterProps, Header};
+
use tui::ui::widget::container::{Footer, Header};
use tui::ui::widget::input::{TextField, TextFieldProps};
use tui::ui::widget::text::{Paragraph, ParagraphProps};
use tui::ui::widget::{Column, Render, Shortcuts, Table, Widget};
@@ -44,7 +44,7 @@ pub struct ListPage<'a> {
    /// State mapped props
    props: ListPageProps,
    /// Notification widget
-
    notifications: Notifications,
+
    notifications: Notifications<'a>,
    /// Search widget
    search: Search,
    /// Help widget
@@ -151,11 +151,11 @@ impl<'a> Render<()> for ListPage<'a> {
    }
}

-
struct NotificationsProps {
+
struct NotificationsProps<'a> {
    notifications: Vec<NotificationItem>,
    mode: Mode,
    stats: HashMap<String, usize>,
-
    columns: Vec<Column>,
+
    columns: Vec<Column<'a>>,
    cutoff: usize,
    cutoff_after: usize,
    focus: bool,
@@ -164,7 +164,7 @@ struct NotificationsProps {
    show_search: bool,
}

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

-
struct Notifications {
+
struct Notifications<'a> {
    /// Action sender
    action_tx: UnboundedSender<Action>,
    /// State mapped props
-
    props: NotificationsProps,
+
    props: NotificationsProps<'a>,
    /// Notification table
-
    table: Table<Action, NotificationItem>,
+
    table: Table<'a, Action, NotificationItem>,
    /// Table footer
-
    footer: Footer<Action>,
+
    footer: Footer<'a, Action>,
}

-
impl Widget<State, Action> for Notifications {
+
impl<'a> Widget<State, Action> for Notifications<'a> {
    fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self {
        let props = NotificationsProps::from(state);
        let name = match state.mode.repository() {
@@ -245,7 +245,7 @@ impl Widget<State, Action> for Notifications {
                        .columns(
                            [
                                Column::new("", Constraint::Length(0)),
-
                                Column::new(&name, Constraint::Fill(1)),
+
                                Column::new(Text::from(name), Constraint::Fill(1)),
                            ]
                            .to_vec(),
                        )
@@ -254,7 +254,7 @@ impl Widget<State, Action> for Notifications {
                )
                .footer(!props.show_search)
                .cutoff(props.cutoff, props.cutoff_after),
-
            footer: Footer::new(state, action_tx),
+
            footer: Footer::new(&(), action_tx),
        }
    }

@@ -262,8 +262,6 @@ impl Widget<State, Action> for Notifications {
    where
        Self: Sized,
    {
-
        let props = NotificationsProps::from(state);
-
        let table = self.table.move_with_state(&());
        let notifications: Vec<NotificationItem> = state
            .notifications
            .iter()
@@ -271,15 +269,21 @@ impl Widget<State, Action> for Notifications {
            .cloned()
            .collect();

+
        let props = NotificationsProps::from(state);
+

+
        let table = self.table.move_with_state(&());
        let table = table
            .items(notifications)
            .footer(!state.ui.show_search)
            .page_size(state.ui.page_size);

+
        let footer = self.footer.move_with_state(&());
+
        let footer = footer.columns(Self::build_footer(&props, table.selected()));
+

        Self {
            props,
            table,
-
            footer: self.footer.move_with_state(state),
+
            footer,
            ..self
        }
    }
@@ -331,8 +335,8 @@ impl Widget<State, Action> for Notifications {
    }
}

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

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

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

-
        match NotificationItemFilter::from_str(&self.props.search)
+
        match NotificationItemFilter::from_str(&props.search)
            .unwrap_or_default()
            .state()
        {
@@ -377,54 +387,34 @@ impl Notifications {
                    NotificationState::Unseen => unseen,
                };

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

-
impl Render<()> for Notifications {
+
impl<'a> Render<()> for Notifications<'a> {
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
        let header_height = 3_usize;

@@ -436,7 +426,7 @@ impl Render<()> for Notifications {
            let layout = Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).split(area);

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

            (area.height as usize).saturating_sub(header_height)
        };
@@ -511,6 +501,7 @@ impl Render<SearchProps> for Search {
    }
}

+
#[derive(Clone)]
pub struct HelpProps<'a> {
    content: Text<'a>,
    focus: bool,
@@ -661,11 +652,11 @@ pub struct Help<'a> {
    /// This widget's render properties
    pub props: HelpProps<'a>,
    /// Container header
-
    header: Header<Action>,
+
    header: Header<'a, Action>,
    /// Content widget
    content: Paragraph<Action>,
    /// Container footer
-
    footer: Footer<Action>,
+
    footer: Footer<'a, Action>,
}

impl<'a> Widget<State, Action> for Help<'a> {
@@ -677,12 +668,10 @@ impl<'a> Widget<State, Action> for Help<'a> {

        Self {
            action_tx: action_tx.clone(),
-
            props: HelpProps::from(state),
-
            header: Header::new(&(), action_tx.clone())
-
                .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec())
-
                .focus(props.focus),
+
            props: props.clone(),
+
            header: Header::new(&(), action_tx.clone()),
            content: Paragraph::new(state, action_tx.clone()),
-
            footer: Footer::new(state, action_tx),
+
            footer: Footer::new(&(), action_tx),
        }
        .move_with_state(state)
    }
@@ -691,11 +680,32 @@ impl<'a> Widget<State, Action> for Help<'a> {
    where
        Self: Sized,
    {
+
        let props = HelpProps::from(state);
+

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

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

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

        Self {
-
            props: HelpProps::from(state),
-
            header: self.header.move_with_state(&()),
-
            content: self.content.move_with_state(state),
-
            footer: self.footer.move_with_state(state),
+
            props,
+
            header,
+
            content,
+
            footer,
            ..self
        }
    }
@@ -743,7 +753,6 @@ impl<'a> Render<()> for Help<'a> {
        .areas(area);

        self.header.render::<B>(frame, header_area, ());
-

        self.content.render::<B>(
            frame,
            content_area,
@@ -754,20 +763,7 @@ impl<'a> Render<()> for Help<'a> {
                has_header: true,
            },
        );
-

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

-
        self.footer.render::<B>(
-
            frame,
-
            footer_area,
-
            FooterProps {
-
                cells: [String::new().into(), progress.clone().into()].to_vec(),
-
                widths: [Constraint::Fill(1), Constraint::Min(4)].to_vec(),
-
                focus: self.props.focus,
-
                cutoff: usize::MAX,
-
                cutoff_after: usize::MAX,
-
            },
-
        );
+
        self.footer.render::<B>(frame, footer_area, ());

        let page_size = content_area.height as usize;
        if page_size != self.props.page_size {
modified bin/commands/issue/select.rs
@@ -92,6 +92,7 @@ impl TryFrom<&Context> for State {

pub enum Action {
    Exit { selection: Option<Selection> },
+
    SelectionChanged,
    PageSize(usize),
    OpenSearch,
    UpdateSearch { value: String },
@@ -141,6 +142,7 @@ impl store::State<Action, Selection> for State {
                self.ui.show_help = false;
                None
            }
+
            Action::SelectionChanged => None,
        }
    }
}
modified bin/commands/issue/select/ui.rs
@@ -16,7 +16,7 @@ use radicle_tui as tui;

use tui::ui::items::{Filter, IssueItem, IssueItemFilter};
use tui::ui::span;
-
use tui::ui::widget::container::{Footer, FooterProps, Header};
+
use tui::ui::widget::container::{Footer, Header};
use tui::ui::widget::input::{TextField, TextFieldProps};
use tui::ui::widget::text::{Paragraph, ParagraphProps};
use tui::ui::widget::{Column, Render, Shortcuts, Table, Widget};
@@ -47,7 +47,7 @@ pub struct ListPage<'a> {
    /// State mapped props
    props: ListPageProps,
    /// Notification widget
-
    issues: Issues,
+
    issues: Issues<'a>,
    /// Search widget
    search: Search,
    /// Help widget
@@ -151,12 +151,12 @@ impl<'a> Render<()> for ListPage<'a> {
}

#[derive(Clone)]
-
struct IssuesProps {
+
struct IssuesProps<'a> {
    mode: Mode,
    issues: Vec<IssueItem>,
    search: String,
    stats: HashMap<String, usize>,
-
    columns: Vec<Column>,
+
    columns: Vec<Column<'a>>,
    cutoff: usize,
    cutoff_after: usize,
    focus: bool,
@@ -164,7 +164,7 @@ struct IssuesProps {
    show_search: bool,
}

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

@@ -225,18 +225,18 @@ impl From<&State> for IssuesProps {
    }
}

-
struct Issues {
+
struct Issues<'a> {
    /// Action sender
    action_tx: UnboundedSender<Action>,
    /// State mapped props
-
    props: IssuesProps,
+
    props: IssuesProps<'a>,
    /// Notification table
-
    table: Table<Action, IssueItem>,
+
    table: Table<'a, Action, IssueItem>,
    /// Footer
-
    footer: Footer<Action>,
+
    footer: Footer<'a, Action>,
}

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

@@ -254,16 +254,15 @@ impl Widget<State, Action> for Issues {
                )
                .footer(!props.show_search)
                .cutoff(props.cutoff, props.cutoff_after),
-
            footer: Footer::new(state, action_tx),
+
            footer: Footer::new(&(), action_tx),
        }
+
        .move_with_state(state)
    }

    fn move_with_state(self, state: &State) -> Self
    where
        Self: Sized,
    {
-
        let props = IssuesProps::from(state);
-
        let table = self.table.move_with_state(&());
        let issues: Vec<IssueItem> = state
            .issues
            .iter()
@@ -271,15 +270,21 @@ impl Widget<State, Action> for Issues {
            .cloned()
            .collect();

+
        let props = IssuesProps::from(state);
+

+
        let table = self.table.move_with_state(&());
        let table = table
            .items(issues)
            .footer(!state.ui.show_search)
            .page_size(state.ui.page_size);

+
        let footer = self.footer.move_with_state(&());
+
        let footer = footer.columns(Self::build_footer(&props, table.selected()));
+

        Self {
            props,
            table,
-
            footer: self.footer.move_with_state(state),
+
            footer,
            ..self
        }
    }
@@ -328,13 +333,14 @@ impl Widget<State, Action> for Issues {
                    &mut self.table,
                    key,
                );
+
                let _ = self.action_tx.send(Action::SelectionChanged);
            }
        }
    }
}

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

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

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

-
        match IssueItemFilter::from_str(&self.props.search)
+
        match IssueItemFilter::from_str(&props.search)
            .unwrap_or_default()
            .state()
        {
@@ -400,56 +408,35 @@ impl Issues {
                    } => solved,
                };

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

-
impl Render<()> for Issues {
+
impl<'a> Render<()> for Issues<'a> {
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
        let header_height = 3_usize;

@@ -461,7 +448,7 @@ impl Render<()> for Issues {
            let layout = Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).split(area);

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

            (area.height as usize).saturating_sub(header_height)
        };
@@ -686,11 +673,11 @@ pub struct Help<'a> {
    /// This widget's render properties
    pub props: HelpProps<'a>,
    /// Container header
-
    header: Header<Action>,
+
    header: Header<'a, Action>,
    /// Content widget
    content: Paragraph<Action>,
    /// Container footer
-
    footer: Footer<Action>,
+
    footer: Footer<'a, Action>,
}

impl<'a> Widget<State, Action> for Help<'a> {
@@ -702,12 +689,10 @@ impl<'a> Widget<State, Action> for Help<'a> {

        Self {
            action_tx: action_tx.clone(),
-
            props: HelpProps::from(state),
-
            header: Header::new(&(), action_tx.clone())
-
                .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec())
-
                .focus(props.focus),
+
            props,
+
            header: Header::new(&(), action_tx.clone()),
            content: Paragraph::new(state, action_tx.clone()),
-
            footer: Footer::new(state, action_tx),
+
            footer: Footer::new(&(), action_tx),
        }
        .move_with_state(state)
    }
@@ -716,11 +701,32 @@ impl<'a> Widget<State, Action> for Help<'a> {
    where
        Self: Sized,
    {
+
        let props = HelpProps::from(state);
+

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

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

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

        Self {
-
            props: HelpProps::from(state),
-
            header: self.header.move_with_state(&()),
-
            content: self.content.move_with_state(state),
-
            footer: self.footer.move_with_state(state),
+
            props,
+
            header,
+
            content,
+
            footer,
            ..self
        }
    }
@@ -778,20 +784,7 @@ impl<'a> Render<()> for Help<'a> {
                has_header: true,
            },
        );
-

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

-
        self.footer.render::<B>(
-
            frame,
-
            footer_area,
-
            FooterProps {
-
                cells: [String::new().into(), progress.clone().into()].to_vec(),
-
                widths: [Constraint::Fill(1), Constraint::Min(4)].to_vec(),
-
                focus: self.props.focus,
-
                cutoff: usize::MAX,
-
                cutoff_after: usize::MAX,
-
            },
-
        );
+
        self.footer.render::<B>(frame, footer_area, ());

        let page_size = content_area.height as usize;
        if page_size != self.props.page_size {
modified bin/commands/patch/select/ui.rs
@@ -17,7 +17,7 @@ use radicle_tui as tui;

use tui::ui::items::{Filter, PatchItem, PatchItemFilter};
use tui::ui::span;
-
use tui::ui::widget::container::{Footer, FooterProps, Header};
+
use tui::ui::widget::container::{Footer, Header};
use tui::ui::widget::input::{TextField, TextFieldProps};
use tui::ui::widget::text::{Paragraph, ParagraphProps};
use tui::ui::widget::{Column, Render, Shortcuts, Table, Widget};
@@ -48,7 +48,7 @@ pub struct ListPage<'a> {
    /// State mapped props
    props: ListPageProps,
    /// Notification widget
-
    patches: Patches,
+
    patches: Patches<'a>,
    /// Search widget
    search: Search,
    /// Help widget
@@ -111,7 +111,7 @@ impl<'a> Widget<State, Action> for ListPage<'a> {
        if self.props.show_search {
            <Search as Widget<State, Action>>::handle_key_event(&mut self.search, key)
        } else if self.props.show_help {
-
            <Help as Widget<State, Action>>::handle_key_event(&mut self.help, key)
+
            <Help as Widget<State, Action>>::handle_key_event(&mut self.help, key);
        } else {
            match key {
                Key::Esc | Key::Ctrl('c') => {
@@ -154,12 +154,12 @@ impl<'a> Render<()> for ListPage<'a> {
}

#[derive(Clone)]
-
struct PatchesProps {
+
struct PatchesProps<'a> {
    mode: Mode,
    patches: Vec<PatchItem>,
    search: String,
    stats: HashMap<String, usize>,
-
    columns: Vec<Column>,
+
    columns: Vec<Column<'a>>,
    cutoff: usize,
    cutoff_after: usize,
    focus: bool,
@@ -167,7 +167,7 @@ struct PatchesProps {
    show_search: bool,
}

-
impl From<&State> for PatchesProps {
+
impl<'a> From<&State> for PatchesProps<'a> {
    fn from(state: &State) -> Self {
        let mut draft = 0;
        let mut open = 0;
@@ -226,18 +226,18 @@ impl From<&State> for PatchesProps {
    }
}

-
struct Patches {
+
struct Patches<'a> {
    /// Action sender
    action_tx: UnboundedSender<Action>,
    /// State mapped props
-
    props: PatchesProps,
+
    props: PatchesProps<'a>,
    /// Notification table
-
    table: Table<Action, PatchItem>,
+
    table: Table<'a, Action, PatchItem>,
    /// Table footer
-
    footer: Footer<Action>,
+
    footer: Footer<'a, Action>,
}

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

@@ -255,15 +255,15 @@ impl Widget<State, Action> for Patches {
                )
                .footer(!props.show_search)
                .cutoff(props.cutoff, props.cutoff_after),
-
            footer: Footer::new(state, action_tx),
+
            footer: Footer::new(&(), action_tx),
        }
+
        .move_with_state(state)
    }

    fn move_with_state(self, state: &State) -> Self
    where
        Self: Sized,
    {
-
        let props = PatchesProps::from(state);
        let patches: Vec<PatchItem> = state
            .patches
            .iter()
@@ -271,6 +271,8 @@ impl Widget<State, Action> for Patches {
            .cloned()
            .collect();

+
        let props = PatchesProps::from(state);
+

        let table = self
            .table
            .move_with_state(&())
@@ -278,10 +280,13 @@ impl Widget<State, Action> for Patches {
            .footer(!state.ui.show_search)
            .page_size(state.ui.page_size);

+
        let footer = self.footer.move_with_state(&());
+
        let footer = footer.columns(Self::build_footer(&props, table.selected()));
+

        Self {
            props,
            table,
-
            footer: self.footer.move_with_state(state),
+
            footer,
            ..self
        }
    }
@@ -351,9 +356,9 @@ impl Widget<State, Action> for Patches {
    }
}

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

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

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

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

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

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

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

        match filter.status() {
@@ -425,60 +432,43 @@ impl Patches {
                    Status::Archived => archived,
                };

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

-
impl Render<()> for Patches {
+
impl<'a> Render<()> for Patches<'a> {
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
        let header_height = 3_usize;

@@ -490,7 +480,7 @@ impl Render<()> for Patches {
            let layout = Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).split(area);

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

            (area.height as usize).saturating_sub(header_height)
        };
@@ -724,11 +714,11 @@ pub struct Help<'a> {
    /// This widget's render properties
    pub props: HelpProps<'a>,
    /// Container header
-
    header: Header<Action>,
+
    header: Header<'a, Action>,
    /// Content widget
    content: Paragraph<Action>,
    /// Container footer
-
    footer: Footer<Action>,
+
    footer: Footer<'a, Action>,
}

impl<'a> Widget<State, Action> for Help<'a> {
@@ -741,11 +731,9 @@ impl<'a> Widget<State, Action> for Help<'a> {
        Self {
            action_tx: action_tx.clone(),
            props: props.clone(),
-
            header: Header::new(&(), action_tx.clone())
-
                .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec())
-
                .focus(props.focus),
+
            header: Header::new(&(), action_tx.clone()),
            content: Paragraph::new(state, action_tx.clone()),
-
            footer: Footer::new(state, action_tx),
+
            footer: Footer::new(&(), action_tx),
        }
        .move_with_state(state)
    }
@@ -754,11 +742,32 @@ impl<'a> Widget<State, Action> for Help<'a> {
    where
        Self: Sized,
    {
+
        let props = HelpProps::from(state);
+

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

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

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

        Self {
-
            props: HelpProps::from(state),
-
            header: self.header.move_with_state(&()),
-
            content: self.content.move_with_state(state),
-
            footer: self.footer.move_with_state(state),
+
            props,
+
            header,
+
            content,
+
            footer,
            ..self
        }
    }
@@ -806,7 +815,6 @@ impl<'a> Render<()> for Help<'a> {
        .areas(area);

        self.header.render::<B>(frame, header_area, ());
-

        self.content.render::<B>(
            frame,
            content_area,
@@ -817,20 +825,7 @@ impl<'a> Render<()> for Help<'a> {
                has_header: true,
            },
        );
-

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

-
        self.footer.render::<B>(
-
            frame,
-
            footer_area,
-
            FooterProps {
-
                cells: [String::new().into(), progress.clone().into()].to_vec(),
-
                widths: [Constraint::Fill(1), Constraint::Min(4)].to_vec(),
-
                focus: self.props.focus,
-
                cutoff: usize::MAX,
-
                cutoff_after: usize::MAX,
-
            },
-
        );
+
        self.footer.render::<B>(frame, footer_area, ());

        let page_size = content_area.height as usize;
        if page_size != self.props.page_size {
modified src/ui/widget.rs
@@ -138,16 +138,16 @@ impl<A> Render<()> for Shortcuts<A> {
}

#[derive(Clone, Debug)]
-
pub struct Column {
-
    pub title: String,
+
pub struct Column<'a> {
+
    pub text: Text<'a>,
    pub width: Constraint,
    pub skip: bool,
}

-
impl Column {
-
    pub fn new(title: &str, width: Constraint) -> Self {
+
impl<'a> Column<'a> {
+
    pub fn new(text: impl Into<Text<'a>>, width: Constraint) -> Self {
        Self {
-
            title: title.to_string(),
+
            text: text.into(),
            width,
            skip: false,
        }
@@ -160,17 +160,17 @@ impl Column {
}

#[derive(Debug)]
-
pub struct TableProps<R: ToRow> {
+
pub struct TableProps<'a, R: ToRow> {
    pub items: Vec<R>,
    pub focus: bool,
-
    pub columns: Vec<Column>,
+
    pub columns: Vec<Column<'a>>,
    pub has_footer: bool,
    pub cutoff: usize,
    pub cutoff_after: usize,
    pub page_size: usize,
}

-
impl<R: ToRow> Default for TableProps<R> {
+
impl<'a, R: ToRow> Default for TableProps<'a, R> {
    fn default() -> Self {
        Self {
            items: vec![],
@@ -184,24 +184,24 @@ impl<R: ToRow> Default for TableProps<R> {
    }
}

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

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

-
    pub fn columns(mut self, columns: Vec<Column>) -> Self {
+
    pub fn columns(mut self, columns: Vec<Column<'a>>) -> Self {
        self.props.columns = columns;
        self
    }
@@ -222,7 +222,7 @@ impl<A, R: ToRow> Table<A, R> {
        self
    }

-
    pub fn header(mut self, header: Header<A>) -> Self {
+
    pub fn header(mut self, header: Header<'a, A>) -> Self {
        self.header = Some(header);
        self
    }
@@ -279,17 +279,8 @@ impl<A, R: ToRow> Table<A, R> {
        self.state.selected()
    }

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

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

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

@@ -308,7 +299,7 @@ impl<A, R: ToRow> Table<A, R> {
    }
}

-
impl<A, R> Widget<(), A> for Table<A, R>
+
impl<'a, A, R> Widget<(), A> for Table<'a, A, R>
where
    R: ToRow,
{
@@ -365,7 +356,7 @@ where
    }
}

-
impl<A, R> Render<()> for Table<A, R>
+
impl<'a, A, R> Render<()> for Table<'a, A, R>
where
    R: ToRow + Debug,
{
modified src/ui/widget/container.rs
@@ -13,102 +13,14 @@ use crate::ui::theme::style;
use super::{Column, Render, Widget};

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

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

-
impl<S, A> Widget<S, A> for Footer<A> {
-
    fn new(state: &S, action_tx: UnboundedSender<A>) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        Self {
-
            action_tx: action_tx.clone(),
-
        }
-
        .move_with_state(state)
-
    }
-

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

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

-
impl<A> Footer<A> {
-
    fn render_cell<'a>(
-
        &self,
-
        frame: &mut ratatui::Frame,
-
        area: Rect,
-
        block_type: FooterBlockType,
-
        text: impl Into<Text<'a>>,
-
        focus: bool,
-
    ) {
-
        let footer_layout = Layout::default()
-
            .direction(Direction::Vertical)
-
            .constraints(vec![Constraint::Min(1)])
-
            .vertical_margin(1)
-
            .horizontal_margin(1)
-
            .split(area);
-

-
        let footer_block = FooterBlock::default()
-
            .border_style(style::border(focus))
-
            .block_type(block_type);
-
        frame.render_widget(footer_block, area);
-
        frame.render_widget(text.into(), footer_layout[0]);
-
    }
-
}
-

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

-
        let layout = Layout::horizontal(widths).split(area);
-
        let cells = props.cells.iter().zip(layout.iter()).collect::<Vec<_>>();
-

-
        let last = cells.len().saturating_sub(1);
-
        let len = cells.len();
-

-
        for (i, (cell, area)) in cells.into_iter().enumerate() {
-
            let block_type = match i {
-
                0 if len == 1 => FooterBlockType::Single,
-
                0 => FooterBlockType::Begin,
-
                _ if i == last => FooterBlockType::End,
-
                _ => FooterBlockType::Repeat,
-
            };
-
            self.render_cell(frame, *area, block_type, cell.clone(), props.focus);
-
        }
-
    }
-
}
-

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

-
impl Default for HeaderProps {
+
impl<'a> Default for HeaderProps<'a> {
    fn default() -> Self {
        Self {
            columns: vec![],
@@ -119,15 +31,15 @@ impl Default for HeaderProps {
    }
}

-
pub struct Header<A> {
+
pub struct Header<'a, A> {
    /// Sending actions to the state store
    pub action_tx: UnboundedSender<A>,
    /// Internal props
-
    props: HeaderProps,
+
    props: HeaderProps<'a>,
}

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

-
impl<A> Widget<(), A> for Header<A> {
+
impl<'a, A> Widget<(), A> for Header<'a, A> {
    fn new(state: &(), action_tx: UnboundedSender<A>) -> Self
    where
        Self: Sized,
@@ -166,7 +78,7 @@ impl<A> Widget<(), A> for Header<A> {
    fn handle_key_event(&mut self, _key: Key) {}
}

-
impl<A> Render<()> for Header<A> {
+
impl<'a, A> Render<()> for Header<'a, A> {
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
        let widths: Vec<Constraint> = self
            .props
@@ -186,7 +98,7 @@ impl<A> Render<()> for Header<A> {
            .iter()
            .filter_map(|column| {
                if !column.skip {
-
                    Some(column.title.clone())
+
                    Some(column.text.clone())
                } else {
                    None
                }
@@ -225,3 +137,144 @@ impl<A> Render<()> for Header<A> {
        frame.render_widget(header, header_layout[0]);
    }
}
+

+
// #[derive(Debug)]
+
// pub struct FooterCell<'a> {
+
//     text: Text<'a>,
+
//     width: Constraint,
+
// }
+

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

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

+
impl<'a> Default for FooterProps<'a> {
+
    fn default() -> Self {
+
        Self {
+
            columns: vec![],
+
            cutoff: usize::MAX,
+
            cutoff_after: usize::MAX,
+
            focus: false,
+
        }
+
    }
+
}
+

+
pub struct Footer<'a, A> {
+
    /// Message sender
+
    pub action_tx: UnboundedSender<A>,
+
    /// Internal properties
+
    props: FooterProps<'a>,
+
}
+

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

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

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

+
impl<'a, A> Widget<(), A> for Footer<'a, A> {
+
    fn new(_state: &(), action_tx: UnboundedSender<A>) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self {
+
            action_tx: action_tx.clone(),
+
            props: FooterProps::default(),
+
        }
+
        .move_with_state(&())
+
    }
+

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

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

+
impl<'a, A> Footer<'a, A> {
+
    fn render_cell(
+
        &self,
+
        frame: &mut ratatui::Frame,
+
        area: Rect,
+
        block_type: FooterBlockType,
+
        text: impl Into<Text<'a>>,
+
        focus: bool,
+
    ) {
+
        let footer_layout = Layout::default()
+
            .direction(Direction::Vertical)
+
            .constraints(vec![Constraint::Min(1)])
+
            .vertical_margin(1)
+
            .horizontal_margin(1)
+
            .split(area);
+

+
        let footer_block = FooterBlock::default()
+
            .border_style(style::border(focus))
+
            .block_type(block_type);
+
        frame.render_widget(footer_block, area);
+
        frame.render_widget(text.into(), footer_layout[0]);
+
    }
+
}
+

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

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

+
        let last = cells.len().saturating_sub(1);
+
        let len = cells.len();
+

+
        for (i, (cell, area)) in cells.into_iter().enumerate() {
+
            let block_type = match i {
+
                0 if len == 1 => FooterBlockType::Single,
+
                0 => FooterBlockType::Begin,
+
                _ if i == last => FooterBlockType::End,
+
                _ => FooterBlockType::Repeat,
+
            };
+
            self.render_cell(frame, *area, block_type, cell.clone(), self.props.focus);
+
        }
+
    }
+
}