tui: Introduce table item and table model
This commit is contained in:
parent
c2dd778183
commit
5d7bf9f663
|
|
@ -41,6 +41,11 @@ pub struct Icons {
|
||||||
pub whitespace: char,
|
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
|
/// The Radicle TUI theme. Will be defined in a JSON config file in the
|
||||||
/// future. e.g.:
|
/// future. e.g.:
|
||||||
/// {
|
/// {
|
||||||
|
|
@ -60,6 +65,7 @@ pub struct Theme {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub colors: Colors,
|
pub colors: Colors,
|
||||||
pub icons: Icons,
|
pub icons: Icons,
|
||||||
|
pub tables: Tables,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn default_dark() -> Theme {
|
pub fn default_dark() -> Theme {
|
||||||
|
|
@ -95,5 +101,6 @@ pub fn default_dark() -> Theme {
|
||||||
tab_overline: '▔',
|
tab_overline: '▔',
|
||||||
whitespace: ' ',
|
whitespace: ' ',
|
||||||
},
|
},
|
||||||
|
tables: Tables { spacing: 2 },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,8 @@
|
||||||
use radicle::Profile;
|
|
||||||
use tuirealm::command::{Cmd, CmdResult};
|
use tuirealm::command::{Cmd, CmdResult};
|
||||||
use tuirealm::props::{
|
use tuirealm::props::{AttrValue, Attribute, BorderSides, BorderType, Color, Props, Style};
|
||||||
AttrValue, Attribute, BorderSides, BorderType, Color, PropPayload, PropValue, Props, Style,
|
|
||||||
TextSpan,
|
|
||||||
};
|
|
||||||
use tuirealm::tui::layout::{Constraint, Direction, Layout, Rect};
|
use tuirealm::tui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
use tuirealm::tui::widgets::{Block, Cell, Row, TableState};
|
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::layout;
|
||||||
use crate::ui::theme::Theme;
|
use crate::ui::theme::Theme;
|
||||||
|
|
@ -15,8 +11,86 @@ use crate::ui::widget::{Widget, WidgetComponent};
|
||||||
use super::container::Header;
|
use super::container::Header;
|
||||||
use super::label::Label;
|
use super::label::Label;
|
||||||
|
|
||||||
pub trait List {
|
/// A generic item that can be displayed in a table with [`W`] columns.
|
||||||
fn row(&self, theme: &Theme, profile: &Profile) -> Vec<TextSpan>;
|
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.
|
/// A component that displays a labeled property.
|
||||||
|
|
@ -107,16 +181,39 @@ impl WidgetComponent for PropertyList {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Table {
|
/// A table component that can display a list of [`TableItem`]s hold by a [`TableModel`].
|
||||||
header: Widget<Header>,
|
pub struct Table<V, const W: usize>
|
||||||
|
where
|
||||||
|
V: TableItem<W> + Clone,
|
||||||
|
{
|
||||||
|
model: TableModel<V, W>,
|
||||||
state: TableState,
|
state: TableState,
|
||||||
|
theme: Theme,
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Table {
|
|
||||||
pub fn new(header: Widget<Header>) -> Self {
|
|
||||||
let mut state = TableState::default();
|
let mut state = TableState::default();
|
||||||
state.select(Some(0));
|
state.select(Some(0));
|
||||||
Self { header, state }
|
|
||||||
|
Self {
|
||||||
|
model,
|
||||||
|
state,
|
||||||
|
theme,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn select_previous(&mut self) {
|
fn select_previous(&mut self) {
|
||||||
|
|
@ -137,52 +234,76 @@ impl Table {
|
||||||
self.state.select(Some(index));
|
self.state.select(Some(index));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rows<'a>(spans: Vec<Vec<TextSpan>>) -> Vec<Row<'a>> {
|
pub fn selection(&self) -> Option<&V> {
|
||||||
spans
|
self.state
|
||||||
.iter()
|
.selected()
|
||||||
.map(|spans| {
|
.and_then(|selected| self.model.items.get(selected))
|
||||||
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)
|
|
||||||
})
|
|
||||||
.collect::<Vec<Row>>()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn widths(widths: Vec<PropValue>) -> Vec<Constraint> {
|
/// 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()
|
||||||
|
.fold(0u16, |total, &width| match width {
|
||||||
|
ColumnWidth::Fixed(w) => total + w,
|
||||||
|
ColumnWidth::Grow => total,
|
||||||
|
})
|
||||||
|
.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);
|
||||||
|
|
||||||
widths
|
widths
|
||||||
.iter()
|
.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()
|
.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) {
|
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
|
let highlight = properties
|
||||||
.get_or(Attribute::HighlightedColor, AttrValue::Color(Color::Reset))
|
.get_or(Attribute::HighlightedColor, AttrValue::Color(Color::Reset))
|
||||||
.unwrap_color();
|
.unwrap_color();
|
||||||
let widths = properties
|
|
||||||
.get_or(
|
|
||||||
Attribute::Custom("widths"),
|
|
||||||
AttrValue::Payload(PropPayload::Vec(vec![])),
|
|
||||||
)
|
|
||||||
.unwrap_payload()
|
|
||||||
.unwrap_vec();
|
|
||||||
|
|
||||||
let layout = Layout::default()
|
let layout = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints(vec![Constraint::Length(3), Constraint::Min(1)])
|
.constraints(vec![Constraint::Length(3), Constraint::Min(1)])
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
let rows = Self::rows(content);
|
let widths = Self::widths(area, self.model.widths(), self.theme.tables.spacing);
|
||||||
let widths = Self::widths(widths);
|
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(
|
||||||
Block::default()
|
Block::default()
|
||||||
.borders(BorderSides::BOTTOM | BorderSides::LEFT | BorderSides::RIGHT)
|
.borders(BorderSides::BOTTOM | BorderSides::LEFT | BorderSides::RIGHT)
|
||||||
|
|
@ -190,48 +311,31 @@ impl WidgetComponent for Table {
|
||||||
.border_type(BorderType::Rounded),
|
.border_type(BorderType::Rounded),
|
||||||
)
|
)
|
||||||
.highlight_style(Style::default().bg(highlight))
|
.highlight_style(Style::default().bg(highlight))
|
||||||
.column_spacing(3u16)
|
.column_spacing(self.theme.tables.spacing)
|
||||||
.widths(&widths);
|
.widths(&widths);
|
||||||
|
|
||||||
self.header.view(frame, layout[0]);
|
let mut header = Widget::new(Header::new(self.model.clone(), self.theme.clone()));
|
||||||
frame.render_stateful_widget(rows, layout[1], &mut self.state);
|
header.view(frame, layout[0]);
|
||||||
|
frame.render_stateful_widget(table, layout[1], &mut self.state);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn state(&self) -> State {
|
fn state(&self) -> State {
|
||||||
State::None
|
State::None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn perform(&mut self, properties: &Props, cmd: Cmd) -> CmdResult {
|
fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
|
||||||
use tuirealm::command::Direction;
|
use tuirealm::command::Direction;
|
||||||
|
|
||||||
let content = properties
|
let len = self.model.count() as usize;
|
||||||
.get_or(Attribute::Content, AttrValue::Table(vec![]))
|
|
||||||
.unwrap_table();
|
|
||||||
|
|
||||||
match cmd {
|
match cmd {
|
||||||
Cmd::Move(Direction::Up) => {
|
Cmd::Move(Direction::Up) => {
|
||||||
self.select_previous();
|
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) => {
|
Cmd::Move(Direction::Down) => {
|
||||||
self.select_next(content.len());
|
self.select_next(len);
|
||||||
if let Some(selected) = self.state.selected() {
|
|
||||||
CmdResult::Changed(State::One(StateValue::Usize(selected)))
|
|
||||||
} else {
|
|
||||||
CmdResult::None
|
CmdResult::None
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Cmd::Submit => {
|
|
||||||
if let Some(selected) = self.state.selected() {
|
|
||||||
CmdResult::Submit(State::One(StateValue::Usize(selected)))
|
|
||||||
} else {
|
|
||||||
CmdResult::None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => CmdResult::None,
|
_ => CmdResult::None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue