Compare commits

...

13 Commits

Author SHA1 Message Date
Dheepak Krishnamurthy
ccf9d92f10 test: Add more tests for selection and marks behavior 2024-05-13 23:26:47 -04:00
Dheepak Krishnamurthy
b712034644 feat: Show highlight_column only if there are marks 2024-05-13 23:23:57 -04:00
Dheepak Krishnamurthy
38fca62fa9 chore: Use assert_eq instead of assert_buffer_eq 2024-05-13 22:51:31 -04:00
Dheepak Krishnamurthy
3a51a027a6 Merge branch 'main' into kd/multi-select-table 2024-05-13 22:49:22 -04:00
Dheepak Krishnamurthy
5b30f2275c docs: Update docstring for field 2024-05-13 22:45:35 -04:00
Dheepak Krishnamurthy
b4c27c744c chore: Move pretty assert to top of file 2024-05-13 20:05:20 -04:00
Dheepak Krishnamurthy
977a4899c8 chore: Update serialized data representation 2024-05-13 20:03:59 -04:00
Dheepak Krishnamurthy
cd27b4829a docs: use pretty assertion 2024-05-13 20:00:56 -04:00
Dheepak Krishnamurthy
feee871519 docs: fix broken test 2024-05-13 19:57:56 -04:00
Dheepak Krishnamurthy
0051bb2037 build(test): Add more tests 2024-05-13 03:59:28 -04:00
Dheepak Krishnamurthy
477217c77a feat: Better spacing 2024-05-13 03:45:22 -04:00
Dheepak Krishnamurthy
31de3586f7 feat: Add mark, unmark and mark_highlight symbols 2024-05-13 03:27:22 -04:00
Dheepak Krishnamurthy
f702025b75 feat: Add multi-selection for table 2024-05-13 01:20:12 -04:00
3 changed files with 511 additions and 23 deletions

View File

@@ -213,6 +213,15 @@ pub struct Table<'a> {
/// Symbol in front of the selected row
highlight_symbol: Text<'a>,
/// Symbol in front of the marked row
mark_symbol: Text<'a>,
/// Symbol in front of the unmarked row
unmark_symbol: Text<'a>,
/// Symbol in front of the marked and selected row
mark_highlight_symbol: Text<'a>,
/// Decides when to allocate spacing for the row selection
highlight_spacing: HighlightSpacing,
@@ -232,6 +241,9 @@ impl<'a> Default for Table<'a> {
style: Style::new(),
highlight_style: Style::new(),
highlight_symbol: Text::default(),
mark_symbol: Text::default(),
unmark_symbol: Text::default(),
mark_highlight_symbol: Text::default(),
highlight_spacing: HighlightSpacing::default(),
flex: Flex::Start,
}
@@ -503,6 +515,60 @@ impl<'a> Table<'a> {
self
}
/// Set the symbol to be displayed in front of the marked row
///
/// This is a fluent setter method which must be chained or used as it consumes self
///
/// # Examples
///
/// ```rust
/// # use ratatui::{prelude::*, widgets::*};
/// # let rows = [Row::new(vec!["Cell1", "Cell2"])];
/// # let widths = [Constraint::Length(5), Constraint::Length(5)];
/// let table = Table::new(rows, widths).mark_symbol("\u{2714}");
/// ```
#[must_use = "method moves the value of self and returns the modified value"]
pub fn mark_symbol<T: Into<Text<'a>>>(mut self, mark_symbol: T) -> Self {
self.mark_symbol = mark_symbol.into();
self
}
/// Set the symbol to be displayed in front of the unmarked row
///
/// This is a fluent setter method which must be chained or used as it consumes self
///
/// # Examples
///
/// ```rust
/// # use ratatui::{prelude::*, widgets::*};
/// # let rows = [Row::new(vec!["Cell1", "Cell2"])];
/// # let widths = [Constraint::Length(5), Constraint::Length(5)];
/// let table = Table::new(rows, widths).unmark_symbol(" ");
/// ```
#[must_use = "method moves the value of self and returns the modified value"]
pub fn unmark_symbol<T: Into<Text<'a>>>(mut self, unmark_symbol: T) -> Self {
self.unmark_symbol = unmark_symbol.into();
self
}
/// Set the symbol to be displayed in front of the marked and selected row
///
/// This is a fluent setter method which must be chained or used as it consumes self
///
/// # Examples
///
/// ```rust
/// # use ratatui::{prelude::*, widgets::*};
/// # let rows = [Row::new(vec!["Cell1", "Cell2"])];
/// # let widths = [Constraint::Length(5), Constraint::Length(5)];
/// let table = Table::new(rows, widths).mark_highlight_symbol("\u{29bf}");
/// ```
#[must_use = "method moves the value of self and returns the modified value"]
pub fn mark_highlight_symbol<T: Into<Text<'a>>>(mut self, mark_highlight_symbol: T) -> Self {
self.mark_highlight_symbol = mark_highlight_symbol.into();
self
}
/// Set when to show the highlight spacing
///
/// The highlight spacing is the spacing that is allocated for the selection symbol column (if
@@ -604,8 +670,8 @@ impl StatefulWidgetRef for Table<'_> {
return;
}
let selection_width = self.selection_width(state);
let columns_widths = self.get_columns_widths(table_area.width, selection_width);
let highlight_column_width = self.highlight_column_width(state);
let columns_widths = self.get_columns_widths(table_area.width, highlight_column_width);
let (header_area, rows_area, footer_area) = self.layout(table_area);
self.render_header(header_area, buf, &columns_widths);
@@ -614,8 +680,13 @@ impl StatefulWidgetRef for Table<'_> {
rows_area,
buf,
state,
selection_width,
&self.highlight_symbol,
highlight_column_width,
(
&self.highlight_symbol,
&self.mark_symbol,
&self.unmark_symbol,
&self.mark_highlight_symbol,
),
&columns_widths,
);
@@ -670,14 +741,16 @@ impl Table<'_> {
area: Rect,
buf: &mut Buffer,
state: &mut TableState,
selection_width: u16,
highlight_symbol: &Text<'_>,
highlight_column_width: u16,
symbols: (&Text<'_>, &Text<'_>, &Text<'_>, &Text<'_>),
columns_widths: &[(u16, u16)],
) {
if self.rows.is_empty() {
return;
}
let (highlight_symbol, mark_symbol, unmark_symbol, mark_highlight_symbol) = symbols;
let (start_index, end_index) =
self.get_row_bounds(state.selected, state.offset, area.height);
state.offset = start_index;
@@ -698,22 +771,37 @@ impl Table<'_> {
);
buf.set_style(row_area, row.style);
let is_selected = state.selected().is_some_and(|index| index == i);
if selection_width > 0 && is_selected {
let selection_area = Rect {
width: selection_width,
let is_marked = state.marked().contains(&(i + state.offset));
let is_highlighted = state.selected().is_some_and(|index| index == i);
if highlight_column_width > 0 {
let area = Rect {
width: highlight_column_width,
..row_area
};
buf.set_style(selection_area, row.style);
highlight_symbol.clone().render(selection_area, buf);
};
buf.set_style(area, row.style);
match (is_marked, is_highlighted) {
(true, true) => {
mark_highlight_symbol.render(area, buf);
}
(true, false) => {
mark_symbol.render(area, buf);
}
(false, true) => {
highlight_symbol.render(area, buf);
}
(false, false) => {
unmark_symbol.render(area, buf);
}
};
}
for ((x, width), cell) in columns_widths.iter().zip(row.cells.iter()) {
cell.render(
Rect::new(row_area.x + x, row_area.y, *width, row_area.height),
buf,
);
}
if is_selected {
if is_highlighted {
buf.set_style(row_area, self.highlight_style);
}
y_offset += row.height_with_margin();
@@ -788,15 +876,35 @@ impl Table<'_> {
(start, end)
}
/// Returns the width of the selection column if a row is selected, or the `highlight_spacing`
/// is set to show the column always, otherwise 0.
fn selection_width(&self, state: &TableState) -> u16 {
let has_selection = state.selected().is_some();
if self.highlight_spacing.should_add(has_selection) {
/// Returns the width of the indicator column if a row is selected, rows are marked,
/// or the `highlight_spacing` is set to show the column always, otherwise 0.
fn highlight_column_width(&self, state: &TableState) -> u16 {
let has_highlight = state.selected().is_some() || state.marked().len() > 0;
let highlight_column_width = if self.highlight_spacing.should_add(has_highlight) {
self.highlight_symbol.width() as u16
} else {
0
}
};
let mark_column_width = if self.highlight_spacing.should_add(has_highlight) {
self.mark_symbol.width() as u16
} else {
0
};
let mark_highlight_column_width = if self.highlight_spacing.should_add(has_highlight) {
self.mark_highlight_symbol.width() as u16
} else {
0
};
let unmark_column_width = if self.highlight_spacing.should_add(has_highlight) {
self.unmark_symbol.width() as u16
} else {
0
};
highlight_column_width
.max(mark_column_width)
.max(mark_highlight_column_width)
.max(unmark_column_width)
}
}
@@ -1190,6 +1298,100 @@ mod tests {
]);
assert_eq!(buf, expected);
}
#[test]
fn render_with_selected_marked_unmarked() {
let rows = vec![
Row::new(vec!["Cell", "Cell"]),
Row::new(vec!["Cell", "Cell"]),
Row::new(vec!["Cell", "Cell"]),
Row::new(vec!["Cell", "Cell"]),
Row::new(vec!["Cell", "Cell"]),
Row::new(vec!["Cell", "Cell"]),
Row::new(vec!["Cell", "Cell"]),
];
let table = Table::new(rows, [Constraint::Length(5); 2])
.highlight_symbol("")
.mark_symbol("")
.unmark_symbol(" ")
.mark_highlight_symbol("⦿");
let mut state = TableState::new().with_selected(0);
state.mark(1);
state.mark(3);
state.mark(5);
let mut buf = Buffer::empty(Rect::new(0, 0, 15, 10));
StatefulWidget::render(table.clone(), Rect::new(0, 0, 15, 10), &mut buf, &mut state);
let expected = Buffer::with_lines(Text::from(vec![
"• Cell Cell ".into(),
"⦾ Cell Cell ".into(),
" Cell Cell ".into(),
"⦾ Cell Cell ".into(),
" Cell Cell ".into(),
"⦾ Cell Cell ".into(),
" Cell Cell ".into(),
" ".into(),
" ".into(),
" ".into(),
]));
assert_eq!(buf, expected);
state.mark(0);
let mut buf = Buffer::empty(Rect::new(0, 0, 15, 10));
StatefulWidget::render(table.clone(), Rect::new(0, 0, 15, 10), &mut buf, &mut state);
let expected = Buffer::with_lines(Text::from(vec![
"⦿ Cell Cell ".into(),
"⦾ Cell Cell ".into(),
" Cell Cell ".into(),
"⦾ Cell Cell ".into(),
" Cell Cell ".into(),
"⦾ Cell Cell ".into(),
" Cell Cell ".into(),
" ".into(),
" ".into(),
" ".into(),
]));
assert_eq!(buf, expected);
state.select(Some(1));
let mut buf = Buffer::empty(Rect::new(0, 0, 15, 10));
StatefulWidget::render(table.clone(), Rect::new(0, 0, 15, 10), &mut buf, &mut state);
let expected = Buffer::with_lines(Text::from(vec![
"⦾ Cell Cell ".into(),
"⦿ Cell Cell ".into(),
" Cell Cell ".into(),
"⦾ Cell Cell ".into(),
" Cell Cell ".into(),
"⦾ Cell Cell ".into(),
" Cell Cell ".into(),
" ".into(),
" ".into(),
" ".into(),
]));
assert_eq!(buf, expected);
state.unmark(0);
let mut buf = Buffer::empty(Rect::new(0, 0, 15, 10));
StatefulWidget::render(table.clone(), Rect::new(0, 0, 15, 10), &mut buf, &mut state);
let expected = Buffer::with_lines(Text::from(vec![
" Cell Cell ".into(),
"⦿ Cell Cell ".into(),
" Cell Cell ".into(),
"⦾ Cell Cell ".into(),
" Cell Cell ".into(),
"⦾ Cell Cell ".into(),
" Cell Cell ".into(),
" ".into(),
" ".into(),
" ".into(),
]));
assert_eq!(buf, expected);
}
}
// test how constraints interact with table column width allocation
@@ -1388,6 +1590,214 @@ mod tests {
assert_eq!(table.get_columns_widths(10, 0), [(0, 5), (5, 5)]);
}
#[track_caller]
fn test_table_with_selection_and_marks<'line, Lines, Marks>(
highlight_spacing: HighlightSpacing,
columns: u16,
spacing: u16,
selection: Option<usize>,
marks: Marks,
expected: Lines,
) where
Lines: IntoIterator,
Lines::Item: Into<Line<'line>>,
Marks: IntoIterator<Item = usize>,
{
let table = Table::default()
.rows(vec![Row::new(vec!["ABCDE", "12345"])])
.highlight_spacing(highlight_spacing)
.highlight_symbol(">>>")
.mark_symbol(" MMM ")
.mark_highlight_symbol(" >M> ")
.column_spacing(spacing);
let area = Rect::new(0, 0, columns, 3);
let mut buf = Buffer::empty(area);
let mut state = TableState::default().with_selected(selection);
for mark in marks {
state.mark(mark);
}
StatefulWidget::render(table, area, &mut buf, &mut state);
assert_eq!(buf, Buffer::with_lines(expected));
}
#[test]
#[allow(clippy::too_many_lines)]
fn highlight_symbol_mark_symbol_and_column_spacing_with_highlight_spacing() {
// no highlight_symbol or mark_symbol rendered ever
test_table_with_selection_and_marks(
HighlightSpacing::Never,
15, // width
0, // spacing
None, // selection
[], // marks
[
"ABCDE 12345 ", /* default layout is Flex::Start but columns length
* constraints are calculated as `max_area / n_columns`,
* i.e. they are distributed amongst available space */
" ", // row 2
" ", // row 3
],
);
// no highlight_symbol or mark_symbol rendered ever
test_table_with_selection_and_marks(
HighlightSpacing::Never,
15, // width
0, // spacing
None, // selection
[0], // marks
[
"ABCDE 12345 ", /* default layout is Flex::Start but columns length
* constraints are calculated as `max_area / n_columns`,
* i.e. they are distributed amongst available space */
" ", // row 2
" ", // row 3
],
);
// no highlight_symbol or mark_symbol rendered ever
test_table_with_selection_and_marks(
HighlightSpacing::Never,
15, // width
0, // spacing
None, // selection
[], // marks
[
"ABCDE 12345 ", /* default layout is Flex::Start but columns length
* constraints are calculated as `max_area / n_columns`,
* i.e. they are distributed amongst available space */
" ", // row 2
" ", // row 3
],
);
// no highlight_symbol or mark_symbol rendered
test_table_with_selection_and_marks(
HighlightSpacing::WhenSelected,
15, // width
0, // spacing
None, // selection
[], // marks
[
"ABCDE 12345 ", /* default layout is Flex::Start but columns length
* constraints are calculated as `max_area / n_columns`,
* i.e. they are distributed amongst available space */
" ", // row 2
" ", // row 3
],
);
// mark_symbol rendered
test_table_with_selection_and_marks(
HighlightSpacing::WhenSelected,
15, // width
0, // spacing
None, // selection
[0], // marks
[
" MMM ABCDE12345", /* default layout is Flex::Start but columns length
* constraints are calculated as `max_area / n_columns`,
* i.e. they are distributed amongst available space */
" ", // row 2
" ", // row 3
],
);
// highlight symbol rendered with mark symbol width
test_table_with_selection_and_marks(
HighlightSpacing::WhenSelected,
15, // width
0, // spacing
Some(0), // selection
[], // marks
[
">>> ABCDE12345", /* default layout is Flex::Start but columns length
* constraints are calculated as `max_area / n_columns`,
* i.e. they are distributed amongst available space */
" ", // row 2
" ", // row 3
],
);
// mark highlight symbol rendered
test_table_with_selection_and_marks(
HighlightSpacing::WhenSelected,
15, // width
0, // spacing
Some(0), // selection
[0], // marks
[
" >M> ABCDE12345", /* default layout is Flex::Start but columns length
* constraints are calculated as `max_area / n_columns`,
* i.e. they are distributed amongst available space */
" ", // row 2
" ", // row 3
],
);
// no highlight_symbol or mark_symbol rendered
test_table_with_selection_and_marks(
HighlightSpacing::Always,
15, // width
0, // spacing
None, // selection
[], // marks
[
" ABCDE12345", /* default layout is Flex::Start but columns length
* constraints are calculated as `max_area / n_columns`,
* i.e. they are distributed amongst available space */
" ", // row 2
" ", // row 3
],
);
// mark_symbol rendered
test_table_with_selection_and_marks(
HighlightSpacing::Always,
15, // width
0, // spacing
None, // selection
[0], // marks
[
" MMM ABCDE12345", /* default layout is Flex::Start but columns length
* constraints are calculated as `max_area / n_columns`,
* i.e. they are distributed amongst available space */
" ", // row 2
" ", // row 3
],
);
// highlight symbol rendered with mark symbol width
test_table_with_selection_and_marks(
HighlightSpacing::Always,
15, // width
0, // spacing
Some(0), // selection
[], // marks
[
">>> ABCDE12345", /* default layout is Flex::Start but columns length
* constraints are calculated as `max_area / n_columns`,
* i.e. they are distributed amongst available space */
" ", // row 2
" ", // row 3
],
);
// mark highlight symbol rendered
test_table_with_selection_and_marks(
HighlightSpacing::Always,
15, // width
0, // spacing
Some(0), // selection
[0], // marks
[
" >M> ABCDE12345", /* default layout is Flex::Start but columns length
* constraints are calculated as `max_area / n_columns`,
* i.e. they are distributed amongst available space */
" ", // row 2
" ", // row 3
],
);
}
#[track_caller]
fn test_table_with_selection<'line, Lines>(
highlight_spacing: HighlightSpacing,

View File

@@ -49,6 +49,7 @@
pub struct TableState {
pub(crate) offset: usize,
pub(crate) selected: Option<usize>,
pub(crate) marked: Vec<usize>,
}
impl TableState {
@@ -64,6 +65,7 @@ impl TableState {
Self {
offset: 0,
selected: None,
marked: vec![],
}
}
@@ -175,6 +177,78 @@ impl TableState {
self.offset = 0;
}
}
/// Sets the index of the row as marked
///
/// # Examples
///
/// ```rust
/// # use ratatui::{prelude::*, widgets::*};
/// let mut state = TableState::default();
/// state.mark(1);
/// ```
pub fn mark(&mut self, index: usize) {
if !self.marked.contains(&index) {
self.marked.push(index);
}
}
/// Sets the index of the row as unmarked
///
/// # Examples
///
/// ```rust
/// # use ratatui::{prelude::*, widgets::*};
/// let mut state = TableState::default();
/// state.unmark(1);
/// ```
pub fn unmark(&mut self, index: usize) {
self.marked.retain(|i| *i != index);
}
/// Toggles the index of the row as marked or unmarked
///
/// # Examples
///
/// ```rust
/// # use ratatui::{prelude::*, widgets::*};
/// let mut state = TableState::default();
/// state.toggle_mark(1);
/// ```
pub fn toggle_mark(&mut self, index: usize) {
if self.marked.contains(&index) {
self.unmark(index);
} else {
self.mark(index);
}
}
/// Returns a iterator of all marked rows
///
/// # Examples
///
/// ```rust
/// # use ratatui::{prelude::*, widgets::*};
/// # use itertools::Itertools;
/// let mut state = TableState::default();
/// state.marked().contains(&1);
/// ```
pub fn marked(&self) -> std::slice::Iter<'_, usize> {
self.marked.iter()
}
/// Clears all marks from all rows
///
/// # Examples
///
/// ```rust
/// # use ratatui::{prelude::*, widgets::*};
/// let mut state = TableState::default();
/// state.clear_marks();
/// ```
pub fn clear_marks(&mut self) {
self.marked.drain(..);
}
}
#[cfg(test)]

View File

@@ -14,6 +14,7 @@
// not too happy about the redundancy in these tests,
// but if that helps readability then it's ok i guess /shrug
use pretty_assertions::assert_eq;
use ratatui::{backend::TestBackend, prelude::*, widgets::*};
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
@@ -98,7 +99,8 @@ const DEFAULT_STATE_REPR: &str = r#"{
},
"table": {
"offset": 0,
"selected": null
"selected": null,
"marked": []
},
"scrollbar": {
"content_length": 10,
@@ -135,7 +137,8 @@ const SELECTED_STATE_REPR: &str = r#"{
},
"table": {
"offset": 0,
"selected": 1
"selected": 1,
"marked": []
},
"scrollbar": {
"content_length": 10,
@@ -174,7 +177,8 @@ const SCROLLED_STATE_REPR: &str = r#"{
},
"table": {
"offset": 4,
"selected": 8
"selected": 8,
"marked": []
},
"scrollbar": {
"content_length": 10,