Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
Restructure widget module
Merged did:key:z6MkswQE...2C1V opened 1 year ago
11 files changed +732 -706 cbf3ebef 780dbaf9
modified bin/commands/inbox/select.rs
@@ -17,9 +17,11 @@ use radicle_tui as tui;
use tui::cob::inbox::{self};
use tui::store;
use tui::store::StateValue;
-
use tui::task::{self, Interrupted};
+
use tui::task;
+
use tui::task::Interrupted;
use tui::ui::items::{Filter, NotificationItem, NotificationItemFilter};
-
use tui::ui::widget::{Properties, Widget, Window, WindowProps};
+
use tui::ui::widget::window::{Window, WindowProps};
+
use tui::ui::widget::{Properties, Widget};
use tui::ui::Frontend;
use tui::Exit;

modified bin/commands/inbox/select/ui.rs
@@ -16,13 +16,15 @@ use tui::ui::items::{NotificationItem, NotificationItemFilter, NotificationState
use tui::ui::span;
use tui::ui::widget;
use tui::ui::widget::container::{
-
    Container, ContainerProps, Footer, FooterProps, Header, HeaderProps,
+
    Column, Container, ContainerProps, Footer, FooterProps, Header, HeaderProps, SectionGroup,
+
    SectionGroupProps,
};
use tui::ui::widget::input::{TextField, TextFieldProps, TextFieldState};
-
use tui::ui::widget::page::{SectionGroup, SectionGroupProps};
+
use tui::ui::widget::list::{Table, TableProps, TableUtils};
use tui::ui::widget::text::{Paragraph, ParagraphProps, ParagraphState};
-
use tui::ui::widget::{BaseView, RenderProps, TableUtils, WidgetState};
-
use tui::ui::widget::{Column, Properties, Shortcuts, ShortcutsProps, Table, TableProps, Widget};
+
use tui::ui::widget::window::{Shortcuts, ShortcutsProps};
+
use tui::ui::widget::{BaseView, Properties, RenderProps, Widget, WidgetState};
+

use tui::Selection;

use crate::tui_inbox::common::{InboxOperation, Mode, RepositoryMode, SelectionMode};
modified bin/commands/issue/select.rs
@@ -16,7 +16,8 @@ use tui::store::StateValue;
use tui::task;
use tui::task::Interrupted;
use tui::ui::items::{Filter, IssueItem, IssueItemFilter};
-
use tui::ui::widget::{Properties, Widget, Window, WindowProps};
+
use tui::ui::widget::window::{Window, WindowProps};
+
use tui::ui::widget::{Properties, Widget};
use tui::ui::Frontend;
use tui::Exit;
use tui::{store, PageStack};
modified bin/commands/issue/select/ui.rs
@@ -16,15 +16,16 @@ use radicle_tui as tui;

use tui::ui::items::{IssueItem, IssueItemFilter};
use tui::ui::span;
+
use tui::ui::widget;
use tui::ui::widget::container::{
-
    Container, ContainerProps, Footer, FooterProps, Header, HeaderProps,
+
    Column, Container, ContainerProps, Footer, FooterProps, Header, HeaderProps,
};
use tui::ui::widget::input::{TextField, TextFieldProps, TextFieldState};
+
use tui::ui::widget::list::{Table, TableProps, TableUtils};
use tui::ui::widget::text::{Paragraph, ParagraphProps, ParagraphState};
-
use tui::ui::widget::{self, BaseView, RenderProps, WidgetState};
-
use tui::ui::widget::{
-
    Column, Properties, Shortcuts, ShortcutsProps, Table, TableProps, TableUtils, Widget,
-
};
+
use tui::ui::widget::window::{Shortcuts, ShortcutsProps};
+
use tui::ui::widget::{BaseView, Properties, RenderProps, Widget, WidgetState};
+

use tui::Selection;

use crate::tui_issue::common::IssueOperation;
modified bin/commands/patch/select.rs
@@ -16,10 +16,8 @@ use tui::store;
use tui::task;
use tui::task::Interrupted;
use tui::ui::items::{Filter, PatchItem, PatchItemFilter};
-
use tui::ui::widget::Properties;
-
use tui::ui::widget::Widget;
-
use tui::ui::widget::Window;
-
use tui::ui::widget::WindowProps;
+
use tui::ui::widget::window::{Window, WindowProps};
+
use tui::ui::widget::{Properties, Widget};
use tui::ui::Frontend;
use tui::Exit;

modified bin/commands/patch/select/ui.rs
@@ -17,14 +17,16 @@ use radicle_tui as tui;

use tui::ui::items::{PatchItem, PatchItemFilter};
use tui::ui::span;
+
use tui::ui::widget;
use tui::ui::widget::container::{
-
    Container, ContainerProps, Footer, FooterProps, Header, HeaderProps,
+
    Column, Container, ContainerProps, Footer, FooterProps, Header, HeaderProps,
};
use tui::ui::widget::input::{TextField, TextFieldProps, TextFieldState};
+
use tui::ui::widget::list::{Table, TableProps, TableUtils};
use tui::ui::widget::text::{Paragraph, ParagraphProps, ParagraphState};
-
use tui::ui::widget::{self, BaseView, RenderProps};
-
use tui::ui::widget::{Column, Properties, Shortcuts, ShortcutsProps, Table, TableProps, Widget};
-
use tui::ui::widget::{TableUtils, WidgetState};
+
use tui::ui::widget::window::{Shortcuts, ShortcutsProps};
+
use tui::ui::widget::{BaseView, Properties, RenderProps, Widget, WidgetState};
+

use tui::Selection;

use crate::tui_patch::common::Mode;
modified src/ui/widget.rs
@@ -1,23 +1,17 @@
pub mod container;
pub mod input;
-
pub mod page;
+
pub mod list;
pub mod text;
+
pub mod window;

use std::any::Any;
-
use std::cmp;
-
use std::collections::HashMap;
-
use std::fmt::Debug;
-
use std::hash::Hash;

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

use termion::event::Key;

use ratatui::prelude::*;
-
use ratatui::widgets::{Cell, Row, TableState};
-

-
use super::theme::style;
-
use super::{layout, span};
+
use ratatui::widgets::Cell;

pub type BoxedWidget<S, A> = Box<dyn Widget<State = S, Action = A>>;

@@ -182,539 +176,3 @@ pub trait WidgetState {
        any.downcast_ref::<Self>().cloned()
    }
}
-

-
#[derive(Clone)]
-
pub struct WindowProps<Id> {
-
    current_page: Option<Id>,
-
}
-

-
impl<Id> WindowProps<Id> {
-
    pub fn current_page(mut self, page: Id) -> Self {
-
        self.current_page = Some(page);
-
        self
-
    }
-
}
-

-
impl<Id> Default for WindowProps<Id> {
-
    fn default() -> Self {
-
        Self { current_page: None }
-
    }
-
}
-

-
impl<Id> Properties for WindowProps<Id> {}
-

-
pub struct Window<S, A, Id> {
-
    /// Internal base
-
    base: BaseView<S, A>,
-
    /// Internal properties
-
    props: WindowProps<Id>,
-
    /// All pages known
-
    pages: HashMap<Id, BoxedWidget<S, A>>,
-
}
-

-
impl<S, A, Id> Window<S, A, Id>
-
where
-
    Id: Clone + Hash + Eq + PartialEq,
-
{
-
    pub fn page(mut self, id: Id, page: BoxedWidget<S, A>) -> Self {
-
        self.pages.insert(id, page);
-
        self
-
    }
-
}
-

-
impl<'a: 'static, S, A, Id> Widget for Window<S, A, Id>
-
where
-
    Id: Clone + Hash + Eq + PartialEq + 'a,
-
{
-
    type Action = A;
-
    type State = S;
-

-
    fn new(_state: &S, action_tx: UnboundedSender<A>) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        Self {
-
            base: BaseView {
-
                action_tx: action_tx.clone(),
-
                on_update: None,
-
                on_event: None,
-
            },
-
            props: WindowProps::default(),
-
            pages: HashMap::new(),
-
        }
-
    }
-

-
    fn handle_event(&mut self, key: termion::event::Key) {
-
        let page = self
-
            .props
-
            .current_page
-
            .as_ref()
-
            .and_then(|id| self.pages.get_mut(id));
-

-
        if let Some(page) = page {
-
            page.handle_event(key);
-
        }
-
    }
-

-
    fn update(&mut self, state: &S) {
-
        self.props =
-
            WindowProps::from_callback(self.base.on_update, state).unwrap_or(self.props.clone());
-

-
        let page = self
-
            .props
-
            .current_page
-
            .as_ref()
-
            .and_then(|id| self.pages.get_mut(id));
-

-
        if let Some(page) = page {
-
            page.update(state);
-
        }
-
    }
-

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

-
        let page = self
-
            .props
-
            .current_page
-
            .as_ref()
-
            .and_then(|id| self.pages.get(id));
-

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

-
    fn base_mut(&mut self) -> &mut BaseView<S, A> {
-
        &mut self.base
-
    }
-
}
-

-
#[derive(Clone)]
-
pub struct ShortcutsProps {
-
    pub shortcuts: Vec<(String, String)>,
-
    pub divider: char,
-
}
-

-
impl ShortcutsProps {
-
    pub fn divider(mut self, divider: char) -> Self {
-
        self.divider = divider;
-
        self
-
    }
-

-
    pub fn shortcuts(mut self, shortcuts: &[(&str, &str)]) -> Self {
-
        self.shortcuts.clear();
-
        for (short, long) in shortcuts {
-
            self.shortcuts.push((short.to_string(), long.to_string()));
-
        }
-
        self
-
    }
-
}
-

-
impl Default for ShortcutsProps {
-
    fn default() -> Self {
-
        Self {
-
            shortcuts: vec![],
-
            divider: '∙',
-
        }
-
    }
-
}
-

-
impl Properties for ShortcutsProps {}
-

-
pub struct Shortcuts<S, A> {
-
    /// Internal properties
-
    props: ShortcutsProps,
-
    /// Internal base
-
    base: BaseView<S, A>,
-
}
-

-
impl<S, A> Shortcuts<S, A> {
-
    pub fn divider(mut self, divider: char) -> Self {
-
        self.props.divider = divider;
-
        self
-
    }
-

-
    pub fn shortcuts(mut self, shortcuts: &[(&str, &str)]) -> Self {
-
        self.props.shortcuts.clear();
-
        for (short, long) in shortcuts {
-
            self.props
-
                .shortcuts
-
                .push((short.to_string(), long.to_string()));
-
        }
-
        self
-
    }
-
}
-

-
impl<S, A> Widget for Shortcuts<S, A> {
-
    type Action = A;
-
    type State = S;
-

-
    fn new(_state: &S, action_tx: UnboundedSender<A>) -> Self {
-
        Self {
-
            base: BaseView {
-
                action_tx: action_tx.clone(),
-
                on_update: None,
-
                on_event: None,
-
            },
-
            props: ShortcutsProps::default(),
-
        }
-
    }
-

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

-
    fn update(&mut self, state: &S) {
-
        self.props =
-
            ShortcutsProps::from_callback(self.base.on_update, state).unwrap_or(self.props.clone());
-
    }
-

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

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

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

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

-
            if shortcuts.peek().is_some() {
-
                row.push((3, divider));
-
            }
-
        }
-

-
        let row_copy = row.clone();
-
        let row: Vec<Text<'_>> = row_copy
-
            .clone()
-
            .iter()
-
            .map(|(_, text)| text.clone())
-
            .collect();
-
        let widths: Vec<Constraint> = row_copy
-
            .clone()
-
            .iter()
-
            .map(|(width, _)| Constraint::Length(*width as u16))
-
            .collect();
-

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

-
    fn base_mut(&mut self) -> &mut BaseView<S, A> {
-
        &mut self.base
-
    }
-
}
-

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

-
impl<'a> Column<'a> {
-
    pub fn new(text: impl Into<Text<'a>>, width: Constraint) -> Self {
-
        Self {
-
            text: text.into(),
-
            width,
-
            skip: false,
-
        }
-
    }
-

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

-
#[derive(Clone, Debug)]
-
pub struct TableProps<'a, R, const W: usize>
-
where
-
    R: ToRow<W>,
-
{
-
    pub items: Vec<R>,
-
    pub selected: Option<usize>,
-
    pub columns: Vec<Column<'a>>,
-
    pub has_footer: bool,
-
    pub cutoff: usize,
-
    pub cutoff_after: usize,
-
    pub page_size: usize,
-
}
-

-
impl<'a, R, const W: usize> Default for TableProps<'a, R, W>
-
where
-
    R: ToRow<W>,
-
{
-
    fn default() -> Self {
-
        Self {
-
            items: vec![],
-
            columns: vec![],
-
            has_footer: false,
-
            cutoff: usize::MAX,
-
            cutoff_after: usize::MAX,
-
            page_size: 1,
-
            selected: Some(0),
-
        }
-
    }
-
}
-

-
impl<'a, R, const W: usize> TableProps<'a, R, W>
-
where
-
    R: ToRow<W>,
-
{
-
    pub fn items(mut self, items: Vec<R>) -> Self {
-
        self.items = items;
-
        self
-
    }
-

-
    pub fn selected(mut self, selected: Option<usize>) -> Self {
-
        self.selected = selected;
-
        self
-
    }
-

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

-
    pub fn footer(mut self, has_footer: bool) -> Self {
-
        self.has_footer = has_footer;
-
        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
-
    }
-
}
-

-
impl<'a: 'static, R, const W: usize> Properties for TableProps<'a, R, W> where R: ToRow<W> + 'static {}
-
impl WidgetState for TableState {}
-

-
pub struct Table<'a, S, A, R, const W: usize>
-
where
-
    R: ToRow<W>,
-
{
-
    /// Internal base
-
    base: BaseView<S, A>,
-
    /// Internal table properties
-
    props: TableProps<'a, R, W>,
-
    /// Internal selection and offset state
-
    state: TableState,
-
}
-

-
impl<'a, S, A, R, const W: usize> Table<'a, S, A, R, W>
-
where
-
    R: ToRow<W>,
-
{
-
    fn prev(&mut self) -> Option<usize> {
-
        let selected = self
-
            .state
-
            .selected()
-
            .map(|current| current.saturating_sub(1));
-
        self.state.select(selected);
-
        selected
-
    }
-

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

-
    fn prev_page(&mut self, page_size: usize) -> Option<usize> {
-
        let selected = self
-
            .state
-
            .selected()
-
            .map(|current| current.saturating_sub(page_size));
-
        self.state.select(selected);
-
        selected
-
    }
-

-
    fn next_page(&mut self, len: usize, page_size: usize) -> Option<usize> {
-
        let selected = self.state.selected().map(|current| {
-
            if current < len.saturating_sub(1) {
-
                cmp::min(current.saturating_add(page_size), len.saturating_sub(1))
-
            } else {
-
                current
-
            }
-
        });
-
        self.state.select(selected);
-
        selected
-
    }
-

-
    fn begin(&mut self) {
-
        self.state.select(Some(0));
-
    }
-

-
    fn end(&mut self, len: usize) {
-
        self.state.select(Some(len.saturating_sub(1)));
-
    }
-
}
-

-
impl<'a: 'static, S, A, R, const W: usize> Widget for Table<'a, S, A, R, W>
-
where
-
    R: ToRow<W> + Clone + 'static,
-
{
-
    type Action = A;
-
    type State = S;
-

-
    fn new(_state: &S, action_tx: UnboundedSender<A>) -> Self {
-
        Self {
-
            base: BaseView {
-
                action_tx: action_tx.clone(),
-
                on_update: None,
-
                on_event: None,
-
            },
-
            props: TableProps::default(),
-
            state: TableState::default().with_selected(Some(0)),
-
        }
-
    }
-

-
    fn handle_event(&mut self, key: Key) {
-
        match key {
-
            Key::Up | Key::Char('k') => {
-
                self.prev();
-
            }
-
            Key::Down | Key::Char('j') => {
-
                self.next(self.props.items.len());
-
            }
-
            Key::PageUp => {
-
                self.prev_page(self.props.page_size);
-
            }
-
            Key::PageDown => {
-
                self.next_page(self.props.items.len(), self.props.page_size);
-
            }
-
            Key::Home => {
-
                self.begin();
-
            }
-
            Key::End => {
-
                self.end(self.props.items.len());
-
            }
-
            _ => {}
-
        }
-

-
        self.props.selected = self.state.selected();
-

-
        if let Some(on_event) = self.base.on_event {
-
            (on_event)(
-
                self.state.clone().to_boxed_any(),
-
                self.base.action_tx.clone(),
-
            );
-
        }
-
    }
-

-
    fn update(&mut self, state: &S) {
-
        self.props =
-
            TableProps::from_callback(self.base.on_update, state).unwrap_or(self.props.clone());
-

-
        // TODO: Move to state reducer
-
        if let Some(selected) = self.state.selected() {
-
            if selected > self.props.items.len() {
-
                self.begin();
-
            }
-
        }
-
    }
-

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

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

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

-
                    for cell in item.to_row() {
-
                        if let Some(col) = it.next() {
-
                            if !col.skip {
-
                                cells.push(cell.clone());
-
                            }
-
                        } else {
-
                            continue;
-
                        }
-
                    }
-

-
                    Row::new(cells)
-
                })
-
                .collect::<Vec<_>>();
-
            let rows = ratatui::widgets::Table::default()
-
                .rows(rows)
-
                .widths(widths)
-
                .column_spacing(1)
-
                .highlight_style(style::highlight());
-

-
            frame.render_stateful_widget(rows, props.area, &mut self.state.clone());
-
        } else {
-
            let center = layout::centered_rect(props.area, 50, 10);
-
            let hint = Text::from(span::default("Nothing to show"))
-
                .centered()
-
                .light_magenta()
-
                .dim();
-

-
            frame.render_widget(hint, center);
-
        }
-
    }
-

-
    fn base_mut(&mut self) -> &mut BaseView<S, A> {
-
        &mut self.base
-
    }
-
}
-

-
pub struct TableUtils {}
-

-
impl TableUtils {
-
    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;
-

-
        let lines = page_size + step.saturating_sub(page_size as usize) as f64;
-
        let progress = (lines / len * 100.0).ceil();
-

-
        if progress > 97.0 {
-
            Self::map_range((0.0, progress), (0.0, 100.0), progress) as usize
-
        } else {
-
            progress as usize
-
        }
-
    }
-

-
    fn map_range(from: (f64, f64), to: (f64, f64), value: f64) -> f64 {
-
        to.0 + (value - from.0) * (to.1 - to.0) / (from.1 - from.0)
-
    }
-
}
modified src/ui/widget/container.rs
@@ -10,7 +10,29 @@ use ratatui::widgets::{Block, BorderType, Borders, Row};
use crate::ui::ext::{FooterBlock, FooterBlockType, HeaderBlock};
use crate::ui::theme::style;

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

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

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

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

#[derive(Clone, Debug)]
pub struct HeaderProps<'a> {
@@ -430,3 +452,138 @@ impl<S, A> Widget for Container<S, A> {
        &mut self.base
    }
}
+

+
#[derive(Clone)]
+
pub struct SectionGroupState {
+
    /// Index of currently focused section.
+
    focus: Option<usize>,
+
}
+

+
impl WidgetState for SectionGroupState {}
+

+
#[derive(Clone, Default)]
+
pub struct SectionGroupProps {
+
    /// If this pages' keys should be handled.
+
    handle_keys: bool,
+
}
+

+
impl SectionGroupProps {
+
    pub fn handle_keys(mut self, handle_keys: bool) -> Self {
+
        self.handle_keys = handle_keys;
+
        self
+
    }
+
}
+

+
impl Properties for SectionGroupProps {}
+

+
pub struct SectionGroup<S, A> {
+
    /// Internal base
+
    base: BaseView<S, A>,
+
    /// Internal table properties
+
    props: SectionGroupProps,
+
    /// All sections
+
    sections: Vec<BoxedWidget<S, A>>,
+
    /// Internal selection and offset state
+
    state: SectionGroupState,
+
}
+

+
impl<S, A> SectionGroup<S, A> {
+
    pub fn section(mut self, section: BoxedWidget<S, A>) -> Self {
+
        self.sections.push(section);
+
        self
+
    }
+

+
    fn prev(&mut self) -> Option<usize> {
+
        let focus = self.state.focus.map(|current| current.saturating_sub(1));
+
        self.state.focus = focus;
+
        focus
+
    }
+

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

+
impl<S, A> Widget for SectionGroup<S, A> {
+
    type State = S;
+
    type Action = A;
+

+
    fn new(_state: &S, action_tx: UnboundedSender<A>) -> Self {
+
        Self {
+
            base: BaseView {
+
                action_tx: action_tx.clone(),
+
                on_update: None,
+
                on_event: None,
+
            },
+
            props: SectionGroupProps::default(),
+
            sections: vec![],
+
            state: SectionGroupState { focus: Some(0) },
+
        }
+
    }
+

+
    fn handle_event(&mut self, key: Key) {
+
        if let Some(section) = self
+
            .state
+
            .focus
+
            .and_then(|focus| self.sections.get_mut(focus))
+
        {
+
            section.handle_event(key);
+
        }
+

+
        if self.props.handle_keys {
+
            match key {
+
                Key::Left => {
+
                    self.prev();
+
                }
+
                Key::Right => {
+
                    self.next(self.sections.len());
+
                }
+
                _ => {}
+
            }
+
        }
+

+
        if let Some(on_event) = self.base.on_event {
+
            (on_event)(
+
                self.state.clone().to_boxed_any(),
+
                self.base.action_tx.clone(),
+
            );
+
        }
+
    }
+

+
    fn update(&mut self, state: &S) {
+
        self.props = SectionGroupProps::from_callback(self.base.on_update, state)
+
            .unwrap_or(self.props.clone());
+

+
        for section in &mut self.sections {
+
            section.update(state);
+
        }
+
    }
+

+
    fn render(&self, frame: &mut ratatui::Frame, props: RenderProps) {
+
        let areas = props.layout.split(props.area);
+

+
        for (index, area) in areas.iter().enumerate() {
+
            if let Some(section) = self.sections.get(index) {
+
                let focus = self
+
                    .state
+
                    .focus
+
                    .map(|focus_index| index == focus_index)
+
                    .unwrap_or_default();
+

+
                section.render(frame, RenderProps::from(*area).focus(focus));
+
            }
+
        }
+
    }
+

+
    fn base_mut(&mut self) -> &mut BaseView<S, A> {
+
        &mut self.base
+
    }
+
}
added src/ui/widget/list.rs
@@ -0,0 +1,302 @@
+
use std::cmp;
+

+
use ratatui::widgets::Row;
+
use tokio::sync::mpsc::UnboundedSender;
+

+
use termion::event::Key;
+

+
use ratatui::layout::Constraint;
+
use ratatui::style::Stylize;
+
use ratatui::text::Text;
+
use ratatui::widgets::TableState;
+

+
use crate::ui::theme::style;
+
use crate::ui::{layout, span};
+

+
use super::{container::Column, BaseView, Properties, RenderProps, ToRow, Widget, WidgetState};
+

+
#[derive(Clone, Debug)]
+
pub struct TableProps<'a, R, const W: usize>
+
where
+
    R: ToRow<W>,
+
{
+
    pub items: Vec<R>,
+
    pub selected: Option<usize>,
+
    pub columns: Vec<Column<'a>>,
+
    pub has_footer: bool,
+
    pub cutoff: usize,
+
    pub cutoff_after: usize,
+
    pub page_size: usize,
+
}
+

+
impl<'a, R, const W: usize> Default for TableProps<'a, R, W>
+
where
+
    R: ToRow<W>,
+
{
+
    fn default() -> Self {
+
        Self {
+
            items: vec![],
+
            columns: vec![],
+
            has_footer: false,
+
            cutoff: usize::MAX,
+
            cutoff_after: usize::MAX,
+
            page_size: 1,
+
            selected: Some(0),
+
        }
+
    }
+
}
+

+
impl<'a, R, const W: usize> TableProps<'a, R, W>
+
where
+
    R: ToRow<W>,
+
{
+
    pub fn items(mut self, items: Vec<R>) -> Self {
+
        self.items = items;
+
        self
+
    }
+

+
    pub fn selected(mut self, selected: Option<usize>) -> Self {
+
        self.selected = selected;
+
        self
+
    }
+

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

+
    pub fn footer(mut self, has_footer: bool) -> Self {
+
        self.has_footer = has_footer;
+
        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
+
    }
+
}
+

+
impl<'a: 'static, R, const W: usize> Properties for TableProps<'a, R, W> where R: ToRow<W> + 'static {}
+
impl WidgetState for TableState {}
+

+
pub struct Table<'a, S, A, R, const W: usize>
+
where
+
    R: ToRow<W>,
+
{
+
    /// Internal base
+
    base: BaseView<S, A>,
+
    /// Internal table properties
+
    props: TableProps<'a, R, W>,
+
    /// Internal selection and offset state
+
    state: TableState,
+
}
+

+
impl<'a, S, A, R, const W: usize> Table<'a, S, A, R, W>
+
where
+
    R: ToRow<W>,
+
{
+
    fn prev(&mut self) -> Option<usize> {
+
        let selected = self
+
            .state
+
            .selected()
+
            .map(|current| current.saturating_sub(1));
+
        self.state.select(selected);
+
        selected
+
    }
+

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

+
    fn prev_page(&mut self, page_size: usize) -> Option<usize> {
+
        let selected = self
+
            .state
+
            .selected()
+
            .map(|current| current.saturating_sub(page_size));
+
        self.state.select(selected);
+
        selected
+
    }
+

+
    fn next_page(&mut self, len: usize, page_size: usize) -> Option<usize> {
+
        let selected = self.state.selected().map(|current| {
+
            if current < len.saturating_sub(1) {
+
                cmp::min(current.saturating_add(page_size), len.saturating_sub(1))
+
            } else {
+
                current
+
            }
+
        });
+
        self.state.select(selected);
+
        selected
+
    }
+

+
    fn begin(&mut self) {
+
        self.state.select(Some(0));
+
    }
+

+
    fn end(&mut self, len: usize) {
+
        self.state.select(Some(len.saturating_sub(1)));
+
    }
+
}
+

+
impl<'a: 'static, S, A, R, const W: usize> Widget for Table<'a, S, A, R, W>
+
where
+
    R: ToRow<W> + Clone + 'static,
+
{
+
    type Action = A;
+
    type State = S;
+

+
    fn new(_state: &S, action_tx: UnboundedSender<A>) -> Self {
+
        Self {
+
            base: BaseView {
+
                action_tx: action_tx.clone(),
+
                on_update: None,
+
                on_event: None,
+
            },
+
            props: TableProps::default(),
+
            state: TableState::default().with_selected(Some(0)),
+
        }
+
    }
+

+
    fn handle_event(&mut self, key: Key) {
+
        match key {
+
            Key::Up | Key::Char('k') => {
+
                self.prev();
+
            }
+
            Key::Down | Key::Char('j') => {
+
                self.next(self.props.items.len());
+
            }
+
            Key::PageUp => {
+
                self.prev_page(self.props.page_size);
+
            }
+
            Key::PageDown => {
+
                self.next_page(self.props.items.len(), self.props.page_size);
+
            }
+
            Key::Home => {
+
                self.begin();
+
            }
+
            Key::End => {
+
                self.end(self.props.items.len());
+
            }
+
            _ => {}
+
        }
+

+
        self.props.selected = self.state.selected();
+

+
        if let Some(on_event) = self.base.on_event {
+
            (on_event)(
+
                self.state.clone().to_boxed_any(),
+
                self.base.action_tx.clone(),
+
            );
+
        }
+
    }
+

+
    fn update(&mut self, state: &S) {
+
        self.props =
+
            TableProps::from_callback(self.base.on_update, state).unwrap_or(self.props.clone());
+

+
        // TODO: Move to state reducer
+
        if let Some(selected) = self.state.selected() {
+
            if selected > self.props.items.len() {
+
                self.begin();
+
            }
+
        }
+
    }
+

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

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

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

+
                    for cell in item.to_row() {
+
                        if let Some(col) = it.next() {
+
                            if !col.skip {
+
                                cells.push(cell.clone());
+
                            }
+
                        } else {
+
                            continue;
+
                        }
+
                    }
+

+
                    Row::new(cells)
+
                })
+
                .collect::<Vec<_>>();
+
            let rows = ratatui::widgets::Table::default()
+
                .rows(rows)
+
                .widths(widths)
+
                .column_spacing(1)
+
                .highlight_style(style::highlight());
+

+
            frame.render_stateful_widget(rows, props.area, &mut self.state.clone());
+
        } else {
+
            let center = layout::centered_rect(props.area, 50, 10);
+
            let hint = Text::from(span::default("Nothing to show"))
+
                .centered()
+
                .light_magenta()
+
                .dim();
+

+
            frame.render_widget(hint, center);
+
        }
+
    }
+

+
    fn base_mut(&mut self) -> &mut BaseView<S, A> {
+
        &mut self.base
+
    }
+
}
+

+
pub struct TableUtils {}
+

+
impl TableUtils {
+
    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;
+

+
        let lines = page_size + step.saturating_sub(page_size as usize) as f64;
+
        let progress = (lines / len * 100.0).ceil();
+

+
        if progress > 97.0 {
+
            Self::map_range((0.0, progress), (0.0, 100.0), progress) as usize
+
        } else {
+
            progress as usize
+
        }
+
    }
+

+
    fn map_range(from: (f64, f64), to: (f64, f64), value: f64) -> f64 {
+
        to.0 + (value - from.0) * (to.1 - to.0) / (from.1 - from.0)
+
    }
+
}
deleted src/ui/widget/page.rs
@@ -1,140 +0,0 @@
-
use tokio::sync::mpsc::UnboundedSender;
-

-
use termion::event::Key;
-

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

-
#[derive(Clone)]
-
pub struct SectionGroupState {
-
    /// Index of currently focused section.
-
    focus: Option<usize>,
-
}
-

-
impl WidgetState for SectionGroupState {}
-

-
#[derive(Clone, Default)]
-
pub struct SectionGroupProps {
-
    /// If this pages' keys should be handled.
-
    handle_keys: bool,
-
}
-

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

-
impl Properties for SectionGroupProps {}
-

-
pub struct SectionGroup<S, A> {
-
    /// Internal base
-
    base: BaseView<S, A>,
-
    /// Internal table properties
-
    props: SectionGroupProps,
-
    /// All sections
-
    sections: Vec<BoxedWidget<S, A>>,
-
    /// Internal selection and offset state
-
    state: SectionGroupState,
-
}
-

-
impl<S, A> SectionGroup<S, A> {
-
    pub fn section(mut self, section: BoxedWidget<S, A>) -> Self {
-
        self.sections.push(section);
-
        self
-
    }
-

-
    fn prev(&mut self) -> Option<usize> {
-
        let focus = self.state.focus.map(|current| current.saturating_sub(1));
-
        self.state.focus = focus;
-
        focus
-
    }
-

-
    fn next(&mut self, len: usize) -> Option<usize> {
-
        let focus = self.state.focus.map(|current| {
-
            if current < len.saturating_sub(1) {
-
                current.saturating_add(1)
-
            } else {
-
                current
-
            }
-
        });
-
        self.state.focus = focus;
-
        focus
-
    }
-
}
-

-
impl<S, A> Widget for SectionGroup<S, A> {
-
    type State = S;
-
    type Action = A;
-

-
    fn new(_state: &S, action_tx: UnboundedSender<A>) -> Self {
-
        Self {
-
            base: BaseView {
-
                action_tx: action_tx.clone(),
-
                on_update: None,
-
                on_event: None,
-
            },
-
            props: SectionGroupProps::default(),
-
            sections: vec![],
-
            state: SectionGroupState { focus: Some(0) },
-
        }
-
    }
-

-
    fn handle_event(&mut self, key: Key) {
-
        if let Some(section) = self
-
            .state
-
            .focus
-
            .and_then(|focus| self.sections.get_mut(focus))
-
        {
-
            section.handle_event(key);
-
        }
-

-
        if self.props.handle_keys {
-
            match key {
-
                Key::Left => {
-
                    self.prev();
-
                }
-
                Key::Right => {
-
                    self.next(self.sections.len());
-
                }
-
                _ => {}
-
            }
-
        }
-

-
        if let Some(on_event) = self.base.on_event {
-
            (on_event)(
-
                self.state.clone().to_boxed_any(),
-
                self.base.action_tx.clone(),
-
            );
-
        }
-
    }
-

-
    fn update(&mut self, state: &S) {
-
        self.props = SectionGroupProps::from_callback(self.base.on_update, state)
-
            .unwrap_or(self.props.clone());
-

-
        for section in &mut self.sections {
-
            section.update(state);
-
        }
-
    }
-

-
    fn render(&self, frame: &mut ratatui::Frame, props: RenderProps) {
-
        let areas = props.layout.split(props.area);
-

-
        for (index, area) in areas.iter().enumerate() {
-
            if let Some(section) = self.sections.get(index) {
-
                let focus = self
-
                    .state
-
                    .focus
-
                    .map(|focus_index| index == focus_index)
-
                    .unwrap_or_default();
-

-
                section.render(frame, RenderProps::from(*area).focus(focus));
-
            }
-
        }
-
    }
-

-
    fn base_mut(&mut self) -> &mut BaseView<S, A> {
-
        &mut self.base
-
    }
-
}
added src/ui/widget/window.rs
@@ -0,0 +1,243 @@
+
use std::collections::HashMap;
+
use std::hash::Hash;
+

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

+
use termion::event::Key;
+

+
use ratatui::layout::Constraint;
+
use ratatui::style::Stylize;
+
use ratatui::text::Text;
+
use ratatui::widgets::Row;
+

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

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

+
#[derive(Clone)]
+
pub struct WindowProps<Id> {
+
    current_page: Option<Id>,
+
}
+

+
impl<Id> WindowProps<Id> {
+
    pub fn current_page(mut self, page: Id) -> Self {
+
        self.current_page = Some(page);
+
        self
+
    }
+
}
+

+
impl<Id> Default for WindowProps<Id> {
+
    fn default() -> Self {
+
        Self { current_page: None }
+
    }
+
}
+

+
impl<Id> Properties for WindowProps<Id> {}
+

+
pub struct Window<S, A, Id> {
+
    /// Internal base
+
    base: BaseView<S, A>,
+
    /// Internal properties
+
    props: WindowProps<Id>,
+
    /// All pages known
+
    pages: HashMap<Id, BoxedWidget<S, A>>,
+
}
+

+
impl<S, A, Id> Window<S, A, Id>
+
where
+
    Id: Clone + Hash + Eq + PartialEq,
+
{
+
    pub fn page(mut self, id: Id, page: BoxedWidget<S, A>) -> Self {
+
        self.pages.insert(id, page);
+
        self
+
    }
+
}
+

+
impl<'a: 'static, S, A, Id> Widget for Window<S, A, Id>
+
where
+
    Id: Clone + Hash + Eq + PartialEq + 'a,
+
{
+
    type Action = A;
+
    type State = S;
+

+
    fn new(_state: &S, action_tx: UnboundedSender<A>) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self {
+
            base: BaseView {
+
                action_tx: action_tx.clone(),
+
                on_update: None,
+
                on_event: None,
+
            },
+
            props: WindowProps::default(),
+
            pages: HashMap::new(),
+
        }
+
    }
+

+
    fn handle_event(&mut self, key: termion::event::Key) {
+
        let page = self
+
            .props
+
            .current_page
+
            .as_ref()
+
            .and_then(|id| self.pages.get_mut(id));
+

+
        if let Some(page) = page {
+
            page.handle_event(key);
+
        }
+
    }
+

+
    fn update(&mut self, state: &S) {
+
        self.props =
+
            WindowProps::from_callback(self.base.on_update, state).unwrap_or(self.props.clone());
+

+
        let page = self
+
            .props
+
            .current_page
+
            .as_ref()
+
            .and_then(|id| self.pages.get_mut(id));
+

+
        if let Some(page) = page {
+
            page.update(state);
+
        }
+
    }
+

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

+
        let page = self
+
            .props
+
            .current_page
+
            .as_ref()
+
            .and_then(|id| self.pages.get(id));
+

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

+
    fn base_mut(&mut self) -> &mut BaseView<S, A> {
+
        &mut self.base
+
    }
+
}
+

+
#[derive(Clone)]
+
pub struct ShortcutsProps {
+
    pub shortcuts: Vec<(String, String)>,
+
    pub divider: char,
+
}
+

+
impl ShortcutsProps {
+
    pub fn divider(mut self, divider: char) -> Self {
+
        self.divider = divider;
+
        self
+
    }
+

+
    pub fn shortcuts(mut self, shortcuts: &[(&str, &str)]) -> Self {
+
        self.shortcuts.clear();
+
        for (short, long) in shortcuts {
+
            self.shortcuts.push((short.to_string(), long.to_string()));
+
        }
+
        self
+
    }
+
}
+

+
impl Default for ShortcutsProps {
+
    fn default() -> Self {
+
        Self {
+
            shortcuts: vec![],
+
            divider: '∙',
+
        }
+
    }
+
}
+

+
impl Properties for ShortcutsProps {}
+

+
pub struct Shortcuts<S, A> {
+
    /// Internal properties
+
    props: ShortcutsProps,
+
    /// Internal base
+
    base: BaseView<S, A>,
+
}
+

+
impl<S, A> Shortcuts<S, A> {
+
    pub fn divider(mut self, divider: char) -> Self {
+
        self.props.divider = divider;
+
        self
+
    }
+

+
    pub fn shortcuts(mut self, shortcuts: &[(&str, &str)]) -> Self {
+
        self.props.shortcuts.clear();
+
        for (short, long) in shortcuts {
+
            self.props
+
                .shortcuts
+
                .push((short.to_string(), long.to_string()));
+
        }
+
        self
+
    }
+
}
+

+
impl<S, A> Widget for Shortcuts<S, A> {
+
    type Action = A;
+
    type State = S;
+

+
    fn new(_state: &S, action_tx: UnboundedSender<A>) -> Self {
+
        Self {
+
            base: BaseView {
+
                action_tx: action_tx.clone(),
+
                on_update: None,
+
                on_event: None,
+
            },
+
            props: ShortcutsProps::default(),
+
        }
+
    }
+

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

+
    fn update(&mut self, state: &S) {
+
        self.props =
+
            ShortcutsProps::from_callback(self.base.on_update, state).unwrap_or(self.props.clone());
+
    }
+

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

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

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

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

+
            if shortcuts.peek().is_some() {
+
                row.push((3, divider));
+
            }
+
        }
+

+
        let row_copy = row.clone();
+
        let row: Vec<Text<'_>> = row_copy
+
            .clone()
+
            .iter()
+
            .map(|(_, text)| text.clone())
+
            .collect();
+
        let widths: Vec<Constraint> = row_copy
+
            .clone()
+
            .iter()
+
            .map(|(width, _)| Constraint::Length(*width as u16))
+
            .collect();
+

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

+
    fn base_mut(&mut self) -> &mut BaseView<S, A> {
+
        &mut self.base
+
    }
+
}