diff options
Diffstat (limited to 'src/tuice/component/base/text_table.rs')
-rw-r--r-- | src/tuice/component/base/text_table.rs | 285 |
1 files changed, 285 insertions, 0 deletions
diff --git a/src/tuice/component/base/text_table.rs b/src/tuice/component/base/text_table.rs new file mode 100644 index 00000000..d2f84249 --- /dev/null +++ b/src/tuice/component/base/text_table.rs @@ -0,0 +1,285 @@ +pub mod table_column; +mod table_scroll_state; + +use std::{borrow::Cow, cmp::min}; + +use tui::{ + backend::Backend, + layout::{Constraint, Rect}, + style::Style, + widgets::{Row, Table}, + Frame, +}; +use unicode_segmentation::UnicodeSegmentation; + +use crate::{ + constants::TABLE_GAP_HEIGHT_LIMIT, + tuice::{Component, Event, Status}, +}; + +pub use self::table_column::{TextColumn, TextColumnConstraint}; +use self::table_scroll_state::ScrollState as TextTableState; + +#[derive(Clone, Debug, Default)] +pub struct StyleSheet { + text: Style, + selected_text: Style, + table_header: Style, +} + +pub enum TextTableMsg {} + +/// A sortable, scrollable table for text data. +pub struct TextTable<'a, Message> { + state: TextTableState, + column_widths: Vec<u16>, + columns: Vec<TextColumn>, + show_gap: bool, + show_selected_entry: bool, + rows: Vec<Row<'a>>, + style_sheet: StyleSheet, + sortable: bool, + table_gap: u16, + on_select: Option<Box<dyn Fn(usize) -> Message>>, + on_selected_click: Option<Box<dyn Fn(usize) -> Message>>, +} + +impl<'a, Message> TextTable<'a, Message> { + pub fn new<S: Into<Cow<'static, str>>>(columns: Vec<S>) -> Self { + Self { + state: TextTableState::default(), + column_widths: vec![0; columns.len()], + columns: columns + .into_iter() + .map(|name| TextColumn::new(name)) + .collect(), + show_gap: true, + show_selected_entry: true, + rows: Vec::default(), + style_sheet: StyleSheet::default(), + sortable: false, + table_gap: 0, + on_select: None, + on_selected_click: None, + } + } + + /// Sets the row to display in the table. + /// + /// Defaults to displaying no data if not set. + pub fn rows(mut self, rows: Vec<Row<'a>>) -> Self { + self.rows = rows; + self + } + + /// Whether to try to show a gap between the table headers and data. + /// Note that if there isn't enough room, the gap will still be hidden. + /// + /// Defaults to `true` if not set. + pub fn show_gap(mut self, show_gap: bool) -> Self { + self.show_gap = show_gap; + self + } + + /// Whether to highlight the selected entry. + /// + /// Defaults to `true` if not set. + pub fn show_selected_entry(mut self, show_selected_entry: bool) -> Self { + self.show_selected_entry = show_selected_entry; + self + } + + /// Whether the table should display as sortable. + /// + /// Defaults to `false` if not set. + pub fn sortable(mut self, sortable: bool) -> Self { + self.sortable = sortable; + self + } + + /// What to do when selecting an entry. Expects a boxed function that takes in + /// the currently selected index and returns a [`Message`]. + /// + /// Defaults to `None` if not set. + pub fn on_select(mut self, on_select: Option<Box<dyn Fn(usize) -> Message>>) -> Self { + self.on_select = on_select; + self + } + + /// What to do when clicking on an entry that is already selected. + /// + /// Defaults to `None` if not set. + pub fn on_selected_click( + mut self, on_selected_click: Option<Box<dyn Fn(usize) -> Message>>, + ) -> Self { + self.on_selected_click = on_selected_click; + self + } + + fn update_column_widths(&mut self, bounds: Rect) { + let total_width = bounds.width; + let mut width_remaining = bounds.width; + + let mut column_widths: Vec<u16> = self + .columns + .iter() + .map(|column| { + let width = match column.width_constraint { + TextColumnConstraint::Fill => { + let desired = column.name.graphemes(true).count().saturating_add(1) as u16; + min(desired, width_remaining) + } + TextColumnConstraint::Length(length) => min(length, width_remaining), + TextColumnConstraint::Percentage(percentage) => { + let length = total_width * percentage / 100; + min(length, width_remaining) + } + TextColumnConstraint::MaxLength(length) => { + let desired = column.name.graphemes(true).count().saturating_add(1) as u16; + min(min(length, desired), width_remaining) + } + TextColumnConstraint::MaxPercentage(percentage) => { + let desired = column.name.graphemes(true).count().saturating_add(1) as u16; + let length = total_width * percentage / 100; + min(min(desired, length), width_remaining) + } + }; + width_remaining -= width; + width + }) + .collect(); + + if !column_widths.is_empty() { + let amount_per_slot = width_remaining / column_widths.len() as u16; + width_remaining %= column_widths.len() as u16; + for (index, width) in column_widths.iter_mut().enumerate() { + if (index as u16) < width_remaining { + *width += amount_per_slot + 1; + } else { + *width += amount_per_slot; + } + } + } + + self.column_widths = column_widths; + } +} + +impl<'a, Message, B> From<TextTable<'a, Message>> for Box<dyn Component<Message, B> + 'a> +where + Message: 'a, + B: Backend, +{ + fn from(table: TextTable<'a, Message>) -> Self { + Box::new(table) + } +} + +impl<'a, Message, B> Component<Message, B> for TextTable<'a, Message> +where + B: Backend, +{ + fn on_event(&mut self, bounds: Rect, event: Event, messages: &mut Vec<Message>) -> Status { + use crate::tuice::MouseBoundIntersect; + use crossterm::event::{MouseButton, MouseEventKind}; + + match event { + Event::Keyboard(key_event) => { + if key_event.modifiers.is_empty() { + match key_event.code { + _ => Status::Ignored, + } + } else { + Status::Ignored + } + } + Event::Mouse(mouse_event) => { + if mouse_event.does_mouse_intersect_bounds(bounds) { + match mouse_event.kind { + MouseEventKind::Down(MouseButton::Left) => { + let y = mouse_event.row - bounds.top(); + + if self.sortable && y == 0 { + todo!() + } else if y > self.table_gap { + let visual_index = usize::from(y - self.table_gap); + self.state.set_visual_index(visual_index) + } else { + Status::Ignored + } + } + MouseEventKind::ScrollDown => { + let status = self.state.move_down(1); + if let Some(on_select) = &self.on_select { + messages.push(on_select(self.state.current_index())); + } + status + } + MouseEventKind::ScrollUp => { + let status = self.state.move_up(1); + if let Some(on_select) = &self.on_select { + messages.push(on_select(self.state.current_index())); + } + status + } + _ => Status::Ignored, + } + } else { + Status::Ignored + } + } + } + } + + fn draw(&mut self, bounds: Rect, frame: &mut Frame<'_, B>) { + self.table_gap = if !self.show_gap + || (self.rows.len() + 2 > bounds.height.into() + && bounds.height < TABLE_GAP_HEIGHT_LIMIT) + { + 0 + } else { + 1 + }; + + let table_extras = 1 + self.table_gap; + let scrollable_height = bounds.height.saturating_sub(table_extras); + self.update_column_widths(bounds); + + // Calculate widths first, since we need them later. + let widths = self + .column_widths + .iter() + .map(|column| Constraint::Length(*column)) + .collect::<Vec<_>>(); + + // Then calculate rows. We truncate the amount of data read based on height, + // as well as truncating some entries based on available width. + let data_slice = { + // Note: `get_list_start` already ensures `start` is within the bounds of the number of items, so no need to check! + let start = self + .state + .display_start_index(bounds, scrollable_height as usize); + let end = min(self.state.num_items(), start + scrollable_height as usize); + + self.rows[start..end].to_vec() + }; + + // Now build up our headers... + let header = Row::new(self.columns.iter().map(|column| column.name.clone())) + .style(self.style_sheet.table_header) + .bottom_margin(self.table_gap); + + let mut table = Table::new(data_slice) + .header(header) + .style(self.style_sheet.text); + + if self.show_selected_entry { + table = table.highlight_style(self.style_sheet.selected_text); + } + + frame.render_stateful_widget(table.widths(&widths), bounds, self.state.tui_state()); + } +} + +#[cfg(test)] +mod tests {} |