Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
Various (breaking) library changes that support issue and patch previews
Merged did:key:z6MkgFq6...nBGz opened 1 year ago
11 files changed +425 -102 dba75b31 0954d14a
modified bin/commands/inbox/select/ui.rs
@@ -45,10 +45,6 @@ pub struct BrowserProps<'a> {
    stats: HashMap<String, usize>,
    /// Table columns
    columns: Vec<Column<'a>>,
-
    /// Max. width, before columns are cut-off.
-
    cutoff: usize,
-
    /// Column index that marks where to cut.
-
    cutoff_after: usize,
    /// Current page size (height of table content).
    page_size: usize,
    /// If search widget should be shown.
@@ -90,16 +86,14 @@ impl<'a> From<&State> for BrowserProps<'a> {
                Column::new("", Constraint::Length(3)),
                Column::new("", Constraint::Length(15))
                    .skip(*state.mode.repository() != RepositoryMode::All),
-
                Column::new("", Constraint::Length(25)),
+
                Column::new("", Constraint::Length(25)).hide_small(),
                Column::new("", Constraint::Fill(1)),
                Column::new("", Constraint::Length(8)),
                Column::new("", Constraint::Length(10)),
-
                Column::new("", Constraint::Length(15)),
-
                Column::new("", Constraint::Length(18)),
+
                Column::new("", Constraint::Length(15)).hide_small(),
+
                Column::new("", Constraint::Length(18)).hide_small(),
            ]
            .to_vec(),
-
            cutoff: 200,
-
            cutoff_after: 5,
            page_size: state.browser.page_size,
            search: state.browser.search.read(),
            show_search: state.browser.show_search,
@@ -129,7 +123,6 @@ impl Browser {
                            ]
                            .to_vec(),
                        )
-
                        .cutoff(props.cutoff, props.cutoff_after)
                        .to_boxed_any()
                        .into()
                }))
@@ -150,7 +143,6 @@ impl Browser {
                                .selected(state.browser.selected)
                                .footer(!state.browser.show_search)
                                .page_size(state.browser.page_size)
-
                                .cutoff(props.cutoff, props.cutoff_after)
                                .to_boxed_any()
                                .into()
                        }),
@@ -252,7 +244,7 @@ impl View for Browser {
        self.search.update(state);
    }

-
    fn render(&self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
+
    fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
        let default = BrowserProps::default();
        let props = props
            .and_then(|props| props.inner_ref::<BrowserProps>())
modified bin/commands/issue/select/ui.rs
@@ -49,10 +49,6 @@ pub struct BrowserProps<'a> {
    header: Vec<Column<'a>>,
    /// Table columns
    columns: Vec<Column<'a>>,
-
    /// Max. width, before columns are cut-off.
-
    cutoff: usize,
-
    /// Column index that marks where to cut.
-
    cutoff_after: usize,
    /// Current page size (height of table content).
    page_size: usize,
    /// If search widget should be shown.
@@ -101,26 +97,24 @@ impl<'a> From<&State> for BrowserProps<'a> {
                Column::new(" ● ", Constraint::Length(3)),
                Column::new("ID", Constraint::Length(8)),
                Column::new("Title", Constraint::Fill(5)),
-
                Column::new("Author", Constraint::Length(16)),
-
                Column::new("", Constraint::Length(16)),
-
                Column::new("Labels", Constraint::Fill(1)),
-
                Column::new("Assignees", Constraint::Fill(1)),
-
                Column::new("Opened", Constraint::Length(16)),
+
                Column::new("Author", Constraint::Length(16)).hide_small(),
+
                Column::new("", Constraint::Length(16)).hide_medium(),
+
                Column::new("Labels", Constraint::Fill(1)).hide_medium(),
+
                Column::new("Assignees", Constraint::Fill(1)).hide_medium(),
+
                Column::new("Opened", Constraint::Length(16)).hide_small(),
            ]
            .to_vec(),
            columns: [
                Column::new(" ● ", Constraint::Length(3)),
                Column::new("ID", Constraint::Length(8)),
                Column::new("Title", Constraint::Fill(5)),
-
                Column::new("Author", Constraint::Length(16)),
-
                Column::new("", Constraint::Length(16)),
-
                Column::new("Labels", Constraint::Fill(1)),
-
                Column::new("Assignees", Constraint::Fill(1)),
-
                Column::new("Opened", Constraint::Length(16)),
+
                Column::new("Author", Constraint::Length(16)).hide_small(),
+
                Column::new("", Constraint::Length(16)).hide_medium(),
+
                Column::new("Labels", Constraint::Fill(1)).hide_medium(),
+
                Column::new("Assignees", Constraint::Fill(1)).hide_medium(),
+
                Column::new("Opened", Constraint::Length(16)).hide_small(),
            ]
            .to_vec(),
-
            cutoff: 200,
-
            cutoff_after: 5,
            page_size: state.browser.page_size,
            search: state.browser.search.read(),
            show_search: state.browser.show_search,
@@ -144,7 +138,6 @@ impl Browser {
                    let props = BrowserProps::from(state);
                    HeaderProps::default()
                        .columns(props.header.clone())
-
                        .cutoff(props.cutoff, props.cutoff_after)
                        .to_boxed_any()
                        .into()
                }))
@@ -165,7 +158,6 @@ impl Browser {
                                .selected(state.browser.selected)
                                .footer(!state.browser.show_search)
                                .page_size(state.browser.page_size)
-
                                .cutoff(props.cutoff, props.cutoff_after)
                                .to_boxed_any()
                                .into()
                        }),
@@ -269,7 +261,7 @@ impl View for Browser {
        self.search.update(state);
    }

-
    fn render(&self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
+
    fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
        let default = BrowserProps::default();
        let props = props
            .and_then(|props| props.inner_ref::<BrowserProps>())
modified bin/commands/patch/select/ui.rs
@@ -51,10 +51,6 @@ pub struct BrowserProps<'a> {
    header: Vec<Column<'a>>,
    /// Table columns
    columns: Vec<Column<'a>>,
-
    /// Max. width, before columns are cut-off.
-
    cutoff: usize,
-
    /// Column index that marks where to cut.
-
    cutoff_after: usize,
    /// Current page size (height of table content).
    page_size: usize,
    /// If search widget should be shown.
@@ -100,28 +96,26 @@ impl<'a> From<&State> for BrowserProps<'a> {
                Column::new(" ● ", Constraint::Length(3)),
                Column::new("ID", Constraint::Length(8)),
                Column::new("Title", Constraint::Fill(1)),
-
                Column::new("Author", Constraint::Length(16)),
-
                Column::new("", Constraint::Length(16)),
-
                Column::new("Head", Constraint::Length(8)),
-
                Column::new("+", Constraint::Length(6)),
-
                Column::new("-", Constraint::Length(6)),
-
                Column::new("Updated", Constraint::Length(16)),
+
                Column::new("Author", Constraint::Length(16)).hide_small(),
+
                Column::new("", Constraint::Length(16)).hide_medium(),
+
                Column::new("Head", Constraint::Length(8)).hide_small(),
+
                Column::new("+", Constraint::Length(6)).hide_small(),
+
                Column::new("-", Constraint::Length(6)).hide_small(),
+
                Column::new("Updated", Constraint::Length(16)).hide_small(),
            ]
            .to_vec(),
            columns: [
                Column::new(" ● ", Constraint::Length(3)),
                Column::new("ID", Constraint::Length(8)),
                Column::new("Title", Constraint::Fill(1)),
-
                Column::new("Author", Constraint::Length(16)),
-
                Column::new("", Constraint::Length(16)),
-
                Column::new("Head", Constraint::Length(8)),
-
                Column::new("+", Constraint::Length(6)),
-
                Column::new("-", Constraint::Length(6)),
-
                Column::new("Updated", Constraint::Length(16)),
+
                Column::new("Author", Constraint::Length(16)).hide_small(),
+
                Column::new("", Constraint::Length(16)).hide_medium(),
+
                Column::new("Head", Constraint::Length(8)).hide_small(),
+
                Column::new("+", Constraint::Length(6)).hide_small(),
+
                Column::new("-", Constraint::Length(6)).hide_small(),
+
                Column::new("Updated", Constraint::Length(16)).hide_small(),
            ]
            .to_vec(),
-
            cutoff: 150,
-
            cutoff_after: 5,
            page_size: state.browser.page_size,
            show_search: state.browser.show_search,
            search: state.browser.search.read(),
@@ -145,7 +139,6 @@ impl Browser {
                    let props = BrowserProps::from(state);
                    HeaderProps::default()
                        .columns(props.header.clone())
-
                        .cutoff(props.cutoff, props.cutoff_after)
                        .to_boxed_any()
                        .into()
                }))
@@ -166,7 +159,6 @@ impl Browser {
                                .selected(state.browser.selected)
                                .footer(!state.browser.show_search)
                                .page_size(state.browser.page_size)
-
                                .cutoff(props.cutoff, props.cutoff_after)
                                .to_boxed_any()
                                .into()
                        }),
@@ -283,7 +275,7 @@ impl View for Browser {
        self.search.update(state);
    }

-
    fn render(&self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
+
    fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
        let default = BrowserProps::default();
        let props = props
            .and_then(|props| props.inner_ref::<BrowserProps>())
added bin/ui/widget.rs
@@ -0,0 +1,91 @@
+
use std::marker::PhantomData;
+

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

+
use radicle_tui as tui;
+

+
use tui::ui::span;
+
use tui::ui::widget::{RenderProps, View, ViewProps};
+

+
use super::items::IssueItem;
+

+
#[derive(Clone, Default)]
+
pub struct IssueDetailsProps {
+
    issue: Option<IssueItem>,
+
}
+

+
impl IssueDetailsProps {
+
    pub fn issue(mut self, issue: Option<IssueItem>) -> Self {
+
        self.issue = issue;
+
        self
+
    }
+
}
+

+
pub struct IssueDetails<S, M> {
+
    /// Phantom
+
    phantom: PhantomData<(S, M)>,
+
}
+

+
impl<S, M> Default for IssueDetails<S, M> {
+
    fn default() -> Self {
+
        Self {
+
            phantom: PhantomData,
+
        }
+
    }
+
}
+

+
impl<S, M> View for IssueDetails<S, M> {
+
    type State = S;
+
    type Message = M;
+

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

+
        if let Some(issue) = props.issue.as_ref() {
+
            let author = match &issue.author.alias {
+
                Some(alias) => {
+
                    if issue.author.you {
+
                        span::alias(&format!("{} (you)", alias))
+
                    } else {
+
                        span::alias(alias)
+
                    }
+
                }
+
                None => match &issue.author.human_nid {
+
                    Some(nid) => span::alias(nid).dim(),
+
                    None => span::blank(),
+
                },
+
            };
+
            let did = match &issue.author.human_nid {
+
                Some(nid) => span::alias(nid).dim(),
+
                None => span::blank(),
+
            };
+

+
            let table = ratatui::widgets::Table::new(
+
                [
+
                    Row::new([
+
                        Text::raw("Title").cyan(),
+
                        Text::raw(issue.title.clone()).bold(),
+
                    ]),
+
                    Row::new([
+
                        Text::raw("Issue").cyan(),
+
                        Text::raw(issue.id.to_string()).bold(),
+
                    ]),
+
                    Row::new([
+
                        Text::raw("Author").cyan(),
+
                        Line::from([author, " ".into(), did].to_vec()).into(),
+
                    ]),
+
                    Row::new([Text::raw("Status").cyan(), Text::raw("???").magenta()]),
+
                ],
+
                [Constraint::Length(8), Constraint::Fill(1)],
+
            );
+
            frame.render_widget(table, render.area);
+
        }
+
    }
+
}
modified src/ui.rs
@@ -22,6 +22,11 @@ use super::terminal;
const RENDERING_TICK_RATE: Duration = Duration::from_millis(250);
const INLINE_HEIGHT: usize = 20;

+
pub const RENDER_WIDTH_XSMALL: usize = 50;
+
pub const RENDER_WIDTH_SMALL: usize = 70;
+
pub const RENDER_WIDTH_MEDIUM: usize = 150;
+
pub const RENDER_WIDTH_LARGE: usize = usize::MAX;
+

/// The `Frontend` runs an applications' view concurrently. It handles
/// terminal events as well as state updates and renders the view accordingly.
///
modified src/ui/widget.rs
@@ -6,6 +6,7 @@ pub mod utils;
pub mod window;

use std::any::Any;
+
use std::rc::Rc;

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

@@ -80,6 +81,42 @@ impl ViewState {
    }
}

+
#[derive(Clone, Default)]
+
pub enum PredefinedLayout {
+
    #[default]
+
    None,
+
    Expandable3,
+
}
+

+
impl PredefinedLayout {
+
    pub fn split(&self, area: Rect) -> Rc<[Rect]> {
+
        match self {
+
            Self::Expandable3 => {
+
                if area.width <= 140 {
+
                    let [left, right] = Layout::horizontal([
+
                        Constraint::Percentage(50),
+
                        Constraint::Percentage(50),
+
                    ])
+
                    .areas(area);
+
                    let [right_top, right_bottom] =
+
                        Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)])
+
                            .areas(right);
+

+
                    [left, right_top, right_bottom].into()
+
                } else {
+
                    Layout::horizontal([
+
                        Constraint::Percentage(33),
+
                        Constraint::Percentage(33),
+
                        Constraint::Percentage(33),
+
                    ])
+
                    .split(area)
+
                }
+
            }
+
            _ => Layout::default().split(area),
+
        }
+
    }
+
}
+

/// General properties that specify how a `View` is rendered.
#[derive(Clone, Default)]
pub struct RenderProps {
@@ -140,7 +177,7 @@ pub trait View {
    fn update(&mut self, _props: Option<&ViewProps>, _state: &Self::State) {}

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

/// A `View` needs to wrapped into a `Widget` before being able to use with the
@@ -206,7 +243,7 @@ impl<S: 'static, M: 'static> Widget<S, M> {
    }

    /// Renders the wrapped view.
-
    pub fn render(&self, render: RenderProps, frame: &mut Frame) {
+
    pub fn render(&mut self, render: RenderProps, frame: &mut Frame) {
        self.view.render(self.props.as_ref(), render.clone(), frame);

        if let Some(on_render) = self.on_render {
modified src/ui/widget/container.rs
@@ -8,14 +8,58 @@ use ratatui::widgets::{Block, BorderType, Borders, Row};

use crate::ui::ext::{FooterBlock, FooterBlockType, HeaderBlock};
use crate::ui::theme::style;
+
use crate::ui::{RENDER_WIDTH_LARGE, RENDER_WIDTH_MEDIUM, RENDER_WIDTH_SMALL};

-
use super::{RenderProps, View, ViewProps, Widget};
+
use super::{PredefinedLayout, RenderProps, View, ViewProps, Widget};
+

+
#[derive(Clone, Debug)]
+
pub struct ColumnView {
+
    small: bool,
+
    medium: bool,
+
    large: bool,
+
}
+

+
impl ColumnView {
+
    pub fn all() -> Self {
+
        Self {
+
            small: true,
+
            medium: true,
+
            large: true,
+
        }
+
    }
+

+
    pub fn small(mut self) -> Self {
+
        self.small = true;
+
        self
+
    }
+

+
    pub fn medium(mut self) -> Self {
+
        self.medium = true;
+
        self
+
    }
+

+
    pub fn large(mut self) -> Self {
+
        self.large = true;
+
        self
+
    }
+
}
+

+
impl Default for ColumnView {
+
    fn default() -> Self {
+
        Self {
+
            small: false,
+
            medium: false,
+
            large: false,
+
        }
+
    }
+
}

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

impl<'a> Column<'a> {
@@ -24,6 +68,7 @@ impl<'a> Column<'a> {
            text: text.into(),
            width,
            skip: false,
+
            view: ColumnView::all(),
        }
    }

@@ -31,6 +76,28 @@ impl<'a> Column<'a> {
        self.skip = skip;
        self
    }
+

+
    pub fn hide_small(mut self) -> Self {
+
        self.view = ColumnView::default().medium().large();
+
        self
+
    }
+

+
    pub fn hide_medium(mut self) -> Self {
+
        self.view = ColumnView::default().large();
+
        self
+
    }
+

+
    pub fn displayed(&self, area_width: usize) -> bool {
+
        if area_width < RENDER_WIDTH_SMALL {
+
            self.view.small
+
        } else if area_width < RENDER_WIDTH_MEDIUM {
+
            self.view.medium
+
        } else if area_width < RENDER_WIDTH_LARGE {
+
            self.view.large
+
        } else {
+
            true
+
        }
+
    }
}

#[derive(Clone, Debug)]
@@ -80,28 +147,31 @@ impl<'a: 'static, S, M> View for Header<S, M> {
    type Message = M;
    type State = S;

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

+
        let width = render.area.width.saturating_sub(2);
+

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

        let cells = props
            .columns
            .iter()
            .filter_map(|column| {
-
                if !column.skip {
+
                if !column.skip && column.displayed(width as usize) {
                    Some(column.text.clone())
                } else {
                    None
@@ -109,12 +179,6 @@ impl<'a: 'static, S, M> View for Header<S, M> {
            })
            .collect::<Vec<_>>();

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

        // Render header
        let block = HeaderBlock::default()
            .borders(Borders::ALL)
@@ -210,7 +274,7 @@ impl<'a: 'static, S, M> View for Footer<S, M> {
    type Message = M;
    type State = S;

-
    fn render(&self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
+
    fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
        let default = FooterProps::default();
        let props = props
            .and_then(|props| props.inner_ref::<FooterProps>())
@@ -326,7 +390,7 @@ where
        }
    }

-
    fn render(&self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
+
    fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
        let default = ContainerProps::default();
        let props = props
            .and_then(|props| props.inner_ref::<ContainerProps>())
@@ -362,23 +426,171 @@ where
            .borders(borders);
        frame.render_widget(block.clone(), content_area);

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

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

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

+
#[derive(Clone, Default)]
+
pub enum SplitContainerFocus {
+
    #[default]
+
    Top,
+
    Bottom,
+
}
+

+
#[derive(Clone, Default)]
+
pub struct SplitContainerProps {
+
    split_focus: SplitContainerFocus,
+
    heights: [Constraint; 2],
+
}
+

+
impl SplitContainerProps {
+
    pub fn split_focus(mut self, split_focus: SplitContainerFocus) -> Self {
+
        self.split_focus = split_focus;
+
        self
+
    }
+

+
    pub fn heights(mut self, heights: [Constraint; 2]) -> Self {
+
        self.heights = heights;
+
        self
+
    }
+
}
+

+
pub struct SplitContainer<S, M> {
+
    /// Container top
+
    top: Option<Widget<S, M>>,
+
    /// Content bottom
+
    bottom: Option<Widget<S, M>>,
+
}
+

+
impl<S, M> Default for SplitContainer<S, M> {
+
    fn default() -> Self {
+
        Self {
+
            top: None,
+
            bottom: None,
+
        }
+
    }
+
}
+

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

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

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

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

+
        match props.split_focus {
+
            SplitContainerFocus::Top => {
+
                if let Some(top) = self.top.as_mut() {
+
                    top.handle_event(key);
+
                }
+
            }
+
            SplitContainerFocus::Bottom => {
+
                if let Some(bottom) = self.bottom.as_mut() {
+
                    bottom.handle_event(key);
+
                }
+
            }
+
        }
+

+
        None
+
    }
+

+
    fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) {
+
        if let Some(top) = self.top.as_mut() {
+
            top.update(state);
+
        }
+

+
        if let Some(bottom) = self.bottom.as_mut() {
+
            bottom.update(state);
+
        }
+
    }
+

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

+
        let heights = props
+
            .heights
+
            .iter()
+
            .map(|c| {
+
                if let Constraint::Length(l) = c {
+
                    Constraint::Length(l + 2)
+
                } else {
+
                    *c
+
                }
+
            })
+
            .collect::<Vec<_>>();
+

+
        let [top_area, bottom_area] = Layout::vertical(heights).areas(render.area);
+

+
        if let Some(top) = self.top.as_mut() {
+
            let block = HeaderBlock::default()
+
                .borders(Borders::ALL)
+
                .border_style(style::border(render.focus))
+
                .border_type(BorderType::Rounded);
+

+
            frame.render_widget(block, top_area);
+

+
            let [top_area] = Layout::default()
+
                .direction(Direction::Vertical)
+
                .constraints(vec![Constraint::Min(1)])
+
                .vertical_margin(1)
+
                .horizontal_margin(2)
+
                .areas(top_area);
+
            top.render(RenderProps::from(top_area).focus(render.focus), frame)
+
        }
+

+
        if let Some(bottom) = self.bottom.as_mut() {
+
            let block = Block::default()
+
                .borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM)
+
                .border_style(style::border(render.focus))
+
                .border_type(BorderType::Rounded);
+

+
            frame.render_widget(block, bottom_area);
+

+
            let [bottom_area, _] = Layout::default()
+
                .direction(Direction::Vertical)
+
                .constraints(vec![Constraint::Min(1), Constraint::Length(1)])
+
                .horizontal_margin(2)
+
                .areas(bottom_area);
+
            bottom.render(RenderProps::from(bottom_area).focus(render.focus), frame)
+
        }
+
    }
+
}
+

#[derive(Clone)]
pub struct SectionGroupState {
    /// Index of currently focused section.
@@ -389,6 +601,8 @@ pub struct SectionGroupState {
pub struct SectionGroupProps {
    /// If this pages' keys should be handled.
    handle_keys: bool,
+
    /// Section layout
+
    layout: PredefinedLayout,
}

impl SectionGroupProps {
@@ -396,6 +610,11 @@ impl SectionGroupProps {
        self.handle_keys = handle_keys;
        self
    }
+

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

pub struct SectionGroup<S, M> {
@@ -482,11 +701,16 @@ where
        }
    }

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

+
        let areas = props.layout.split(render.area);

        for (index, area) in areas.iter().enumerate() {
-
            if let Some(section) = self.sections.get(index) {
+
            if let Some(section) = self.sections.get_mut(index) {
                let focus = self
                    .state
                    .focus
modified src/ui/widget/input.rs
@@ -227,7 +227,7 @@ where
        self.state.text = Some(props.text.clone());
    }

-
    fn render(&self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
+
    fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
        let default = TextFieldProps::default();
        let props = props
            .and_then(|props| props.inner_ref::<TextFieldProps>())
modified src/ui/widget/list.rs
@@ -30,8 +30,6 @@ where
    pub selected: Option<usize>,
    pub columns: Vec<Column<'a>>,
    pub has_footer: bool,
-
    pub cutoff: usize,
-
    pub cutoff_after: usize,
    pub page_size: usize,
}

@@ -44,8 +42,6 @@ where
            items: vec![],
            columns: vec![],
            has_footer: false,
-
            cutoff: usize::MAX,
-
            cutoff_after: usize::MAX,
            page_size: 1,
            selected: Some(0),
        }
@@ -76,12 +72,6 @@ where
        self
    }

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

    pub fn page_size(mut self, page_size: usize) -> Self {
        self.page_size = page_size;
        self
@@ -216,7 +206,7 @@ where
        }
    }

-
    fn render(&self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
+
    fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
        let default = TableProps::default();
        let props = props
            .and_then(|props| props.inner_ref::<TableProps<R, W>>())
@@ -225,15 +215,15 @@ where
        let widths: Vec<Constraint> = props
            .columns
            .iter()
-
            .filter_map(|c| if !c.skip { Some(c.width) } else { None })
+
            .filter_map(|c| {
+
                if !c.skip && c.displayed(render.area.width as usize) {
+
                    Some(c.width)
+
                } else {
+
                    None
+
                }
+
            })
            .collect();

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

        if !props.items.is_empty() {
            let rows = props
                .items
@@ -244,7 +234,7 @@ where

                    for cell in item.to_row() {
                        if let Some(col) = it.next() {
-
                            if !col.skip {
+
                            if !col.skip && col.displayed(render.area.width as usize) {
                                cells.push(cell.clone());
                            }
                        } else {
@@ -261,7 +251,7 @@ where
                .column_spacing(1)
                .highlight_style(style::highlight());

-
            frame.render_stateful_widget(rows, render.area, &mut self.state.clone());
+
            frame.render_stateful_widget(rows, render.area, &mut self.state);
        } else {
            let center = layout::centered_rect(render.area, 50, 10);
            let hint = Text::from(span::default("Nothing to show"))
modified src/ui/widget/text.rs
@@ -168,7 +168,7 @@ where
        None
    }

-
    fn render(&self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
+
    fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
        let default = TextAreaProps::default();
        let props = props
            .and_then(|props| props.inner_ref::<TextAreaProps>())
modified src/ui/widget/window.rs
@@ -98,7 +98,7 @@ where
        }
    }

-
    fn render(&self, props: Option<&ViewProps>, _render: RenderProps, frame: &mut Frame) {
+
    fn render(&mut self, props: Option<&ViewProps>, _render: RenderProps, frame: &mut Frame) {
        let default = WindowProps::default();
        let props = props
            .and_then(|props| props.inner_ref::<WindowProps<Id>>())
@@ -109,7 +109,7 @@ where
        let page = props
            .current_page
            .as_ref()
-
            .and_then(|id| self.pages.get(id));
+
            .and_then(|id| self.pages.get_mut(id));

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

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

-
        if let Some(content) = self.content.as_ref() {
+
        if let Some(content) = self.content.as_mut() {
            content.render(
                RenderProps::from(content_area)
                    .layout(Layout::horizontal([Constraint::Min(1)]))
@@ -203,7 +203,7 @@ where
            );
        }

-
        if let Some(shortcuts) = self.shortcuts.as_ref() {
+
        if let Some(shortcuts) = self.shortcuts.as_mut() {
            shortcuts.render(RenderProps::from(shortcuts_area), frame);
        }
    }
@@ -256,7 +256,7 @@ impl<S, M> View for Shortcuts<S, M> {
    type Message = M;
    type State = S;

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

        let default = ShortcutsProps::default();