Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
lib: Improve container rendering w/ flux
Erik Kundt committed 2 years ago
commit 06a01946c250776e7caaab28603930a7da35e07e
parent 49a6dfe21f90c5624293d6edec5187adc2437ef7
5 files changed +385 -186
modified bin/commands/inbox/flux/select/ui.rs
@@ -5,15 +5,15 @@ use tokio::sync::mpsc::UnboundedSender;
use termion::event::Key;

use ratatui::backend::Backend;
-
use ratatui::layout::{Constraint, Rect};
-
use ratatui::widgets::Cell;
+
use ratatui::layout::{Constraint, Direction, Layout, Rect};

use radicle_tui as tui;

use tui::flux::ui::cob::NotificationItem;
use tui::flux::ui::span;
+
use tui::flux::ui::widget::container::{Footer, FooterProps, Header, HeaderProps};
use tui::flux::ui::widget::{
-
    FooterProps, Render, Shortcut, Shortcuts, ShortcutsProps, Table, TableProps, Widget,
+
    Render, Shortcut, Shortcuts, ShortcutsProps, Table, TableProps, Widget,
};
use tui::Selection;

@@ -157,8 +157,12 @@ struct Notifications {
    action_tx: UnboundedSender<Action>,
    /// State mapped props
    props: NotificationsProps,
+
    /// Table header
+
    header: Header<Action>,
    /// Notification table
    table: Table<Action>,
+
    /// Table footer
+
    footer: Footer<Action>,
}

impl Widget<InboxState, Action> for Notifications {
@@ -166,7 +170,9 @@ impl Widget<InboxState, Action> for Notifications {
        Self {
            action_tx: action_tx.clone(),
            props: NotificationsProps::from(state),
+
            header: Header::new(state, action_tx.clone()),
            table: Table::new(state, action_tx.clone()),
+
            footer: Footer::new(state, action_tx),
        }
    }

@@ -176,13 +182,15 @@ impl Widget<InboxState, Action> for Notifications {
    {
        Self {
            props: NotificationsProps::from(state),
+
            header: self.header.move_with_state(state),
            table: self.table.move_with_state(state),
+
            footer: self.footer.move_with_state(state),
            ..self
        }
    }

    fn name(&self) -> &str {
-
        "notification-list"
+
        "notifications"
    }

    fn handle_key_event(&mut self, key: Key) {
@@ -224,16 +232,18 @@ impl Widget<InboxState, Action> for Notifications {

impl Render<()> for Notifications {
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
-
        let header: [Cell; 8] = [
-
            String::from("").into(),
-
            String::from(" ● ").into(),
-
            String::from("ID / Name").into(),
-
            String::from("Summary").into(),
-
            String::from("Type").into(),
-
            String::from("Status").into(),
-
            String::from("Author").into(),
-
            String::from("Updated").into(),
-
        ];
+
        let cutoff = 200;
+
        let cutoff_after = 8;
+
        let focus = false;
+

+
        let layout = Layout::default()
+
            .direction(Direction::Vertical)
+
            .constraints(vec![
+
                Constraint::Length(3),
+
                Constraint::Min(1),
+
                Constraint::Length(3),
+
            ])
+
            .split(area);

        let widths = [
            Constraint::Length(5),
@@ -257,34 +267,60 @@ impl Render<()> for Notifications {
            span::badge(format!("{}/{}", step, length))
        };

-
        let footer = FooterProps {
-
            cells: [
-
                span::badge("/".to_string()),
-
                String::from("").into(),
-
                String::from("").into(),
-
                progress.clone(),
-
            ]
-
            .to_vec(),
-
            widths: [
-
                Constraint::Length(3),
-
                Constraint::Fill(1),
-
                Constraint::Fill(1),
-
                Constraint::Length(progress.width() as u16),
-
            ]
-
            .to_vec(),
-
        };
+
        self.header.render::<B>(
+
            frame,
+
            layout[0],
+
            HeaderProps {
+
                cells: [
+
                    String::from("").into(),
+
                    String::from(" ● ").into(),
+
                    String::from("ID / Name").into(),
+
                    String::from("Summary").into(),
+
                    String::from("Type").into(),
+
                    String::from("Status").into(),
+
                    String::from("Author").into(),
+
                    String::from("Updated").into(),
+
                ],
+
                widths,
+
                focus,
+
                cutoff,
+
                cutoff_after,
+
            },
+
        );

        self.table.render::<B>(
            frame,
-
            area,
+
            layout[1],
            TableProps {
                items: self.props.notifications.to_vec(),
-
                focus: false,
+
                has_header: true,
+
                has_footer: true,
+
                focus,
                widths,
-
                header,
-
                footer: Some(footer),
-
                cutoff: 200,
-
                cutoff_after: 6,
+
                cutoff,
+
                cutoff_after,
+
            },
+
        );
+

+
        self.footer.render::<B>(
+
            frame,
+
            layout[2],
+
            FooterProps {
+
                cells: [
+
                    span::badge("/".to_string()),
+
                    String::from("").into(),
+
                    String::from("").into(),
+
                    progress.clone(),
+
                ],
+
                widths: [
+
                    Constraint::Length(3),
+
                    Constraint::Fill(1),
+
                    Constraint::Fill(1),
+
                    Constraint::Length(progress.width() as u16),
+
                ],
+
                focus,
+
                cutoff,
+
                cutoff_after,
            },
        );
    }
modified bin/commands/issue/flux/select/ui.rs
@@ -6,15 +6,16 @@ use tokio::sync::mpsc::UnboundedSender;
use termion::event::Key;

use ratatui::backend::Backend;
-
use ratatui::layout::{Constraint, Rect};
+
use ratatui::layout::{Constraint, Direction, Layout, Rect};

use radicle_tui as tui;

use tui::common::cob::issue::Filter;
use tui::flux::ui::cob::IssueItem;
use tui::flux::ui::span;
+
use tui::flux::ui::widget::container::{Footer, FooterProps, Header, HeaderProps};
use tui::flux::ui::widget::{
-
    FooterProps, Render, Shortcut, Shortcuts, ShortcutsProps, Table, TableProps, Widget,
+
    Render, Shortcut, Shortcuts, ShortcutsProps, Table, TableProps, Widget,
};
use tui::Selection;

@@ -185,8 +186,12 @@ struct Issues {
    action_tx: UnboundedSender<Action>,
    /// State mapped props
    props: IssuesProps,
+
    /// Header
+
    header: Header<Action>,
    /// Notification table
    table: Table<Action>,
+
    /// Footer
+
    footer: Footer<Action>,
}

impl Widget<IssuesState, Action> for Issues {
@@ -194,7 +199,9 @@ impl Widget<IssuesState, Action> for Issues {
        Self {
            action_tx: action_tx.clone(),
            props: IssuesProps::from(state),
+
            header: Header::new(state, action_tx.clone()),
            table: Table::new(state, action_tx.clone()),
+
            footer: Footer::new(state, action_tx),
        }
    }

@@ -205,12 +212,14 @@ impl Widget<IssuesState, Action> for Issues {
        Self {
            props: IssuesProps::from(state),
            table: self.table.move_with_state(state),
+
            header: self.header.move_with_state(state),
+
            footer: self.footer.move_with_state(state),
            ..self
        }
    }

    fn name(&self) -> &str {
-
        "notification-list"
+
        "issues"
    }

    fn handle_key_event(&mut self, key: Key) {
@@ -252,16 +261,18 @@ impl Widget<IssuesState, Action> for Issues {

impl Render<()> for Issues {
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
-
        let header = [
-
            String::from(" ● ").into(),
-
            String::from("ID").into(),
-
            String::from("Title").into(),
-
            String::from("Author").into(),
-
            String::from("").into(),
-
            String::from("Labels").into(),
-
            String::from("Assignees ").into(),
-
            String::from("Opened").into(),
-
        ];
+
        let cutoff = 200;
+
        let cutoff_after = 5;
+
        let focus = false;
+

+
        let layout = Layout::default()
+
            .direction(Direction::Vertical)
+
            .constraints(vec![
+
                Constraint::Length(3),
+
                Constraint::Min(1),
+
                Constraint::Length(3),
+
            ])
+
            .split(area);

        let widths = [
            Constraint::Length(3),
@@ -285,34 +296,60 @@ impl Render<()> for Issues {
            span::badge(format!("{}/{}", step, length))
        };

-
        let footer = FooterProps {
-
            cells: [
-
                span::badge("/".to_string()),
-
                span::default(self.props.filter.to_string()).magenta().dim(),
-
                String::from("").into(),
-
                progress.clone(),
-
            ]
-
            .to_vec(),
-
            widths: [
-
                Constraint::Length(3),
-
                Constraint::Fill(1),
-
                Constraint::Fill(1),
-
                Constraint::Length(progress.width() as u16),
-
            ]
-
            .to_vec(),
-
        };
+
        self.header.render::<B>(
+
            frame,
+
            layout[0],
+
            HeaderProps {
+
                cells: [
+
                    String::from(" ● ").into(),
+
                    String::from("ID").into(),
+
                    String::from("Title").into(),
+
                    String::from("Author").into(),
+
                    String::from("").into(),
+
                    String::from("Labels").into(),
+
                    String::from("Assignees ").into(),
+
                    String::from("Opened").into(),
+
                ],
+
                widths,
+
                focus,
+
                cutoff,
+
                cutoff_after,
+
            },
+
        );

        self.table.render::<B>(
            frame,
-
            area,
+
            layout[1],
            TableProps {
                items: self.props.issues.to_vec(),
-
                focus: false,
+
                has_footer: true,
+
                has_header: true,
+
                focus,
                widths,
-
                header,
-
                footer: Some(footer),
-
                cutoff: 200,
-
                cutoff_after: 5,
+
                cutoff,
+
                cutoff_after,
+
            },
+
        );
+

+
        self.footer.render::<B>(
+
            frame,
+
            layout[2],
+
            FooterProps {
+
                cells: [
+
                    span::badge("/".to_string()),
+
                    span::default(self.props.filter.to_string()).magenta().dim(),
+
                    String::from("").into(),
+
                    progress.clone(),
+
                ],
+
                widths: [
+
                    Constraint::Length(3),
+
                    Constraint::Fill(1),
+
                    Constraint::Fill(1),
+
                    Constraint::Length(progress.width() as u16),
+
                ],
+
                focus,
+
                cutoff,
+
                cutoff_after,
            },
        );
    }
modified bin/commands/patch/flux/select/ui.rs
@@ -6,15 +6,16 @@ use tokio::sync::mpsc::UnboundedSender;
use termion::event::Key;

use ratatui::backend::Backend;
-
use ratatui::layout::{Constraint, Rect};
+
use ratatui::layout::{Constraint, Direction, Layout, Rect};

use radicle_tui as tui;

use tui::common::cob::patch::Filter;
use tui::flux::ui::cob::PatchItem;
use tui::flux::ui::span;
+
use tui::flux::ui::widget::container::{Footer, FooterProps, Header, HeaderProps};
use tui::flux::ui::widget::{
-
    FooterProps, Render, Shortcut, Shortcuts, ShortcutsProps, Table, TableProps, Widget,
+
    Render, Shortcut, Shortcuts, ShortcutsProps, Table, TableProps, Widget,
};
use tui::Selection;

@@ -197,8 +198,12 @@ struct Patches {
    action_tx: UnboundedSender<Action>,
    /// State mapped props
    props: PatchesProps,
+
    /// Table header
+
    header: Header<Action>,
    /// Notification table
    table: Table<Action>,
+
    /// Table footer
+
    footer: Footer<Action>,
}

impl Widget<PatchesState, Action> for Patches {
@@ -206,7 +211,9 @@ impl Widget<PatchesState, Action> for Patches {
        Self {
            action_tx: action_tx.clone(),
            props: PatchesProps::from(state),
+
            header: Header::new(state, action_tx.clone()),
            table: Table::new(state, action_tx.clone()),
+
            footer: Footer::new(state, action_tx),
        }
    }

@@ -216,13 +223,15 @@ impl Widget<PatchesState, Action> for Patches {
    {
        Self {
            props: PatchesProps::from(state),
+
            header: self.header.move_with_state(state),
            table: self.table.move_with_state(state),
+
            footer: self.footer.move_with_state(state),
            ..self
        }
    }

    fn name(&self) -> &str {
-
        "notification-list"
+
        "patches"
    }

    fn handle_key_event(&mut self, key: Key) {
@@ -264,17 +273,18 @@ impl Widget<PatchesState, Action> for Patches {

impl Render<()> for Patches {
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
-
        let header = [
-
            String::from(" ● ").into(),
-
            String::from("ID").into(),
-
            String::from("Title").into(),
-
            String::from("Author").into(),
-
            String::from("").into(),
-
            String::from("Head").into(),
-
            String::from("+").into(),
-
            String::from("- ").into(),
-
            String::from("Updated").into(),
-
        ];
+
        let cutoff = 200;
+
        let cutoff_after = 5;
+
        let focus = false;
+

+
        let layout = Layout::default()
+
            .direction(Direction::Vertical)
+
            .constraints(vec![
+
                Constraint::Length(3),
+
                Constraint::Min(1),
+
                Constraint::Length(3),
+
            ])
+
            .split(area);

        let widths = [
            Constraint::Length(3),
@@ -299,34 +309,61 @@ impl Render<()> for Patches {
            span::badge(format!("{}/{}", step, length))
        };

-
        let footer = FooterProps {
-
            cells: [
-
                span::badge("/".to_string()),
-
                span::default(self.props.filter.to_string()).magenta().dim(),
-
                String::from("").into(),
-
                progress.clone(),
-
            ]
-
            .to_vec(),
-
            widths: [
-
                Constraint::Length(3),
-
                Constraint::Fill(1),
-
                Constraint::Fill(1),
-
                Constraint::Length(progress.width() as u16),
-
            ]
-
            .to_vec(),
-
        };
+
        self.header.render::<B>(
+
            frame,
+
            layout[0],
+
            HeaderProps {
+
                cells: [
+
                    String::from(" ● ").into(),
+
                    String::from("ID").into(),
+
                    String::from("Title").into(),
+
                    String::from("Author").into(),
+
                    String::from("").into(),
+
                    String::from("Head").into(),
+
                    String::from("+").into(),
+
                    String::from("- ").into(),
+
                    String::from("Updated").into(),
+
                ],
+
                widths,
+
                focus,
+
                cutoff,
+
                cutoff_after,
+
            },
+
        );

        self.table.render::<B>(
            frame,
-
            area,
+
            layout[1],
            TableProps {
                items: self.props.patches.to_vec(),
-
                focus: false,
+
                has_header: true,
+
                has_footer: true,
+
                focus,
                widths,
-
                header,
-
                footer: Some(footer),
-
                cutoff: 200,
-
                cutoff_after: 5,
+
                cutoff,
+
                cutoff_after,
+
            },
+
        );
+

+
        self.footer.render::<B>(
+
            frame,
+
            layout[2],
+
            FooterProps {
+
                cells: [
+
                    span::badge("/".to_string()),
+
                    span::default(self.props.filter.to_string()).magenta().dim(),
+
                    String::from("").into(),
+
                    progress.clone(),
+
                ],
+
                widths: [
+
                    Constraint::Length(3),
+
                    Constraint::Fill(1),
+
                    Constraint::Fill(1),
+
                    Constraint::Length(progress.width() as u16),
+
                ],
+
                focus,
+
                cutoff,
+
                cutoff_after,
            },
        );
    }
modified src/flux/ui/widget.rs
@@ -1,3 +1,5 @@
+
pub mod container;
+

use std::fmt::Debug;

use tokio::sync::mpsc::UnboundedSender;
@@ -7,7 +9,6 @@ use termion::event::Key;
use ratatui::prelude::*;
use ratatui::widgets::{Block, BorderType, Borders, Cell, Row, TableState};

-
use super::ext::{FooterBlock, HeaderBlock};
use super::theme::style;

pub trait Widget<S, A> {
@@ -120,18 +121,12 @@ pub trait ToRow<const W: usize> {
}

#[derive(Debug)]
-
pub struct FooterProps<'a> {
-
    pub cells: Vec<Text<'a>>,
-
    pub widths: Vec<Constraint>,
-
}
-

-
#[derive(Debug)]
-
pub struct TableProps<'a, R: ToRow<W>, const W: usize> {
+
pub struct TableProps<R: ToRow<W>, const W: usize> {
    pub items: Vec<R>,
    pub focus: bool,
    pub widths: [Constraint; W],
-
    pub header: [Cell<'a>; W],
-
    pub footer: Option<FooterProps<'a>>,
+
    pub has_header: bool,
+
    pub has_footer: bool,
    pub cutoff: usize,
    pub cutoff_after: usize,
}
@@ -191,7 +186,7 @@ impl<S, A> Widget<S, A> for Table<A> {
    fn handle_key_event(&mut self, _key: Key) {}
}

-
impl<'a, A, R, const W: usize> Render<TableProps<'a, R, W>> for Table<A>
+
impl<A, R, const W: usize> Render<TableProps<R, W>> for Table<A>
where
    R: ToRow<W> + Debug,
{
@@ -203,49 +198,11 @@ where
            widths.iter().collect::<Vec<_>>()
        };

-
        let layout = if props.footer.is_some() {
-
            Layout::default()
-
                .direction(Direction::Vertical)
-
                .constraints(vec![
-
                    Constraint::Length(3),
-
                    Constraint::Min(1),
-
                    Constraint::Length(3),
-
                ])
-
                .split(area)
-
        } else {
-
            Layout::default()
-
                .direction(Direction::Vertical)
-
                .constraints(vec![Constraint::Length(3), Constraint::Min(1)])
-
                .split(area)
-
        };
-

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

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

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

-
        frame.render_widget(block, layout[0]);
-
        frame.render_widget(header, header_layout[0]);
-

-
        // Render content
-
        let table_borders = if props.footer.is_some() {
-
            Borders::LEFT | Borders::RIGHT
-
        } else {
-
            Borders::BOTTOM | Borders::LEFT | Borders::RIGHT
+
        let borders = match (props.has_header, props.has_footer) {
+
            (false, false) => Borders::ALL,
+
            (true, false) => Borders::BOTTOM | Borders::LEFT | Borders::RIGHT,
+
            (false, true) => Borders::TOP | Borders::LEFT | Borders::RIGHT,
+
            (true, true) => Borders::LEFT | Borders::RIGHT,
        };

        let rows = props
@@ -261,33 +218,10 @@ where
                Block::default()
                    .border_style(style::border(props.focus))
                    .border_type(BorderType::Rounded)
-
                    .borders(table_borders),
+
                    .borders(borders),
            )
            .highlight_style(style::highlight());

-
        frame.render_stateful_widget(rows, layout[1], &mut self.state.clone());
-

-
        if let Some(footer) = props.footer {
-
            // Render footer
-
            let footer_block = FooterBlock::default()
-
                .borders(Borders::ALL)
-
                .border_style(style::border(props.focus))
-
                .border_type(BorderType::Rounded);
-

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

-
            let footer = ratatui::widgets::Table::default()
-
                .column_spacing(1)
-
                .header(Row::new(footer.cells))
-
                .widths(footer.widths);
-

-
            frame.render_widget(footer_block, layout[2]);
-
            frame.render_widget(footer, footer_layout[0]);
-
        }
+
        frame.render_stateful_widget(rows, area, &mut self.state.clone());
    }
}
added src/flux/ui/widget/container.rs
@@ -0,0 +1,155 @@
+
use std::fmt::Debug;
+

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

+
use termion::event::Key;
+

+
use ratatui::prelude::*;
+
use ratatui::widgets::{BorderType, Borders, Row};
+

+
use crate::flux::ui::ext::{FooterBlock, HeaderBlock};
+
use crate::flux::ui::theme::style;
+

+
use super::{Render, Widget};
+

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

+
pub struct 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 name(&self) -> &str {
+
        "footer"
+
    }
+

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

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

+
        let footer_block = FooterBlock::default()
+
            .borders(Borders::ALL)
+
            .border_style(style::border(props.focus))
+
            .border_type(BorderType::Rounded);
+

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

+
        let footer = ratatui::widgets::Table::default()
+
            .column_spacing(1)
+
            .header(Row::new(props.cells))
+
            .widths(widths);
+

+
        frame.render_widget(footer_block, area);
+
        frame.render_widget(footer, footer_layout[0]);
+
    }
+
}
+

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

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

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

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

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

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

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

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

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

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

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