Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
tui: Introduce table item and table model
Erik Kundt committed 2 years ago
commit 20f4bbed3b26abd1cae56d744e40a5c9cbea4656
parent 062a4a4d4855218edaadbb1aa51e8bffc203af5c
2 files changed +176 -65
modified radicle-tui/src/ui/theme.rs
@@ -41,6 +41,11 @@ pub struct Icons {
    pub whitespace: char,
}

+
#[derive(Debug, Clone)]
+
pub struct Tables {
+
    pub spacing: u16,
+
}
+

/// The Radicle TUI theme. Will be defined in a JSON config file in the
/// future. e.g.:
/// {
@@ -60,6 +65,7 @@ pub struct Theme {
    pub name: String,
    pub colors: Colors,
    pub icons: Icons,
+
    pub tables: Tables,
}

pub fn default_dark() -> Theme {
@@ -95,5 +101,6 @@ pub fn default_dark() -> Theme {
            tab_overline: '▔',
            whitespace: ' ',
        },
+
        tables: Tables { spacing: 2 },
    }
}
modified radicle-tui/src/ui/widget/common/list.rs
@@ -1,12 +1,8 @@
-
use radicle::Profile;
use tuirealm::command::{Cmd, CmdResult};
-
use tuirealm::props::{
-
    AttrValue, Attribute, BorderSides, BorderType, Color, PropPayload, PropValue, Props, Style,
-
    TextSpan,
-
};
+
use tuirealm::props::{AttrValue, Attribute, BorderSides, BorderType, Color, Props, Style};
use tuirealm::tui::layout::{Constraint, Direction, Layout, Rect};
use tuirealm::tui::widgets::{Block, Cell, Row, TableState};
-
use tuirealm::{Frame, MockComponent, State, StateValue};
+
use tuirealm::{Frame, MockComponent, State};

use crate::ui::layout;
use crate::ui::theme::Theme;
@@ -15,8 +11,86 @@ use crate::ui::widget::{Widget, WidgetComponent};
use super::container::Header;
use super::label::Label;

-
pub trait List {
-
    fn row(&self, theme: &Theme, profile: &Profile) -> Vec<TextSpan>;
+
/// A generic item that can be displayed in a table with [`W`] columns.
+
pub trait TableItem<const W: usize> {
+
    /// Should return fields as table cells.
+
    fn row(&self, theme: &Theme) -> [Cell; W];
+
}
+

+
/// Grow behavior of a table column.
+
///
+
/// [`tui::widgets::Table`] does only support percental column widths.
+
/// A [`ColumnWidth`] is used to specify the grow behaviour of a table column
+
/// and a percental column width is calculated based on that.
+
#[derive(Clone, Copy, Eq, PartialEq)]
+
pub enum ColumnWidth {
+
    /// A fixed-size column.
+
    Fixed(u16),
+
    /// A growable column.
+
    Grow,
+
}
+

+
/// A generic table model with [`W`] columns.
+
///
+
/// [`V`] needs to implement `TableItem` in order to be displayed by the
+
/// table this model is used in.
+
#[derive(Clone)]
+
pub struct TableModel<V, const W: usize>
+
where
+
    V: TableItem<W>,
+
{
+
    /// The table header.
+
    header: [Widget<Label>; W],
+
    /// Grow behavior of table columns.
+
    widths: [ColumnWidth; W],
+
    /// Items hold by this model.
+
    items: Vec<V>,
+
}
+

+
impl<V, const W: usize> TableModel<V, W>
+
where
+
    V: TableItem<W>,
+
{
+
    pub fn new(header: [Widget<Label>; W], widths: [ColumnWidth; W]) -> Self {
+
        Self {
+
            header,
+
            widths,
+
            items: vec![],
+
        }
+
    }
+

+
    /// Pushes a new row to this model.
+
    pub fn push_item(&mut self, item: V) {
+
        self.items.push(item);
+
    }
+

+
    /// Get all column widhts defined by this model.
+
    pub fn widths(&self) -> &[ColumnWidth; W] {
+
        &self.widths
+
    }
+

+
    /// Get the item count.
+
    pub fn count(&self) -> u16 {
+
        self.items.len() as u16
+
    }
+

+
    /// Get this model's table header.
+
    pub fn header(&self, theme: &Theme) -> [Cell; W] {
+
        self.header
+
            .iter()
+
            .map(|label| {
+
                let cell: Cell = label.into();
+
                cell.style(Style::default().fg(theme.colors.default_fg))
+
            })
+
            .collect::<Vec<_>>()
+
            .try_into()
+
            .unwrap()
+
    }
+

+
    /// Get this model's table rows.
+
    pub fn rows(&self, theme: &Theme) -> Vec<[Cell; W]> {
+
        self.items.iter().map(|item| item.row(theme)).collect()
+
    }
}

/// A component that displays a labeled property.
@@ -107,16 +181,39 @@ impl WidgetComponent for PropertyList {
    }
}

-
pub struct Table {
-
    header: Widget<Header>,
+
/// A table component that can display a list of [`TableItem`]s hold by a [`TableModel`].
+
pub struct Table<V, const W: usize>
+
where
+
    V: TableItem<W> + Clone,
+
{
+
    model: TableModel<V, W>,
    state: TableState,
+
    theme: Theme,
}

-
impl Table {
-
    pub fn new(header: Widget<Header>) -> Self {
+
impl<V, const W: usize> Table<V, W>
+
where
+
    V: TableItem<W> + Clone,
+
{
+
    pub fn new(
+
        items: &[V],
+
        header: [Widget<Label>; W],
+
        widths: [ColumnWidth; W],
+
        theme: Theme,
+
    ) -> Self {
+
        let mut model = TableModel::new(header, widths);
+
        for item in items {
+
            model.push_item(item.clone());
+
        }
+

        let mut state = TableState::default();
        state.select(Some(0));
-
        Self { header, state }
+

+
        Self {
+
            model,
+
            state,
+
            theme,
+
        }
    }

    fn select_previous(&mut self) {
@@ -137,52 +234,76 @@ impl Table {
        self.state.select(Some(index));
    }

-
    fn rows<'a>(spans: Vec<Vec<TextSpan>>) -> Vec<Row<'a>> {
-
        spans
+
    pub fn selection(&self) -> Option<&V> {
+
        self.state
+
            .selected()
+
            .and_then(|selected| self.model.items.get(selected))
+
    }
+

+
    /// Calculates `Constraint::Percentage` for each fixed column width in `widths`,
+
    /// taking into account the available width in `area` and the column spacing given by `spacing`.
+
    pub fn widths(area: Rect, widths: &[ColumnWidth], spacing: u16) -> Vec<Constraint> {
+
        let total_spacing = spacing.saturating_mul(widths.len() as u16);
+
        let fixed_width = widths
            .iter()
-
            .map(|spans| {
-
                let cells = spans.iter().map(|span| {
-
                    let style = Style::default().fg(span.fg);
-
                    Cell::from(span.content.clone()).style(style)
-
                });
-
                Row::new(cells).height(1)
+
            .fold(0u16, |total, &width| match width {
+
                ColumnWidth::Fixed(w) => total + w,
+
                ColumnWidth::Grow => total,
            })
-
            .collect::<Vec<Row>>()
-
    }
+
            .saturating_add(total_spacing);
+

+
        let grow_count = widths.iter().fold(0u16, |count, &w| {
+
            if w == ColumnWidth::Grow {
+
                count + 1
+
            } else {
+
                count
+
            }
+
        });
+
        let grow_width = area
+
            .width
+
            .saturating_sub(fixed_width)
+
            .checked_div(grow_count)
+
            .unwrap_or(0);

-
    fn widths(widths: Vec<PropValue>) -> Vec<Constraint> {
        widths
            .iter()
-
            .map(|prop| Constraint::Percentage(prop.clone().unwrap_u16()))
+
            .map(|width| match width {
+
                ColumnWidth::Fixed(w) => {
+
                    let p: f64 = *w as f64 / area.width as f64 * 100_f64;
+
                    Constraint::Percentage(p.ceil() as u16)
+
                }
+
                ColumnWidth::Grow => {
+
                    let p: f64 = grow_width as f64 / area.width as f64 * 100_f64;
+
                    Constraint::Percentage(p.floor() as u16)
+
                }
+
            })
            .collect()
    }
}

-
impl WidgetComponent for Table {
+
impl<V, const W: usize> WidgetComponent for Table<V, W>
+
where
+
    V: TableItem<W> + Clone,
+
{
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
-
        let content = properties
-
            .get_or(Attribute::Content, AttrValue::Table(vec![]))
-
            .unwrap_table();
        let highlight = properties
            .get_or(Attribute::HighlightedColor, AttrValue::Color(Color::Reset))
            .unwrap_color();
-
        let widths = properties
-
            .get_or(
-
                Attribute::Custom("widths"),
-
                AttrValue::Payload(PropPayload::Vec(vec![])),
-
            )
-
            .unwrap_payload()
-
            .unwrap_vec();

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

-
        let rows = Self::rows(content);
-
        let widths = Self::widths(widths);
+
        let widths = Self::widths(area, self.model.widths(), self.theme.tables.spacing);
+
        let rows: Vec<Row<'_>> = self
+
            .model
+
            .rows(&self.theme)
+
            .iter()
+
            .map(|cells| Row::new(cells.clone()))
+
            .collect();

-
        let rows = tuirealm::tui::widgets::Table::new(rows)
+
        let table = tuirealm::tui::widgets::Table::new(rows)
            .block(
                Block::default()
                    .borders(BorderSides::BOTTOM | BorderSides::LEFT | BorderSides::RIGHT)
@@ -190,47 +311,30 @@ impl WidgetComponent for Table {
                    .border_type(BorderType::Rounded),
            )
            .highlight_style(Style::default().bg(highlight))
-
            .column_spacing(3u16)
+
            .column_spacing(self.theme.tables.spacing)
            .widths(&widths);

-
        self.header.view(frame, layout[0]);
-
        frame.render_stateful_widget(rows, layout[1], &mut self.state);
+
        let mut header = Widget::new(Header::new(self.model.clone(), self.theme.clone()));
+
        header.view(frame, layout[0]);
+
        frame.render_stateful_widget(table, layout[1], &mut self.state);
    }

    fn state(&self) -> State {
        State::None
    }

-
    fn perform(&mut self, properties: &Props, cmd: Cmd) -> CmdResult {
+
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
        use tuirealm::command::Direction;

-
        let content = properties
-
            .get_or(Attribute::Content, AttrValue::Table(vec![]))
-
            .unwrap_table();
-

+
        let len = self.model.count() as usize;
        match cmd {
            Cmd::Move(Direction::Up) => {
                self.select_previous();
-
                if let Some(selected) = self.state.selected() {
-
                    CmdResult::Changed(State::One(StateValue::Usize(selected)))
-
                } else {
-
                    CmdResult::None
-
                }
+
                CmdResult::None
            }
            Cmd::Move(Direction::Down) => {
-
                self.select_next(content.len());
-
                if let Some(selected) = self.state.selected() {
-
                    CmdResult::Changed(State::One(StateValue::Usize(selected)))
-
                } else {
-
                    CmdResult::None
-
                }
-
            }
-
            Cmd::Submit => {
-
                if let Some(selected) = self.state.selected() {
-
                    CmdResult::Submit(State::One(StateValue::Usize(selected)))
-
                } else {
-
                    CmdResult::None
-
                }
+
                self.select_next(len);
+
                CmdResult::None
            }
            _ => CmdResult::None,
        }