Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
lib: Implement flux widget for tables
Erik Kundt committed 2 years ago
commit eaa4d07c7b487cf8865c1aa66c8100507f0b032c
parent 98132c68e0f5344687b40e7eb6377b6749720e9a
7 files changed +506 -81
modified src/flux/store.rs
@@ -1,3 +1,4 @@
+
use std::fmt::Debug;
use std::marker::PhantomData;
use std::time::Duration;

@@ -6,10 +7,14 @@ use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};

use super::termination::{Interrupted, Terminator};

+
const STORE_TICK_RATE: Duration = Duration::from_millis(1000);
+

pub trait State<A> {
+
    type Exit;
+

    fn tick(&self);

-
    fn handle_action(self, action: A) -> anyhow::Result<bool>;
+
    fn handle_action(&mut self, action: A) -> Option<Self::Exit>;
}

pub struct Store<A, S>
@@ -39,11 +44,11 @@ where

impl<A, S> Store<A, S>
where
-
    S: State<A> + Clone + Send + Sync + 'static,
+
    S: State<A> + Clone + Send + Sync + 'static + Debug,
{
    pub async fn main_loop(
        self,
-
        state: S,
+
        mut state: S,
        mut terminator: Terminator,
        mut action_rx: UnboundedReceiver<A>,
        mut interrupt_rx: broadcast::Receiver<Interrupted>,
@@ -51,15 +56,14 @@ where
        // the initial state once
        self.state_tx.send(state.clone())?;

-
        let mut ticker = tokio::time::interval(Duration::from_secs(1));
+
        let mut ticker = tokio::time::interval(STORE_TICK_RATE);

        let result = loop {
            tokio::select! {
                // Handle the actions coming from the UI
                // and process them to do async operations
                Some(action) = action_rx.recv() => {
-
                    let exit = state.clone().handle_action(action)?;
-
                    if exit {
+
                    if let Some(_exit) = state.handle_action(action) {
                        let _ = terminator.terminate(Interrupted::UserInt);

                        break Interrupted::UserInt;
modified src/flux/ui.rs
@@ -1,6 +1,10 @@
+
pub mod cob;
+
pub mod ext;
+
pub mod format;
pub mod layout;
-
pub mod widget;
+
pub mod span;
pub mod theme;
+
pub mod widget;

use std::io::{self};
use std::thread;
@@ -49,15 +53,13 @@ impl<A> Frontend<A> {
            W::new(&state, self.action_tx.clone())
        };

+
        // let mut last_frame: Option<CompletedFrame> = None;
        let result: anyhow::Result<Interrupted> = loop {
            tokio::select! {
                // Tick to terminate the select every N milliseconds
                _ = ticker.tick() => (),
-
                Some(event) = events_rx.recv() => match event {
-
                    Event::Key(key) => {
-
                        root.handle_key_event(key)
-
                    }
-
                    _ => (),
+
                Some(event) = events_rx.recv() => if let Event::Key(key) = event {
+
                    root.handle_key_event(key)
                },
                // Handle state updates
                Some(state) = state_rx.recv() => {
@@ -65,10 +67,13 @@ impl<A> Frontend<A> {
                },
                // Catch and handle interrupt signal to gracefully shutdown
                Ok(interrupted) = interrupt_rx.recv() => {
+
                    let size = terminal.get_frame().size();
+
                    // terminal.set_cursor(size.width, size.height + size.y)?;
+
                    terminal.set_cursor(size.x, size.y)?;
+

                    break Ok(interrupted);
                }
            }
-

            terminal.draw(|frame| root.render::<Backend>(frame, frame.size(), ()))?;
        };

@@ -81,7 +86,7 @@ impl<A> Frontend<A> {
fn setup_terminal() -> anyhow::Result<Terminal<Backend>> {
    let stdout = io::stdout().into_raw_mode()?;
    let options = TerminalOptions {
-
        viewport: Viewport::Inline(10),
+
        viewport: Viewport::Inline(20),
    };

    Ok(Terminal::with_options(
@@ -90,8 +95,8 @@ fn setup_terminal() -> anyhow::Result<Terminal<Backend>> {
    )?)
}

-
fn restore_terminal(terminal: &mut Terminal<Backend>) -> anyhow::Result<()> {
-
    Ok(terminal.show_cursor()?)
+
fn restore_terminal(_terminal: &mut Terminal<Backend>) -> anyhow::Result<()> {
+
    Ok(())
}

fn events() -> mpsc::UnboundedReceiver<Event> {
@@ -100,8 +105,7 @@ fn events() -> mpsc::UnboundedReceiver<Event> {
    thread::spawn(move || {
        let stdin = io::stdin();
        for key in stdin.keys().flatten() {
-
            if let Err(err) = keys_tx.send(Event::Key(key)) {
-
                eprintln!("{err}");
+
            if keys_tx.send(Event::Key(key)).is_err() {
                return;
            }
        }
added src/flux/ui/ext.rs
@@ -0,0 +1,223 @@
+
use ratatui::buffer::Buffer;
+
use ratatui::layout::Rect;
+
use ratatui::style::Style;
+
use ratatui::symbols;
+
use ratatui::widgets::{BorderType, Borders, Widget};
+

+
pub struct HeaderBlock {
+
    /// Visible borders
+
    borders: Borders,
+
    /// Border style
+
    border_style: Style,
+
    /// Type of the border. The default is plain lines but one can choose to have rounded corners
+
    /// or doubled lines instead.
+
    border_type: BorderType,
+
    /// Widget style
+
    style: Style,
+
}
+

+
impl Default for HeaderBlock {
+
    fn default() -> HeaderBlock {
+
        HeaderBlock {
+
            borders: Borders::NONE,
+
            border_style: Default::default(),
+
            border_type: BorderType::Rounded,
+
            style: Default::default(),
+
        }
+
    }
+
}
+

+
impl HeaderBlock {
+
    pub fn border_style(mut self, style: Style) -> HeaderBlock {
+
        self.border_style = style;
+
        self
+
    }
+

+
    pub fn style(mut self, style: Style) -> HeaderBlock {
+
        self.style = style;
+
        self
+
    }
+

+
    pub fn borders(mut self, flag: Borders) -> HeaderBlock {
+
        self.borders = flag;
+
        self
+
    }
+

+
    pub fn border_type(mut self, border_type: BorderType) -> HeaderBlock {
+
        self.border_type = border_type;
+
        self
+
    }
+
}
+

+
impl Widget for HeaderBlock {
+
    fn render(self, area: Rect, buf: &mut Buffer) {
+
        if area.area() == 0 {
+
            return;
+
        }
+
        buf.set_style(area, self.style);
+
        let symbols = BorderType::to_border_set(self.border_type);
+

+
        // Sides
+
        if self.borders.intersects(Borders::LEFT) {
+
            for y in area.top()..area.bottom() {
+
                buf.get_mut(area.left(), y)
+
                    .set_symbol(symbols.vertical_left)
+
                    .set_style(self.border_style);
+
            }
+
        }
+
        if self.borders.intersects(Borders::TOP) {
+
            for x in area.left()..area.right() {
+
                buf.get_mut(x, area.top())
+
                    .set_symbol(symbols.horizontal_top)
+
                    .set_style(self.border_style);
+
            }
+
        }
+
        if self.borders.intersects(Borders::RIGHT) {
+
            let x = area.right() - 1;
+
            for y in area.top()..area.bottom() {
+
                buf.get_mut(x, y)
+
                    .set_symbol(symbols.vertical_right)
+
                    .set_style(self.border_style);
+
            }
+
        }
+
        if self.borders.intersects(Borders::BOTTOM) {
+
            let y = area.bottom() - 1;
+
            for x in area.left()..area.right() {
+
                buf.get_mut(x, y)
+
                    .set_symbol(symbols.horizontal_bottom)
+
                    .set_style(self.border_style);
+
            }
+
        }
+

+
        // Corners
+
        if self.borders.contains(Borders::RIGHT | Borders::BOTTOM) {
+
            buf.get_mut(area.right() - 1, area.bottom() - 1)
+
                .set_symbol(symbols::line::VERTICAL_LEFT)
+
                .set_style(self.border_style);
+
        }
+
        if self.borders.contains(Borders::RIGHT | Borders::TOP) {
+
            buf.get_mut(area.right() - 1, area.top())
+
                .set_symbol(symbols.top_right)
+
                .set_style(self.border_style);
+
        }
+
        if self.borders.contains(Borders::LEFT | Borders::BOTTOM) {
+
            buf.get_mut(area.left(), area.bottom() - 1)
+
                .set_symbol(symbols::line::VERTICAL_RIGHT)
+
                .set_style(self.border_style);
+
        }
+
        if self.borders.contains(Borders::LEFT | Borders::TOP) {
+
            buf.get_mut(area.left(), area.top())
+
                .set_symbol(symbols.top_left)
+
                .set_style(self.border_style);
+
        }
+
    }
+
}
+

+
pub struct FooterBlock {
+
    /// Visible borders
+
    borders: Borders,
+
    /// Border style
+
    border_style: Style,
+
    /// Type of the border. The default is plain lines but one can choose to have rounded corners
+
    /// or doubled lines instead.
+
    border_type: BorderType,
+
    /// Widget style
+
    style: Style,
+
}
+

+
impl Default for FooterBlock {
+
    fn default() -> Self {
+
        Self {
+
            borders: Borders::NONE,
+
            border_style: Default::default(),
+
            border_type: BorderType::Rounded,
+
            style: Default::default(),
+
        }
+
    }
+
}
+

+
impl FooterBlock {
+
    pub fn border_style(mut self, style: Style) -> FooterBlock {
+
        self.border_style = style;
+
        self
+
    }
+

+
    pub fn style(mut self, style: Style) -> FooterBlock {
+
        self.style = style;
+
        self
+
    }
+

+
    pub fn borders(mut self, flag: Borders) -> FooterBlock {
+
        self.borders = flag;
+
        self
+
    }
+

+
    pub fn border_type(mut self, border_type: BorderType) -> FooterBlock {
+
        self.border_type = border_type;
+
        self
+
    }
+
}
+

+
impl Widget for FooterBlock {
+
    fn render(self, area: Rect, buf: &mut Buffer) {
+
        if area.area() == 0 {
+
            return;
+
        }
+
        buf.set_style(area, self.style);
+
        let symbols = BorderType::to_border_set(self.border_type);
+

+
        // Sides
+
        if self.borders.intersects(Borders::LEFT) {
+
            for y in area.top()..area.bottom() {
+
                buf.get_mut(area.left(), y)
+
                    .set_symbol(symbols.vertical_left)
+
                    .set_style(self.border_style);
+
            }
+
        }
+
        if self.borders.intersects(Borders::TOP) {
+
            for x in area.left()..area.right() {
+
                buf.get_mut(x, area.top())
+
                    .set_symbol(symbols.horizontal_top)
+
                    .set_style(self.border_style);
+
            }
+
        }
+
        if self.borders.intersects(Borders::RIGHT) {
+
            let x = area.right() - 1;
+
            for y in area.top()..area.bottom() {
+
                buf.get_mut(x, y)
+
                    .set_symbol(symbols.vertical_right)
+
                    .set_style(self.border_style);
+
            }
+
        }
+
        if self.borders.intersects(Borders::BOTTOM) {
+
            let y = area.bottom() - 1;
+
            for x in area.left()..area.right() {
+
                buf.get_mut(x, y)
+
                    .set_symbol(symbols.horizontal_bottom)
+
                    .set_style(self.border_style);
+
            }
+
        }
+

+
        // Corners
+
        if self.borders.contains(Borders::RIGHT | Borders::BOTTOM) {
+
            buf.get_mut(area.right() - 1, area.bottom() - 1)
+
                .set_symbol(symbols.bottom_right)
+
                .set_style(self.border_style);
+
        }
+
        if self.borders.contains(Borders::RIGHT | Borders::TOP) {
+
            buf.get_mut(area.right() - 1, area.top())
+
                .set_symbol(symbols::line::VERTICAL_LEFT)
+
                .set_style(self.border_style);
+
        }
+
        if self.borders.contains(Borders::LEFT | Borders::BOTTOM) {
+
            buf.get_mut(area.left(), area.bottom() - 1)
+
                .set_symbol(symbols.bottom_left)
+
                .set_style(self.border_style);
+
        }
+
        if self.borders.contains(Borders::LEFT | Borders::TOP) {
+
            buf.get_mut(area.left(), area.top())
+
                .set_symbol(symbols::line::VERTICAL_RIGHT)
+
                .set_style(self.border_style);
+
        }
+
    }
+
}
added src/flux/ui/format.rs
@@ -0,0 +1,27 @@
+
use radicle::cob::{ObjectId, Timestamp};
+
use radicle::prelude::Did;
+

+
/// Format a git Oid.
+
pub fn oid(oid: impl Into<radicle::git::Oid>) -> String {
+
    format!("{:.7}", oid.into())
+
}
+

+
/// Format a COB id.
+
pub fn cob(id: &ObjectId) -> String {
+
    format!("{:.7}", id.to_string())
+
}
+

+
/// Format a DID.
+
pub fn did(did: &Did) -> String {
+
    let nid = did.as_key().to_human();
+
    format!("{}…{}", &nid[..7], &nid[nid.len() - 7..])
+
}
+

+
/// Format a timestamp.
+
pub fn timestamp(time: &Timestamp) -> String {
+
    let fmt = timeago::Formatter::new();
+
    let now = Timestamp::now();
+
    let duration = std::time::Duration::from_secs(now.as_secs() - time.as_secs());
+

+
    fmt.convert(duration)
+
}
added src/flux/ui/span.rs
@@ -0,0 +1,33 @@
+
use ratatui::style::{Style, Stylize};
+
use ratatui::text::Text;
+

+
use crate::flux::ui::theme::style;
+

+
pub fn blank() -> Text<'static> {
+
    Text::styled("", Style::default())
+
}
+

+
pub fn default(content: String) -> Text<'static> {
+
    Text::styled(content, Style::default())
+
}
+

+
pub fn primary(content: String) -> Text<'static> {
+
    default(content).style(style::cyan())
+
}
+

+
pub fn secondary(content: String) -> Text<'static> {
+
    default(content).style(style::magenta())
+
}
+

+
pub fn positive(content: String) -> Text<'static> {
+
    default(content).style(style::green())
+
}
+

+
pub fn badge(content: String) -> Text<'static> {
+
    let content = &format!(" {content} ");
+
    default(content.to_string()).magenta().reversed()
+
}
+

+
pub fn timestamp(content: String) -> Text<'static> {
+
    default(content).style(style::gray().dim())
+
}
modified src/flux/ui/theme.rs
@@ -1,16 +1,10 @@
pub mod style {
-
    use ratatui::style::{Color, Modifier, Style};
+
    use ratatui::style::{Color, Style, Stylize};

    pub fn reset() -> Style {
        Style::default().fg(Color::Reset)
    }

-
    pub fn reset_dim() -> Style {
-
        Style::default()
-
            .fg(Color::Reset)
-
            .add_modifier(Modifier::DIM)
-
    }
-

    pub fn red() -> Style {
        Style::default().fg(Color::Red)
    }
@@ -23,14 +17,6 @@ pub mod style {
        Style::default().fg(Color::Yellow)
    }

-
    pub fn yellow_dim() -> Style {
-
        yellow().add_modifier(Modifier::DIM)
-
    }
-

-
    pub fn yellow_dim_reversed() -> Style {
-
        yellow_dim().add_modifier(Modifier::REVERSED)
-
    }
-

    pub fn blue() -> Style {
        Style::default().fg(Color::Blue)
    }
@@ -39,12 +25,6 @@ pub mod style {
        Style::default().fg(Color::Magenta)
    }

-
    pub fn magenta_dim() -> Style {
-
        Style::default()
-
            .fg(Color::Magenta)
-
            .add_modifier(Modifier::DIM)
-
    }
-

    pub fn cyan() -> Style {
        Style::default().fg(Color::Cyan)
    }
@@ -57,45 +37,19 @@ pub mod style {
        Style::default().fg(Color::Gray)
    }

-
    pub fn gray_dim() -> Style {
-
        Style::default().fg(Color::Gray).add_modifier(Modifier::DIM)
-
    }
-

    pub fn darkgray() -> Style {
        Style::default().fg(Color::DarkGray)
    }

-
    pub fn reversed() -> Style {
-
        Style::default().add_modifier(Modifier::REVERSED)
-
    }
-

-
    pub fn default_reversed() -> Style {
-
        Style::default()
-
            .fg(Color::DarkGray)
-
            .add_modifier(Modifier::REVERSED)
-
    }
-

-
    pub fn magenta_reversed() -> Style {
-
        Style::default()
-
            .fg(Color::Magenta)
-
            .add_modifier(Modifier::REVERSED)
-
    }
-

-
    pub fn yellow_reversed() -> Style {
-
        Style::default().fg(Color::DarkGray).bg(Color::Yellow)
-
    }
-

    pub fn border(focus: bool) -> Style {
        if focus {
-
            gray_dim()
+
            gray()
        } else {
-
            darkgray()
+
            gray().dim()
        }
    }

    pub fn highlight() -> Style {
-
        Style::default()
-
            .fg(Color::Cyan)
-
            .add_modifier(Modifier::REVERSED)
+
        cyan().not_dim().reversed()
    }
}
modified src/flux/ui/widget.rs
@@ -1,10 +1,13 @@
+
use std::fmt::Debug;
+

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

use termion::event::Key;

use ratatui::prelude::*;
-
use ratatui::widgets::{Row, Table};
+
use ratatui::widgets::{Block, BorderType, Borders, Cell, Row, TableState};

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

pub trait Widget<S, A> {
@@ -22,7 +25,7 @@ pub trait Widget<S, A> {
}

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

///
@@ -42,16 +45,16 @@ impl Shortcut {
    }
}

-
pub struct ShortcutWidgetProps {
+
pub struct ShortcutsProps {
    pub shortcuts: Vec<Shortcut>,
    pub divider: char,
}

-
pub struct ShortcutWidget<A> {
+
pub struct Shortcuts<A> {
    pub action_tx: UnboundedSender<A>,
}

-
impl<S, A> Widget<S, A> for ShortcutWidget<A> {
+
impl<S, A> Widget<S, A> for Shortcuts<A> {
    fn new(state: &S, action_tx: UnboundedSender<A>) -> Self
    where
        Self: Sized,
@@ -76,26 +79,27 @@ impl<S, A> Widget<S, A> for ShortcutWidget<A> {
    fn handle_key_event(&mut self, _key: termion::event::Key) {}
}

-
impl<A> Render<ShortcutWidgetProps> for ShortcutWidget<A> {
+
impl<A> Render<ShortcutsProps> for Shortcuts<A> {
    fn render<B: Backend>(
-
        &self,
+
        &mut self,
        frame: &mut ratatui::Frame,
        area: Rect,
-
        props: ShortcutWidgetProps,
+
        props: ShortcutsProps,
    ) {
+
        use ratatui::widgets::Table;
+

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

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

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

            if shortcuts.peek().is_some() {
                row.push((3, divider));
@@ -118,3 +122,179 @@ impl<A> Render<ShortcutWidgetProps> for ShortcutWidget<A> {
        frame.render_widget(table, area);
    }
}
+

+
///
+
///
+
///
+
pub trait ToRow<const W: usize> {
+
    fn to_row(&self) -> [Cell; W];
+
}
+

+
#[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 items: Vec<R>,
+
    pub focus: bool,
+
    pub widths: [Constraint; W],
+
    pub header: [Cell<'a>; W],
+
    pub footer: Option<FooterProps<'a>>,
+
}
+

+
pub struct Table<A> {
+
    /// Sending actions to the state store
+
    pub action_tx: UnboundedSender<A>,
+
    /// Internal selection state
+
    state: TableState,
+
}
+

+
impl<A> Table<A> {
+
    pub fn selected(&self) -> Option<usize> {
+
        self.state.selected()
+
    }
+

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

+
    pub fn next(&mut self, len: usize) {
+
        let selected = self.selected().map(|current| {
+
            if current < len.saturating_sub(1) {
+
                current.saturating_add(1)
+
            } else {
+
                current
+
            }
+
        });
+
        self.state.select(selected);
+
    }
+
}
+

+
impl<S, A> Widget<S, A> for Table<A> {
+
    fn new(state: &S, action_tx: UnboundedSender<A>) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self {
+
            action_tx: action_tx.clone(),
+
            state: TableState::default().with_selected(Some(0)),
+
        }
+
        .move_with_state(state)
+
    }
+

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

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

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

+
impl<'a, A, R, const W: usize> Render<TableProps<'a, R, W>> for Table<A>
+
where
+
    R: ToRow<W> + Debug,
+
{
+
    fn render<B: Backend>(
+
        &mut self,
+
        frame: &mut ratatui::Frame,
+
        area: Rect,
+
        props: TableProps<R, W>,
+
    ) {
+
        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().dim());
+
        let header = ratatui::widgets::Table::default()
+
            .column_spacing(1)
+
            .header(header)
+
            .widths(props.widths);
+

+
        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 rows = props
+
            .items
+
            .iter()
+
            .map(|item| Row::new(item.to_row()))
+
            .collect::<Vec<_>>();
+
        let rows = ratatui::widgets::Table::default()
+
            .rows(rows)
+
            .widths(props.widths)
+
            .column_spacing(1)
+
            .block(
+
                Block::default()
+
                    .border_style(style::border(props.focus))
+
                    .border_type(BorderType::Rounded)
+
                    .borders(table_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]);
+
        }
+
    }
+
}