Skip to content

Commit

Permalink
feat(widgets::table): add option to always allocate the "selection" c…
Browse files Browse the repository at this point in the history
…onstraint (#375)

* feat(table): add option to configure selection layout changes

Before this option was available, selecting a row in the table when no row was selected
previously made the tables layout change (the same applies to unselecting) by adding the width
of the "highlight symbol" in the front of the first column, this option allows to configure this
behavior.

* refactor(table): refactor "get_columns_widths" to return (x, width)

and "render" to make use of that

* refactor(table): refactor "get_columns_widths" to take in a selection_width instead of a boolean

also refactor "render" to make use of this change

* fix(table): rename "highlight_set_selection_space" to "highlight_spacing"

* style(table): apply doc-comment suggestions from code review

Co-authored-by: Dheepak Krishnamurthy <me@kdheepak.com>

---------

Co-authored-by: Dheepak Krishnamurthy <me@kdheepak.com>
  • Loading branch information
hasezoey and kdheepak committed Aug 11, 2023
1 parent 3293c6b commit f63ac72
Show file tree
Hide file tree
Showing 3 changed files with 213 additions and 39 deletions.
2 changes: 1 addition & 1 deletion src/widgets/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ pub use self::{
paragraph::{Paragraph, Wrap},
scrollbar::{ScrollDirection, Scrollbar, ScrollbarOrientation, ScrollbarState},
sparkline::{RenderDirection, Sparkline},
table::{Cell, Row, Table, TableState},
table::{Cell, HighlightSpacing, Row, Table, TableState},
tabs::Tabs,
};
use crate::{buffer::Buffer, layout::Rect};
Expand Down
115 changes: 78 additions & 37 deletions src/widgets/table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,39 @@ impl<'a> Styled for Row<'a> {
}
}

/// This option allows the user to configure the "highlight symbol" column width spacing
#[derive(Debug, PartialEq, Eq, Clone, Default, Hash)]
pub enum HighlightSpacing {
/// Always add spacing for the selection symbol column
///
/// With this variant, the column for the selection symbol will always be allocated, and so the
/// table will never change size, regardless of if a row is selected or not
Always,
/// Only add spacing for the selection symbol column if a row is selected
///
/// With this variant, the column for the selection symbol will only be allocated if there is a
/// selection, causing the table to shift if selected / unselected
#[default]
WhenSelected,
/// Never add spacing to the selection symbol column, regardless of whether something is
/// selected or not
///
/// This means that the highlight symbol will never be drawn
Never,
}

impl HighlightSpacing {
/// Determine if a selection should be done, based on variant
/// Input "selection_state" should be similar to `state.selected.is_some()`
pub fn should_add(&self, selection_state: bool) -> bool {
match self {
HighlightSpacing::Always => true,
HighlightSpacing::WhenSelected => selection_state,
HighlightSpacing::Never => false,
}
}
}

/// A widget to display data in formatted columns.
///
/// It is a collection of [`Row`]s, themselves composed of [`Cell`]s:
Expand Down Expand Up @@ -229,6 +262,8 @@ pub struct Table<'a> {
header: Option<Row<'a>>,
/// Data to display in each row
rows: Vec<Row<'a>>,
/// Decides when to allocate spacing for the row selection
highlight_spacing: HighlightSpacing,
}

impl<'a> Table<'a> {
Expand All @@ -245,6 +280,7 @@ impl<'a> Table<'a> {
highlight_symbol: None,
header: None,
rows: rows.into_iter().collect(),
highlight_spacing: HighlightSpacing::default(),
}
}

Expand Down Expand Up @@ -286,17 +322,24 @@ impl<'a> Table<'a> {
self
}

/// Set when to show the highlight spacing
///
/// See [HighlightSpacing] about which variant affects spacing in which way
pub fn highlight_spacing(mut self, value: HighlightSpacing) -> Self {
self.highlight_spacing = value;
self
}

pub fn column_spacing(mut self, spacing: u16) -> Self {
self.column_spacing = spacing;
self
}

fn get_columns_widths(&self, max_width: u16, has_selection: bool) -> Vec<u16> {
/// Get all offsets and widths of all user specified columns
/// Returns (x, width)
fn get_columns_widths(&self, max_width: u16, selection_width: u16) -> Vec<(u16, u16)> {
let mut constraints = Vec::with_capacity(self.widths.len() * 2 + 1);
if has_selection {
let highlight_symbol_width = self.highlight_symbol.map_or(0, |s| s.width() as u16);
constraints.push(Constraint::Length(highlight_symbol_width));
}
constraints.push(Constraint::Length(selection_width));
for constraint in self.widths {
constraints.push(*constraint);
constraints.push(Constraint::Length(self.column_spacing));
Expand All @@ -314,11 +357,12 @@ impl<'a> Table<'a> {
width: max_width,
height: 1,
});
let mut chunks = &chunks[..];
if has_selection {
chunks = &chunks[1..];
}
chunks.iter().step_by(2).map(|c| c.width).collect()
chunks
.iter()
.skip(1)
.step_by(2)
.map(|c| (c.x, c.width))
.collect()
}

fn get_row_bounds(
Expand Down Expand Up @@ -426,10 +470,13 @@ impl<'a> StatefulWidget for Table<'a> {
None => area,
};

let has_selection = state.selected.is_some();
let columns_widths = self.get_columns_widths(table_area.width, has_selection);
let selection_width = if self.highlight_spacing.should_add(state.selected.is_some()) {
self.highlight_symbol.map_or(0, |s| s.width() as u16)
} else {
0
};
let columns_widths = self.get_columns_widths(table_area.width, selection_width);
let highlight_symbol = self.highlight_symbol.unwrap_or("");
let blank_symbol = " ".repeat(highlight_symbol.width());
let mut current_height = 0;
let mut rows_height = table_area.height;

Expand All @@ -445,22 +492,18 @@ impl<'a> StatefulWidget for Table<'a> {
},
header.style,
);
let mut col = table_area.left();
if has_selection {
col += (highlight_symbol.width() as u16).min(table_area.width);
}
for (width, cell) in columns_widths.iter().zip(header.cells.iter()) {
let inner_offset = table_area.left();
for ((x, width), cell) in columns_widths.iter().zip(header.cells.iter()) {
render_cell(
buf,
cell,
Rect {
x: col,
x: inner_offset + x,
y: table_area.top(),
width: *width,
height: max_header_height,
},
);
col += *width + self.column_spacing;
}
current_height += max_header_height;
rows_height = rows_height.saturating_sub(max_header_height);
Expand All @@ -479,41 +522,39 @@ impl<'a> StatefulWidget for Table<'a> {
.skip(state.offset)
.take(end - start)
{
let (row, col) = (table_area.top() + current_height, table_area.left());
let (row, inner_offset) = (table_area.top() + current_height, table_area.left());
current_height += table_row.total_height();
let table_row_area = Rect {
x: col,
x: inner_offset,
y: row,
width: table_area.width,
height: table_row.height,
};
buf.set_style(table_row_area, table_row.style);
let is_selected = state.selected.map_or(false, |s| s == i);
let table_row_start_col = if has_selection {
let symbol = if is_selected {
highlight_symbol
} else {
&blank_symbol
};
let (col, _) =
buf.set_stringn(col, row, symbol, table_area.width as usize, table_row.style);
col
} else {
col
if selection_width > 0 && is_selected {
// this should in normal cases be safe, because "get_columns_widths" allocates
// "highlight_symbol.width()" space but "get_columns_widths"
// currently does not bind it to max table.width()
buf.set_stringn(
inner_offset,
row,
highlight_symbol,
table_area.width as usize,
table_row.style,
);
};
let mut col = table_row_start_col;
for (width, cell) in columns_widths.iter().zip(table_row.cells.iter()) {
for ((x, width), cell) in columns_widths.iter().zip(table_row.cells.iter()) {
render_cell(
buf,
cell,
Rect {
x: col,
x: inner_offset + x,
y: row,
width: *width,
height: table_row.height,
},
);
col += *width + self.column_spacing;
}
if is_selected {
buf.set_style(table_row_area, self.highlight_style);
Expand Down
135 changes: 134 additions & 1 deletion tests/widgets_table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use ratatui::{
layout::Constraint,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, Row, Table, TableState},
widgets::{Block, Borders, Cell, HighlightSpacing, Row, Table, TableState},
Terminal,
};

Expand Down Expand Up @@ -611,6 +611,139 @@ fn widgets_table_can_have_rows_with_multi_lines() {
);
}

#[test]
fn widgets_table_enable_always_highlight_spacing() {
let test_case = |state: &mut TableState, space: HighlightSpacing, expected: Buffer| {
let backend = TestBackend::new(30, 8);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let size = f.size();
let table = Table::new(vec![
Row::new(vec!["Row11", "Row12", "Row13"]),
Row::new(vec!["Row21", "Row22", "Row23"]).height(2),
Row::new(vec!["Row31", "Row32", "Row33"]),
Row::new(vec!["Row41", "Row42", "Row43"]).height(2),
])
.header(Row::new(vec!["Head1", "Head2", "Head3"]).bottom_margin(1))
.block(Block::default().borders(Borders::ALL))
.highlight_symbol(">> ")
.highlight_spacing(space)
.widths(&[
Constraint::Length(5),
Constraint::Length(5),
Constraint::Length(5),
])
.column_spacing(1);
f.render_stateful_widget(table, size, state);
})
.unwrap();
terminal.backend().assert_buffer(&expected);
};

assert_eq!(HighlightSpacing::default(), HighlightSpacing::WhenSelected);

let mut state = TableState::default();
// no selection, "WhenSelected" should only allocate if selected
test_case(
&mut state,
HighlightSpacing::default(),
Buffer::with_lines(vec![
"β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”",
"β”‚Head1 Head2 Head3 β”‚",
"β”‚ β”‚",
"β”‚Row11 Row12 Row13 β”‚",
"β”‚Row21 Row22 Row23 β”‚",
"β”‚ β”‚",
"β”‚Row31 Row32 Row33 β”‚",
"β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜",
]),
);

// no selection, "Always" should allocate regardless if selected or not
test_case(
&mut state,
HighlightSpacing::Always,
Buffer::with_lines(vec![
"β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”",
"β”‚ Head1 Head2 Head3 β”‚",
"β”‚ β”‚",
"β”‚ Row11 Row12 Row13 β”‚",
"β”‚ Row21 Row22 Row23 β”‚",
"β”‚ β”‚",
"β”‚ Row31 Row32 Row33 β”‚",
"β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜",
]),
);

// no selection, "Never" should never allocate regadless if selected or not
test_case(
&mut state,
HighlightSpacing::Never,
Buffer::with_lines(vec![
"β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”",
"β”‚Head1 Head2 Head3 β”‚",
"β”‚ β”‚",
"β”‚Row11 Row12 Row13 β”‚",
"β”‚Row21 Row22 Row23 β”‚",
"β”‚ β”‚",
"β”‚Row31 Row32 Row33 β”‚",
"β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜",
]),
);

// select first, "WhenSelected" should only allocate if selected
state.select(Some(0));
test_case(
&mut state,
HighlightSpacing::default(),
Buffer::with_lines(vec![
"β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”",
"β”‚ Head1 Head2 Head3 β”‚",
"β”‚ β”‚",
"β”‚>> Row11 Row12 Row13 β”‚",
"β”‚ Row21 Row22 Row23 β”‚",
"β”‚ β”‚",
"β”‚ Row31 Row32 Row33 β”‚",
"β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜",
]),
);

// select first, "Always" should allocate regardless if selected or not
state.select(Some(0));
test_case(
&mut state,
HighlightSpacing::Always,
Buffer::with_lines(vec![
"β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”",
"β”‚ Head1 Head2 Head3 β”‚",
"β”‚ β”‚",
"β”‚>> Row11 Row12 Row13 β”‚",
"β”‚ Row21 Row22 Row23 β”‚",
"β”‚ β”‚",
"β”‚ Row31 Row32 Row33 β”‚",
"β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜",
]),
);

// select first, "Never" should never allocate regadless if selected or not
state.select(Some(0));
test_case(
&mut state,
HighlightSpacing::Never,
Buffer::with_lines(vec![
"β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”",
"β”‚Head1 Head2 Head3 β”‚",
"β”‚ β”‚",
"β”‚Row11 Row12 Row13 β”‚",
"β”‚Row21 Row22 Row23 β”‚",
"β”‚ β”‚",
"β”‚Row31 Row32 Row33 β”‚",
"β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜",
]),
);
}

#[test]
fn widgets_table_can_have_elements_styled_individually() {
let backend = TestBackend::new(30, 4);
Expand Down

0 comments on commit f63ac72

Please sign in to comment.