Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
lib: Restructure imUI / rmUI mode modules
Erik Kundt committed 1 year ago
commit d1de315f957a5c7644f387a12f49f727ad6b5b1a
parent 8501603a66260828a4e978ba1de9545d206db717
19 files changed +4129 -4137
modified examples/basic.rs
@@ -7,10 +7,10 @@ use ratatui::layout::Constraint;
use radicle_tui as tui;

use tui::store;
-
use tui::ui::widget::container::{Column, Container, Header, HeaderProps};
-
use tui::ui::widget::input::{TextView, TextViewProps, TextViewState};
-
use tui::ui::widget::window::{Page, Shortcuts, ShortcutsProps, Window, WindowProps};
-
use tui::ui::widget::ToWidget;
+
use tui::ui::rm::widget::container::{Column, Container, Header, HeaderProps};
+
use tui::ui::rm::widget::input::{TextView, TextViewProps, TextViewState};
+
use tui::ui::rm::widget::window::{Page, Shortcuts, ShortcutsProps, Window, WindowProps};
+
use tui::ui::rm::widget::ToWidget;
use tui::{BoxedAny, Channel, Exit};

const CONTENT: &str = r#"
modified examples/hello.rs
@@ -8,8 +8,8 @@ use ratatui::text::Text;
use radicle_tui as tui;

use tui::store;
-
use tui::ui::widget::input::{TextArea, TextAreaProps};
-
use tui::ui::widget::ToWidget;
+
use tui::ui::rm::widget::input::{TextArea, TextAreaProps};
+
use tui::ui::rm::widget::ToWidget;
use tui::{BoxedAny, Channel, Exit};

const ALIEN: &str = r#"
modified src/lib.rs
@@ -16,7 +16,7 @@ use anyhow::Result;
use store::State;
use task::Interrupted;
use ui::im;
-
use ui::widget::Widget;
+
use ui::rm::widget::Widget;
use ui::Frontend;

/// An optional return value.
modified src/ui.rs
@@ -1,9 +1,9 @@
pub mod ext;
pub mod im;
pub mod layout;
+
pub mod rm;
pub mod span;
pub mod theme;
-
pub mod widget;

use std::fmt::Debug;
use std::time::Duration;
@@ -11,9 +11,8 @@ use std::time::Duration;
use tokio::sync::broadcast;
use tokio::sync::mpsc::UnboundedReceiver;

-
use crate::ui::widget::RenderProps;
-

-
use self::widget::Widget;
+
use self::rm::widget::RenderProps;
+
use self::rm::widget::Widget;

use super::event::Event;
use super::store::State;
modified src/ui/im.rs
@@ -1,3 +1,5 @@
+
pub mod widget;
+

use std::collections::VecDeque;
use std::fmt::Debug;
use std::rc::Rc;
@@ -18,11 +20,11 @@ use crate::event::Event;
use crate::store::State;
use crate::task::Interrupted;
use crate::terminal;
+
use crate::ui::rm::widget::container::Column;
+
use crate::ui::rm::widget::list::ToRow;
use crate::ui::theme::Theme;
-
use crate::ui::widget::container::Column;
-
use crate::ui::widget::list::ToRow;

-
use self::widget::{HeaderedTable, Widget};
+
use crate::ui::im::widget::{HeaderedTable, Widget};

const RENDERING_TICK_RATE: Duration = Duration::from_millis(250);
const INLINE_HEIGHT: usize = 20;
@@ -470,1132 +472,6 @@ impl Ui {
    }
}

-
pub mod widget {
-
    use std::cmp;
-

-
    use ratatui::layout::{Direction, Layout, Rect};
-
    use ratatui::style::{Style, Stylize};
-
    use ratatui::text::{Line, Span, Text};
-
    use ratatui::widgets::{Block, BorderType, Row, Scrollbar, ScrollbarState};
-
    use ratatui::Frame;
-
    use ratatui::{layout::Constraint, widgets::Paragraph};
-
    use termion::event::Key;
-

-
    use crate::ui::ext::{FooterBlock, FooterBlockType, HeaderBlock};
-
    use crate::ui::theme::style;
-
    use crate::ui::widget::container::Column;
-
    use crate::ui::widget::list::ToRow;
-
    use crate::ui::{layout, span};
-

-
    use super::{Borders, Context, InnerResponse, Response, Ui};
-

-
    pub trait Widget {
-
        fn ui(self, ui: &mut Ui, frame: &mut Frame) -> Response;
-
    }
-

-
    #[derive(Default)]
-
    pub struct Window {}
-

-
    impl Window {
-
        #[inline]
-
        pub fn show<R>(
-
            self,
-
            ctx: &Context,
-
            add_contents: impl FnOnce(&mut Ui) -> R,
-
        ) -> Option<InnerResponse<Option<R>>> {
-
            self.show_dyn(ctx, Box::new(add_contents))
-
        }
-

-
        fn show_dyn<'c, R>(
-
            self,
-
            ctx: &Context,
-
            add_contents: Box<dyn FnOnce(&mut Ui) -> R + 'c>,
-
        ) -> Option<InnerResponse<Option<R>>> {
-
            let mut ui = Ui::default()
-
                .with_area(ctx.frame_size())
-
                .with_ctx(ctx.clone())
-
                .with_layout(Layout::horizontal([Constraint::Min(1)]).into());
-

-
            let inner = add_contents(&mut ui);
-

-
            Some(InnerResponse::new(Some(inner), Response::default()))
-
        }
-
    }
-

-
    #[derive(Clone, Debug)]
-
    pub struct GroupState {
-
        len: usize,
-
        focus: Option<usize>,
-
    }
-

-
    impl GroupState {
-
        pub fn new(len: usize, focus: Option<usize>) -> Self {
-
            Self { len, focus }
-
        }
-

-
        pub fn focus(&self) -> Option<usize> {
-
            self.focus
-
        }
-

-
        pub fn len(&self) -> usize {
-
            self.len
-
        }
-

-
        pub fn focus_next(&mut self) {
-
            self.focus = self
-
                .focus
-
                .map(|focus| cmp::min(focus.saturating_add(1), self.len.saturating_sub(1)))
-
        }
-

-
        pub fn focus_prev(&mut self) {
-
            self.focus = self.focus.map(|focus| focus.saturating_sub(1))
-
        }
-
    }
-

-
    pub struct Group<'a> {
-
        focus: &'a mut Option<usize>,
-
        len: usize,
-
    }
-

-
    impl<'a> Group<'a> {
-
        pub fn new(len: usize, focus: &'a mut Option<usize>) -> Self {
-
            Self { len, focus }
-
        }
-

-
        pub fn show<R>(
-
            self,
-
            ui: &mut Ui,
-
            add_contents: impl FnOnce(&mut Ui) -> R,
-
        ) -> InnerResponse<R> {
-
            self.show_dyn(ui, Box::new(add_contents))
-
        }
-

-
        pub fn show_dyn<'c, R>(
-
            self,
-
            ui: &mut Ui,
-
            add_contents: Box<dyn FnOnce(&mut Ui) -> R + 'c>,
-
        ) -> InnerResponse<R> {
-
            let mut response = Response::default();
-

-
            let mut state = GroupState {
-
                focus: *self.focus,
-
                len: self.len,
-
            };
-

-
            if let Some(key) = ui.input_with_key(|_| true) {
-
                match key {
-
                    Key::Char('\t') => {
-
                        state.focus_next();
-
                        response.changed = true;
-
                    }
-
                    Key::BackTab => {
-
                        state.focus_prev();
-
                        response.changed = true;
-
                    }
-
                    _ => {}
-
                }
-
            }
-
            *self.focus = state.focus;
-

-
            let mut ui = Ui {
-
                focus: state.focus,
-
                ..ui.clone()
-
            };
-

-
            let inner = add_contents(&mut ui);
-

-
            InnerResponse::new(inner, response)
-
        }
-
    }
-

-
    pub struct Label<'a> {
-
        content: Text<'a>,
-
    }
-

-
    impl<'a> Label<'a> {
-
        pub fn new(content: impl Into<Text<'a>>) -> Self {
-
            Self {
-
                content: content.into(),
-
            }
-
        }
-
    }
-

-
    impl<'a> Widget for Label<'a> {
-
        fn ui(self, ui: &mut Ui, frame: &mut Frame) -> Response {
-
            let (area, _) = ui.next_area().unwrap_or_default();
-
            frame.render_widget(self.content, area);
-

-
            Response::default()
-
        }
-
    }
-

-
    #[derive(Clone, Debug)]
-
    pub struct TableState {
-
        internal: ratatui::widgets::TableState,
-
    }
-

-
    impl TableState {
-
        pub fn new(selected: Option<usize>) -> Self {
-
            let mut internal = ratatui::widgets::TableState::default();
-
            internal.select(selected);
-

-
            Self { internal }
-
        }
-

-
        pub fn selected(&self) -> Option<usize> {
-
            self.internal.selected()
-
        }
-

-
        pub fn select_first(&mut self) {
-
            self.internal.select(Some(0));
-
        }
-
    }
-

-
    impl TableState {
-
        fn prev(&mut self) -> Option<usize> {
-
            let selected = self
-
                .internal
-
                .selected()
-
                .map(|current| current.saturating_sub(1));
-
            self.select(selected);
-
            selected
-
        }
-

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

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

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

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

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

-
        fn select(&mut self, selected: Option<usize>) {
-
            self.internal.select(selected);
-
        }
-
    }
-

-
    pub struct Table<'a, R, const W: usize> {
-
        items: &'a Vec<R>,
-
        selected: &'a mut Option<usize>,
-
        columns: Vec<Column<'a>>,
-
        borders: Option<Borders>,
-
        show_scrollbar: bool,
-
        dim: bool,
-
    }
-

-
    impl<'a, R, const W: usize> Table<'a, R, W>
-
    where
-
        R: ToRow<W>,
-
    {
-
        pub fn new(
-
            selected: &'a mut Option<usize>,
-
            items: &'a Vec<R>,
-
            columns: Vec<Column<'a>>,
-
            borders: Option<Borders>,
-
        ) -> Self {
-
            Self {
-
                items,
-
                selected,
-
                columns,
-
                borders,
-
                show_scrollbar: true,
-
                dim: false,
-
            }
-
        }
-

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

-
    impl<'a, R, const W: usize> Widget for Table<'a, R, W>
-
    where
-
        R: ToRow<W> + Clone,
-
    {
-
        fn ui(self, ui: &mut Ui, frame: &mut Frame) -> Response {
-
            let mut response = Response::default();
-

-
            let (area, has_focus) = ui.next_area().unwrap_or_default();
-

-
            let show_scrollbar = self.show_scrollbar && self.items.len() >= area.height.into();
-
            let has_items = !self.items.is_empty();
-

-
            let mut state = TableState {
-
                internal: {
-
                    let mut state = ratatui::widgets::TableState::default();
-
                    state.select(self.selected.clone());
-
                    state
-
                },
-
            };
-

-
            let border_style = if has_focus {
-
                ui.theme.focus_border_style
-
            } else {
-
                ui.theme.border_style
-
            };
-

-
            let area = render_block(frame, area, self.borders, border_style);
-

-
            if let Some(key) = ui.input_with_key(|_| true) {
-
                let len = self.items.len();
-
                let page_size = area.height as usize;
-

-
                match key {
-
                    Key::Up | Key::Char('k') => {
-
                        state.prev();
-
                    }
-
                    Key::Down | Key::Char('j') => {
-
                        state.next(len);
-
                    }
-
                    Key::PageUp => {
-
                        state.prev_page(page_size);
-
                    }
-
                    Key::PageDown => {
-
                        state.next_page(len, page_size);
-
                    }
-
                    Key::Home => {
-
                        state.begin();
-
                    }
-
                    Key::End => {
-
                        state.end(len);
-
                    }
-
                    _ => {}
-
                }
-
                response.changed = true;
-
            }
-

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

-
            if has_items {
-
                let [table_area, scroller_area] =
-
                    Layout::horizontal([Constraint::Min(1), Constraint::Length(1)]).areas(area);
-

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

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

-
                        Row::new(cells)
-
                    })
-
                    .collect::<Vec<_>>();
-

-
                let table = ratatui::widgets::Table::default()
-
                    .rows(rows)
-
                    .widths(widths)
-
                    .column_spacing(1)
-
                    .highlight_style(style::highlight(has_focus));
-

-
                let table = if !has_focus && self.dim {
-
                    table.dim()
-
                } else {
-
                    table
-
                };
-

-
                frame.render_stateful_widget(table, table_area, &mut state.internal);
-

-
                if show_scrollbar {
-
                    let content_length = self.items.len();
-
                    let scroller = Scrollbar::default()
-
                        .begin_symbol(None)
-
                        .track_symbol(None)
-
                        .end_symbol(None)
-
                        .thumb_symbol("┃")
-
                        .style(if has_focus {
-
                            Style::default()
-
                        } else {
-
                            Style::default().dim()
-
                        });
-

-
                    let mut state = ScrollbarState::default()
-
                        .content_length(content_length)
-
                        .viewport_content_length(1)
-
                        .position(state.internal.offset());
-

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

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

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

-
            response
-
        }
-
    }
-

-
    pub struct HeaderedTable<'a, R, const W: usize> {
-
        items: &'a Vec<R>,
-
        selected: &'a mut Option<usize>,
-
        header: Vec<Column<'a>>,
-
    }
-

-
    impl<'a, R, const W: usize> HeaderedTable<'a, R, W> {
-
        pub fn new(
-
            selected: &'a mut Option<usize>,
-
            items: &'a Vec<R>,
-
            header: impl IntoIterator<Item = Column<'a>>,
-
        ) -> Self {
-
            Self {
-
                items,
-
                selected,
-
                header: header.into_iter().collect(),
-
            }
-
        }
-

-
        pub fn items(&self) -> &Vec<R> {
-
            &self.items
-
        }
-
    }
-

-
    /// TODO(erikli): Implement `show` that returns an `InnerResponse` such that it can
-
    /// used like a group.
-
    impl<'a, R, const W: usize> Widget for HeaderedTable<'a, R, W>
-
    where
-
        R: ToRow<W> + Clone,
-
    {
-
        fn ui(self, ui: &mut Ui, frame: &mut Frame) -> Response {
-
            let mut response = Response::default();
-

-
            let (_, has_focus) = ui.current_area().unwrap_or_default();
-

-
            ui.layout(
-
                Layout::vertical([Constraint::Length(3), Constraint::Min(1)]),
-
                |ui| {
-
                    // TODO(erikli): Find better solution for border focus workaround or improve
-
                    // interface for manually advancing / setting the focus index.
-
                    if has_focus {
-
                        ui.set_focus(Some(0));
-
                    }
-
                    ui.columns(frame, self.header.clone().to_vec(), Some(Borders::Top));
-

-
                    if has_focus {
-
                        ui.set_focus(Some(1));
-
                    }
-
                    let table = ui.table(
-
                        frame,
-
                        self.selected,
-
                        &self.items,
-
                        self.header.to_vec(),
-
                        Some(Borders::BottomSides),
-
                    );
-
                    response.changed = table.changed | response.changed;
-
                },
-
            );
-

-
            response
-
        }
-
    }
-

-
    pub struct Columns<'a> {
-
        columns: Vec<Column<'a>>,
-
        borders: Option<Borders>,
-
    }
-

-
    impl<'a> Columns<'a> {
-
        pub fn new(columns: Vec<Column<'a>>, borders: Option<Borders>) -> Self {
-
            Self { columns, borders }
-
        }
-
    }
-

-
    impl<'a> Widget for Columns<'a> {
-
        fn ui(self, ui: &mut Ui, frame: &mut Frame) -> Response {
-
            let (area, has_focus) = ui.next_area().unwrap_or_default();
-

-
            let border_style = if has_focus {
-
                ui.theme.focus_border_style
-
            } else {
-
                ui.theme.border_style
-
            };
-

-
            let area = render_block(frame, area, self.borders, border_style);
-
            let area = Rect {
-
                width: area.width.saturating_sub(1),
-
                ..area
-
            };
-

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

-
            let cells = self
-
                .columns
-
                .iter()
-
                .filter(|c| !c.skip && c.displayed(area.width as usize))
-
                .map(|c| c.text.clone())
-
                .collect::<Vec<_>>();
-

-
            let table = ratatui::widgets::Table::default()
-
                .column_spacing(1)
-
                .rows([Row::new(cells)])
-
                .widths(widths);
-
            frame.render_widget(table, area);
-

-
            Response::default()
-
        }
-
    }
-

-
    pub struct Bar<'a> {
-
        columns: Vec<Column<'a>>,
-
        borders: Option<Borders>,
-
    }
-

-
    impl<'a> Bar<'a> {
-
        pub fn new(columns: Vec<Column<'a>>, borders: Option<Borders>) -> Self {
-
            Self { columns, borders }
-
        }
-
    }
-

-
    impl<'a> Widget for Bar<'a> {
-
        fn ui(self, ui: &mut Ui, frame: &mut Frame) -> Response {
-
            let (area, has_focus) = ui.next_area().unwrap_or_default();
-

-
            let border_style = if has_focus {
-
                ui.theme.focus_border_style
-
            } else {
-
                ui.theme.border_style
-
            };
-

-
            let widths = self.columns.iter().map(|c| c.width).collect::<Vec<_>>();
-
            let cells = self
-
                .columns
-
                .iter()
-
                .map(|c| c.text.clone())
-
                .collect::<Vec<_>>();
-

-
            let area = render_block(frame, area, self.borders, border_style);
-
            let table = ratatui::widgets::Table::default()
-
                .header(Row::new(cells))
-
                .widths(widths)
-
                .column_spacing(0);
-
            frame.render_widget(table, area);
-

-
            Response::default()
-
        }
-
    }
-

-
    #[derive(Clone, Debug)]
-
    pub struct TextViewState {
-
        text: String,
-
        cursor: (usize, usize),
-
    }
-

-
    impl TextViewState {
-
        pub fn new(text: impl Into<String>, cursor: (usize, usize)) -> Self {
-
            Self {
-
                text: text.into(),
-
                cursor,
-
            }
-
        }
-

-
        pub fn text(&self) -> &String {
-
            &self.text
-
        }
-

-
        pub fn cursor(&self) -> (usize, usize) {
-
            self.cursor
-
        }
-
    }
-

-
    impl TextViewState {
-
        fn scroll_up(&mut self) {
-
            self.cursor.0 = self.cursor.0.saturating_sub(1);
-
        }
-

-
        fn scroll_down(&mut self, len: usize, page_size: usize) {
-
            let end = len.saturating_sub(page_size);
-
            self.cursor.0 = std::cmp::min(self.cursor.0.saturating_add(1), end);
-
        }
-

-
        fn scroll_left(&mut self) {
-
            self.cursor.1 = self.cursor.1.saturating_sub(3);
-
        }
-

-
        fn scroll_right(&mut self, max_line_length: usize) {
-
            self.cursor.1 = std::cmp::min(
-
                self.cursor.1.saturating_add(3),
-
                max_line_length.saturating_add(3),
-
            );
-
        }
-

-
        fn prev_page(&mut self, page_size: usize) {
-
            self.cursor.0 = self.cursor.0.saturating_sub(page_size);
-
        }
-

-
        fn next_page(&mut self, len: usize, page_size: usize) {
-
            let end = len.saturating_sub(page_size);
-

-
            self.cursor.0 = std::cmp::min(self.cursor.0.saturating_add(page_size), end);
-
        }
-

-
        fn begin(&mut self) {
-
            self.cursor.0 = 0;
-
        }
-

-
        fn end(&mut self, len: usize, page_size: usize) {
-
            self.cursor.0 = len.saturating_sub(page_size);
-
        }
-
    }
-

-
    pub struct TextView<'a> {
-
        text: String,
-
        borders: Option<Borders>,
-
        cursor: &'a mut (usize, usize),
-
    }
-

-
    impl<'a> TextView<'a> {
-
        pub fn new(
-
            text: impl ToString,
-
            cursor: &'a mut (usize, usize),
-
            borders: Option<Borders>,
-
        ) -> Self {
-
            Self {
-
                text: text.to_string(),
-
                borders,
-
                cursor,
-
            }
-
        }
-
    }
-

-
    impl<'a> Widget for TextView<'a> {
-
        fn ui(self, ui: &mut Ui, frame: &mut Frame) -> Response {
-
            let mut response = Response::default();
-

-
            let (area, has_focus) = ui.next_area().unwrap_or_default();
-

-
            let show_scrollbar = true;
-
            let border_style = if has_focus {
-
                ui.theme.focus_border_style
-
            } else {
-
                ui.theme.border_style
-
            };
-
            let length = self.text.lines().count();
-
            // let virtual_length = length * ((length as f64).log2() as usize) / 100;
-
            // let content_length = area.height as usize + virtual_length;
-
            // let content_length = length;
-
            let content_length = area.height as usize;
-

-
            let area = render_block(frame, area, self.borders, border_style);
-
            let area = Rect {
-
                x: area.x.saturating_add(1),
-
                width: area.width.saturating_sub(1),
-
                ..area
-
            };
-
            let [text_area, scroller_area] = Layout::horizontal([
-
                Constraint::Min(1),
-
                if show_scrollbar {
-
                    Constraint::Length(1)
-
                } else {
-
                    Constraint::Length(0)
-
                },
-
            ])
-
            .areas(area);
-

-
            let scroller = Scrollbar::default()
-
                .begin_symbol(None)
-
                .track_symbol(None)
-
                .end_symbol(None)
-
                .thumb_symbol("┃")
-
                .style(if has_focus {
-
                    Style::default()
-
                } else {
-
                    Style::default().dim()
-
                });
-

-
            let mut scroller_state = ScrollbarState::default()
-
                .content_length(length.saturating_sub(content_length))
-
                .viewport_content_length(1)
-
                .position(self.cursor.0);
-

-
            frame.render_stateful_widget(scroller, scroller_area, &mut scroller_state);
-
            frame.render_widget(
-
                Paragraph::new(self.text.clone())
-
                    .scroll((self.cursor.0 as u16, self.cursor.1 as u16)),
-
                text_area,
-
            );
-

-
            let mut state = TextViewState::new(self.text.clone(), *self.cursor);
-

-
            if let Some(key) = ui.input_with_key(|_| true) {
-
                let lines = self.text.lines().clone();
-
                let len = lines.clone().count();
-
                let max_line_len = lines.map(|l| l.chars().count()).max().unwrap_or_default();
-
                let page_size = area.height as usize;
-

-
                match key {
-
                    Key::Up | Key::Char('k') => {
-
                        state.scroll_up();
-
                    }
-
                    Key::Down | Key::Char('j') => {
-
                        state.scroll_down(len, page_size);
-
                    }
-
                    Key::Left | Key::Char('h') => {
-
                        state.scroll_left();
-
                    }
-
                    Key::Right | Key::Char('l') => {
-
                        state.scroll_right(max_line_len.saturating_sub(area.height.into()));
-
                    }
-
                    Key::PageUp => {
-
                        state.prev_page(page_size);
-
                    }
-
                    Key::PageDown => {
-
                        state.next_page(len, page_size);
-
                    }
-
                    Key::Home => {
-
                        state.begin();
-
                    }
-
                    Key::End => {
-
                        state.end(len, page_size);
-
                    }
-
                    _ => {}
-
                }
-
                *self.cursor = state.cursor;
-
                response.changed = true;
-
            }
-

-
            response
-
        }
-
    }
-

-
    #[derive(Clone, Debug)]
-
    pub struct TextEditState {
-
        pub text: String,
-
        pub cursor: usize,
-
    }
-

-
    impl TextEditState {
-
        fn move_cursor_left(&mut self) {
-
            let cursor_moved_left = self.cursor.saturating_sub(1);
-
            self.cursor = self.clamp_cursor(cursor_moved_left);
-
        }
-

-
        fn move_cursor_right(&mut self) {
-
            let cursor_moved_right = self.cursor.saturating_add(1);
-
            self.cursor = self.clamp_cursor(cursor_moved_right);
-
        }
-

-
        fn enter_char(&mut self, new_char: char) {
-
            self.text = self.text.clone();
-
            self.text.insert(self.cursor, new_char);
-
            self.move_cursor_right();
-
        }
-

-
        fn delete_char_right(&mut self) {
-
            self.text = self.text.clone();
-

-
            // Method "remove" is not used on the saved text for deleting the selected char.
-
            // Reason: Using remove on String works on bytes instead of the chars.
-
            // Using remove would require special care because of char boundaries.
-

-
            let current_index = self.cursor;
-
            let from_left_to_current_index = current_index;
-

-
            // Getting all characters before the selected character.
-
            let before_char_to_delete = self.text.chars().take(from_left_to_current_index);
-
            // Getting all characters after selected character.
-
            let after_char_to_delete = self.text.chars().skip(current_index.saturating_add(1));
-

-
            // Put all characters together except the selected one.
-
            // By leaving the selected one out, it is forgotten and therefore deleted.
-
            self.text = before_char_to_delete.chain(after_char_to_delete).collect();
-
        }
-

-
        fn delete_char_left(&mut self) {
-
            self.text = self.text.clone();
-

-
            let is_not_cursor_leftmost = self.cursor != 0;
-
            if is_not_cursor_leftmost {
-
                // Method "remove" is not used on the saved text for deleting the selected char.
-
                // Reason: Using remove on String works on bytes instead of the chars.
-
                // Using remove would require special care because of char boundaries.
-

-
                let current_index = self.cursor;
-
                let from_left_to_current_index = current_index - 1;
-

-
                // Getting all characters before the selected character.
-
                let before_char_to_delete = self.text.chars().take(from_left_to_current_index);
-
                // Getting all characters after selected character.
-
                let after_char_to_delete = self.text.chars().skip(current_index);
-

-
                // Put all characters together except the selected one.
-
                // By leaving the selected one out, it is forgotten and therefore deleted.
-
                self.text = before_char_to_delete.chain(after_char_to_delete).collect();
-

-
                self.move_cursor_left();
-
            }
-
        }
-

-
        fn clamp_cursor(&self, new_cursor_pos: usize) -> usize {
-
            new_cursor_pos.clamp(0, self.text.len())
-
        }
-
    }
-

-
    pub struct TextEditOutput {
-
        pub response: Response,
-
        pub state: TextEditState,
-
    }
-

-
    pub struct TextEdit<'a> {
-
        text: &'a mut String,
-
        cursor: &'a mut usize,
-
        borders: Option<Borders>,
-
        label: Option<String>,
-
        inline_label: bool,
-
        show_cursor: bool,
-
        dim: bool,
-
    }
-

-
    impl<'a> TextEdit<'a> {
-
        /// # Example
-
        ///
-
        /// ```
-
        /// let mut state = TextEditState::default();
-
        /// let output = im::widget::TextEdit::new(&mut text, &mut cursor).show(ui, frame);
-
        /// if output.response.changed {
-
        ///     state = output.state;
-
        /// }
-
        /// ```
-
        pub fn new(text: &'a mut String, cursor: &'a mut usize, borders: Option<Borders>) -> Self {
-
            Self {
-
                text,
-
                cursor,
-
                label: None,
-
                borders,
-
                inline_label: true,
-
                show_cursor: true,
-
                dim: true,
-
            }
-
        }
-

-
        pub fn with_label(mut self, label: impl ToString) -> Self {
-
            self.label = Some(label.to_string());
-
            self
-
        }
-
    }
-

-
    impl<'a> TextEdit<'a> {
-
        pub fn show(self, ui: &mut Ui, frame: &mut Frame) -> TextEditOutput {
-
            let mut response = Response::default();
-

-
            let (area, has_focus) = ui.next_area().unwrap_or_default();
-

-
            let border_style = if has_focus {
-
                ui.theme.focus_border_style
-
            } else {
-
                ui.theme.border_style
-
            };
-

-
            let area = render_block(frame, area, self.borders, border_style);
-

-
            let layout = Layout::vertical(Constraint::from_lengths([1, 1])).split(area);
-

-
            let mut state = TextEditState {
-
                text: self.text.to_string(),
-
                cursor: *self.cursor,
-
            };
-

-
            let label_content = format!(" {} ", self.label.unwrap_or_default());
-
            let overline = String::from("▔").repeat(area.width as usize);
-
            let cursor_pos = *self.cursor as u16;
-

-
            let (label, input, overline) = if !has_focus && self.dim {
-
                (
-
                    Span::from(label_content.clone()).magenta().dim().reversed(),
-
                    Span::from(state.text.clone()).reset().dim(),
-
                    Span::raw(overline).magenta().dim(),
-
                )
-
            } else {
-
                (
-
                    Span::from(label_content.clone()).magenta().reversed(),
-
                    Span::from(state.text.clone()).reset(),
-
                    Span::raw(overline).magenta(),
-
                )
-
            };
-

-
            if self.inline_label {
-
                let top_layout = Layout::horizontal([
-
                    Constraint::Length(label_content.chars().count() as u16),
-
                    Constraint::Length(1),
-
                    Constraint::Min(1),
-
                ])
-
                .split(layout[0]);
-

-
                let overline = Line::from([overline].to_vec());
-

-
                frame.render_widget(label, top_layout[0]);
-
                frame.render_widget(input, top_layout[2]);
-
                frame.render_widget(overline, layout[1]);
-

-
                if self.show_cursor {
-
                    frame.set_cursor(top_layout[2].x + cursor_pos, top_layout[2].y)
-
                }
-
            } else {
-
                let top = Line::from([input].to_vec());
-
                let bottom = Line::from([label, overline].to_vec());
-

-
                frame.render_widget(top, layout[0]);
-
                frame.render_widget(bottom, layout[1]);
-

-
                if self.show_cursor {
-
                    frame.set_cursor(area.x + cursor_pos, area.y)
-
                }
-
            }
-

-
            if let Some(key) = ui.input_with_key(|_| true) {
-
                match key {
-
                    Key::Char(to_insert)
-
                        if (key != Key::Alt('\n'))
-
                            && (key != Key::Char('\n'))
-
                            && (key != Key::Ctrl('\n')) =>
-
                    {
-
                        state.enter_char(to_insert);
-
                    }
-
                    Key::Backspace => {
-
                        state.delete_char_left();
-
                    }
-
                    Key::Delete => {
-
                        state.delete_char_right();
-
                    }
-
                    Key::Left => {
-
                        state.move_cursor_left();
-
                    }
-
                    Key::Right => {
-
                        state.move_cursor_right();
-
                    }
-
                    _ => {}
-
                }
-
                response.changed = true;
-
            }
-

-
            *self.text = state.text.clone();
-
            *self.cursor = state.cursor;
-

-
            TextEditOutput { response, state }
-
        }
-
    }
-

-
    impl<'a> Widget for TextEdit<'a> {
-
        fn ui(self, ui: &mut Ui, frame: &mut Frame) -> Response {
-
            self.show(ui, frame).response
-
        }
-
    }
-

-
    pub struct Shortcuts {
-
        pub shortcuts: Vec<(String, String)>,
-
        pub divider: char,
-
    }
-

-
    impl Shortcuts {
-
        pub fn new(shortcuts: &[(&str, &str)], divider: char) -> Self {
-
            Self {
-
                shortcuts: shortcuts
-
                    .iter()
-
                    .map(|(s, a)| (s.to_string(), a.to_string()))
-
                    .collect(),
-
                divider,
-
            }
-
        }
-
    }
-

-
    impl Widget for Shortcuts {
-
        fn ui(self, ui: &mut Ui, frame: &mut Frame) -> Response {
-
            use ratatui::widgets::Table;
-

-
            let (area, _) = ui.next_area().unwrap_or_default();
-

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

-
            while let Some(shortcut) = shortcuts.next() {
-
                let short = Text::from(shortcut.0.clone()).style(ui.theme.shortcuts_keys_style);
-
                let long = Text::from(shortcut.1.clone()).style(ui.theme.shortcuts_action_style);
-
                let spacer = Text::from(String::new());
-
                let divider = Text::from(format!(" {} ", self.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, area);
-

-
            Response::default()
-
        }
-
    }
-

-
    fn render_block(frame: &mut Frame, area: Rect, borders: Option<Borders>, style: Style) -> Rect {
-
        if let Some(border) = borders {
-
            match border {
-
                Borders::None => area,
-
                Borders::Spacer { top, left } => {
-
                    let areas = Layout::horizontal([Constraint::Fill(1)])
-
                        .vertical_margin(top as u16)
-
                        .horizontal_margin(left as u16)
-
                        .split(area);
-

-
                    areas[0]
-
                }
-
                Borders::All => {
-
                    let block = Block::default()
-
                        .border_style(style)
-
                        .border_type(BorderType::Rounded)
-
                        .borders(ratatui::widgets::Borders::ALL);
-
                    frame.render_widget(block.clone(), area);
-

-
                    block.inner(area)
-
                }
-
                Borders::Top => {
-
                    let block = HeaderBlock::default()
-
                        .border_style(style)
-
                        .border_type(BorderType::Rounded)
-
                        .borders(ratatui::widgets::Borders::ALL);
-
                    frame.render_widget(block, area);
-

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

-
                    areas[0]
-
                }
-
                Borders::Sides => {
-
                    let block = Block::default()
-
                        .border_style(style)
-
                        .border_type(BorderType::Rounded)
-
                        .borders(
-
                            ratatui::widgets::Borders::LEFT | ratatui::widgets::Borders::RIGHT,
-
                        );
-
                    frame.render_widget(block.clone(), area);
-

-
                    block.inner(area)
-
                }
-
                Borders::Bottom => {
-
                    let areas = Layout::default()
-
                        .direction(Direction::Vertical)
-
                        .constraints(vec![Constraint::Min(1)])
-
                        .vertical_margin(1)
-
                        .horizontal_margin(1)
-
                        .split(area);
-

-
                    let footer_block = FooterBlock::default()
-
                        .border_style(style)
-
                        .block_type(FooterBlockType::Single { top: true });
-
                    frame.render_widget(footer_block, area);
-

-
                    areas[0]
-
                }
-
                Borders::BottomSides => {
-
                    let areas = Layout::default()
-
                        .direction(Direction::Vertical)
-
                        .constraints(vec![Constraint::Min(1)])
-
                        .horizontal_margin(1)
-
                        .split(area);
-

-
                    let footer_block = FooterBlock::default()
-
                        .border_style(style)
-
                        .block_type(FooterBlockType::Single { top: false });
-
                    frame.render_widget(footer_block, area);
-

-
                    Rect {
-
                        height: areas[0].height.saturating_sub(1),
-
                        ..areas[0]
-
                    }
-
                }
-
            }
-
        } else {
-
            area
-
        }
-
    }
-
}
-

/// A `BufferedValue` that writes updates to an internal
/// buffer. This buffer can be applied or reset.
///
added src/ui/im/widget.rs
@@ -0,0 +1,1116 @@
+
use std::cmp;
+

+
use ratatui::layout::{Direction, Layout, Rect};
+
use ratatui::style::{Style, Stylize};
+
use ratatui::text::{Line, Span, Text};
+
use ratatui::widgets::{Block, BorderType, Row, Scrollbar, ScrollbarState};
+
use ratatui::Frame;
+
use ratatui::{layout::Constraint, widgets::Paragraph};
+
use termion::event::Key;
+

+
use crate::ui::ext::{FooterBlock, FooterBlockType, HeaderBlock};
+
use crate::ui::rm::widget::container::Column;
+
use crate::ui::rm::widget::list::ToRow;
+
use crate::ui::theme::style;
+
use crate::ui::{layout, span};
+

+
use super::{Borders, Context, InnerResponse, Response, Ui};
+

+
pub trait Widget {
+
    fn ui(self, ui: &mut Ui, frame: &mut Frame) -> Response;
+
}
+

+
#[derive(Default)]
+
pub struct Window {}
+

+
impl Window {
+
    #[inline]
+
    pub fn show<R>(
+
        self,
+
        ctx: &Context,
+
        add_contents: impl FnOnce(&mut Ui) -> R,
+
    ) -> Option<InnerResponse<Option<R>>> {
+
        self.show_dyn(ctx, Box::new(add_contents))
+
    }
+

+
    fn show_dyn<'c, R>(
+
        self,
+
        ctx: &Context,
+
        add_contents: Box<dyn FnOnce(&mut Ui) -> R + 'c>,
+
    ) -> Option<InnerResponse<Option<R>>> {
+
        let mut ui = Ui::default()
+
            .with_area(ctx.frame_size())
+
            .with_ctx(ctx.clone())
+
            .with_layout(Layout::horizontal([Constraint::Min(1)]).into());
+

+
        let inner = add_contents(&mut ui);
+

+
        Some(InnerResponse::new(Some(inner), Response::default()))
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct GroupState {
+
    len: usize,
+
    focus: Option<usize>,
+
}
+

+
impl GroupState {
+
    pub fn new(len: usize, focus: Option<usize>) -> Self {
+
        Self { len, focus }
+
    }
+

+
    pub fn focus(&self) -> Option<usize> {
+
        self.focus
+
    }
+

+
    pub fn len(&self) -> usize {
+
        self.len
+
    }
+

+
    pub fn focus_next(&mut self) {
+
        self.focus = self
+
            .focus
+
            .map(|focus| cmp::min(focus.saturating_add(1), self.len.saturating_sub(1)))
+
    }
+

+
    pub fn focus_prev(&mut self) {
+
        self.focus = self.focus.map(|focus| focus.saturating_sub(1))
+
    }
+
}
+

+
pub struct Group<'a> {
+
    focus: &'a mut Option<usize>,
+
    len: usize,
+
}
+

+
impl<'a> Group<'a> {
+
    pub fn new(len: usize, focus: &'a mut Option<usize>) -> Self {
+
        Self { len, focus }
+
    }
+

+
    pub fn show<R>(self, ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> InnerResponse<R> {
+
        self.show_dyn(ui, Box::new(add_contents))
+
    }
+

+
    pub fn show_dyn<'c, R>(
+
        self,
+
        ui: &mut Ui,
+
        add_contents: Box<dyn FnOnce(&mut Ui) -> R + 'c>,
+
    ) -> InnerResponse<R> {
+
        let mut response = Response::default();
+

+
        let mut state = GroupState {
+
            focus: *self.focus,
+
            len: self.len,
+
        };
+

+
        if let Some(key) = ui.input_with_key(|_| true) {
+
            match key {
+
                Key::Char('\t') => {
+
                    state.focus_next();
+
                    response.changed = true;
+
                }
+
                Key::BackTab => {
+
                    state.focus_prev();
+
                    response.changed = true;
+
                }
+
                _ => {}
+
            }
+
        }
+
        *self.focus = state.focus;
+

+
        let mut ui = Ui {
+
            focus: state.focus,
+
            ..ui.clone()
+
        };
+

+
        let inner = add_contents(&mut ui);
+

+
        InnerResponse::new(inner, response)
+
    }
+
}
+

+
pub struct Label<'a> {
+
    content: Text<'a>,
+
}
+

+
impl<'a> Label<'a> {
+
    pub fn new(content: impl Into<Text<'a>>) -> Self {
+
        Self {
+
            content: content.into(),
+
        }
+
    }
+
}
+

+
impl<'a> Widget for Label<'a> {
+
    fn ui(self, ui: &mut Ui, frame: &mut Frame) -> Response {
+
        let (area, _) = ui.next_area().unwrap_or_default();
+
        frame.render_widget(self.content, area);
+

+
        Response::default()
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct TableState {
+
    internal: ratatui::widgets::TableState,
+
}
+

+
impl TableState {
+
    pub fn new(selected: Option<usize>) -> Self {
+
        let mut internal = ratatui::widgets::TableState::default();
+
        internal.select(selected);
+

+
        Self { internal }
+
    }
+

+
    pub fn selected(&self) -> Option<usize> {
+
        self.internal.selected()
+
    }
+

+
    pub fn select_first(&mut self) {
+
        self.internal.select(Some(0));
+
    }
+
}
+

+
impl TableState {
+
    fn prev(&mut self) -> Option<usize> {
+
        let selected = self
+
            .internal
+
            .selected()
+
            .map(|current| current.saturating_sub(1));
+
        self.select(selected);
+
        selected
+
    }
+

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

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

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

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

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

+
    fn select(&mut self, selected: Option<usize>) {
+
        self.internal.select(selected);
+
    }
+
}
+

+
pub struct Table<'a, R, const W: usize> {
+
    items: &'a Vec<R>,
+
    selected: &'a mut Option<usize>,
+
    columns: Vec<Column<'a>>,
+
    borders: Option<Borders>,
+
    show_scrollbar: bool,
+
    dim: bool,
+
}
+

+
impl<'a, R, const W: usize> Table<'a, R, W>
+
where
+
    R: ToRow<W>,
+
{
+
    pub fn new(
+
        selected: &'a mut Option<usize>,
+
        items: &'a Vec<R>,
+
        columns: Vec<Column<'a>>,
+
        borders: Option<Borders>,
+
    ) -> Self {
+
        Self {
+
            items,
+
            selected,
+
            columns,
+
            borders,
+
            show_scrollbar: true,
+
            dim: false,
+
        }
+
    }
+

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

+
impl<'a, R, const W: usize> Widget for Table<'a, R, W>
+
where
+
    R: ToRow<W> + Clone,
+
{
+
    fn ui(self, ui: &mut Ui, frame: &mut Frame) -> Response {
+
        let mut response = Response::default();
+

+
        let (area, has_focus) = ui.next_area().unwrap_or_default();
+

+
        let show_scrollbar = self.show_scrollbar && self.items.len() >= area.height.into();
+
        let has_items = !self.items.is_empty();
+

+
        let mut state = TableState {
+
            internal: {
+
                let mut state = ratatui::widgets::TableState::default();
+
                state.select(self.selected.clone());
+
                state
+
            },
+
        };
+

+
        let border_style = if has_focus {
+
            ui.theme.focus_border_style
+
        } else {
+
            ui.theme.border_style
+
        };
+

+
        let area = render_block(frame, area, self.borders, border_style);
+

+
        if let Some(key) = ui.input_with_key(|_| true) {
+
            let len = self.items.len();
+
            let page_size = area.height as usize;
+

+
            match key {
+
                Key::Up | Key::Char('k') => {
+
                    state.prev();
+
                }
+
                Key::Down | Key::Char('j') => {
+
                    state.next(len);
+
                }
+
                Key::PageUp => {
+
                    state.prev_page(page_size);
+
                }
+
                Key::PageDown => {
+
                    state.next_page(len, page_size);
+
                }
+
                Key::Home => {
+
                    state.begin();
+
                }
+
                Key::End => {
+
                    state.end(len);
+
                }
+
                _ => {}
+
            }
+
            response.changed = true;
+
        }
+

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

+
        if has_items {
+
            let [table_area, scroller_area] =
+
                Layout::horizontal([Constraint::Min(1), Constraint::Length(1)]).areas(area);
+

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

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

+
                    Row::new(cells)
+
                })
+
                .collect::<Vec<_>>();
+

+
            let table = ratatui::widgets::Table::default()
+
                .rows(rows)
+
                .widths(widths)
+
                .column_spacing(1)
+
                .highlight_style(style::highlight(has_focus));
+

+
            let table = if !has_focus && self.dim {
+
                table.dim()
+
            } else {
+
                table
+
            };
+

+
            frame.render_stateful_widget(table, table_area, &mut state.internal);
+

+
            if show_scrollbar {
+
                let content_length = self.items.len();
+
                let scroller = Scrollbar::default()
+
                    .begin_symbol(None)
+
                    .track_symbol(None)
+
                    .end_symbol(None)
+
                    .thumb_symbol("┃")
+
                    .style(if has_focus {
+
                        Style::default()
+
                    } else {
+
                        Style::default().dim()
+
                    });
+

+
                let mut state = ScrollbarState::default()
+
                    .content_length(content_length)
+
                    .viewport_content_length(1)
+
                    .position(state.internal.offset());
+

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

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

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

+
        response
+
    }
+
}
+

+
pub struct HeaderedTable<'a, R, const W: usize> {
+
    items: &'a Vec<R>,
+
    selected: &'a mut Option<usize>,
+
    header: Vec<Column<'a>>,
+
}
+

+
impl<'a, R, const W: usize> HeaderedTable<'a, R, W> {
+
    pub fn new(
+
        selected: &'a mut Option<usize>,
+
        items: &'a Vec<R>,
+
        header: impl IntoIterator<Item = Column<'a>>,
+
    ) -> Self {
+
        Self {
+
            items,
+
            selected,
+
            header: header.into_iter().collect(),
+
        }
+
    }
+

+
    pub fn items(&self) -> &Vec<R> {
+
        &self.items
+
    }
+
}
+

+
/// TODO(erikli): Implement `show` that returns an `InnerResponse` such that it can
+
/// used like a group.
+
impl<'a, R, const W: usize> Widget for HeaderedTable<'a, R, W>
+
where
+
    R: ToRow<W> + Clone,
+
{
+
    fn ui(self, ui: &mut Ui, frame: &mut Frame) -> Response {
+
        let mut response = Response::default();
+

+
        let (_, has_focus) = ui.current_area().unwrap_or_default();
+

+
        ui.layout(
+
            Layout::vertical([Constraint::Length(3), Constraint::Min(1)]),
+
            |ui| {
+
                // TODO(erikli): Find better solution for border focus workaround or improve
+
                // interface for manually advancing / setting the focus index.
+
                if has_focus {
+
                    ui.set_focus(Some(0));
+
                }
+
                ui.columns(frame, self.header.clone().to_vec(), Some(Borders::Top));
+

+
                if has_focus {
+
                    ui.set_focus(Some(1));
+
                }
+
                let table = ui.table(
+
                    frame,
+
                    self.selected,
+
                    &self.items,
+
                    self.header.to_vec(),
+
                    Some(Borders::BottomSides),
+
                );
+
                response.changed = table.changed | response.changed;
+
            },
+
        );
+

+
        response
+
    }
+
}
+

+
pub struct Columns<'a> {
+
    columns: Vec<Column<'a>>,
+
    borders: Option<Borders>,
+
}
+

+
impl<'a> Columns<'a> {
+
    pub fn new(columns: Vec<Column<'a>>, borders: Option<Borders>) -> Self {
+
        Self { columns, borders }
+
    }
+
}
+

+
impl<'a> Widget for Columns<'a> {
+
    fn ui(self, ui: &mut Ui, frame: &mut Frame) -> Response {
+
        let (area, has_focus) = ui.next_area().unwrap_or_default();
+

+
        let border_style = if has_focus {
+
            ui.theme.focus_border_style
+
        } else {
+
            ui.theme.border_style
+
        };
+

+
        let area = render_block(frame, area, self.borders, border_style);
+
        let area = Rect {
+
            width: area.width.saturating_sub(1),
+
            ..area
+
        };
+

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

+
        let cells = self
+
            .columns
+
            .iter()
+
            .filter(|c| !c.skip && c.displayed(area.width as usize))
+
            .map(|c| c.text.clone())
+
            .collect::<Vec<_>>();
+

+
        let table = ratatui::widgets::Table::default()
+
            .column_spacing(1)
+
            .rows([Row::new(cells)])
+
            .widths(widths);
+
        frame.render_widget(table, area);
+

+
        Response::default()
+
    }
+
}
+

+
pub struct Bar<'a> {
+
    columns: Vec<Column<'a>>,
+
    borders: Option<Borders>,
+
}
+

+
impl<'a> Bar<'a> {
+
    pub fn new(columns: Vec<Column<'a>>, borders: Option<Borders>) -> Self {
+
        Self { columns, borders }
+
    }
+
}
+

+
impl<'a> Widget for Bar<'a> {
+
    fn ui(self, ui: &mut Ui, frame: &mut Frame) -> Response {
+
        let (area, has_focus) = ui.next_area().unwrap_or_default();
+

+
        let border_style = if has_focus {
+
            ui.theme.focus_border_style
+
        } else {
+
            ui.theme.border_style
+
        };
+

+
        let widths = self.columns.iter().map(|c| c.width).collect::<Vec<_>>();
+
        let cells = self
+
            .columns
+
            .iter()
+
            .map(|c| c.text.clone())
+
            .collect::<Vec<_>>();
+

+
        let area = render_block(frame, area, self.borders, border_style);
+
        let table = ratatui::widgets::Table::default()
+
            .header(Row::new(cells))
+
            .widths(widths)
+
            .column_spacing(0);
+
        frame.render_widget(table, area);
+

+
        Response::default()
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct TextViewState {
+
    text: String,
+
    cursor: (usize, usize),
+
}
+

+
impl TextViewState {
+
    pub fn new(text: impl Into<String>, cursor: (usize, usize)) -> Self {
+
        Self {
+
            text: text.into(),
+
            cursor,
+
        }
+
    }
+

+
    pub fn text(&self) -> &String {
+
        &self.text
+
    }
+

+
    pub fn cursor(&self) -> (usize, usize) {
+
        self.cursor
+
    }
+
}
+

+
impl TextViewState {
+
    fn scroll_up(&mut self) {
+
        self.cursor.0 = self.cursor.0.saturating_sub(1);
+
    }
+

+
    fn scroll_down(&mut self, len: usize, page_size: usize) {
+
        let end = len.saturating_sub(page_size);
+
        self.cursor.0 = std::cmp::min(self.cursor.0.saturating_add(1), end);
+
    }
+

+
    fn scroll_left(&mut self) {
+
        self.cursor.1 = self.cursor.1.saturating_sub(3);
+
    }
+

+
    fn scroll_right(&mut self, max_line_length: usize) {
+
        self.cursor.1 = std::cmp::min(
+
            self.cursor.1.saturating_add(3),
+
            max_line_length.saturating_add(3),
+
        );
+
    }
+

+
    fn prev_page(&mut self, page_size: usize) {
+
        self.cursor.0 = self.cursor.0.saturating_sub(page_size);
+
    }
+

+
    fn next_page(&mut self, len: usize, page_size: usize) {
+
        let end = len.saturating_sub(page_size);
+

+
        self.cursor.0 = std::cmp::min(self.cursor.0.saturating_add(page_size), end);
+
    }
+

+
    fn begin(&mut self) {
+
        self.cursor.0 = 0;
+
    }
+

+
    fn end(&mut self, len: usize, page_size: usize) {
+
        self.cursor.0 = len.saturating_sub(page_size);
+
    }
+
}
+

+
pub struct TextView<'a> {
+
    text: String,
+
    borders: Option<Borders>,
+
    cursor: &'a mut (usize, usize),
+
}
+

+
impl<'a> TextView<'a> {
+
    pub fn new(
+
        text: impl ToString,
+
        cursor: &'a mut (usize, usize),
+
        borders: Option<Borders>,
+
    ) -> Self {
+
        Self {
+
            text: text.to_string(),
+
            borders,
+
            cursor,
+
        }
+
    }
+
}
+

+
impl<'a> Widget for TextView<'a> {
+
    fn ui(self, ui: &mut Ui, frame: &mut Frame) -> Response {
+
        let mut response = Response::default();
+

+
        let (area, has_focus) = ui.next_area().unwrap_or_default();
+

+
        let show_scrollbar = true;
+
        let border_style = if has_focus {
+
            ui.theme.focus_border_style
+
        } else {
+
            ui.theme.border_style
+
        };
+
        let length = self.text.lines().count();
+
        // let virtual_length = length * ((length as f64).log2() as usize) / 100;
+
        // let content_length = area.height as usize + virtual_length;
+
        // let content_length = length;
+
        let content_length = area.height as usize;
+

+
        let area = render_block(frame, area, self.borders, border_style);
+
        let area = Rect {
+
            x: area.x.saturating_add(1),
+
            width: area.width.saturating_sub(1),
+
            ..area
+
        };
+
        let [text_area, scroller_area] = Layout::horizontal([
+
            Constraint::Min(1),
+
            if show_scrollbar {
+
                Constraint::Length(1)
+
            } else {
+
                Constraint::Length(0)
+
            },
+
        ])
+
        .areas(area);
+

+
        let scroller = Scrollbar::default()
+
            .begin_symbol(None)
+
            .track_symbol(None)
+
            .end_symbol(None)
+
            .thumb_symbol("┃")
+
            .style(if has_focus {
+
                Style::default()
+
            } else {
+
                Style::default().dim()
+
            });
+

+
        let mut scroller_state = ScrollbarState::default()
+
            .content_length(length.saturating_sub(content_length))
+
            .viewport_content_length(1)
+
            .position(self.cursor.0);
+

+
        frame.render_stateful_widget(scroller, scroller_area, &mut scroller_state);
+
        frame.render_widget(
+
            Paragraph::new(self.text.clone()).scroll((self.cursor.0 as u16, self.cursor.1 as u16)),
+
            text_area,
+
        );
+

+
        let mut state = TextViewState::new(self.text.clone(), *self.cursor);
+

+
        if let Some(key) = ui.input_with_key(|_| true) {
+
            let lines = self.text.lines().clone();
+
            let len = lines.clone().count();
+
            let max_line_len = lines.map(|l| l.chars().count()).max().unwrap_or_default();
+
            let page_size = area.height as usize;
+

+
            match key {
+
                Key::Up | Key::Char('k') => {
+
                    state.scroll_up();
+
                }
+
                Key::Down | Key::Char('j') => {
+
                    state.scroll_down(len, page_size);
+
                }
+
                Key::Left | Key::Char('h') => {
+
                    state.scroll_left();
+
                }
+
                Key::Right | Key::Char('l') => {
+
                    state.scroll_right(max_line_len.saturating_sub(area.height.into()));
+
                }
+
                Key::PageUp => {
+
                    state.prev_page(page_size);
+
                }
+
                Key::PageDown => {
+
                    state.next_page(len, page_size);
+
                }
+
                Key::Home => {
+
                    state.begin();
+
                }
+
                Key::End => {
+
                    state.end(len, page_size);
+
                }
+
                _ => {}
+
            }
+
            *self.cursor = state.cursor;
+
            response.changed = true;
+
        }
+

+
        response
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct TextEditState {
+
    pub text: String,
+
    pub cursor: usize,
+
}
+

+
impl TextEditState {
+
    fn move_cursor_left(&mut self) {
+
        let cursor_moved_left = self.cursor.saturating_sub(1);
+
        self.cursor = self.clamp_cursor(cursor_moved_left);
+
    }
+

+
    fn move_cursor_right(&mut self) {
+
        let cursor_moved_right = self.cursor.saturating_add(1);
+
        self.cursor = self.clamp_cursor(cursor_moved_right);
+
    }
+

+
    fn enter_char(&mut self, new_char: char) {
+
        self.text = self.text.clone();
+
        self.text.insert(self.cursor, new_char);
+
        self.move_cursor_right();
+
    }
+

+
    fn delete_char_right(&mut self) {
+
        self.text = self.text.clone();
+

+
        // Method "remove" is not used on the saved text for deleting the selected char.
+
        // Reason: Using remove on String works on bytes instead of the chars.
+
        // Using remove would require special care because of char boundaries.
+

+
        let current_index = self.cursor;
+
        let from_left_to_current_index = current_index;
+

+
        // Getting all characters before the selected character.
+
        let before_char_to_delete = self.text.chars().take(from_left_to_current_index);
+
        // Getting all characters after selected character.
+
        let after_char_to_delete = self.text.chars().skip(current_index.saturating_add(1));
+

+
        // Put all characters together except the selected one.
+
        // By leaving the selected one out, it is forgotten and therefore deleted.
+
        self.text = before_char_to_delete.chain(after_char_to_delete).collect();
+
    }
+

+
    fn delete_char_left(&mut self) {
+
        self.text = self.text.clone();
+

+
        let is_not_cursor_leftmost = self.cursor != 0;
+
        if is_not_cursor_leftmost {
+
            // Method "remove" is not used on the saved text for deleting the selected char.
+
            // Reason: Using remove on String works on bytes instead of the chars.
+
            // Using remove would require special care because of char boundaries.
+

+
            let current_index = self.cursor;
+
            let from_left_to_current_index = current_index - 1;
+

+
            // Getting all characters before the selected character.
+
            let before_char_to_delete = self.text.chars().take(from_left_to_current_index);
+
            // Getting all characters after selected character.
+
            let after_char_to_delete = self.text.chars().skip(current_index);
+

+
            // Put all characters together except the selected one.
+
            // By leaving the selected one out, it is forgotten and therefore deleted.
+
            self.text = before_char_to_delete.chain(after_char_to_delete).collect();
+

+
            self.move_cursor_left();
+
        }
+
    }
+

+
    fn clamp_cursor(&self, new_cursor_pos: usize) -> usize {
+
        new_cursor_pos.clamp(0, self.text.len())
+
    }
+
}
+

+
pub struct TextEditOutput {
+
    pub response: Response,
+
    pub state: TextEditState,
+
}
+

+
pub struct TextEdit<'a> {
+
    text: &'a mut String,
+
    cursor: &'a mut usize,
+
    borders: Option<Borders>,
+
    label: Option<String>,
+
    inline_label: bool,
+
    show_cursor: bool,
+
    dim: bool,
+
}
+

+
impl<'a> TextEdit<'a> {
+
    /// # Example
+
    ///
+
    /// ```
+
    /// let mut state = TextEditState::default();
+
    /// let output = im::widget::TextEdit::new(&mut text, &mut cursor).show(ui, frame);
+
    /// if output.response.changed {
+
    ///     state = output.state;
+
    /// }
+
    /// ```
+
    pub fn new(text: &'a mut String, cursor: &'a mut usize, borders: Option<Borders>) -> Self {
+
        Self {
+
            text,
+
            cursor,
+
            label: None,
+
            borders,
+
            inline_label: true,
+
            show_cursor: true,
+
            dim: true,
+
        }
+
    }
+

+
    pub fn with_label(mut self, label: impl ToString) -> Self {
+
        self.label = Some(label.to_string());
+
        self
+
    }
+
}
+

+
impl<'a> TextEdit<'a> {
+
    pub fn show(self, ui: &mut Ui, frame: &mut Frame) -> TextEditOutput {
+
        let mut response = Response::default();
+

+
        let (area, has_focus) = ui.next_area().unwrap_or_default();
+

+
        let border_style = if has_focus {
+
            ui.theme.focus_border_style
+
        } else {
+
            ui.theme.border_style
+
        };
+

+
        let area = render_block(frame, area, self.borders, border_style);
+

+
        let layout = Layout::vertical(Constraint::from_lengths([1, 1])).split(area);
+

+
        let mut state = TextEditState {
+
            text: self.text.to_string(),
+
            cursor: *self.cursor,
+
        };
+

+
        let label_content = format!(" {} ", self.label.unwrap_or_default());
+
        let overline = String::from("▔").repeat(area.width as usize);
+
        let cursor_pos = *self.cursor as u16;
+

+
        let (label, input, overline) = if !has_focus && self.dim {
+
            (
+
                Span::from(label_content.clone()).magenta().dim().reversed(),
+
                Span::from(state.text.clone()).reset().dim(),
+
                Span::raw(overline).magenta().dim(),
+
            )
+
        } else {
+
            (
+
                Span::from(label_content.clone()).magenta().reversed(),
+
                Span::from(state.text.clone()).reset(),
+
                Span::raw(overline).magenta(),
+
            )
+
        };
+

+
        if self.inline_label {
+
            let top_layout = Layout::horizontal([
+
                Constraint::Length(label_content.chars().count() as u16),
+
                Constraint::Length(1),
+
                Constraint::Min(1),
+
            ])
+
            .split(layout[0]);
+

+
            let overline = Line::from([overline].to_vec());
+

+
            frame.render_widget(label, top_layout[0]);
+
            frame.render_widget(input, top_layout[2]);
+
            frame.render_widget(overline, layout[1]);
+

+
            if self.show_cursor {
+
                frame.set_cursor(top_layout[2].x + cursor_pos, top_layout[2].y)
+
            }
+
        } else {
+
            let top = Line::from([input].to_vec());
+
            let bottom = Line::from([label, overline].to_vec());
+

+
            frame.render_widget(top, layout[0]);
+
            frame.render_widget(bottom, layout[1]);
+

+
            if self.show_cursor {
+
                frame.set_cursor(area.x + cursor_pos, area.y)
+
            }
+
        }
+

+
        if let Some(key) = ui.input_with_key(|_| true) {
+
            match key {
+
                Key::Char(to_insert)
+
                    if (key != Key::Alt('\n'))
+
                        && (key != Key::Char('\n'))
+
                        && (key != Key::Ctrl('\n')) =>
+
                {
+
                    state.enter_char(to_insert);
+
                }
+
                Key::Backspace => {
+
                    state.delete_char_left();
+
                }
+
                Key::Delete => {
+
                    state.delete_char_right();
+
                }
+
                Key::Left => {
+
                    state.move_cursor_left();
+
                }
+
                Key::Right => {
+
                    state.move_cursor_right();
+
                }
+
                _ => {}
+
            }
+
            response.changed = true;
+
        }
+

+
        *self.text = state.text.clone();
+
        *self.cursor = state.cursor;
+

+
        TextEditOutput { response, state }
+
    }
+
}
+

+
impl<'a> Widget for TextEdit<'a> {
+
    fn ui(self, ui: &mut Ui, frame: &mut Frame) -> Response {
+
        self.show(ui, frame).response
+
    }
+
}
+

+
pub struct Shortcuts {
+
    pub shortcuts: Vec<(String, String)>,
+
    pub divider: char,
+
}
+

+
impl Shortcuts {
+
    pub fn new(shortcuts: &[(&str, &str)], divider: char) -> Self {
+
        Self {
+
            shortcuts: shortcuts
+
                .iter()
+
                .map(|(s, a)| (s.to_string(), a.to_string()))
+
                .collect(),
+
            divider,
+
        }
+
    }
+
}
+

+
impl Widget for Shortcuts {
+
    fn ui(self, ui: &mut Ui, frame: &mut Frame) -> Response {
+
        use ratatui::widgets::Table;
+

+
        let (area, _) = ui.next_area().unwrap_or_default();
+

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

+
        while let Some(shortcut) = shortcuts.next() {
+
            let short = Text::from(shortcut.0.clone()).style(ui.theme.shortcuts_keys_style);
+
            let long = Text::from(shortcut.1.clone()).style(ui.theme.shortcuts_action_style);
+
            let spacer = Text::from(String::new());
+
            let divider = Text::from(format!(" {} ", self.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, area);
+

+
        Response::default()
+
    }
+
}
+

+
fn render_block(frame: &mut Frame, area: Rect, borders: Option<Borders>, style: Style) -> Rect {
+
    if let Some(border) = borders {
+
        match border {
+
            Borders::None => area,
+
            Borders::Spacer { top, left } => {
+
                let areas = Layout::horizontal([Constraint::Fill(1)])
+
                    .vertical_margin(top as u16)
+
                    .horizontal_margin(left as u16)
+
                    .split(area);
+

+
                areas[0]
+
            }
+
            Borders::All => {
+
                let block = Block::default()
+
                    .border_style(style)
+
                    .border_type(BorderType::Rounded)
+
                    .borders(ratatui::widgets::Borders::ALL);
+
                frame.render_widget(block.clone(), area);
+

+
                block.inner(area)
+
            }
+
            Borders::Top => {
+
                let block = HeaderBlock::default()
+
                    .border_style(style)
+
                    .border_type(BorderType::Rounded)
+
                    .borders(ratatui::widgets::Borders::ALL);
+
                frame.render_widget(block, area);
+

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

+
                areas[0]
+
            }
+
            Borders::Sides => {
+
                let block = Block::default()
+
                    .border_style(style)
+
                    .border_type(BorderType::Rounded)
+
                    .borders(ratatui::widgets::Borders::LEFT | ratatui::widgets::Borders::RIGHT);
+
                frame.render_widget(block.clone(), area);
+

+
                block.inner(area)
+
            }
+
            Borders::Bottom => {
+
                let areas = Layout::default()
+
                    .direction(Direction::Vertical)
+
                    .constraints(vec![Constraint::Min(1)])
+
                    .vertical_margin(1)
+
                    .horizontal_margin(1)
+
                    .split(area);
+

+
                let footer_block = FooterBlock::default()
+
                    .border_style(style)
+
                    .block_type(FooterBlockType::Single { top: true });
+
                frame.render_widget(footer_block, area);
+

+
                areas[0]
+
            }
+
            Borders::BottomSides => {
+
                let areas = Layout::default()
+
                    .direction(Direction::Vertical)
+
                    .constraints(vec![Constraint::Min(1)])
+
                    .horizontal_margin(1)
+
                    .split(area);
+

+
                let footer_block = FooterBlock::default()
+
                    .border_style(style)
+
                    .block_type(FooterBlockType::Single { top: false });
+
                frame.render_widget(footer_block, area);
+

+
                Rect {
+
                    height: areas[0].height.saturating_sub(1),
+
                    ..areas[0]
+
                }
+
            }
+
        }
+
    } else {
+
        area
+
    }
+
}
added src/ui/rm.rs
@@ -0,0 +1 @@
+
pub mod widget;
added src/ui/rm/widget.rs
@@ -0,0 +1,358 @@
+
pub mod container;
+
pub mod input;
+
pub mod list;
+
pub mod utils;
+
pub mod window;
+

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

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

+
use termion::event::Key;
+

+
use ratatui::prelude::*;
+

+
use self::{
+
    container::SectionGroupState,
+
    input::{TextAreaState, TextViewState},
+
};
+

+
pub type BoxedView<S, M> = Box<dyn View<State = S, Message = M>>;
+
pub type UpdateCallback<S> = fn(&S) -> ViewProps;
+
pub type EventCallback<M> = fn(Key, Option<&ViewState>, Option<&ViewProps>) -> Option<M>;
+
pub type RenderCallback<M> = fn(Option<&ViewProps>, &RenderProps) -> Option<M>;
+

+
/// `ViewProps` are properties of a `View`. They define a `View`s data, configuration etc.
+
/// Since the framework itself does not know the concrete type of `View`, it also does not
+
/// know the concrete type of a `View`s properties.
+
/// Hence, view properties are stored inside a `Box<dyn Any>` and downcasted to the concrete
+
/// type when needed.
+
pub struct ViewProps {
+
    inner: Box<dyn Any>,
+
}
+

+
impl ViewProps {
+
    pub fn inner<T>(self) -> Option<T>
+
    where
+
        T: Default + Clone + 'static,
+
    {
+
        self.inner.downcast::<T>().ok().map(|inner| *inner)
+
    }
+

+
    pub fn inner_ref<T>(&self) -> Option<&T>
+
    where
+
        T: Default + Clone + 'static,
+
    {
+
        self.inner.downcast_ref::<T>()
+
    }
+
}
+

+
impl From<Box<dyn Any>> for ViewProps {
+
    fn from(props: Box<dyn Any>) -> Self {
+
        ViewProps { inner: props }
+
    }
+
}
+

+
impl From<&'static dyn Any> for ViewProps {
+
    fn from(inner: &'static dyn Any) -> Self {
+
        Self {
+
            inner: Box::new(inner),
+
        }
+
    }
+
}
+

+
/// A `ViewState` is the representation of a `View`s internal state. e.g. current
+
/// table selection or contents of a text field.
+
#[derive(Debug)]
+
pub enum ViewState {
+
    USize(usize),
+
    String(String),
+
    Table { selected: usize, scroll: usize },
+
    Tree(Vec<String>),
+
    TextView(TextViewState),
+
    TextArea(TextAreaState),
+
    SectionGroup(SectionGroupState),
+
}
+

+
impl ViewState {
+
    pub fn unwrap_usize(&self) -> Option<usize> {
+
        match self {
+
            ViewState::USize(value) => Some(*value),
+
            _ => None,
+
        }
+
    }
+

+
    pub fn unwrap_string(&self) -> Option<String> {
+
        match self {
+
            ViewState::String(value) => Some(value.clone()),
+
            _ => None,
+
        }
+
    }
+

+
    pub fn unwrap_table(&self) -> Option<(usize, usize)> {
+
        match self {
+
            ViewState::Table { selected, scroll } => Some((*selected, *scroll)),
+
            _ => None,
+
        }
+
    }
+

+
    pub fn unwrap_textview(&self) -> Option<TextViewState> {
+
        match self {
+
            ViewState::TextView(state) => Some(state.clone()),
+
            _ => None,
+
        }
+
    }
+

+
    pub fn unwrap_textarea(&self) -> Option<TextAreaState> {
+
        match self {
+
            ViewState::TextArea(state) => Some(state.clone()),
+
            _ => None,
+
        }
+
    }
+

+
    pub fn unwrap_section_group(&self) -> Option<SectionGroupState> {
+
        match self {
+
            ViewState::SectionGroup(state) => Some(state.clone()),
+
            _ => None,
+
        }
+
    }
+

+
    pub fn unwrap_tree(&self) -> Option<Vec<String>> {
+
        match self {
+
            ViewState::Tree(value) => Some(value.clone().to_vec()),
+
            _ => None,
+
        }
+
    }
+
}
+

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

+
impl PredefinedLayout {
+
    pub fn split(&self, area: Rect) -> Rc<[Rect]> {
+
        match self {
+
            Self::Expandable3 { left_only } => {
+
                if *left_only {
+
                    [area].into()
+
                } else 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(65), Constraint::Percentage(35)])
+
                            .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 {
+
    /// Area of the render props.
+
    pub area: Rect,
+
    /// Layout to be rendered in.
+
    pub layout: Layout,
+
    /// Focus of the render props.
+
    pub focus: bool,
+
}
+

+
impl RenderProps {
+
    /// Sets the area to render in.
+
    pub fn area(mut self, area: Rect) -> Self {
+
        self.area = area;
+
        self
+
    }
+

+
    /// Sets the focus of these render props.
+
    pub fn focus(mut self, focus: bool) -> Self {
+
        self.focus = focus;
+
        self
+
    }
+

+
    /// Sets the layout of these render props.
+
    pub fn layout(mut self, layout: Layout) -> Self {
+
        self.layout = layout;
+
        self
+
    }
+
}
+

+
impl From<Rect> for RenderProps {
+
    fn from(area: Rect) -> Self {
+
        Self {
+
            area,
+
            layout: Layout::default(),
+
            focus: false,
+
        }
+
    }
+
}
+

+
/// Main trait defining a `View` behaviour, which needs be implemented in order to
+
/// build a custom widget. A `View` operates on an application state and can emit
+
/// application messages. It's usually is accompanied by a definition of view-specific
+
/// properties, which are being built from the application state by the framework.
+
pub trait View {
+
    type State;
+
    type Message;
+

+
    /// Should return the internal state.
+
    fn view_state(&self) -> Option<ViewState> {
+
        None
+
    }
+

+
    /// Should reset the internal state and call `reset` on all children.
+
    fn reset(&mut self) {}
+

+
    /// Should handle key events and call `handle_event` on all children.
+
    fn handle_event(&mut self, _props: Option<&ViewProps>, _key: Key) -> Option<Self::Message> {
+
        None
+
    }
+

+
    /// Should update the internal props of this and all children.
+
    fn update(&mut self, _props: Option<&ViewProps>, _state: &Self::State) {}
+

+
    /// Should render the view using the given `RenderProps`.
+
    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
+
/// framework. A `Widget` enhances a `View` with event and update callbacks and takes
+
/// care of calling them before / after calling into the `View`.
+
pub struct Widget<S, M> {
+
    view: BoxedView<S, M>,
+
    props: Option<ViewProps>,
+
    sender: UnboundedSender<M>,
+
    on_update: Option<UpdateCallback<S>>,
+
    on_event: Option<EventCallback<M>>,
+
    on_render: Option<RenderCallback<M>>,
+
}
+

+
impl<S: 'static, M: 'static> Widget<S, M> {
+
    pub fn new<V>(view: V, sender: UnboundedSender<M>) -> Self
+
    where
+
        Self: Sized,
+
        V: View<State = S, Message = M> + 'static,
+
    {
+
        Self {
+
            view: Box::new(view),
+
            props: None,
+
            sender: sender.clone(),
+
            on_update: None,
+
            on_event: None,
+
            on_render: None,
+
        }
+
    }
+

+
    /// Calls `reset` on the wrapped view.
+
    pub fn reset(&mut self) {
+
        self.view.reset()
+
    }
+

+
    /// Calls `handle_event` on the wrapped view as well as the `on_event` callback.
+
    /// Sends any message returned by either the view or the callback.
+
    pub fn handle_event(&mut self, key: Key) {
+
        if let Some(message) = self.view.handle_event(self.props.as_ref(), key) {
+
            let _ = self.sender.send(message);
+
        }
+

+
        if let Some(on_event) = self.on_event {
+
            if let Some(message) =
+
                (on_event)(key, self.view.view_state().as_ref(), self.props.as_ref())
+
            {
+
                let _ = self.sender.send(message);
+
            }
+
        }
+
    }
+

+
    /// Applications are usually defined by app-specific widgets that do know
+
    /// the type of `state`. These can use widgets from the library that do not know the
+
    /// type of `state`.
+
    ///
+
    /// If `on_update` is set, implementations of this function should call it to
+
    /// construct and update the internal props. If it is not set, app widgets can construct
+
    /// props directly via their state converters, whereas library widgets can just fallback
+
    /// to their current props.
+
    pub fn update(&mut self, state: &S) {
+
        self.props = self.on_update.map(|on_update| (on_update)(state));
+
        self.view.update(self.props.as_ref(), state);
+
    }
+

+
    /// Renders the wrapped view.
+
    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 {
+
            (on_render)(self.props.as_ref(), &render)
+
                .and_then(|message| self.sender.send(message).ok());
+
        }
+
    }
+

+
    /// Sets the optional custom event handler.
+
    pub fn on_event(mut self, callback: EventCallback<M>) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        self.on_event = Some(callback);
+
        self
+
    }
+

+
    /// Sets the optional update handler.
+
    pub fn on_update(mut self, callback: UpdateCallback<S>) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        self.on_update = Some(callback);
+
        self
+
    }
+

+
    /// Sets the optional update handler.
+
    pub fn on_render(mut self, callback: RenderCallback<M>) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        self.on_render = Some(callback);
+
        self
+
    }
+
}
+

+
/// A `View` needs to be wrapped into a `Widget` in order to be used with the framework.
+
/// `ToWidget` provides a blanket implementation for all `View`s.
+
pub trait ToWidget<S, M> {
+
    fn to_widget(self, tx: UnboundedSender<M>) -> Widget<S, M>
+
    where
+
        Self: Sized + 'static;
+
}
+

+
impl<T, S, M> ToWidget<S, M> for T
+
where
+
    T: View<State = S, Message = M>,
+
    S: 'static,
+
    M: 'static,
+
{
+
    fn to_widget(self, tx: UnboundedSender<M>) -> Widget<S, M>
+
    where
+
        Self: Sized + 'static,
+
    {
+
        Widget::new(self, tx)
+
    }
+
}
added src/ui/rm/widget/container.rs
@@ -0,0 +1,846 @@
+
use std::fmt::Debug;
+
use std::marker::PhantomData;
+

+
use termion::event::Key;
+

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

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

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

+
#[derive(Clone, Debug, Default)]
+
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
+
    }
+
}
+

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

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

+
    pub fn skip(mut self, skip: bool) -> Self {
+
        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)]
+
pub struct HeaderProps<'a> {
+
    pub columns: Vec<Column<'a>>,
+
    pub cutoff: usize,
+
    pub cutoff_after: usize,
+
    pub border_style: Style,
+
    pub focus_border_style: Style,
+
}
+

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

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

+
    pub fn border_style(mut self, color: Style) -> Self {
+
        self.border_style = color;
+
        self
+
    }
+

+
    pub fn focus_border_style(mut self, color: Style) -> Self {
+
        self.focus_border_style = color;
+
        self
+
    }
+
}
+

+
impl<'a> Default for HeaderProps<'a> {
+
    fn default() -> Self {
+
        let theme = Theme::default();
+

+
        Self {
+
            columns: vec![],
+
            cutoff: usize::MAX,
+
            cutoff_after: usize::MAX,
+
            border_style: theme.border_style,
+
            focus_border_style: theme.focus_border_style,
+
        }
+
    }
+
}
+

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

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

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

+
    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(|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 && column.displayed(width as usize) {
+
                    Some(column.text.clone())
+
                } else {
+
                    None
+
                }
+
            })
+
            .collect::<Vec<_>>();
+

+
        let border_style = if render.focus {
+
            props.focus_border_style
+
        } else {
+
            props.border_style
+
        };
+

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

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

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

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

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

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

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

+
    pub fn border_style(mut self, color: Style) -> Self {
+
        self.border_style = color;
+
        self
+
    }
+

+
    pub fn focus_border_style(mut self, color: Style) -> Self {
+
        self.focus_border_style = color;
+
        self
+
    }
+
}
+

+
impl<'a> Default for FooterProps<'a> {
+
    fn default() -> Self {
+
        let theme = Theme::default();
+

+
        Self {
+
            columns: vec![],
+
            cutoff: usize::MAX,
+
            cutoff_after: usize::MAX,
+
            border_style: theme.border_style,
+
            focus_border_style: theme.focus_border_style,
+
        }
+
    }
+
}
+

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

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

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

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

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

+
    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>())
+
            .unwrap_or(&default);
+

+
        let border_style = if render.focus {
+
            props.focus_border_style
+
        } else {
+
            props.border_style
+
        };
+

+
        let widths = props
+
            .columns
+
            .iter()
+
            .map(|c| match c.width {
+
                Constraint::Min(min) => Constraint::Length(min.saturating_add(3)),
+
                _ => c.width,
+
            })
+
            .collect::<Vec<_>>();
+

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

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

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

+
#[derive(Clone)]
+
pub struct ContainerProps {
+
    hide_footer: bool,
+
    border_style: Style,
+
    focus_border_style: Style,
+
}
+

+
impl Default for ContainerProps {
+
    fn default() -> Self {
+
        let theme = Theme::default();
+

+
        Self {
+
            hide_footer: false,
+
            border_style: theme.border_style,
+
            focus_border_style: theme.focus_border_style,
+
        }
+
    }
+
}
+

+
impl ContainerProps {
+
    pub fn hide_footer(mut self, hide: bool) -> Self {
+
        self.hide_footer = hide;
+
        self
+
    }
+

+
    pub fn border_style(mut self, color: Style) -> Self {
+
        self.border_style = color;
+
        self
+
    }
+

+
    pub fn focus_border_style(mut self, color: Style) -> Self {
+
        self.focus_border_style = color;
+
        self
+
    }
+
}
+

+
pub struct Container<S, M> {
+
    /// Container header
+
    header: Option<Widget<S, M>>,
+
    /// Content widget
+
    content: Option<Widget<S, M>>,
+
    /// Container footer
+
    footer: Option<Widget<S, M>>,
+
}
+

+
impl<S, M> Default for Container<S, M> {
+
    fn default() -> Self {
+
        Self {
+
            header: None,
+
            content: None,
+
            footer: None,
+
        }
+
    }
+
}
+

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

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

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

+
impl<S, M> View for Container<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> {
+
        if let Some(content) = &mut self.content {
+
            content.handle_event(key);
+
        }
+

+
        None
+
    }
+

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

+
        if let Some(content) = &mut self.content {
+
            content.update(state);
+
        }
+

+
        if let Some(footer) = &mut self.footer {
+
            footer.update(state);
+
        }
+
    }
+

+
    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>())
+
            .unwrap_or(&default);
+

+
        let border_style = if render.focus {
+
            props.focus_border_style
+
        } else {
+
            props.border_style
+
        };
+

+
        let header_h = if self.header.is_some() { 3 } else { 0 };
+
        let footer_h = if self.footer.is_some() && !props.hide_footer {
+
            3
+
        } else {
+
            0
+
        };
+

+
        let [header_area, content_area, footer_area] = Layout::vertical([
+
            Constraint::Length(header_h),
+
            Constraint::Min(1),
+
            Constraint::Length(footer_h),
+
        ])
+
        .areas(render.area);
+

+
        let borders = match (
+
            self.header.is_some(),
+
            (self.footer.is_some() && !props.hide_footer),
+
        ) {
+
            (false, false) => Borders::ALL,
+
            (true, false) => Borders::BOTTOM | Borders::LEFT | Borders::RIGHT,
+
            (false, true) => Borders::TOP | Borders::LEFT | Borders::RIGHT,
+
            (true, true) => Borders::LEFT | Borders::RIGHT,
+
        };
+

+
        let block = Block::default()
+
            .border_style(border_style)
+
            .border_type(BorderType::Rounded)
+
            .borders(borders);
+
        frame.render_widget(block.clone(), content_area);
+

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

+
        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.as_mut() {
+
            footer.render(RenderProps::from(footer_area).focus(render.focus), frame);
+
        }
+
    }
+
}
+

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

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

+
impl Default for SplitContainerProps {
+
    fn default() -> Self {
+
        let theme = Theme::default();
+

+
        Self {
+
            split_focus: SplitContainerFocus::default(),
+
            heights: [Constraint::Percentage(50), Constraint::Percentage(50)],
+
            border_style: theme.border_style,
+
            focus_border_style: theme.focus_border_style,
+
        }
+
    }
+
}
+

+
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 fn border_style(mut self, color: Style) -> Self {
+
        self.border_style = color;
+
        self
+
    }
+

+
    pub fn focus_border_style(mut self, color: Style) -> Self {
+
        self.focus_border_style = color;
+
        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 border_style = if render.focus {
+
            props.focus_border_style
+
        } else {
+
            props.border_style
+
        };
+

+
        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(border_style)
+
                .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(1)
+
                .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(border_style)
+
                .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(1)
+
                .areas(bottom_area);
+
            bottom.render(RenderProps::from(bottom_area).focus(render.focus), frame)
+
        }
+
    }
+
}
+

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

+
#[derive(Clone, Default)]
+
pub struct SectionGroupProps {
+
    /// Index of currently focused section. If set, it will override the widgets'
+
    /// internal state.
+
    focus: Option<usize>,
+
    /// If this pages' keys should be handled.
+
    handle_keys: bool,
+
    /// Section layout
+
    layout: PredefinedLayout,
+
}
+

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

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

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

+
pub struct SectionGroup<S, M> {
+
    /// All sections
+
    sections: Vec<Widget<S, M>>,
+
    /// Internal selection and offset state
+
    state: SectionGroupState,
+
}
+

+
impl<S, M> Default for SectionGroup<S, M> {
+
    fn default() -> Self {
+
        Self {
+
            sections: vec![],
+
            state: SectionGroupState { focus: Some(0) },
+
        }
+
    }
+
}
+

+
impl<S, M> SectionGroup<S, M> {
+
    pub fn section(mut self, section: Widget<S, M>) -> 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, M> View for SectionGroup<S, M>
+
where
+
    S: 'static,
+
    M: 'static,
+
{
+
    type State = S;
+
    type Message = M;
+

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

+
        if let Some(section) = self
+
            .state
+
            .focus
+
            .and_then(|focus| self.sections.get_mut(focus))
+
        {
+
            section.handle_event(key);
+
        }
+

+
        if props.handle_keys {
+
            match key {
+
                Key::BackTab => {
+
                    self.prev();
+
                }
+
                Key::Char('\t') => {
+
                    self.next(self.sections.len());
+
                }
+
                _ => {}
+
            }
+
        }
+

+
        None
+
    }
+

+
    fn update(&mut self, props: Option<&ViewProps>, state: &Self::State) {
+
        let default = SectionGroupProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<SectionGroupProps>())
+
            .unwrap_or(&default);
+

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

+
        if props.focus.is_some() && props.focus != self.state.focus {
+
            self.state.focus = props.focus;
+
        }
+
    }
+

+
    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_mut(index) {
+
                let focus = self
+
                    .state
+
                    .focus
+
                    .map(|focus_index| index == focus_index)
+
                    .unwrap_or_default();
+

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

+
    fn view_state(&self) -> Option<super::ViewState> {
+
        Some(ViewState::SectionGroup(self.state.clone()))
+
    }
+
}
added src/ui/rm/widget/input.rs
@@ -0,0 +1,902 @@
+
use std::marker::PhantomData;
+

+
use ratatui::widgets::Paragraph;
+
use ratatui::Frame;
+
use termion::event::Key;
+

+
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
+
use ratatui::style::{Style, Stylize};
+
use ratatui::text::{Line, Span, Text};
+

+
use crate::ui::theme::Theme;
+

+
use super::{utils, RenderProps, View, ViewProps, ViewState};
+

+
#[derive(Clone)]
+
pub struct TextFieldProps {
+
    /// The label of this input field.
+
    pub title: String,
+
    /// The input text.
+
    pub text: String,
+
    /// Sets if the label should be displayed inline with the input. The default is `false`.
+
    pub inline_label: bool,
+
    /// Sets if the cursor should be shown. The default is `true`.
+
    pub show_cursor: bool,
+
    /// Set to `true` if the content style should be dimmed whenever the widget
+
    /// has no focus.
+
    pub dim: bool,
+
}
+

+
impl TextFieldProps {
+
    pub fn text(mut self, new_text: &str) -> Self {
+
        if self.text != new_text {
+
            self.text = String::from(new_text);
+
        }
+
        self
+
    }
+

+
    pub fn title(mut self, title: &str) -> Self {
+
        self.title = title.to_string();
+
        self
+
    }
+

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

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

+
impl Default for TextFieldProps {
+
    fn default() -> Self {
+
        Self {
+
            title: String::new(),
+
            inline_label: false,
+
            show_cursor: true,
+
            text: String::new(),
+
            dim: false,
+
        }
+
    }
+
}
+

+
#[derive(Clone)]
+
struct TextFieldState {
+
    pub text: Option<String>,
+
    pub cursor_position: usize,
+
}
+

+
pub struct TextField<S, M> {
+
    /// Internal state
+
    state: TextFieldState,
+
    /// Phantom
+
    phantom: PhantomData<(S, M)>,
+
}
+

+
impl<S, M> Default for TextField<S, M> {
+
    fn default() -> Self {
+
        Self {
+
            state: TextFieldState {
+
                text: None,
+
                cursor_position: 0,
+
            },
+
            phantom: PhantomData,
+
        }
+
    }
+
}
+

+
impl<S, M> TextField<S, M> {
+
    fn move_cursor_left(&mut self) {
+
        let cursor_moved_left = self.state.cursor_position.saturating_sub(1);
+
        self.state.cursor_position = self.clamp_cursor(cursor_moved_left);
+
    }
+

+
    fn move_cursor_right(&mut self) {
+
        let cursor_moved_right = self.state.cursor_position.saturating_add(1);
+
        self.state.cursor_position = self.clamp_cursor(cursor_moved_right);
+
    }
+

+
    fn enter_char(&mut self, new_char: char) {
+
        self.state.text = Some(self.state.text.clone().unwrap_or_default());
+
        self.state
+
            .text
+
            .as_mut()
+
            .unwrap()
+
            .insert(self.state.cursor_position, new_char);
+
        self.move_cursor_right();
+
    }
+

+
    fn delete_char_right(&mut self) {
+
        self.state.text = Some(self.state.text.clone().unwrap_or_default());
+

+
        // Method "remove" is not used on the saved text for deleting the selected char.
+
        // Reason: Using remove on String works on bytes instead of the chars.
+
        // Using remove would require special care because of char boundaries.
+

+
        let current_index = self.state.cursor_position;
+
        let from_left_to_current_index = current_index;
+

+
        // Getting all characters before the selected character.
+
        let before_char_to_delete = self
+
            .state
+
            .text
+
            .as_ref()
+
            .unwrap()
+
            .chars()
+
            .take(from_left_to_current_index);
+
        // Getting all characters after selected character.
+
        let after_char_to_delete = self
+
            .state
+
            .text
+
            .as_ref()
+
            .unwrap()
+
            .chars()
+
            .skip(current_index.saturating_add(1));
+

+
        // Put all characters together except the selected one.
+
        // By leaving the selected one out, it is forgotten and therefore deleted.
+
        self.state.text = Some(before_char_to_delete.chain(after_char_to_delete).collect());
+
    }
+

+
    fn delete_char_left(&mut self) {
+
        self.state.text = Some(self.state.text.clone().unwrap_or_default());
+

+
        let is_not_cursor_leftmost = self.state.cursor_position != 0;
+
        if is_not_cursor_leftmost {
+
            // Method "remove" is not used on the saved text for deleting the selected char.
+
            // Reason: Using remove on String works on bytes instead of the chars.
+
            // Using remove would require special care because of char boundaries.
+

+
            let current_index = self.state.cursor_position;
+
            let from_left_to_current_index = current_index - 1;
+

+
            // Getting all characters before the selected character.
+
            let before_char_to_delete = self
+
                .state
+
                .text
+
                .as_ref()
+
                .unwrap()
+
                .chars()
+
                .take(from_left_to_current_index);
+
            // Getting all characters after selected character.
+
            let after_char_to_delete = self
+
                .state
+
                .text
+
                .as_ref()
+
                .unwrap()
+
                .chars()
+
                .skip(current_index);
+

+
            // Put all characters together except the selected one.
+
            // By leaving the selected one out, it is forgotten and therefore deleted.
+
            self.state.text = Some(before_char_to_delete.chain(after_char_to_delete).collect());
+
            self.move_cursor_left();
+
        }
+
    }
+

+
    fn clamp_cursor(&self, new_cursor_pos: usize) -> usize {
+
        new_cursor_pos.clamp(0, self.state.text.clone().unwrap_or_default().len())
+
    }
+
}
+

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

+
    fn view_state(&self) -> Option<ViewState> {
+
        self.state
+
            .text
+
            .as_ref()
+
            .map(|text| ViewState::String(text.to_string()))
+
    }
+

+
    fn reset(&mut self) {
+
        self.state = TextFieldState {
+
            text: None,
+
            cursor_position: 0,
+
        };
+
    }
+

+
    fn handle_event(&mut self, _props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
+
        match key {
+
            Key::Char(to_insert)
+
                if (key != Key::Alt('\n'))
+
                    && (key != Key::Char('\n'))
+
                    && (key != Key::Ctrl('\n')) =>
+
            {
+
                self.enter_char(to_insert);
+
            }
+
            Key::Backspace => {
+
                self.delete_char_left();
+
            }
+
            Key::Delete => {
+
                self.delete_char_right();
+
            }
+
            Key::Left => {
+
                self.move_cursor_left();
+
            }
+
            Key::Right => {
+
                self.move_cursor_right();
+
            }
+
            _ => {}
+
        }
+

+
        None
+
    }
+

+
    fn update(&mut self, props: Option<&ViewProps>, _state: &Self::State) {
+
        let default = TextFieldProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<TextFieldProps>())
+
            .unwrap_or(&default);
+

+
        if self.state.text.is_none() {
+
            self.state.cursor_position = props.text.len().saturating_sub(1);
+
        }
+
        self.state.text = Some(props.text.clone());
+
    }
+

+
    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>())
+
            .unwrap_or(&default);
+

+
        let area = render.area;
+
        let layout = Layout::vertical(Constraint::from_lengths([1, 1])).split(area);
+

+
        let text = self.state.text.clone().unwrap_or_default();
+
        let input = text.as_str();
+
        let label_content = format!(" {} ", props.title);
+
        let overline = String::from("▔").repeat(area.width as usize);
+
        let cursor_pos = self.state.cursor_position as u16;
+

+
        let (label, input, overline) = if !render.focus && props.dim {
+
            (
+
                Span::from(label_content.clone()).magenta().dim().reversed(),
+
                Span::from(input).reset().dim(),
+
                Span::raw(overline).magenta().dim(),
+
            )
+
        } else {
+
            (
+
                Span::from(label_content.clone()).magenta().reversed(),
+
                Span::from(input).reset(),
+
                Span::raw(overline).magenta(),
+
            )
+
        };
+

+
        if props.inline_label {
+
            let top_layout = Layout::horizontal([
+
                Constraint::Length(label_content.chars().count() as u16),
+
                Constraint::Length(1),
+
                Constraint::Min(1),
+
            ])
+
            .split(layout[0]);
+

+
            let overline = Line::from([overline].to_vec());
+

+
            frame.render_widget(label, top_layout[0]);
+
            frame.render_widget(input, top_layout[2]);
+
            frame.render_widget(overline, layout[1]);
+

+
            if props.show_cursor {
+
                frame.set_cursor(top_layout[2].x + cursor_pos, top_layout[2].y)
+
            }
+
        } else {
+
            let top = Line::from([input].to_vec());
+
            let bottom = Line::from([label, overline].to_vec());
+

+
            frame.render_widget(top, layout[0]);
+
            frame.render_widget(bottom, layout[1]);
+

+
            if props.show_cursor {
+
                frame.set_cursor(area.x + cursor_pos, area.y)
+
            }
+
        }
+
    }
+
}
+

+
/// The state of a `TextArea`.
+
#[derive(Clone, Default, Debug)]
+
pub struct TextAreaState {
+
    /// Current vertical scroll position.
+
    pub scroll: usize,
+
    /// Current cursor position.
+
    pub cursor: (usize, usize),
+
}
+

+
/// The properties of a `TextArea`.
+
#[derive(Clone)]
+
pub struct TextAreaProps<'a> {
+
    /// Content of this text area.
+
    content: Text<'a>,
+
    /// Current cursor position. Default: `(0, 0)`.
+
    cursor: (usize, usize),
+
    /// If this text area should handle events. Default: `true`.
+
    handle_keys: bool,
+
    /// If this text area is in insert mode. Default: `false`.
+
    insert_mode: bool,
+
    /// If this text area should render its scroll progress. Default: `false`.
+
    show_scroll_progress: bool,
+
    /// If this text area should render its cursor progress. Default: `false`.
+
    show_column_progress: bool,
+
    /// Set to `true` if the content style should be dimmed whenever the widget
+
    /// has no focus.
+
    dim: bool,
+
}
+

+
impl<'a> Default for TextAreaProps<'a> {
+
    fn default() -> Self {
+
        Self {
+
            content: String::new().into(),
+
            cursor: (0, 0),
+
            handle_keys: true,
+
            insert_mode: false,
+
            show_scroll_progress: false,
+
            show_column_progress: false,
+
            dim: false,
+
        }
+
    }
+
}
+

+
impl<'a> TextAreaProps<'a> {
+
    pub fn content<T>(mut self, content: T) -> Self
+
    where
+
        T: Into<Text<'a>>,
+
    {
+
        self.content = content.into();
+
        self
+
    }
+

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

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

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

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

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

+
/// A non-editable text area that can be behave like a text editor.
+
/// It can scroll through text by moving around the cursor.
+
pub struct TextArea<'a, S, M> {
+
    phantom: PhantomData<(S, M)>,
+
    textarea: tui_textarea::TextArea<'a>,
+
    area: (u16, u16),
+
}
+

+
impl<'a, S, M> Default for TextArea<'a, S, M> {
+
    fn default() -> Self {
+
        Self {
+
            phantom: PhantomData,
+
            textarea: tui_textarea::TextArea::default(),
+
            area: (0, 0),
+
        }
+
    }
+
}
+

+
impl<'a, S, M> View for TextArea<'a, S, M> {
+
    type State = S;
+
    type Message = M;
+

+
    fn handle_event(&mut self, props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
+
        use tui_textarea::Input;
+

+
        let default = TextAreaProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<TextAreaProps>())
+
            .unwrap_or(&default);
+

+
        if props.handle_keys {
+
            if !props.insert_mode {
+
                match key {
+
                    Key::Left | Key::Char('h') => {
+
                        self.textarea.input(Input {
+
                            key: tui_textarea::Key::Left,
+
                            ..Default::default()
+
                        });
+
                    }
+
                    Key::Right | Key::Char('l') => {
+
                        self.textarea.input(Input {
+
                            key: tui_textarea::Key::Right,
+
                            ..Default::default()
+
                        });
+
                    }
+
                    Key::Up | Key::Char('k') => {
+
                        self.textarea.input(Input {
+
                            key: tui_textarea::Key::Up,
+
                            ..Default::default()
+
                        });
+
                    }
+
                    Key::Down | Key::Char('j') => {
+
                        self.textarea.input(Input {
+
                            key: tui_textarea::Key::Down,
+
                            ..Default::default()
+
                        });
+
                    }
+
                    _ => {}
+
                }
+
            } else {
+
                // TODO: Implement insert mode.
+
            }
+
        }
+

+
        None
+
    }
+

+
    fn update(&mut self, props: Option<&ViewProps>, _state: &Self::State) {
+
        let default = TextAreaProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<TextAreaProps>())
+
            .unwrap_or(&default);
+

+
        self.textarea = tui_textarea::TextArea::new(
+
            props
+
                .content
+
                .lines
+
                .iter()
+
                .map(|line| line.to_string())
+
                .collect::<Vec<_>>(),
+
        );
+
    }
+

+
    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>())
+
            .unwrap_or(&default);
+

+
        let [area] = Layout::default()
+
            .constraints([Constraint::Min(1)])
+
            .horizontal_margin(1)
+
            .areas(render.area);
+

+
        let [content_area, progress_area] = Layout::vertical([
+
            Constraint::Min(1),
+
            Constraint::Length(
+
                if props.show_scroll_progress || props.show_column_progress {
+
                    1
+
                } else {
+
                    0
+
                },
+
            ),
+
        ])
+
        .areas(area);
+

+
        let cursor_line_style = Style::default();
+
        let cursor_style = if render.focus {
+
            Style::default().reversed()
+
        } else {
+
            cursor_line_style
+
        };
+
        let content_style = if !render.focus && props.dim {
+
            Style::default().dim()
+
        } else {
+
            Style::default()
+
        };
+

+
        self.textarea.move_cursor(tui_textarea::CursorMove::Jump(
+
            props.cursor.0 as u16,
+
            props.cursor.1 as u16,
+
        ));
+
        self.textarea.set_cursor_line_style(cursor_line_style);
+
        self.textarea.set_cursor_style(cursor_style);
+
        self.textarea.set_style(content_style);
+

+
        let (scroll_progress, cursor_progress) = (
+
            utils::scroll::percent_absolute(
+
                self.textarea.cursor().0,
+
                props.content.lines.len(),
+
                content_area.height.into(),
+
            ),
+
            (self.textarea.cursor().0, self.textarea.cursor().1),
+
        );
+

+
        frame.render_widget(self.textarea.widget(), content_area);
+

+
        let mut progress_info = vec![];
+

+
        if props.show_scroll_progress {
+
            progress_info.push(Span::styled(
+
                format!("{}%", scroll_progress),
+
                Style::default().dim(),
+
            ))
+
        }
+

+
        if props.show_scroll_progress && props.show_column_progress {
+
            progress_info.push(Span::raw(" "));
+
        }
+

+
        if props.show_column_progress {
+
            progress_info.push(Span::styled(
+
                format!("[{},{}]", cursor_progress.0, cursor_progress.1),
+
                Style::default().dim(),
+
            ))
+
        }
+

+
        frame.render_widget(
+
            Line::from(progress_info).alignment(Alignment::Right),
+
            progress_area,
+
        );
+

+
        self.area = (content_area.height, content_area.width);
+
    }
+

+
    fn view_state(&self) -> Option<ViewState> {
+
        Some(ViewState::TextArea(TextAreaState {
+
            cursor: self.textarea.cursor(),
+
            scroll: utils::scroll::percent_absolute(
+
                self.textarea.cursor().0.saturating_sub(self.area.0.into()),
+
                self.textarea.lines().len(),
+
                self.area.0.into(),
+
            ),
+
        }))
+
    }
+
}
+

+
/// State of a `TextView`.
+
#[derive(Clone, Default, Debug)]
+
pub struct TextViewState {
+
    /// Current vertical scroll position.
+
    pub scroll: usize,
+
    /// Current cursor position.
+
    pub cursor: (usize, usize),
+
    /// Content of this text view.
+
    pub content: String,
+
}
+

+
impl TextViewState {
+
    pub fn content<T>(mut self, content: T) -> Self
+
    where
+
        T: Into<String>,
+
    {
+
        self.content = content.into();
+
        self
+
    }
+

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

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

+
    pub fn reset_cursor(&mut self) {
+
        self.cursor = (0, 0);
+
    }
+
}
+

+
/// Properties of a `TextView`.
+
#[derive(Clone)]
+
pub struct TextViewProps<'a> {
+
    /// Optional state. If set, it will override the internal view state.
+
    state: Option<TextViewState>,
+
    /// If this widget should handle events. Default: `true`.
+
    handle_keys: bool,
+
    /// If this widget should render its scroll progress. Default: `false`.
+
    show_scroll_progress: bool,
+
    /// An optional text that is rendered inside the footer bar on the bottom.
+
    footer: Option<Text<'a>>,
+
    /// The style used whenever the widget has focus.
+
    content_style: Style,
+
    /// Default scroll progress style.
+
    scroll_style: Style,
+
    /// Scroll progress style whenever the the widget has focus.
+
    focus_scroll_style: Style,
+
    /// Set to `true` if the content style should be dimmed whenever the widget
+
    /// has no focus.
+
    dim: bool,
+
}
+

+
impl<'a> TextViewProps<'a> {
+
    pub fn footer<T>(mut self, footer: Option<T>) -> Self
+
    where
+
        T: Into<Text<'a>>,
+
    {
+
        self.footer = footer.map(|f| f.into());
+
        self
+
    }
+

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

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

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

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

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

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

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

+
impl<'a> Default for TextViewProps<'a> {
+
    fn default() -> Self {
+
        let theme = Theme::default();
+

+
        Self {
+
            state: None,
+
            handle_keys: true,
+
            show_scroll_progress: false,
+
            footer: None,
+
            content_style: theme.textview_style,
+
            scroll_style: theme.textview_scroll_style,
+
            focus_scroll_style: theme.textview_focus_scroll_style,
+
            dim: false,
+
        }
+
    }
+
}
+

+
/// A scrollable, non-editable text view widget. It can scroll through text by
+
/// moving around the viewport.
+
pub struct TextView<S, M> {
+
    /// Internal view state.
+
    state: TextViewState,
+
    /// Current render area.
+
    area: (u16, u16),
+
    /// Phantom.
+
    phantom: PhantomData<(S, M)>,
+
}
+

+
impl<S, M> Default for TextView<S, M> {
+
    fn default() -> Self {
+
        Self {
+
            state: TextViewState::default(),
+
            area: (0, 0),
+
            phantom: PhantomData,
+
        }
+
    }
+
}
+

+
impl<S, M> TextView<S, M> {
+
    fn scroll_up(&mut self) {
+
        self.state.cursor.0 = self.state.cursor.0.saturating_sub(1);
+
    }
+

+
    fn scroll_down(&mut self, len: usize, page_size: usize) {
+
        let end = len.saturating_sub(page_size);
+
        self.state.cursor.0 = std::cmp::min(self.state.cursor.0.saturating_add(1), end);
+
    }
+

+
    fn scroll_left(&mut self) {
+
        self.state.cursor.1 = self.state.cursor.1.saturating_sub(3);
+
    }
+

+
    fn scroll_right(&mut self, max_line_length: usize) {
+
        self.state.cursor.1 = std::cmp::min(
+
            self.state.cursor.1.saturating_add(3),
+
            max_line_length.saturating_add(3),
+
        );
+
    }
+

+
    fn prev_page(&mut self, page_size: usize) {
+
        self.state.cursor.0 = self.state.cursor.0.saturating_sub(page_size);
+
    }
+

+
    fn next_page(&mut self, len: usize, page_size: usize) {
+
        let end = len.saturating_sub(page_size);
+

+
        self.state.cursor.0 = std::cmp::min(self.state.cursor.0.saturating_add(page_size), end);
+
    }
+

+
    fn begin(&mut self) {
+
        self.state.cursor.0 = 0;
+
    }
+

+
    fn end(&mut self, len: usize, page_size: usize) {
+
        self.state.cursor.0 = len.saturating_sub(page_size);
+
    }
+

+
    fn update_area(&mut self, area: Rect) {
+
        self.area = (area.height, area.width);
+
    }
+

+
    fn render_content(&self, frame: &mut Frame, props: &TextViewProps, render: &RenderProps) {
+
        let content_style = if !render.focus && props.dim {
+
            props.content_style.dim()
+
        } else {
+
            props.content_style
+
        };
+

+
        let content = Paragraph::new(self.state.content.clone())
+
            .style(content_style)
+
            .scroll((self.state.cursor.0 as u16, self.state.cursor.1 as u16));
+

+
        frame.render_widget(content, render.area);
+
    }
+

+
    fn render_footer(
+
        &self,
+
        frame: &mut Frame,
+
        props: &TextViewProps,
+
        render: &RenderProps,
+
        content_height: u16,
+
    ) {
+
        let [text_area, scroll_area] =
+
            Layout::horizontal([Constraint::Min(1), Constraint::Length(10)]).areas(render.area);
+

+
        let scroll_style = if render.focus {
+
            props.focus_scroll_style
+
        } else {
+
            props.scroll_style
+
        };
+

+
        let mut scroll = vec![];
+
        if props.show_scroll_progress {
+
            let content_len = self.state.content.lines().count();
+
            let scroll_progress = utils::scroll::percent_absolute(
+
                self.state.cursor.0,
+
                content_len,
+
                content_height.into(),
+
            );
+
            if (content_height as usize) < content_len {
+
                // vec![Span::styled(format!("All / {}", content_len), scroll_style)]
+
                scroll = vec![Span::styled(format!("{}%", scroll_progress), scroll_style)];
+
            }
+
        }
+

+
        frame.render_widget(
+
            props
+
                .footer
+
                .as_ref()
+
                .cloned()
+
                .unwrap_or_default()
+
                .alignment(Alignment::Left)
+
                .dim(),
+
            text_area,
+
        );
+
        frame.render_widget(Line::from(scroll).alignment(Alignment::Right), scroll_area);
+
    }
+
}
+

+
impl<S, M> View for TextView<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 = TextViewProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<TextViewProps>())
+
            .unwrap_or(&default);
+

+
        let lines = self.state.content.lines().clone();
+
        let len = lines.clone().count();
+
        let max_line_len = lines.map(|l| l.chars().count()).max().unwrap_or_default();
+
        let page_size = self.area.0 as usize;
+

+
        if props.handle_keys {
+
            match key {
+
                Key::Up | Key::Char('k') => {
+
                    self.scroll_up();
+
                }
+
                Key::Down | Key::Char('j') => {
+
                    self.scroll_down(len, page_size);
+
                }
+
                Key::Left | Key::Char('h') => {
+
                    self.scroll_left();
+
                }
+
                Key::Right | Key::Char('l') => {
+
                    self.scroll_right(max_line_len.saturating_sub(self.area.1.into()));
+
                }
+
                Key::PageUp => {
+
                    self.prev_page(page_size);
+
                }
+
                Key::PageDown => {
+
                    self.next_page(len, page_size);
+
                }
+
                Key::Home => {
+
                    self.begin();
+
                }
+
                Key::End => {
+
                    self.end(len, page_size);
+
                }
+
                _ => {}
+
            }
+
        }
+

+
        self.state.scroll = utils::scroll::percent_absolute(
+
            self.state.cursor.0,
+
            self.state.content.lines().count(),
+
            self.area.0.into(),
+
        );
+

+
        None
+
    }
+

+
    fn update(&mut self, props: Option<&ViewProps>, _state: &Self::State) {
+
        let default = TextViewProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<TextViewProps>())
+
            .unwrap_or(&default);
+

+
        if let Some(state) = &props.state {
+
            self.state = state.clone();
+
        }
+
    }
+

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

+
        let [area] = Layout::default()
+
            .constraints([Constraint::Min(1)])
+
            .horizontal_margin(1)
+
            .areas(render.area);
+

+
        if render_footer {
+
            let [content_area, footer_area] = Layout::vertical([
+
                Constraint::Min(1),
+
                Constraint::Length(if render_footer { 1 } else { 0 }),
+
            ])
+
            .areas(area);
+

+
            self.render_content(frame, props, &render.clone().area(content_area));
+
            self.render_footer(frame, props, &render.area(footer_area), content_area.height);
+
            self.update_area(content_area);
+
        } else {
+
            self.render_content(frame, props, &render.clone().area(area));
+
            self.update_area(area);
+
        }
+
    }
+

+
    fn view_state(&self) -> Option<ViewState> {
+
        Some(ViewState::TextView(self.state.clone()))
+
    }
+
}
added src/ui/rm/widget/list.rs
@@ -0,0 +1,553 @@
+
use std::collections::HashSet;
+
use std::hash::Hash;
+
use std::marker::PhantomData;
+
use std::{cmp, vec};
+

+
use termion::event::Key;
+

+
use ratatui::layout::{Constraint, Layout};
+
use ratatui::style::{Style, Stylize};
+
use ratatui::symbols::border;
+
use ratatui::text::Text;
+
use ratatui::widgets::TableState;
+
use ratatui::widgets::{
+
    Block, Borders, Cell, Row, Scrollbar, ScrollbarOrientation, ScrollbarState,
+
};
+
use ratatui::Frame;
+

+
use tui_tree_widget::{TreeItem, TreeState};
+

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

+
use super::{container::Column, RenderProps, View};
+
use super::{utils, ViewProps, ViewState};
+

+
/// Needs to be implemented for items that are supposed to be rendered in tables.
+
pub trait ToRow<const W: usize> {
+
    fn to_row(&self) -> [Cell; W];
+
}
+

+
/// Needs to be implemented for items that are supposed to be rendered in trees.
+
pub trait ToTree<Id>
+
where
+
    Id: ToString,
+
{
+
    fn rows(&self) -> Vec<TreeItem<'_, Id>>;
+
}
+

+
#[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 show_scrollbar: bool,
+
    pub dim: bool,
+
}
+

+
impl<'a, R, const W: usize> Default for TableProps<'a, R, W>
+
where
+
    R: ToRow<W>,
+
{
+
    fn default() -> Self {
+
        Self {
+
            items: vec![],
+
            columns: vec![],
+
            show_scrollbar: true,
+
            selected: Some(0),
+
            dim: false,
+
        }
+
    }
+
}
+

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

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

+
pub struct Table<S, M, R, const W: usize>
+
where
+
    R: ToRow<W>,
+
{
+
    /// Internal selection and offset state
+
    state: (TableState, usize),
+
    /// Phantom
+
    phantom: PhantomData<(S, M, R)>,
+
    /// Current render height
+
    height: u16,
+
}
+

+
impl<S, M, R, const W: usize> Default for Table<S, M, R, W>
+
where
+
    R: ToRow<W>,
+
{
+
    fn default() -> Self {
+
        Self {
+
            state: (TableState::default().with_selected(Some(0)), 0),
+
            phantom: PhantomData,
+
            height: 1,
+
        }
+
    }
+
}
+

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

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

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

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

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

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

+
impl<S, M, R, const W: usize> View for Table<S, M, R, W>
+
where
+
    S: 'static,
+
    M: 'static,
+
    R: ToRow<W> + Clone + 'static,
+
{
+
    type Message = M;
+
    type State = S;
+

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

+
        let page_size = self.height;
+

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

+
        None
+
    }
+

+
    fn update(&mut self, props: Option<&ViewProps>, _state: &Self::State) {
+
        let default = TableProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<TableProps<R, W>>())
+
            .unwrap_or(&default);
+

+
        if props.selected != self.state.0.selected() {
+
            self.state.0.select(props.selected);
+
        }
+
        self.state.1 = props.items.len();
+
    }
+

+
    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>>())
+
            .unwrap_or(&default);
+

+
        let show_scrollbar = props.show_scrollbar && props.items.len() >= self.height.into();
+
        let has_items = !props.items.is_empty();
+

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

+
        if has_items {
+
            let [table_area, scroller_area] = Layout::horizontal([
+
                Constraint::Min(1),
+
                if show_scrollbar {
+
                    Constraint::Length(1)
+
                } else {
+
                    Constraint::Length(0)
+
                },
+
            ])
+
            .areas(render.area);
+

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

+
                    for cell in item.to_row() {
+
                        if let Some(col) = it.next() {
+
                            if !col.skip && col.displayed(render.area.width as usize) {
+
                                cells.push(cell.clone())
+
                            }
+
                        } else {
+
                            continue;
+
                        }
+
                    }
+

+
                    Row::new(cells)
+
                })
+
                .collect::<Vec<_>>();
+

+
            let table = ratatui::widgets::Table::default()
+
                .rows(rows)
+
                .widths(widths)
+
                .column_spacing(1)
+
                .highlight_style(style::highlight(render.focus));
+

+
            let table = if !render.focus && props.dim {
+
                table.dim()
+
            } else {
+
                table
+
            };
+

+
            frame.render_stateful_widget(table, table_area, &mut self.state.0);
+

+
            let scroller = Scrollbar::default()
+
                .begin_symbol(None)
+
                .track_symbol(None)
+
                .end_symbol(None)
+
                .thumb_symbol("┃")
+
                .style(if render.focus {
+
                    Style::default()
+
                } else {
+
                    Style::default().dim()
+
                });
+
            let mut scroller_state = ScrollbarState::default()
+
                .content_length(props.items.len().saturating_sub(self.height.into()))
+
                .position(self.state.0.offset());
+
            frame.render_stateful_widget(scroller, scroller_area, &mut scroller_state);
+
        } else {
+
            let center = layout::centered_rect(render.area, 50, 10);
+
            let hint = Text::from(span::default("Nothing to show"))
+
                .centered()
+
                .light_magenta()
+
                .dim();
+

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

+
        self.height = render.area.height;
+
    }
+

+
    fn view_state(&self) -> Option<ViewState> {
+
        let selected = self.state.0.selected().unwrap_or_default();
+

+
        Some(ViewState::Table {
+
            selected,
+
            scroll: utils::scroll::percent_absolute(
+
                selected.saturating_sub(self.height.into()),
+
                self.state.1,
+
                self.height.into(),
+
            ),
+
        })
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct TreeProps<R, Id>
+
where
+
    R: ToTree<Id> + Clone,
+
    Id: ToString,
+
{
+
    /// Root items.
+
    pub items: Vec<R>,
+
    /// Optional path to selected item, e.g. ["1.0", "1.0.1", "1.0.2"]. If not `None`,
+
    /// it will override the internal tree state.
+
    pub selected: Option<Vec<Id>>,
+
    /// If this widget should render its scrollbar. Default: `true`.
+
    pub show_scrollbar: bool,
+
    /// Optional identifier set of opened items. If not `None`,
+
    /// it will override the internal tree state.
+
    pub opened: Option<HashSet<Vec<Id>>>,
+
    /// Set to `true` if the content style should be dimmed whenever the widget
+
    /// has no focus.
+
    pub dim: bool,
+
}
+

+
impl<R, Id> Default for TreeProps<R, Id>
+
where
+
    R: ToTree<Id> + Clone,
+
    Id: ToString,
+
{
+
    fn default() -> Self {
+
        Self {
+
            items: vec![],
+
            selected: None,
+
            show_scrollbar: true,
+
            opened: None,
+
            dim: false,
+
        }
+
    }
+
}
+

+
impl<R, Id> TreeProps<R, Id>
+
where
+
    R: ToTree<Id> + Clone,
+
    Id: ToString + Clone,
+
{
+
    pub fn items(mut self, items: Vec<R>) -> Self {
+
        self.items = items;
+
        self
+
    }
+

+
    pub fn selected(mut self, selected: Option<&[Id]>) -> Self {
+
        self.selected = selected.map(|s| s.to_vec());
+
        self
+
    }
+

+
    pub fn opened(mut self, opened: Option<HashSet<Vec<Id>>>) -> Self {
+
        self.opened = opened;
+
        self
+
    }
+

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

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

+
/// A `Tree` is an expandable, collapsable and scrollable tree widget, that takes
+
/// a list of root items which implement `ToTree`. It can be updated with a selection
+
/// and a set of opened items.
+
pub struct Tree<S, M, R, Id>
+
where
+
    R: ToTree<Id>,
+
    Id: ToString + Clone,
+
{
+
    /// Internal selection and offset state
+
    state: TreeState<Id>,
+
    /// Phantom
+
    phantom: PhantomData<(S, M, R, Id)>,
+
}
+

+
impl<S, M, R, Id> Default for Tree<S, M, R, Id>
+
where
+
    R: ToTree<Id>,
+
    Id: ToString + Clone + Default,
+
{
+
    fn default() -> Self {
+
        Self {
+
            state: TreeState::default(),
+
            phantom: PhantomData,
+
        }
+
    }
+
}
+

+
impl<S, M, R, Id> View for Tree<S, M, R, Id>
+
where
+
    R: ToTree<Id> + Clone + 'static,
+
    Id: ToString + Clone + Default + Eq + PartialEq + Hash + 'static,
+
{
+
    type State = S;
+
    type Message = M;
+

+
    fn reset(&mut self) {
+
        self.state = TreeState::default();
+
    }
+

+
    fn update(&mut self, props: Option<&ViewProps>, _state: &Self::State) {
+
        let default = TreeProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<TreeProps<R, Id>>())
+
            .unwrap_or(&default);
+

+
        if let Some(selected) = &props.selected {
+
            if selected != self.state.selected() {
+
                self.state.select(selected.clone());
+
            }
+
        }
+

+
        if let Some(opened) = &props.opened {
+
            if opened != self.state.opened() {
+
                self.state.close_all();
+
                for path in opened {
+
                    self.state.open(path.to_vec());
+
                }
+
            }
+
        }
+
    }
+

+
    fn handle_event(&mut self, _props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
+
        match key {
+
            Key::Up | Key::Char('k') => {
+
                self.state.key_up();
+
            }
+
            Key::Down | Key::Char('j') => {
+
                self.state.key_down();
+
            }
+
            Key::Left | Key::Char('h')
+
                if !self.state.selected().is_empty() && !self.state.opened().is_empty() =>
+
            {
+
                self.state.key_left();
+
            }
+
            Key::Right | Key::Char('l') => {
+
                self.state.key_right();
+
            }
+
            _ => {}
+
        }
+

+
        None
+
    }
+

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

+
        let mut items = vec![];
+
        for item in &props.items {
+
            items.extend(item.rows());
+
        }
+

+
        let tree_style = if !render.focus && props.dim {
+
            Style::default().dim()
+
        } else {
+
            Style::default()
+
        };
+

+
        let tree = if props.show_scrollbar {
+
            tui_tree_widget::Tree::new(&items)
+
                .expect("all item identifiers are unique")
+
                .block(
+
                    Block::default()
+
                        .borders(Borders::RIGHT)
+
                        .border_set(border::Set {
+
                            vertical_right: " ",
+
                            ..Default::default()
+
                        })
+
                        .border_style(if render.focus {
+
                            Style::default()
+
                        } else {
+
                            Style::default().dim()
+
                        }),
+
                )
+
                .experimental_scrollbar(Some(
+
                    Scrollbar::new(ScrollbarOrientation::VerticalRight)
+
                        .begin_symbol(None)
+
                        .track_symbol(None)
+
                        .end_symbol(None)
+
                        .thumb_symbol("┃"),
+
                ))
+
                .highlight_style(style::highlight(render.focus))
+
                .style(tree_style)
+
        } else {
+
            tui_tree_widget::Tree::new(&items)
+
                .expect("all item identifiers are unique")
+
                .style(tree_style)
+
                .highlight_style(style::highlight(render.focus))
+
        };
+

+
        frame.render_stateful_widget(tree, render.area, &mut self.state);
+
    }
+

+
    fn view_state(&self) -> Option<ViewState> {
+
        Some(ViewState::Tree(
+
            self.state
+
                .selected()
+
                .to_vec()
+
                .iter()
+
                .map(|s| s.to_string())
+
                .collect(),
+
        ))
+
    }
+
}
added src/ui/rm/widget/utils.rs
@@ -0,0 +1,29 @@
+
pub mod scroll {
+
    pub fn percent_seen(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 {
+
            map_range((0.0, progress), (0.0, 100.0), progress) as usize
+
        } else {
+
            progress as usize
+
        }
+
    }
+

+
    pub fn percent_absolute(offset: usize, len: usize, height: usize) -> usize {
+
        let y = offset as f64;
+
        let h = height as f64;
+
        let t = len.saturating_sub(1) as f64;
+
        let v = y / (t - h) * 100_f64;
+

+
        (v as usize).clamp(0, 100)
+
    }
+

+
    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)
+
    }
+
}
added src/ui/rm/widget/window.rs
@@ -0,0 +1,309 @@
+
use std::hash::Hash;
+
use std::{collections::HashMap, marker::PhantomData};
+

+
use ratatui::Frame;
+
use termion::event::Key;
+

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

+
use crate::ui::theme::{style, Theme};
+

+
use super::{RenderProps, View, ViewProps, 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 }
+
    }
+
}
+

+
pub struct Window<S, M, Id> {
+
    /// All pages known
+
    pages: HashMap<Id, Widget<S, M>>,
+
}
+

+
impl<S, M, Id> Default for Window<S, M, Id> {
+
    fn default() -> Self {
+
        Self {
+
            pages: HashMap::new(),
+
        }
+
    }
+
}
+

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

+
impl<'a, S, M, Id> View for Window<S, M, Id>
+
where
+
    'a: 'static,
+
    S: 'static,
+
    M: 'static,
+
    Id: Clone + Hash + Eq + PartialEq + 'static,
+
{
+
    type Message = M;
+
    type State = S;
+

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

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

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

+
        None
+
    }
+

+
    fn update(&mut self, props: Option<&ViewProps>, state: &Self::State) {
+
        let default = WindowProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<WindowProps<Id>>())
+
            .unwrap_or(&default);
+

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

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

+
    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>>())
+
            .unwrap_or(&default);
+

+
        let area = frame.size();
+

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

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

+
#[derive(Clone, Default)]
+
pub struct PageProps {
+
    /// If this view's should handle keys
+
    pub handle_keys: bool,
+
}
+

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

+
pub struct Page<S, M> {
+
    /// Content widget
+
    content: Option<Widget<S, M>>,
+
    /// Shortcut widget
+
    shortcuts: Option<Widget<S, M>>,
+
}
+

+
impl<S, M> Default for Page<S, M> {
+
    fn default() -> Self {
+
        Self {
+
            content: None,
+
            shortcuts: None,
+
        }
+
    }
+
}
+

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

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

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

+
    fn handle_event(&mut self, _props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
+
        if let Some(content) = self.content.as_mut() {
+
            content.handle_event(key);
+
        }
+

+
        None
+
    }
+

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

+
    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_mut() {
+
            content.render(
+
                RenderProps::from(content_area)
+
                    .layout(Layout::horizontal([Constraint::Min(1)]))
+
                    .focus(true),
+
                frame,
+
            );
+
        }
+

+
        if let Some(shortcuts) = self.shortcuts.as_mut() {
+
            shortcuts.render(RenderProps::from(shortcuts_area), frame);
+
        }
+
    }
+
}
+

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

+
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
+
    }
+

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

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

+
impl Default for ShortcutsProps {
+
    fn default() -> Self {
+
        let theme = Theme::default();
+

+
        Self {
+
            shortcuts: vec![],
+
            divider: '∙',
+
            shortcuts_keys_style: theme.shortcuts_keys_style,
+
            shortcuts_action_style: theme.shortcuts_action_style,
+
        }
+
    }
+
}
+

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

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

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

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

+
        let default = ShortcutsProps::default();
+
        let props = props
+
            .and_then(|props| props.inner_ref::<ShortcutsProps>())
+
            .unwrap_or(&default);
+

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

+
        while let Some(shortcut) = shortcuts.next() {
+
            let short = Text::from(shortcut.0.clone()).style(props.shortcuts_keys_style);
+
            let long = Text::from(shortcut.1.clone()).style(props.shortcuts_action_style);
+
            let spacer = Text::from(String::new());
+
            let divider = Text::from(format!(" {} ", 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, render.area);
+
    }
+
}
deleted src/ui/widget.rs
@@ -1,358 +0,0 @@
-
pub mod container;
-
pub mod input;
-
pub mod list;
-
pub mod utils;
-
pub mod window;
-

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

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

-
use termion::event::Key;
-

-
use ratatui::prelude::*;
-

-
use self::{
-
    container::SectionGroupState,
-
    input::{TextAreaState, TextViewState},
-
};
-

-
pub type BoxedView<S, M> = Box<dyn View<State = S, Message = M>>;
-
pub type UpdateCallback<S> = fn(&S) -> ViewProps;
-
pub type EventCallback<M> = fn(Key, Option<&ViewState>, Option<&ViewProps>) -> Option<M>;
-
pub type RenderCallback<M> = fn(Option<&ViewProps>, &RenderProps) -> Option<M>;
-

-
/// `ViewProps` are properties of a `View`. They define a `View`s data, configuration etc.
-
/// Since the framework itself does not know the concrete type of `View`, it also does not
-
/// know the concrete type of a `View`s properties.
-
/// Hence, view properties are stored inside a `Box<dyn Any>` and downcasted to the concrete
-
/// type when needed.
-
pub struct ViewProps {
-
    inner: Box<dyn Any>,
-
}
-

-
impl ViewProps {
-
    pub fn inner<T>(self) -> Option<T>
-
    where
-
        T: Default + Clone + 'static,
-
    {
-
        self.inner.downcast::<T>().ok().map(|inner| *inner)
-
    }
-

-
    pub fn inner_ref<T>(&self) -> Option<&T>
-
    where
-
        T: Default + Clone + 'static,
-
    {
-
        self.inner.downcast_ref::<T>()
-
    }
-
}
-

-
impl From<Box<dyn Any>> for ViewProps {
-
    fn from(props: Box<dyn Any>) -> Self {
-
        ViewProps { inner: props }
-
    }
-
}
-

-
impl From<&'static dyn Any> for ViewProps {
-
    fn from(inner: &'static dyn Any) -> Self {
-
        Self {
-
            inner: Box::new(inner),
-
        }
-
    }
-
}
-

-
/// A `ViewState` is the representation of a `View`s internal state. e.g. current
-
/// table selection or contents of a text field.
-
#[derive(Debug)]
-
pub enum ViewState {
-
    USize(usize),
-
    String(String),
-
    Table { selected: usize, scroll: usize },
-
    Tree(Vec<String>),
-
    TextView(TextViewState),
-
    TextArea(TextAreaState),
-
    SectionGroup(SectionGroupState),
-
}
-

-
impl ViewState {
-
    pub fn unwrap_usize(&self) -> Option<usize> {
-
        match self {
-
            ViewState::USize(value) => Some(*value),
-
            _ => None,
-
        }
-
    }
-

-
    pub fn unwrap_string(&self) -> Option<String> {
-
        match self {
-
            ViewState::String(value) => Some(value.clone()),
-
            _ => None,
-
        }
-
    }
-

-
    pub fn unwrap_table(&self) -> Option<(usize, usize)> {
-
        match self {
-
            ViewState::Table { selected, scroll } => Some((*selected, *scroll)),
-
            _ => None,
-
        }
-
    }
-

-
    pub fn unwrap_textview(&self) -> Option<TextViewState> {
-
        match self {
-
            ViewState::TextView(state) => Some(state.clone()),
-
            _ => None,
-
        }
-
    }
-

-
    pub fn unwrap_textarea(&self) -> Option<TextAreaState> {
-
        match self {
-
            ViewState::TextArea(state) => Some(state.clone()),
-
            _ => None,
-
        }
-
    }
-

-
    pub fn unwrap_section_group(&self) -> Option<SectionGroupState> {
-
        match self {
-
            ViewState::SectionGroup(state) => Some(state.clone()),
-
            _ => None,
-
        }
-
    }
-

-
    pub fn unwrap_tree(&self) -> Option<Vec<String>> {
-
        match self {
-
            ViewState::Tree(value) => Some(value.clone().to_vec()),
-
            _ => None,
-
        }
-
    }
-
}
-

-
#[derive(Clone, Default)]
-
pub enum PredefinedLayout {
-
    #[default]
-
    None,
-
    Expandable3 {
-
        left_only: bool,
-
    },
-
}
-

-
impl PredefinedLayout {
-
    pub fn split(&self, area: Rect) -> Rc<[Rect]> {
-
        match self {
-
            Self::Expandable3 { left_only } => {
-
                if *left_only {
-
                    [area].into()
-
                } else 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(65), Constraint::Percentage(35)])
-
                            .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 {
-
    /// Area of the render props.
-
    pub area: Rect,
-
    /// Layout to be rendered in.
-
    pub layout: Layout,
-
    /// Focus of the render props.
-
    pub focus: bool,
-
}
-

-
impl RenderProps {
-
    /// Sets the area to render in.
-
    pub fn area(mut self, area: Rect) -> Self {
-
        self.area = area;
-
        self
-
    }
-

-
    /// Sets the focus of these render props.
-
    pub fn focus(mut self, focus: bool) -> Self {
-
        self.focus = focus;
-
        self
-
    }
-

-
    /// Sets the layout of these render props.
-
    pub fn layout(mut self, layout: Layout) -> Self {
-
        self.layout = layout;
-
        self
-
    }
-
}
-

-
impl From<Rect> for RenderProps {
-
    fn from(area: Rect) -> Self {
-
        Self {
-
            area,
-
            layout: Layout::default(),
-
            focus: false,
-
        }
-
    }
-
}
-

-
/// Main trait defining a `View` behaviour, which needs be implemented in order to
-
/// build a custom widget. A `View` operates on an application state and can emit
-
/// application messages. It's usually is accompanied by a definition of view-specific
-
/// properties, which are being built from the application state by the framework.
-
pub trait View {
-
    type State;
-
    type Message;
-

-
    /// Should return the internal state.
-
    fn view_state(&self) -> Option<ViewState> {
-
        None
-
    }
-

-
    /// Should reset the internal state and call `reset` on all children.
-
    fn reset(&mut self) {}
-

-
    /// Should handle key events and call `handle_event` on all children.
-
    fn handle_event(&mut self, _props: Option<&ViewProps>, _key: Key) -> Option<Self::Message> {
-
        None
-
    }
-

-
    /// Should update the internal props of this and all children.
-
    fn update(&mut self, _props: Option<&ViewProps>, _state: &Self::State) {}
-

-
    /// Should render the view using the given `RenderProps`.
-
    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
-
/// framework. A `Widget` enhances a `View` with event and update callbacks and takes
-
/// care of calling them before / after calling into the `View`.
-
pub struct Widget<S, M> {
-
    view: BoxedView<S, M>,
-
    props: Option<ViewProps>,
-
    sender: UnboundedSender<M>,
-
    on_update: Option<UpdateCallback<S>>,
-
    on_event: Option<EventCallback<M>>,
-
    on_render: Option<RenderCallback<M>>,
-
}
-

-
impl<S: 'static, M: 'static> Widget<S, M> {
-
    pub fn new<V>(view: V, sender: UnboundedSender<M>) -> Self
-
    where
-
        Self: Sized,
-
        V: View<State = S, Message = M> + 'static,
-
    {
-
        Self {
-
            view: Box::new(view),
-
            props: None,
-
            sender: sender.clone(),
-
            on_update: None,
-
            on_event: None,
-
            on_render: None,
-
        }
-
    }
-

-
    /// Calls `reset` on the wrapped view.
-
    pub fn reset(&mut self) {
-
        self.view.reset()
-
    }
-

-
    /// Calls `handle_event` on the wrapped view as well as the `on_event` callback.
-
    /// Sends any message returned by either the view or the callback.
-
    pub fn handle_event(&mut self, key: Key) {
-
        if let Some(message) = self.view.handle_event(self.props.as_ref(), key) {
-
            let _ = self.sender.send(message);
-
        }
-

-
        if let Some(on_event) = self.on_event {
-
            if let Some(message) =
-
                (on_event)(key, self.view.view_state().as_ref(), self.props.as_ref())
-
            {
-
                let _ = self.sender.send(message);
-
            }
-
        }
-
    }
-

-
    /// Applications are usually defined by app-specific widgets that do know
-
    /// the type of `state`. These can use widgets from the library that do not know the
-
    /// type of `state`.
-
    ///
-
    /// If `on_update` is set, implementations of this function should call it to
-
    /// construct and update the internal props. If it is not set, app widgets can construct
-
    /// props directly via their state converters, whereas library widgets can just fallback
-
    /// to their current props.
-
    pub fn update(&mut self, state: &S) {
-
        self.props = self.on_update.map(|on_update| (on_update)(state));
-
        self.view.update(self.props.as_ref(), state);
-
    }
-

-
    /// Renders the wrapped view.
-
    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 {
-
            (on_render)(self.props.as_ref(), &render)
-
                .and_then(|message| self.sender.send(message).ok());
-
        }
-
    }
-

-
    /// Sets the optional custom event handler.
-
    pub fn on_event(mut self, callback: EventCallback<M>) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        self.on_event = Some(callback);
-
        self
-
    }
-

-
    /// Sets the optional update handler.
-
    pub fn on_update(mut self, callback: UpdateCallback<S>) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        self.on_update = Some(callback);
-
        self
-
    }
-

-
    /// Sets the optional update handler.
-
    pub fn on_render(mut self, callback: RenderCallback<M>) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        self.on_render = Some(callback);
-
        self
-
    }
-
}
-

-
/// A `View` needs to be wrapped into a `Widget` in order to be used with the framework.
-
/// `ToWidget` provides a blanket implementation for all `View`s.
-
pub trait ToWidget<S, M> {
-
    fn to_widget(self, tx: UnboundedSender<M>) -> Widget<S, M>
-
    where
-
        Self: Sized + 'static;
-
}
-

-
impl<T, S, M> ToWidget<S, M> for T
-
where
-
    T: View<State = S, Message = M>,
-
    S: 'static,
-
    M: 'static,
-
{
-
    fn to_widget(self, tx: UnboundedSender<M>) -> Widget<S, M>
-
    where
-
        Self: Sized + 'static,
-
    {
-
        Widget::new(self, tx)
-
    }
-
}
deleted src/ui/widget/container.rs
@@ -1,846 +0,0 @@
-
use std::fmt::Debug;
-
use std::marker::PhantomData;
-

-
use termion::event::Key;
-

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

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

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

-
#[derive(Clone, Debug, Default)]
-
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
-
    }
-
}
-

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

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

-
    pub fn skip(mut self, skip: bool) -> Self {
-
        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)]
-
pub struct HeaderProps<'a> {
-
    pub columns: Vec<Column<'a>>,
-
    pub cutoff: usize,
-
    pub cutoff_after: usize,
-
    pub border_style: Style,
-
    pub focus_border_style: Style,
-
}
-

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

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

-
    pub fn border_style(mut self, color: Style) -> Self {
-
        self.border_style = color;
-
        self
-
    }
-

-
    pub fn focus_border_style(mut self, color: Style) -> Self {
-
        self.focus_border_style = color;
-
        self
-
    }
-
}
-

-
impl<'a> Default for HeaderProps<'a> {
-
    fn default() -> Self {
-
        let theme = Theme::default();
-

-
        Self {
-
            columns: vec![],
-
            cutoff: usize::MAX,
-
            cutoff_after: usize::MAX,
-
            border_style: theme.border_style,
-
            focus_border_style: theme.focus_border_style,
-
        }
-
    }
-
}
-

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

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

-
impl<'a: 'static, S, M> View for Header<S, M> {
-
    type Message = M;
-
    type State = S;
-

-
    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(|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 && column.displayed(width as usize) {
-
                    Some(column.text.clone())
-
                } else {
-
                    None
-
                }
-
            })
-
            .collect::<Vec<_>>();
-

-
        let border_style = if render.focus {
-
            props.focus_border_style
-
        } else {
-
            props.border_style
-
        };
-

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

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

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

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

-
#[derive(Clone, Debug)]
-
pub struct FooterProps<'a> {
-
    pub columns: Vec<Column<'a>>,
-
    pub cutoff: usize,
-
    pub cutoff_after: usize,
-
    pub border_style: Style,
-
    pub focus_border_style: Style,
-
}
-

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

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

-
    pub fn border_style(mut self, color: Style) -> Self {
-
        self.border_style = color;
-
        self
-
    }
-

-
    pub fn focus_border_style(mut self, color: Style) -> Self {
-
        self.focus_border_style = color;
-
        self
-
    }
-
}
-

-
impl<'a> Default for FooterProps<'a> {
-
    fn default() -> Self {
-
        let theme = Theme::default();
-

-
        Self {
-
            columns: vec![],
-
            cutoff: usize::MAX,
-
            cutoff_after: usize::MAX,
-
            border_style: theme.border_style,
-
            focus_border_style: theme.focus_border_style,
-
        }
-
    }
-
}
-

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

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

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

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

-
impl<'a: 'static, S, M> View for Footer<S, M> {
-
    type Message = M;
-
    type State = S;
-

-
    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>())
-
            .unwrap_or(&default);
-

-
        let border_style = if render.focus {
-
            props.focus_border_style
-
        } else {
-
            props.border_style
-
        };
-

-
        let widths = props
-
            .columns
-
            .iter()
-
            .map(|c| match c.width {
-
                Constraint::Min(min) => Constraint::Length(min.saturating_add(3)),
-
                _ => c.width,
-
            })
-
            .collect::<Vec<_>>();
-

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

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

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

-
#[derive(Clone)]
-
pub struct ContainerProps {
-
    hide_footer: bool,
-
    border_style: Style,
-
    focus_border_style: Style,
-
}
-

-
impl Default for ContainerProps {
-
    fn default() -> Self {
-
        let theme = Theme::default();
-

-
        Self {
-
            hide_footer: false,
-
            border_style: theme.border_style,
-
            focus_border_style: theme.focus_border_style,
-
        }
-
    }
-
}
-

-
impl ContainerProps {
-
    pub fn hide_footer(mut self, hide: bool) -> Self {
-
        self.hide_footer = hide;
-
        self
-
    }
-

-
    pub fn border_style(mut self, color: Style) -> Self {
-
        self.border_style = color;
-
        self
-
    }
-

-
    pub fn focus_border_style(mut self, color: Style) -> Self {
-
        self.focus_border_style = color;
-
        self
-
    }
-
}
-

-
pub struct Container<S, M> {
-
    /// Container header
-
    header: Option<Widget<S, M>>,
-
    /// Content widget
-
    content: Option<Widget<S, M>>,
-
    /// Container footer
-
    footer: Option<Widget<S, M>>,
-
}
-

-
impl<S, M> Default for Container<S, M> {
-
    fn default() -> Self {
-
        Self {
-
            header: None,
-
            content: None,
-
            footer: None,
-
        }
-
    }
-
}
-

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

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

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

-
impl<S, M> View for Container<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> {
-
        if let Some(content) = &mut self.content {
-
            content.handle_event(key);
-
        }
-

-
        None
-
    }
-

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

-
        if let Some(content) = &mut self.content {
-
            content.update(state);
-
        }
-

-
        if let Some(footer) = &mut self.footer {
-
            footer.update(state);
-
        }
-
    }
-

-
    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>())
-
            .unwrap_or(&default);
-

-
        let border_style = if render.focus {
-
            props.focus_border_style
-
        } else {
-
            props.border_style
-
        };
-

-
        let header_h = if self.header.is_some() { 3 } else { 0 };
-
        let footer_h = if self.footer.is_some() && !props.hide_footer {
-
            3
-
        } else {
-
            0
-
        };
-

-
        let [header_area, content_area, footer_area] = Layout::vertical([
-
            Constraint::Length(header_h),
-
            Constraint::Min(1),
-
            Constraint::Length(footer_h),
-
        ])
-
        .areas(render.area);
-

-
        let borders = match (
-
            self.header.is_some(),
-
            (self.footer.is_some() && !props.hide_footer),
-
        ) {
-
            (false, false) => Borders::ALL,
-
            (true, false) => Borders::BOTTOM | Borders::LEFT | Borders::RIGHT,
-
            (false, true) => Borders::TOP | Borders::LEFT | Borders::RIGHT,
-
            (true, true) => Borders::LEFT | Borders::RIGHT,
-
        };
-

-
        let block = Block::default()
-
            .border_style(border_style)
-
            .border_type(BorderType::Rounded)
-
            .borders(borders);
-
        frame.render_widget(block.clone(), content_area);
-

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

-
        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.as_mut() {
-
            footer.render(RenderProps::from(footer_area).focus(render.focus), frame);
-
        }
-
    }
-
}
-

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

-
#[derive(Clone)]
-
pub struct SplitContainerProps {
-
    split_focus: SplitContainerFocus,
-
    heights: [Constraint; 2],
-
    border_style: Style,
-
    focus_border_style: Style,
-
}
-

-
impl Default for SplitContainerProps {
-
    fn default() -> Self {
-
        let theme = Theme::default();
-

-
        Self {
-
            split_focus: SplitContainerFocus::default(),
-
            heights: [Constraint::Percentage(50), Constraint::Percentage(50)],
-
            border_style: theme.border_style,
-
            focus_border_style: theme.focus_border_style,
-
        }
-
    }
-
}
-

-
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 fn border_style(mut self, color: Style) -> Self {
-
        self.border_style = color;
-
        self
-
    }
-

-
    pub fn focus_border_style(mut self, color: Style) -> Self {
-
        self.focus_border_style = color;
-
        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 border_style = if render.focus {
-
            props.focus_border_style
-
        } else {
-
            props.border_style
-
        };
-

-
        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(border_style)
-
                .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(1)
-
                .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(border_style)
-
                .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(1)
-
                .areas(bottom_area);
-
            bottom.render(RenderProps::from(bottom_area).focus(render.focus), frame)
-
        }
-
    }
-
}
-

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

-
#[derive(Clone, Default)]
-
pub struct SectionGroupProps {
-
    /// Index of currently focused section. If set, it will override the widgets'
-
    /// internal state.
-
    focus: Option<usize>,
-
    /// If this pages' keys should be handled.
-
    handle_keys: bool,
-
    /// Section layout
-
    layout: PredefinedLayout,
-
}
-

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

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

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

-
pub struct SectionGroup<S, M> {
-
    /// All sections
-
    sections: Vec<Widget<S, M>>,
-
    /// Internal selection and offset state
-
    state: SectionGroupState,
-
}
-

-
impl<S, M> Default for SectionGroup<S, M> {
-
    fn default() -> Self {
-
        Self {
-
            sections: vec![],
-
            state: SectionGroupState { focus: Some(0) },
-
        }
-
    }
-
}
-

-
impl<S, M> SectionGroup<S, M> {
-
    pub fn section(mut self, section: Widget<S, M>) -> 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, M> View for SectionGroup<S, M>
-
where
-
    S: 'static,
-
    M: 'static,
-
{
-
    type State = S;
-
    type Message = M;
-

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

-
        if let Some(section) = self
-
            .state
-
            .focus
-
            .and_then(|focus| self.sections.get_mut(focus))
-
        {
-
            section.handle_event(key);
-
        }
-

-
        if props.handle_keys {
-
            match key {
-
                Key::BackTab => {
-
                    self.prev();
-
                }
-
                Key::Char('\t') => {
-
                    self.next(self.sections.len());
-
                }
-
                _ => {}
-
            }
-
        }
-

-
        None
-
    }
-

-
    fn update(&mut self, props: Option<&ViewProps>, state: &Self::State) {
-
        let default = SectionGroupProps::default();
-
        let props = props
-
            .and_then(|props| props.inner_ref::<SectionGroupProps>())
-
            .unwrap_or(&default);
-

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

-
        if props.focus.is_some() && props.focus != self.state.focus {
-
            self.state.focus = props.focus;
-
        }
-
    }
-

-
    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_mut(index) {
-
                let focus = self
-
                    .state
-
                    .focus
-
                    .map(|focus_index| index == focus_index)
-
                    .unwrap_or_default();
-

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

-
    fn view_state(&self) -> Option<super::ViewState> {
-
        Some(ViewState::SectionGroup(self.state.clone()))
-
    }
-
}
deleted src/ui/widget/input.rs
@@ -1,902 +0,0 @@
-
use std::marker::PhantomData;
-

-
use ratatui::widgets::Paragraph;
-
use ratatui::Frame;
-
use termion::event::Key;
-

-
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
-
use ratatui::style::{Style, Stylize};
-
use ratatui::text::{Line, Span, Text};
-

-
use crate::ui::theme::Theme;
-

-
use super::{utils, RenderProps, View, ViewProps, ViewState};
-

-
#[derive(Clone)]
-
pub struct TextFieldProps {
-
    /// The label of this input field.
-
    pub title: String,
-
    /// The input text.
-
    pub text: String,
-
    /// Sets if the label should be displayed inline with the input. The default is `false`.
-
    pub inline_label: bool,
-
    /// Sets if the cursor should be shown. The default is `true`.
-
    pub show_cursor: bool,
-
    /// Set to `true` if the content style should be dimmed whenever the widget
-
    /// has no focus.
-
    pub dim: bool,
-
}
-

-
impl TextFieldProps {
-
    pub fn text(mut self, new_text: &str) -> Self {
-
        if self.text != new_text {
-
            self.text = String::from(new_text);
-
        }
-
        self
-
    }
-

-
    pub fn title(mut self, title: &str) -> Self {
-
        self.title = title.to_string();
-
        self
-
    }
-

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

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

-
impl Default for TextFieldProps {
-
    fn default() -> Self {
-
        Self {
-
            title: String::new(),
-
            inline_label: false,
-
            show_cursor: true,
-
            text: String::new(),
-
            dim: false,
-
        }
-
    }
-
}
-

-
#[derive(Clone)]
-
struct TextFieldState {
-
    pub text: Option<String>,
-
    pub cursor_position: usize,
-
}
-

-
pub struct TextField<S, M> {
-
    /// Internal state
-
    state: TextFieldState,
-
    /// Phantom
-
    phantom: PhantomData<(S, M)>,
-
}
-

-
impl<S, M> Default for TextField<S, M> {
-
    fn default() -> Self {
-
        Self {
-
            state: TextFieldState {
-
                text: None,
-
                cursor_position: 0,
-
            },
-
            phantom: PhantomData,
-
        }
-
    }
-
}
-

-
impl<S, M> TextField<S, M> {
-
    fn move_cursor_left(&mut self) {
-
        let cursor_moved_left = self.state.cursor_position.saturating_sub(1);
-
        self.state.cursor_position = self.clamp_cursor(cursor_moved_left);
-
    }
-

-
    fn move_cursor_right(&mut self) {
-
        let cursor_moved_right = self.state.cursor_position.saturating_add(1);
-
        self.state.cursor_position = self.clamp_cursor(cursor_moved_right);
-
    }
-

-
    fn enter_char(&mut self, new_char: char) {
-
        self.state.text = Some(self.state.text.clone().unwrap_or_default());
-
        self.state
-
            .text
-
            .as_mut()
-
            .unwrap()
-
            .insert(self.state.cursor_position, new_char);
-
        self.move_cursor_right();
-
    }
-

-
    fn delete_char_right(&mut self) {
-
        self.state.text = Some(self.state.text.clone().unwrap_or_default());
-

-
        // Method "remove" is not used on the saved text for deleting the selected char.
-
        // Reason: Using remove on String works on bytes instead of the chars.
-
        // Using remove would require special care because of char boundaries.
-

-
        let current_index = self.state.cursor_position;
-
        let from_left_to_current_index = current_index;
-

-
        // Getting all characters before the selected character.
-
        let before_char_to_delete = self
-
            .state
-
            .text
-
            .as_ref()
-
            .unwrap()
-
            .chars()
-
            .take(from_left_to_current_index);
-
        // Getting all characters after selected character.
-
        let after_char_to_delete = self
-
            .state
-
            .text
-
            .as_ref()
-
            .unwrap()
-
            .chars()
-
            .skip(current_index.saturating_add(1));
-

-
        // Put all characters together except the selected one.
-
        // By leaving the selected one out, it is forgotten and therefore deleted.
-
        self.state.text = Some(before_char_to_delete.chain(after_char_to_delete).collect());
-
    }
-

-
    fn delete_char_left(&mut self) {
-
        self.state.text = Some(self.state.text.clone().unwrap_or_default());
-

-
        let is_not_cursor_leftmost = self.state.cursor_position != 0;
-
        if is_not_cursor_leftmost {
-
            // Method "remove" is not used on the saved text for deleting the selected char.
-
            // Reason: Using remove on String works on bytes instead of the chars.
-
            // Using remove would require special care because of char boundaries.
-

-
            let current_index = self.state.cursor_position;
-
            let from_left_to_current_index = current_index - 1;
-

-
            // Getting all characters before the selected character.
-
            let before_char_to_delete = self
-
                .state
-
                .text
-
                .as_ref()
-
                .unwrap()
-
                .chars()
-
                .take(from_left_to_current_index);
-
            // Getting all characters after selected character.
-
            let after_char_to_delete = self
-
                .state
-
                .text
-
                .as_ref()
-
                .unwrap()
-
                .chars()
-
                .skip(current_index);
-

-
            // Put all characters together except the selected one.
-
            // By leaving the selected one out, it is forgotten and therefore deleted.
-
            self.state.text = Some(before_char_to_delete.chain(after_char_to_delete).collect());
-
            self.move_cursor_left();
-
        }
-
    }
-

-
    fn clamp_cursor(&self, new_cursor_pos: usize) -> usize {
-
        new_cursor_pos.clamp(0, self.state.text.clone().unwrap_or_default().len())
-
    }
-
}
-

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

-
    fn view_state(&self) -> Option<ViewState> {
-
        self.state
-
            .text
-
            .as_ref()
-
            .map(|text| ViewState::String(text.to_string()))
-
    }
-

-
    fn reset(&mut self) {
-
        self.state = TextFieldState {
-
            text: None,
-
            cursor_position: 0,
-
        };
-
    }
-

-
    fn handle_event(&mut self, _props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
-
        match key {
-
            Key::Char(to_insert)
-
                if (key != Key::Alt('\n'))
-
                    && (key != Key::Char('\n'))
-
                    && (key != Key::Ctrl('\n')) =>
-
            {
-
                self.enter_char(to_insert);
-
            }
-
            Key::Backspace => {
-
                self.delete_char_left();
-
            }
-
            Key::Delete => {
-
                self.delete_char_right();
-
            }
-
            Key::Left => {
-
                self.move_cursor_left();
-
            }
-
            Key::Right => {
-
                self.move_cursor_right();
-
            }
-
            _ => {}
-
        }
-

-
        None
-
    }
-

-
    fn update(&mut self, props: Option<&ViewProps>, _state: &Self::State) {
-
        let default = TextFieldProps::default();
-
        let props = props
-
            .and_then(|props| props.inner_ref::<TextFieldProps>())
-
            .unwrap_or(&default);
-

-
        if self.state.text.is_none() {
-
            self.state.cursor_position = props.text.len().saturating_sub(1);
-
        }
-
        self.state.text = Some(props.text.clone());
-
    }
-

-
    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>())
-
            .unwrap_or(&default);
-

-
        let area = render.area;
-
        let layout = Layout::vertical(Constraint::from_lengths([1, 1])).split(area);
-

-
        let text = self.state.text.clone().unwrap_or_default();
-
        let input = text.as_str();
-
        let label_content = format!(" {} ", props.title);
-
        let overline = String::from("▔").repeat(area.width as usize);
-
        let cursor_pos = self.state.cursor_position as u16;
-

-
        let (label, input, overline) = if !render.focus && props.dim {
-
            (
-
                Span::from(label_content.clone()).magenta().dim().reversed(),
-
                Span::from(input).reset().dim(),
-
                Span::raw(overline).magenta().dim(),
-
            )
-
        } else {
-
            (
-
                Span::from(label_content.clone()).magenta().reversed(),
-
                Span::from(input).reset(),
-
                Span::raw(overline).magenta(),
-
            )
-
        };
-

-
        if props.inline_label {
-
            let top_layout = Layout::horizontal([
-
                Constraint::Length(label_content.chars().count() as u16),
-
                Constraint::Length(1),
-
                Constraint::Min(1),
-
            ])
-
            .split(layout[0]);
-

-
            let overline = Line::from([overline].to_vec());
-

-
            frame.render_widget(label, top_layout[0]);
-
            frame.render_widget(input, top_layout[2]);
-
            frame.render_widget(overline, layout[1]);
-

-
            if props.show_cursor {
-
                frame.set_cursor(top_layout[2].x + cursor_pos, top_layout[2].y)
-
            }
-
        } else {
-
            let top = Line::from([input].to_vec());
-
            let bottom = Line::from([label, overline].to_vec());
-

-
            frame.render_widget(top, layout[0]);
-
            frame.render_widget(bottom, layout[1]);
-

-
            if props.show_cursor {
-
                frame.set_cursor(area.x + cursor_pos, area.y)
-
            }
-
        }
-
    }
-
}
-

-
/// The state of a `TextArea`.
-
#[derive(Clone, Default, Debug)]
-
pub struct TextAreaState {
-
    /// Current vertical scroll position.
-
    pub scroll: usize,
-
    /// Current cursor position.
-
    pub cursor: (usize, usize),
-
}
-

-
/// The properties of a `TextArea`.
-
#[derive(Clone)]
-
pub struct TextAreaProps<'a> {
-
    /// Content of this text area.
-
    content: Text<'a>,
-
    /// Current cursor position. Default: `(0, 0)`.
-
    cursor: (usize, usize),
-
    /// If this text area should handle events. Default: `true`.
-
    handle_keys: bool,
-
    /// If this text area is in insert mode. Default: `false`.
-
    insert_mode: bool,
-
    /// If this text area should render its scroll progress. Default: `false`.
-
    show_scroll_progress: bool,
-
    /// If this text area should render its cursor progress. Default: `false`.
-
    show_column_progress: bool,
-
    /// Set to `true` if the content style should be dimmed whenever the widget
-
    /// has no focus.
-
    dim: bool,
-
}
-

-
impl<'a> Default for TextAreaProps<'a> {
-
    fn default() -> Self {
-
        Self {
-
            content: String::new().into(),
-
            cursor: (0, 0),
-
            handle_keys: true,
-
            insert_mode: false,
-
            show_scroll_progress: false,
-
            show_column_progress: false,
-
            dim: false,
-
        }
-
    }
-
}
-

-
impl<'a> TextAreaProps<'a> {
-
    pub fn content<T>(mut self, content: T) -> Self
-
    where
-
        T: Into<Text<'a>>,
-
    {
-
        self.content = content.into();
-
        self
-
    }
-

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

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

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

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

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

-
/// A non-editable text area that can be behave like a text editor.
-
/// It can scroll through text by moving around the cursor.
-
pub struct TextArea<'a, S, M> {
-
    phantom: PhantomData<(S, M)>,
-
    textarea: tui_textarea::TextArea<'a>,
-
    area: (u16, u16),
-
}
-

-
impl<'a, S, M> Default for TextArea<'a, S, M> {
-
    fn default() -> Self {
-
        Self {
-
            phantom: PhantomData,
-
            textarea: tui_textarea::TextArea::default(),
-
            area: (0, 0),
-
        }
-
    }
-
}
-

-
impl<'a, S, M> View for TextArea<'a, S, M> {
-
    type State = S;
-
    type Message = M;
-

-
    fn handle_event(&mut self, props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
-
        use tui_textarea::Input;
-

-
        let default = TextAreaProps::default();
-
        let props = props
-
            .and_then(|props| props.inner_ref::<TextAreaProps>())
-
            .unwrap_or(&default);
-

-
        if props.handle_keys {
-
            if !props.insert_mode {
-
                match key {
-
                    Key::Left | Key::Char('h') => {
-
                        self.textarea.input(Input {
-
                            key: tui_textarea::Key::Left,
-
                            ..Default::default()
-
                        });
-
                    }
-
                    Key::Right | Key::Char('l') => {
-
                        self.textarea.input(Input {
-
                            key: tui_textarea::Key::Right,
-
                            ..Default::default()
-
                        });
-
                    }
-
                    Key::Up | Key::Char('k') => {
-
                        self.textarea.input(Input {
-
                            key: tui_textarea::Key::Up,
-
                            ..Default::default()
-
                        });
-
                    }
-
                    Key::Down | Key::Char('j') => {
-
                        self.textarea.input(Input {
-
                            key: tui_textarea::Key::Down,
-
                            ..Default::default()
-
                        });
-
                    }
-
                    _ => {}
-
                }
-
            } else {
-
                // TODO: Implement insert mode.
-
            }
-
        }
-

-
        None
-
    }
-

-
    fn update(&mut self, props: Option<&ViewProps>, _state: &Self::State) {
-
        let default = TextAreaProps::default();
-
        let props = props
-
            .and_then(|props| props.inner_ref::<TextAreaProps>())
-
            .unwrap_or(&default);
-

-
        self.textarea = tui_textarea::TextArea::new(
-
            props
-
                .content
-
                .lines
-
                .iter()
-
                .map(|line| line.to_string())
-
                .collect::<Vec<_>>(),
-
        );
-
    }
-

-
    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>())
-
            .unwrap_or(&default);
-

-
        let [area] = Layout::default()
-
            .constraints([Constraint::Min(1)])
-
            .horizontal_margin(1)
-
            .areas(render.area);
-

-
        let [content_area, progress_area] = Layout::vertical([
-
            Constraint::Min(1),
-
            Constraint::Length(
-
                if props.show_scroll_progress || props.show_column_progress {
-
                    1
-
                } else {
-
                    0
-
                },
-
            ),
-
        ])
-
        .areas(area);
-

-
        let cursor_line_style = Style::default();
-
        let cursor_style = if render.focus {
-
            Style::default().reversed()
-
        } else {
-
            cursor_line_style
-
        };
-
        let content_style = if !render.focus && props.dim {
-
            Style::default().dim()
-
        } else {
-
            Style::default()
-
        };
-

-
        self.textarea.move_cursor(tui_textarea::CursorMove::Jump(
-
            props.cursor.0 as u16,
-
            props.cursor.1 as u16,
-
        ));
-
        self.textarea.set_cursor_line_style(cursor_line_style);
-
        self.textarea.set_cursor_style(cursor_style);
-
        self.textarea.set_style(content_style);
-

-
        let (scroll_progress, cursor_progress) = (
-
            utils::scroll::percent_absolute(
-
                self.textarea.cursor().0,
-
                props.content.lines.len(),
-
                content_area.height.into(),
-
            ),
-
            (self.textarea.cursor().0, self.textarea.cursor().1),
-
        );
-

-
        frame.render_widget(self.textarea.widget(), content_area);
-

-
        let mut progress_info = vec![];
-

-
        if props.show_scroll_progress {
-
            progress_info.push(Span::styled(
-
                format!("{}%", scroll_progress),
-
                Style::default().dim(),
-
            ))
-
        }
-

-
        if props.show_scroll_progress && props.show_column_progress {
-
            progress_info.push(Span::raw(" "));
-
        }
-

-
        if props.show_column_progress {
-
            progress_info.push(Span::styled(
-
                format!("[{},{}]", cursor_progress.0, cursor_progress.1),
-
                Style::default().dim(),
-
            ))
-
        }
-

-
        frame.render_widget(
-
            Line::from(progress_info).alignment(Alignment::Right),
-
            progress_area,
-
        );
-

-
        self.area = (content_area.height, content_area.width);
-
    }
-

-
    fn view_state(&self) -> Option<ViewState> {
-
        Some(ViewState::TextArea(TextAreaState {
-
            cursor: self.textarea.cursor(),
-
            scroll: utils::scroll::percent_absolute(
-
                self.textarea.cursor().0.saturating_sub(self.area.0.into()),
-
                self.textarea.lines().len(),
-
                self.area.0.into(),
-
            ),
-
        }))
-
    }
-
}
-

-
/// State of a `TextView`.
-
#[derive(Clone, Default, Debug)]
-
pub struct TextViewState {
-
    /// Current vertical scroll position.
-
    pub scroll: usize,
-
    /// Current cursor position.
-
    pub cursor: (usize, usize),
-
    /// Content of this text view.
-
    pub content: String,
-
}
-

-
impl TextViewState {
-
    pub fn content<T>(mut self, content: T) -> Self
-
    where
-
        T: Into<String>,
-
    {
-
        self.content = content.into();
-
        self
-
    }
-

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

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

-
    pub fn reset_cursor(&mut self) {
-
        self.cursor = (0, 0);
-
    }
-
}
-

-
/// Properties of a `TextView`.
-
#[derive(Clone)]
-
pub struct TextViewProps<'a> {
-
    /// Optional state. If set, it will override the internal view state.
-
    state: Option<TextViewState>,
-
    /// If this widget should handle events. Default: `true`.
-
    handle_keys: bool,
-
    /// If this widget should render its scroll progress. Default: `false`.
-
    show_scroll_progress: bool,
-
    /// An optional text that is rendered inside the footer bar on the bottom.
-
    footer: Option<Text<'a>>,
-
    /// The style used whenever the widget has focus.
-
    content_style: Style,
-
    /// Default scroll progress style.
-
    scroll_style: Style,
-
    /// Scroll progress style whenever the the widget has focus.
-
    focus_scroll_style: Style,
-
    /// Set to `true` if the content style should be dimmed whenever the widget
-
    /// has no focus.
-
    dim: bool,
-
}
-

-
impl<'a> TextViewProps<'a> {
-
    pub fn footer<T>(mut self, footer: Option<T>) -> Self
-
    where
-
        T: Into<Text<'a>>,
-
    {
-
        self.footer = footer.map(|f| f.into());
-
        self
-
    }
-

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

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

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

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

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

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

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

-
impl<'a> Default for TextViewProps<'a> {
-
    fn default() -> Self {
-
        let theme = Theme::default();
-

-
        Self {
-
            state: None,
-
            handle_keys: true,
-
            show_scroll_progress: false,
-
            footer: None,
-
            content_style: theme.textview_style,
-
            scroll_style: theme.textview_scroll_style,
-
            focus_scroll_style: theme.textview_focus_scroll_style,
-
            dim: false,
-
        }
-
    }
-
}
-

-
/// A scrollable, non-editable text view widget. It can scroll through text by
-
/// moving around the viewport.
-
pub struct TextView<S, M> {
-
    /// Internal view state.
-
    state: TextViewState,
-
    /// Current render area.
-
    area: (u16, u16),
-
    /// Phantom.
-
    phantom: PhantomData<(S, M)>,
-
}
-

-
impl<S, M> Default for TextView<S, M> {
-
    fn default() -> Self {
-
        Self {
-
            state: TextViewState::default(),
-
            area: (0, 0),
-
            phantom: PhantomData,
-
        }
-
    }
-
}
-

-
impl<S, M> TextView<S, M> {
-
    fn scroll_up(&mut self) {
-
        self.state.cursor.0 = self.state.cursor.0.saturating_sub(1);
-
    }
-

-
    fn scroll_down(&mut self, len: usize, page_size: usize) {
-
        let end = len.saturating_sub(page_size);
-
        self.state.cursor.0 = std::cmp::min(self.state.cursor.0.saturating_add(1), end);
-
    }
-

-
    fn scroll_left(&mut self) {
-
        self.state.cursor.1 = self.state.cursor.1.saturating_sub(3);
-
    }
-

-
    fn scroll_right(&mut self, max_line_length: usize) {
-
        self.state.cursor.1 = std::cmp::min(
-
            self.state.cursor.1.saturating_add(3),
-
            max_line_length.saturating_add(3),
-
        );
-
    }
-

-
    fn prev_page(&mut self, page_size: usize) {
-
        self.state.cursor.0 = self.state.cursor.0.saturating_sub(page_size);
-
    }
-

-
    fn next_page(&mut self, len: usize, page_size: usize) {
-
        let end = len.saturating_sub(page_size);
-

-
        self.state.cursor.0 = std::cmp::min(self.state.cursor.0.saturating_add(page_size), end);
-
    }
-

-
    fn begin(&mut self) {
-
        self.state.cursor.0 = 0;
-
    }
-

-
    fn end(&mut self, len: usize, page_size: usize) {
-
        self.state.cursor.0 = len.saturating_sub(page_size);
-
    }
-

-
    fn update_area(&mut self, area: Rect) {
-
        self.area = (area.height, area.width);
-
    }
-

-
    fn render_content(&self, frame: &mut Frame, props: &TextViewProps, render: &RenderProps) {
-
        let content_style = if !render.focus && props.dim {
-
            props.content_style.dim()
-
        } else {
-
            props.content_style
-
        };
-

-
        let content = Paragraph::new(self.state.content.clone())
-
            .style(content_style)
-
            .scroll((self.state.cursor.0 as u16, self.state.cursor.1 as u16));
-

-
        frame.render_widget(content, render.area);
-
    }
-

-
    fn render_footer(
-
        &self,
-
        frame: &mut Frame,
-
        props: &TextViewProps,
-
        render: &RenderProps,
-
        content_height: u16,
-
    ) {
-
        let [text_area, scroll_area] =
-
            Layout::horizontal([Constraint::Min(1), Constraint::Length(10)]).areas(render.area);
-

-
        let scroll_style = if render.focus {
-
            props.focus_scroll_style
-
        } else {
-
            props.scroll_style
-
        };
-

-
        let mut scroll = vec![];
-
        if props.show_scroll_progress {
-
            let content_len = self.state.content.lines().count();
-
            let scroll_progress = utils::scroll::percent_absolute(
-
                self.state.cursor.0,
-
                content_len,
-
                content_height.into(),
-
            );
-
            if (content_height as usize) < content_len {
-
                // vec![Span::styled(format!("All / {}", content_len), scroll_style)]
-
                scroll = vec![Span::styled(format!("{}%", scroll_progress), scroll_style)];
-
            }
-
        }
-

-
        frame.render_widget(
-
            props
-
                .footer
-
                .as_ref()
-
                .cloned()
-
                .unwrap_or_default()
-
                .alignment(Alignment::Left)
-
                .dim(),
-
            text_area,
-
        );
-
        frame.render_widget(Line::from(scroll).alignment(Alignment::Right), scroll_area);
-
    }
-
}
-

-
impl<S, M> View for TextView<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 = TextViewProps::default();
-
        let props = props
-
            .and_then(|props| props.inner_ref::<TextViewProps>())
-
            .unwrap_or(&default);
-

-
        let lines = self.state.content.lines().clone();
-
        let len = lines.clone().count();
-
        let max_line_len = lines.map(|l| l.chars().count()).max().unwrap_or_default();
-
        let page_size = self.area.0 as usize;
-

-
        if props.handle_keys {
-
            match key {
-
                Key::Up | Key::Char('k') => {
-
                    self.scroll_up();
-
                }
-
                Key::Down | Key::Char('j') => {
-
                    self.scroll_down(len, page_size);
-
                }
-
                Key::Left | Key::Char('h') => {
-
                    self.scroll_left();
-
                }
-
                Key::Right | Key::Char('l') => {
-
                    self.scroll_right(max_line_len.saturating_sub(self.area.1.into()));
-
                }
-
                Key::PageUp => {
-
                    self.prev_page(page_size);
-
                }
-
                Key::PageDown => {
-
                    self.next_page(len, page_size);
-
                }
-
                Key::Home => {
-
                    self.begin();
-
                }
-
                Key::End => {
-
                    self.end(len, page_size);
-
                }
-
                _ => {}
-
            }
-
        }
-

-
        self.state.scroll = utils::scroll::percent_absolute(
-
            self.state.cursor.0,
-
            self.state.content.lines().count(),
-
            self.area.0.into(),
-
        );
-

-
        None
-
    }
-

-
    fn update(&mut self, props: Option<&ViewProps>, _state: &Self::State) {
-
        let default = TextViewProps::default();
-
        let props = props
-
            .and_then(|props| props.inner_ref::<TextViewProps>())
-
            .unwrap_or(&default);
-

-
        if let Some(state) = &props.state {
-
            self.state = state.clone();
-
        }
-
    }
-

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

-
        let [area] = Layout::default()
-
            .constraints([Constraint::Min(1)])
-
            .horizontal_margin(1)
-
            .areas(render.area);
-

-
        if render_footer {
-
            let [content_area, footer_area] = Layout::vertical([
-
                Constraint::Min(1),
-
                Constraint::Length(if render_footer { 1 } else { 0 }),
-
            ])
-
            .areas(area);
-

-
            self.render_content(frame, props, &render.clone().area(content_area));
-
            self.render_footer(frame, props, &render.area(footer_area), content_area.height);
-
            self.update_area(content_area);
-
        } else {
-
            self.render_content(frame, props, &render.clone().area(area));
-
            self.update_area(area);
-
        }
-
    }
-

-
    fn view_state(&self) -> Option<ViewState> {
-
        Some(ViewState::TextView(self.state.clone()))
-
    }
-
}
deleted src/ui/widget/list.rs
@@ -1,553 +0,0 @@
-
use std::collections::HashSet;
-
use std::hash::Hash;
-
use std::marker::PhantomData;
-
use std::{cmp, vec};
-

-
use termion::event::Key;
-

-
use ratatui::layout::{Constraint, Layout};
-
use ratatui::style::{Style, Stylize};
-
use ratatui::symbols::border;
-
use ratatui::text::Text;
-
use ratatui::widgets::TableState;
-
use ratatui::widgets::{
-
    Block, Borders, Cell, Row, Scrollbar, ScrollbarOrientation, ScrollbarState,
-
};
-
use ratatui::Frame;
-

-
use tui_tree_widget::{TreeItem, TreeState};
-

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

-
use super::{container::Column, RenderProps, View};
-
use super::{utils, ViewProps, ViewState};
-

-
/// Needs to be implemented for items that are supposed to be rendered in tables.
-
pub trait ToRow<const W: usize> {
-
    fn to_row(&self) -> [Cell; W];
-
}
-

-
/// Needs to be implemented for items that are supposed to be rendered in trees.
-
pub trait ToTree<Id>
-
where
-
    Id: ToString,
-
{
-
    fn rows(&self) -> Vec<TreeItem<'_, Id>>;
-
}
-

-
#[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 show_scrollbar: bool,
-
    pub dim: bool,
-
}
-

-
impl<'a, R, const W: usize> Default for TableProps<'a, R, W>
-
where
-
    R: ToRow<W>,
-
{
-
    fn default() -> Self {
-
        Self {
-
            items: vec![],
-
            columns: vec![],
-
            show_scrollbar: true,
-
            selected: Some(0),
-
            dim: false,
-
        }
-
    }
-
}
-

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

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

-
pub struct Table<S, M, R, const W: usize>
-
where
-
    R: ToRow<W>,
-
{
-
    /// Internal selection and offset state
-
    state: (TableState, usize),
-
    /// Phantom
-
    phantom: PhantomData<(S, M, R)>,
-
    /// Current render height
-
    height: u16,
-
}
-

-
impl<S, M, R, const W: usize> Default for Table<S, M, R, W>
-
where
-
    R: ToRow<W>,
-
{
-
    fn default() -> Self {
-
        Self {
-
            state: (TableState::default().with_selected(Some(0)), 0),
-
            phantom: PhantomData,
-
            height: 1,
-
        }
-
    }
-
}
-

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

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

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

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

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

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

-
impl<S, M, R, const W: usize> View for Table<S, M, R, W>
-
where
-
    S: 'static,
-
    M: 'static,
-
    R: ToRow<W> + Clone + 'static,
-
{
-
    type Message = M;
-
    type State = S;
-

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

-
        let page_size = self.height;
-

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

-
        None
-
    }
-

-
    fn update(&mut self, props: Option<&ViewProps>, _state: &Self::State) {
-
        let default = TableProps::default();
-
        let props = props
-
            .and_then(|props| props.inner_ref::<TableProps<R, W>>())
-
            .unwrap_or(&default);
-

-
        if props.selected != self.state.0.selected() {
-
            self.state.0.select(props.selected);
-
        }
-
        self.state.1 = props.items.len();
-
    }
-

-
    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>>())
-
            .unwrap_or(&default);
-

-
        let show_scrollbar = props.show_scrollbar && props.items.len() >= self.height.into();
-
        let has_items = !props.items.is_empty();
-

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

-
        if has_items {
-
            let [table_area, scroller_area] = Layout::horizontal([
-
                Constraint::Min(1),
-
                if show_scrollbar {
-
                    Constraint::Length(1)
-
                } else {
-
                    Constraint::Length(0)
-
                },
-
            ])
-
            .areas(render.area);
-

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

-
                    for cell in item.to_row() {
-
                        if let Some(col) = it.next() {
-
                            if !col.skip && col.displayed(render.area.width as usize) {
-
                                cells.push(cell.clone())
-
                            }
-
                        } else {
-
                            continue;
-
                        }
-
                    }
-

-
                    Row::new(cells)
-
                })
-
                .collect::<Vec<_>>();
-

-
            let table = ratatui::widgets::Table::default()
-
                .rows(rows)
-
                .widths(widths)
-
                .column_spacing(1)
-
                .highlight_style(style::highlight(render.focus));
-

-
            let table = if !render.focus && props.dim {
-
                table.dim()
-
            } else {
-
                table
-
            };
-

-
            frame.render_stateful_widget(table, table_area, &mut self.state.0);
-

-
            let scroller = Scrollbar::default()
-
                .begin_symbol(None)
-
                .track_symbol(None)
-
                .end_symbol(None)
-
                .thumb_symbol("┃")
-
                .style(if render.focus {
-
                    Style::default()
-
                } else {
-
                    Style::default().dim()
-
                });
-
            let mut scroller_state = ScrollbarState::default()
-
                .content_length(props.items.len().saturating_sub(self.height.into()))
-
                .position(self.state.0.offset());
-
            frame.render_stateful_widget(scroller, scroller_area, &mut scroller_state);
-
        } else {
-
            let center = layout::centered_rect(render.area, 50, 10);
-
            let hint = Text::from(span::default("Nothing to show"))
-
                .centered()
-
                .light_magenta()
-
                .dim();
-

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

-
        self.height = render.area.height;
-
    }
-

-
    fn view_state(&self) -> Option<ViewState> {
-
        let selected = self.state.0.selected().unwrap_or_default();
-

-
        Some(ViewState::Table {
-
            selected,
-
            scroll: utils::scroll::percent_absolute(
-
                selected.saturating_sub(self.height.into()),
-
                self.state.1,
-
                self.height.into(),
-
            ),
-
        })
-
    }
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct TreeProps<R, Id>
-
where
-
    R: ToTree<Id> + Clone,
-
    Id: ToString,
-
{
-
    /// Root items.
-
    pub items: Vec<R>,
-
    /// Optional path to selected item, e.g. ["1.0", "1.0.1", "1.0.2"]. If not `None`,
-
    /// it will override the internal tree state.
-
    pub selected: Option<Vec<Id>>,
-
    /// If this widget should render its scrollbar. Default: `true`.
-
    pub show_scrollbar: bool,
-
    /// Optional identifier set of opened items. If not `None`,
-
    /// it will override the internal tree state.
-
    pub opened: Option<HashSet<Vec<Id>>>,
-
    /// Set to `true` if the content style should be dimmed whenever the widget
-
    /// has no focus.
-
    pub dim: bool,
-
}
-

-
impl<R, Id> Default for TreeProps<R, Id>
-
where
-
    R: ToTree<Id> + Clone,
-
    Id: ToString,
-
{
-
    fn default() -> Self {
-
        Self {
-
            items: vec![],
-
            selected: None,
-
            show_scrollbar: true,
-
            opened: None,
-
            dim: false,
-
        }
-
    }
-
}
-

-
impl<R, Id> TreeProps<R, Id>
-
where
-
    R: ToTree<Id> + Clone,
-
    Id: ToString + Clone,
-
{
-
    pub fn items(mut self, items: Vec<R>) -> Self {
-
        self.items = items;
-
        self
-
    }
-

-
    pub fn selected(mut self, selected: Option<&[Id]>) -> Self {
-
        self.selected = selected.map(|s| s.to_vec());
-
        self
-
    }
-

-
    pub fn opened(mut self, opened: Option<HashSet<Vec<Id>>>) -> Self {
-
        self.opened = opened;
-
        self
-
    }
-

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

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

-
/// A `Tree` is an expandable, collapsable and scrollable tree widget, that takes
-
/// a list of root items which implement `ToTree`. It can be updated with a selection
-
/// and a set of opened items.
-
pub struct Tree<S, M, R, Id>
-
where
-
    R: ToTree<Id>,
-
    Id: ToString + Clone,
-
{
-
    /// Internal selection and offset state
-
    state: TreeState<Id>,
-
    /// Phantom
-
    phantom: PhantomData<(S, M, R, Id)>,
-
}
-

-
impl<S, M, R, Id> Default for Tree<S, M, R, Id>
-
where
-
    R: ToTree<Id>,
-
    Id: ToString + Clone + Default,
-
{
-
    fn default() -> Self {
-
        Self {
-
            state: TreeState::default(),
-
            phantom: PhantomData,
-
        }
-
    }
-
}
-

-
impl<S, M, R, Id> View for Tree<S, M, R, Id>
-
where
-
    R: ToTree<Id> + Clone + 'static,
-
    Id: ToString + Clone + Default + Eq + PartialEq + Hash + 'static,
-
{
-
    type State = S;
-
    type Message = M;
-

-
    fn reset(&mut self) {
-
        self.state = TreeState::default();
-
    }
-

-
    fn update(&mut self, props: Option<&ViewProps>, _state: &Self::State) {
-
        let default = TreeProps::default();
-
        let props = props
-
            .and_then(|props| props.inner_ref::<TreeProps<R, Id>>())
-
            .unwrap_or(&default);
-

-
        if let Some(selected) = &props.selected {
-
            if selected != self.state.selected() {
-
                self.state.select(selected.clone());
-
            }
-
        }
-

-
        if let Some(opened) = &props.opened {
-
            if opened != self.state.opened() {
-
                self.state.close_all();
-
                for path in opened {
-
                    self.state.open(path.to_vec());
-
                }
-
            }
-
        }
-
    }
-

-
    fn handle_event(&mut self, _props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
-
        match key {
-
            Key::Up | Key::Char('k') => {
-
                self.state.key_up();
-
            }
-
            Key::Down | Key::Char('j') => {
-
                self.state.key_down();
-
            }
-
            Key::Left | Key::Char('h')
-
                if !self.state.selected().is_empty() && !self.state.opened().is_empty() =>
-
            {
-
                self.state.key_left();
-
            }
-
            Key::Right | Key::Char('l') => {
-
                self.state.key_right();
-
            }
-
            _ => {}
-
        }
-

-
        None
-
    }
-

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

-
        let mut items = vec![];
-
        for item in &props.items {
-
            items.extend(item.rows());
-
        }
-

-
        let tree_style = if !render.focus && props.dim {
-
            Style::default().dim()
-
        } else {
-
            Style::default()
-
        };
-

-
        let tree = if props.show_scrollbar {
-
            tui_tree_widget::Tree::new(&items)
-
                .expect("all item identifiers are unique")
-
                .block(
-
                    Block::default()
-
                        .borders(Borders::RIGHT)
-
                        .border_set(border::Set {
-
                            vertical_right: " ",
-
                            ..Default::default()
-
                        })
-
                        .border_style(if render.focus {
-
                            Style::default()
-
                        } else {
-
                            Style::default().dim()
-
                        }),
-
                )
-
                .experimental_scrollbar(Some(
-
                    Scrollbar::new(ScrollbarOrientation::VerticalRight)
-
                        .begin_symbol(None)
-
                        .track_symbol(None)
-
                        .end_symbol(None)
-
                        .thumb_symbol("┃"),
-
                ))
-
                .highlight_style(style::highlight(render.focus))
-
                .style(tree_style)
-
        } else {
-
            tui_tree_widget::Tree::new(&items)
-
                .expect("all item identifiers are unique")
-
                .style(tree_style)
-
                .highlight_style(style::highlight(render.focus))
-
        };
-

-
        frame.render_stateful_widget(tree, render.area, &mut self.state);
-
    }
-

-
    fn view_state(&self) -> Option<ViewState> {
-
        Some(ViewState::Tree(
-
            self.state
-
                .selected()
-
                .to_vec()
-
                .iter()
-
                .map(|s| s.to_string())
-
                .collect(),
-
        ))
-
    }
-
}
deleted src/ui/widget/utils.rs
@@ -1,29 +0,0 @@
-
pub mod scroll {
-
    pub fn percent_seen(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 {
-
            map_range((0.0, progress), (0.0, 100.0), progress) as usize
-
        } else {
-
            progress as usize
-
        }
-
    }
-

-
    pub fn percent_absolute(offset: usize, len: usize, height: usize) -> usize {
-
        let y = offset as f64;
-
        let h = height as f64;
-
        let t = len.saturating_sub(1) as f64;
-
        let v = y / (t - h) * 100_f64;
-

-
        (v as usize).clamp(0, 100)
-
    }
-

-
    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/window.rs
@@ -1,309 +0,0 @@
-
use std::hash::Hash;
-
use std::{collections::HashMap, marker::PhantomData};
-

-
use ratatui::Frame;
-
use termion::event::Key;
-

-
use ratatui::layout::{Constraint, Layout};
-
use ratatui::style::{Style, Stylize};
-
use ratatui::text::Text;
-
use ratatui::widgets::Row;
-

-
use crate::ui::theme::{style, Theme};
-

-
use super::{RenderProps, View, ViewProps, 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 }
-
    }
-
}
-

-
pub struct Window<S, M, Id> {
-
    /// All pages known
-
    pages: HashMap<Id, Widget<S, M>>,
-
}
-

-
impl<S, M, Id> Default for Window<S, M, Id> {
-
    fn default() -> Self {
-
        Self {
-
            pages: HashMap::new(),
-
        }
-
    }
-
}
-

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

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

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

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

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

-
        None
-
    }
-

-
    fn update(&mut self, props: Option<&ViewProps>, state: &Self::State) {
-
        let default = WindowProps::default();
-
        let props = props
-
            .and_then(|props| props.inner_ref::<WindowProps<Id>>())
-
            .unwrap_or(&default);
-

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

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

-
    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>>())
-
            .unwrap_or(&default);
-

-
        let area = frame.size();
-

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

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

-
#[derive(Clone, Default)]
-
pub struct PageProps {
-
    /// If this view's should handle keys
-
    pub handle_keys: bool,
-
}
-

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

-
pub struct Page<S, M> {
-
    /// Content widget
-
    content: Option<Widget<S, M>>,
-
    /// Shortcut widget
-
    shortcuts: Option<Widget<S, M>>,
-
}
-

-
impl<S, M> Default for Page<S, M> {
-
    fn default() -> Self {
-
        Self {
-
            content: None,
-
            shortcuts: None,
-
        }
-
    }
-
}
-

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

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

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

-
    fn handle_event(&mut self, _props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
-
        if let Some(content) = self.content.as_mut() {
-
            content.handle_event(key);
-
        }
-

-
        None
-
    }
-

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

-
    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_mut() {
-
            content.render(
-
                RenderProps::from(content_area)
-
                    .layout(Layout::horizontal([Constraint::Min(1)]))
-
                    .focus(true),
-
                frame,
-
            );
-
        }
-

-
        if let Some(shortcuts) = self.shortcuts.as_mut() {
-
            shortcuts.render(RenderProps::from(shortcuts_area), frame);
-
        }
-
    }
-
}
-

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

-
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
-
    }
-

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

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

-
impl Default for ShortcutsProps {
-
    fn default() -> Self {
-
        let theme = Theme::default();
-

-
        Self {
-
            shortcuts: vec![],
-
            divider: '∙',
-
            shortcuts_keys_style: theme.shortcuts_keys_style,
-
            shortcuts_action_style: theme.shortcuts_action_style,
-
        }
-
    }
-
}
-

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

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

-
impl<S, M> View for Shortcuts<S, M> {
-
    type Message = M;
-
    type State = S;
-

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

-
        let default = ShortcutsProps::default();
-
        let props = props
-
            .and_then(|props| props.inner_ref::<ShortcutsProps>())
-
            .unwrap_or(&default);
-

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

-
        while let Some(shortcut) = shortcuts.next() {
-
            let short = Text::from(shortcut.0.clone()).style(props.shortcuts_keys_style);
-
            let long = Text::from(shortcut.1.clone()).style(props.shortcuts_action_style);
-
            let spacer = Text::from(String::new());
-
            let divider = Text::from(format!(" {} ", 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, render.area);
-
    }
-
}