tui: Fire proper list selection messages

This commit is contained in:
Erik Kundt 2023-06-04 15:52:40 +02:00
parent a3077d5b15
commit 8e7d53aa9a
8 changed files with 168 additions and 189 deletions

View File

@ -60,9 +60,16 @@ impl tuirealm::Component<Message, NoUserEvent> for Widget<PatchBrowser> {
} }
Event::Keyboard(KeyEvent { Event::Keyboard(KeyEvent {
code: Key::Enter, .. code: Key::Enter, ..
}) => self }) => {
.selected_item() let result = self.perform(Cmd::Submit);
.map(|item| Message::Patch(PatchMessage::Show(item.id().to_owned()))), match result {
CmdResult::Submit(State::One(StateValue::Usize(selected))) => {
let item = self.items().get(selected)?;
Some(Message::Patch(PatchMessage::Show(item.id().to_owned())))
}
_ => None,
}
}
_ => None, _ => None,
} }
} }
@ -83,9 +90,16 @@ impl tuirealm::Component<Message, NoUserEvent> for Widget<IssueBrowser> {
} }
Event::Keyboard(KeyEvent { Event::Keyboard(KeyEvent {
code: Key::Enter, .. code: Key::Enter, ..
}) => self }) => {
.selected_item() let result = self.perform(Cmd::Submit);
.map(|item| Message::Issue(IssueMessage::Show(item.id().to_owned()))), match result {
CmdResult::Submit(State::One(StateValue::Usize(selected))) => {
let item = self.items().get(selected)?;
Some(Message::Issue(IssueMessage::Show(item.id().to_owned())))
}
_ => None,
}
}
_ => None, _ => None,
} }
} }

View File

@ -256,12 +256,6 @@ impl TableItem<7> for IssueItem {
} }
} }
impl TableItem<1> for () {
fn row(&self, _theme: &Theme) -> [Cell; 1] {
[Cell::default()]
}
}
pub fn format_patch_state(state: &PatchState) -> (String, Color) { pub fn format_patch_state(state: &PatchState) -> (String, Color) {
match state { match state {
PatchState::Open { conflicts: _ } => ("".into(), Color::Green), PatchState::Open { conflicts: _ } => ("".into(), Color::Green),

View File

@ -2,6 +2,7 @@ pub mod common;
pub mod home; pub mod home;
pub mod issue; pub mod issue;
pub mod patch; pub mod patch;
mod utils;
use std::ops::Deref; use std::ops::Deref;

View File

@ -11,7 +11,7 @@ use context::{Shortcut, Shortcuts};
use label::Label; use label::Label;
use list::{Property, PropertyList}; use list::{Property, PropertyList};
use self::list::{ColumnWidth, TableModel}; use self::list::ColumnWidth;
use super::Widget; use super::Widget;
@ -37,10 +37,10 @@ pub fn reversable_label(content: &str) -> Widget<Label> {
label(content) label(content)
} }
pub fn container_header(theme: &Theme, label: Widget<Label>) -> Widget<Header<(), 1>> { pub fn container_header(theme: &Theme, label: Widget<Label>) -> Widget<Header<1>> {
let model = TableModel::new([label], [ColumnWidth::Fixed(100)]); let header = Header::new([label], [ColumnWidth::Fixed(100)], theme.clone());
Widget::new(Header::new(model, theme.clone())) Widget::new(header)
} }
pub fn labeled_container( pub fn labeled_container(

View File

@ -3,17 +3,17 @@ use tuirealm::props::{
AttrValue, Attribute, BorderSides, BorderType, Color, Props, Style, TextModifiers, AttrValue, Attribute, BorderSides, BorderType, Color, Props, Style, TextModifiers,
}; };
use tuirealm::tui::layout::{Constraint, Direction, Layout, Rect}; use tuirealm::tui::layout::{Constraint, Direction, Layout, Rect};
use tuirealm::tui::widgets::{Block, Row}; use tuirealm::tui::widgets::{Block, Cell, Row};
use tuirealm::{Frame, MockComponent, State, StateValue}; use tuirealm::{Frame, MockComponent, State, StateValue};
use crate::ui::ext::HeaderBlock; use crate::ui::ext::HeaderBlock;
use crate::ui::layout; use crate::ui::layout;
use crate::ui::state::TabState; use crate::ui::state::TabState;
use crate::ui::theme::Theme; use crate::ui::theme::Theme;
use crate::ui::widget::{Widget, WidgetComponent}; use crate::ui::widget::{utils, Widget, WidgetComponent};
use super::label::Label; use super::label::Label;
use super::list::{Table, TableItem, TableModel}; use super::list::ColumnWidth;
/// Some user events need to be handled globally (e.g. user presses key `q` to quit /// Some user events need to be handled globally (e.g. user presses key `q` to quit
/// the application). This component can be used in conjunction with SubEventClause /// the application). This component can be used in conjunction with SubEventClause
@ -128,27 +128,23 @@ impl WidgetComponent for Tabs {
} }
/// A labeled container header. /// A labeled container header.
pub struct Header<V, const W: usize> pub struct Header<const W: usize> {
where header: [Widget<Label>; W],
V: TableItem<W>, widths: [ColumnWidth; W],
{
model: TableModel<V, W>,
theme: Theme, theme: Theme,
} }
impl<V, const W: usize> Header<V, W> impl<const W: usize> Header<W> {
where pub fn new(header: [Widget<Label>; W], widths: [ColumnWidth; W], theme: Theme) -> Self {
V: TableItem<W>, Self {
{ header,
pub fn new(model: TableModel<V, W>, theme: Theme) -> Self { widths,
Self { model, theme } theme,
}
} }
} }
impl<V, const W: usize> WidgetComponent for Header<V, W> impl<const W: usize> WidgetComponent for Header<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 display = properties let display = properties
.get_or(Attribute::Display, AttrValue::Flag(true)) .get_or(Attribute::Display, AttrValue::Flag(true))
@ -167,9 +163,18 @@ where
.horizontal_margin(1) .horizontal_margin(1)
.split(area); .split(area);
let widths = let widths = utils::column_widths(area, &self.widths, self.theme.tables.spacing);
Table::<V, W>::widths(area, self.model.widths(), self.theme.tables.spacing); let header: [Cell; W] = self
let header: Row<'_> = Row::new(self.model.header(&self.theme)); .header
.iter()
.map(|label| {
let cell: Cell = label.into();
cell.style(Style::default().fg(self.theme.colors.default_fg))
})
.collect::<Vec<_>>()
.try_into()
.unwrap();
let header: Row<'_> = Row::new(header);
let table = tuirealm::tui::widgets::Table::new(vec![]) let table = tuirealm::tui::widgets::Table::new(vec![])
.column_spacing(self.theme.tables.spacing) .column_spacing(self.theme.tables.spacing)
@ -189,12 +194,12 @@ where
} }
pub struct LabeledContainer { pub struct LabeledContainer {
header: Widget<Header<(), 1>>, header: Widget<Header<1>>,
component: Box<dyn MockComponent>, component: Box<dyn MockComponent>,
} }
impl LabeledContainer { impl LabeledContainer {
pub fn new(header: Widget<Header<(), 1>>, component: Box<dyn MockComponent>) -> Self { pub fn new(header: Widget<Header<1>>, component: Box<dyn MockComponent>) -> Self {
Self { header, component } Self { header, component }
} }
} }

View File

@ -2,11 +2,11 @@ use tuirealm::command::{Cmd, CmdResult};
use tuirealm::props::{AttrValue, Attribute, BorderSides, BorderType, Color, Props, Style}; use tuirealm::props::{AttrValue, Attribute, BorderSides, BorderType, Color, Props, Style};
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}; use tuirealm::{Frame, MockComponent, State, StateValue};
use crate::ui::layout; use crate::ui::layout;
use crate::ui::theme::Theme; use crate::ui::theme::Theme;
use crate::ui::widget::{Widget, WidgetComponent}; use crate::ui::widget::{utils, Widget, WidgetComponent};
use super::container::Header; use super::container::Header;
use super::label::Label; use super::label::Label;
@ -30,69 +30,6 @@ pub enum ColumnWidth {
Grow, 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.
#[derive(Clone)] #[derive(Clone)]
pub struct Property { pub struct Property {
@ -186,7 +123,12 @@ pub struct Table<V, const W: usize>
where where
V: TableItem<W> + Clone, V: TableItem<W> + Clone,
{ {
model: TableModel<V, W>, /// Items hold by this model.
items: Vec<V>,
/// The table header.
header: [Widget<Label>; W],
/// Grow behavior of table columns.
widths: [ColumnWidth; W],
state: TableState, state: TableState,
theme: Theme, theme: Theme,
} }
@ -201,83 +143,48 @@ where
widths: [ColumnWidth; W], widths: [ColumnWidth; W],
theme: Theme, theme: Theme,
) -> Self { ) -> Self {
let mut model = TableModel::new(header, widths);
for item in items {
model.push_item(item.clone());
}
let mut state = TableState::default(); let mut state = TableState::default();
state.select(Some(0)); state.select(Some(0));
Self { Self {
model, items: items.to_vec(),
header,
widths,
state, state,
theme, theme,
} }
} }
fn select_previous(&mut self) { fn select_previous(&mut self) -> Option<usize> {
let index = match self.state.selected() { let old_index = self.state.selected();
Some(selected) if selected == 0 => 0, let new_index = match old_index {
Some(selected) => selected.saturating_sub(1), Some(selected) if selected == 0 => Some(0),
None => 0, Some(selected) => Some(selected.saturating_sub(1)),
None => Some(0),
}; };
self.state.select(Some(index));
}
fn select_next(&mut self, len: usize) { if old_index != new_index {
let index = match self.state.selected() { self.state.select(new_index);
Some(selected) if selected >= len.saturating_sub(1) => len.saturating_sub(1), self.state.selected()
Some(selected) => selected.saturating_add(1),
None => 0,
};
self.state.select(Some(index));
}
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()
.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 { } else {
count None
}
} }
});
let grow_width = area
.width
.saturating_sub(fixed_width)
.checked_div(grow_count)
.unwrap_or(0);
widths fn select_next(&mut self, len: usize) -> Option<usize> {
.iter() let old_index = self.state.selected();
.map(|width| match width { let new_index = match old_index {
ColumnWidth::Fixed(w) => { Some(selected) if selected >= len.saturating_sub(1) => Some(len.saturating_sub(1)),
let p: f64 = *w as f64 / area.width as f64 * 100_f64; Some(selected) => Some(selected.saturating_add(1)),
Constraint::Percentage(p.ceil() as u16) None => Some(0),
};
if old_index != new_index {
self.state.select(new_index);
self.state.selected()
} else {
None
} }
ColumnWidth::Grow => {
let p: f64 = grow_width as f64 / area.width as f64 * 100_f64;
Constraint::Percentage(p.floor() as u16)
}
})
.collect()
} }
} }
@ -295,12 +202,11 @@ where
.constraints(vec![Constraint::Length(3), Constraint::Min(1)]) .constraints(vec![Constraint::Length(3), Constraint::Min(1)])
.split(area); .split(area);
let widths = Self::widths(area, self.model.widths(), self.theme.tables.spacing); let widths = utils::column_widths(area, &self.widths, self.theme.tables.spacing);
let rows: Vec<Row<'_>> = self let rows: Vec<Row<'_>> = self
.model .items
.rows(&self.theme)
.iter() .iter()
.map(|cells| Row::new(cells.clone())) .map(|item| Row::new(item.row(&self.theme)))
.collect(); .collect();
let table = tuirealm::tui::widgets::Table::new(rows) let table = tuirealm::tui::widgets::Table::new(rows)
@ -314,7 +220,11 @@ where
.column_spacing(self.theme.tables.spacing) .column_spacing(self.theme.tables.spacing)
.widths(&widths); .widths(&widths);
let mut header = Widget::new(Header::new(self.model.clone(), self.theme.clone())); let mut header = Widget::new(Header::new(
self.header.clone(),
self.widths,
self.theme.clone(),
));
header.view(frame, layout[0]); header.view(frame, layout[0]);
frame.render_stateful_widget(table, layout[1], &mut self.state); frame.render_stateful_widget(table, layout[1], &mut self.state);
} }
@ -325,17 +235,19 @@ where
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 len = self.model.count() as usize;
match cmd { match cmd {
Cmd::Move(Direction::Up) => { Cmd::Move(Direction::Up) => match self.select_previous() {
self.select_previous(); Some(selected) => CmdResult::Changed(State::One(StateValue::Usize(selected))),
CmdResult::None None => CmdResult::None,
} },
Cmd::Move(Direction::Down) => { Cmd::Move(Direction::Down) => match self.select_next(self.items.len()) {
self.select_next(len); Some(selected) => CmdResult::Changed(State::One(StateValue::Usize(selected))),
CmdResult::None None => CmdResult::None,
} },
Cmd::Submit => match self.state.selected() {
Some(selected) => CmdResult::Submit(State::One(StateValue::Usize(selected))),
None => CmdResult::None,
},
_ => CmdResult::None, _ => CmdResult::None,
} }
} }

View File

@ -49,6 +49,7 @@ impl WidgetComponent for Dashboard {
} }
pub struct IssueBrowser { pub struct IssueBrowser {
items: Vec<IssueItem>,
table: Widget<Table<IssueItem, 7>>, table: Widget<Table<IssueItem, 7>>,
shortcuts: Widget<Shortcuts>, shortcuts: Widget<Shortcuts>,
} }
@ -92,11 +93,15 @@ impl IssueBrowser {
let table = Widget::new(Table::new(&items, header, widths, theme.clone())) let table = Widget::new(Table::new(&items, header, widths, theme.clone()))
.highlight(theme.colors.item_list_highlighted_bg); .highlight(theme.colors.item_list_highlighted_bg);
Self { table, shortcuts } Self {
items,
table,
shortcuts,
}
} }
pub fn selected_item(&self) -> Option<&IssueItem> { pub fn items(&self) -> &Vec<IssueItem> {
self.table.selection() &self.items
} }
} }
@ -123,6 +128,7 @@ impl WidgetComponent for IssueBrowser {
} }
pub struct PatchBrowser { pub struct PatchBrowser {
items: Vec<PatchItem>,
table: Widget<Table<PatchItem, 8>>, table: Widget<Table<PatchItem, 8>>,
shortcuts: Widget<Shortcuts>, shortcuts: Widget<Shortcuts>,
} }
@ -168,11 +174,15 @@ impl PatchBrowser {
let table = Widget::new(Table::new(&items, header, widths, theme.clone())) let table = Widget::new(Table::new(&items, header, widths, theme.clone()))
.highlight(theme.colors.item_list_highlighted_bg); .highlight(theme.colors.item_list_highlighted_bg);
Self { table, shortcuts } Self {
items,
table,
shortcuts,
}
} }
pub fn selected_item(&self) -> Option<&PatchItem> { pub fn items(&self) -> &Vec<PatchItem> {
self.table.selection() &self.items
} }
} }

View File

@ -0,0 +1,43 @@
use tuirealm::tui::layout::{Constraint, Rect};
use super::common::list::ColumnWidth;
/// 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 column_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
.iter()
.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()
}