feat(table)!: add support for selecting column and cell (#1331)

Fixes https://github.com/ratatui-org/ratatui/issues/1250

Adds support for selecting a column and cell in `TableState`. The
selected column, and cells style can be set by
`Table::column_highlight_style` and `Table::cell_highlight_style`
respectively.

The table example has also been updated to display the new
functionality:


https://github.com/user-attachments/assets/e5fd2858-4931-4ce1-a2f6-a5ea1eacbecc

BREAKING CHANGE: The Serialized output of the state will now include the
"selected_column" field. Software that manually parse the serialized the
output (with anything other than the `Serialize` implementation on
`TableState`) may have to be refactored if the "selected_column" field
is not accounted for. This does not affect users who rely on the
`Deserialize`, or `Serialize` implementation on the state.

BREAKING CHANGE: The `Table::highlight_style` is now deprecated in favor
of `Table::row_highlight_style`.

---------

Co-authored-by: Orhun Parmaksız <orhunparmaksiz@gmail.com>
This commit is contained in:
Tayfun Bocek 2024-10-13 14:06:29 +03:00 committed by GitHub
parent 23c0d52c29
commit dc8d0587ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 847 additions and 98 deletions

View file

@ -10,6 +10,9 @@ GitHub with a [breaking change] label.
This is a quick summary of the sections below:
- [v0.29.0](#v0290)
- `Table::highlight_style` is now `Table::row_highlight_style`
- [v0.28.0](#v0280)
`Backend::size` returns `Size` instead of `Rect`
- `Backend` trait migrates to `get/set_cursor_position`
@ -65,6 +68,19 @@ This is a quick summary of the sections below:
- MSRV is now 1.63.0
- `List` no longer ignores empty strings
## v0.29.0
### `Table::highlight_style` is now `Table::row_highlight_style` ([#1331])
[#1331]: https://github.com/ratatui/ratatui/pull/1331
The `Table::highlight_style` is now deprecated in favor of `Table::row_highlight_style`.
Also, the serialized output of the `TableState` will now include the "selected_column" field.
Software that manually parse the serialized the output (with anything other than the `Serialize`
implementation on `TableState`) may have to be refactored if the "selected_column" field is not accounted for.
This does not affect users who rely on the `Deserialize`, or `Serialize` implementation on the state.
## v0.28.0
### `Backend::size` returns `Size` instead of `Rect` ([#1254])

View file

@ -244,7 +244,7 @@ impl Widget for &PullRequestListWidget {
.block(block)
.highlight_spacing(HighlightSpacing::Always)
.highlight_symbol(">>")
.highlight_style(Style::new().on_blue());
.row_highlight_style(Style::new().on_blue());
StatefulWidget::render(table, area, buf, &mut state.table_state);
}

View file

@ -164,7 +164,7 @@ fn render_ingredients(selected_row: usize, area: Rect, buf: &mut Buffer) {
Table::new(rows, [Constraint::Length(7), Constraint::Length(30)])
.block(Block::new().style(theme.ingredients))
.header(Row::new(vec!["Qty", "Ingredient"]).style(theme.ingredients_header))
.highlight_style(Style::new().light_yellow()),
.row_highlight_style(Style::new().light_yellow()),
area,
buf,
&mut state,

View file

@ -60,7 +60,7 @@ fn render_hops(selected_row: usize, area: Rect, buf: &mut Buffer) {
StatefulWidget::render(
Table::new(rows, [Constraint::Max(100), Constraint::Length(15)])
.header(Row::new(vec!["Host", "Address"]).set_style(THEME.traceroute.header))
.highlight_style(THEME.traceroute.selected)
.row_highlight_style(THEME.traceroute.selected)
.block(block),
area,
buf,

View file

@ -14,12 +14,13 @@
//! [examples readme]: https://github.com/ratatui/ratatui/blob/main/examples/README.md
use color_eyre::Result;
use crossterm::event::KeyModifiers;
use itertools::Itertools;
use ratatui::{
crossterm::event::{self, Event, KeyCode, KeyEventKind},
layout::{Constraint, Layout, Margin, Rect},
style::{self, Color, Modifier, Style, Stylize},
text::{Line, Text},
text::Text,
widgets::{
Block, BorderType, Cell, HighlightSpacing, Paragraph, Row, Scrollbar, ScrollbarOrientation,
ScrollbarState, Table, TableState,
@ -35,8 +36,10 @@ const PALETTES: [tailwind::Palette; 4] = [
tailwind::INDIGO,
tailwind::RED,
];
const INFO_TEXT: &str =
"(Esc) quit | (↑) move up | (↓) move down | (→) next color | (←) previous color";
const INFO_TEXT: [&str; 2] = [
"(Esc) quit | (↑) move up | (↓) move down | (←) move left | (→) move right",
"(Shift + →) next color | (Shift + ←) previous color",
];
const ITEM_HEIGHT: usize = 4;
@ -52,7 +55,9 @@ struct TableColors {
header_bg: Color,
header_fg: Color,
row_fg: Color,
selected_style_fg: Color,
selected_row_style_fg: Color,
selected_column_style_fg: Color,
selected_cell_style_fg: Color,
normal_row_color: Color,
alt_row_color: Color,
footer_border_color: Color,
@ -65,7 +70,9 @@ impl TableColors {
header_bg: color.c900,
header_fg: tailwind::SLATE.c200,
row_fg: tailwind::SLATE.c200,
selected_style_fg: color.c400,
selected_row_style_fg: color.c400,
selected_column_style_fg: color.c400,
selected_cell_style_fg: color.c600,
normal_row_color: tailwind::SLATE.c950,
alt_row_color: tailwind::SLATE.c900,
footer_border_color: color.c400,
@ -118,8 +125,7 @@ impl App {
items: data_vec,
}
}
pub fn next(&mut self) {
pub fn next_row(&mut self) {
let i = match self.state.selected() {
Some(i) => {
if i >= self.items.len() - 1 {
@ -134,7 +140,7 @@ impl App {
self.scroll_state = self.scroll_state.position(i * ITEM_HEIGHT);
}
pub fn previous(&mut self) {
pub fn previous_row(&mut self) {
let i = match self.state.selected() {
Some(i) => {
if i == 0 {
@ -149,6 +155,14 @@ impl App {
self.scroll_state = self.scroll_state.position(i * ITEM_HEIGHT);
}
pub fn next_column(&mut self) {
self.state.select_next_column();
}
pub fn previous_column(&mut self) {
self.state.select_previous_column();
}
pub fn next_color(&mut self) {
self.color_index = (self.color_index + 1) % PALETTES.len();
}
@ -168,12 +182,17 @@ impl App {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
let shift_pressed = key.modifiers.contains(KeyModifiers::SHIFT);
match key.code {
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
KeyCode::Char('j') | KeyCode::Down => self.next(),
KeyCode::Char('k') | KeyCode::Up => self.previous(),
KeyCode::Char('l') | KeyCode::Right => self.next_color(),
KeyCode::Char('h') | KeyCode::Left => self.previous_color(),
KeyCode::Char('j') | KeyCode::Down => self.next_row(),
KeyCode::Char('k') | KeyCode::Up => self.previous_row(),
KeyCode::Char('l') | KeyCode::Right if shift_pressed => self.next_color(),
KeyCode::Char('h') | KeyCode::Left if shift_pressed => {
self.previous_color();
}
KeyCode::Char('l') | KeyCode::Right => self.next_column(),
KeyCode::Char('h') | KeyCode::Left => self.previous_column(),
_ => {}
}
}
@ -182,7 +201,7 @@ impl App {
}
fn draw(&mut self, frame: &mut Frame) {
let vertical = &Layout::vertical([Constraint::Min(5), Constraint::Length(3)]);
let vertical = &Layout::vertical([Constraint::Min(5), Constraint::Length(4)]);
let rects = vertical.split(frame.area());
self.set_colors();
@ -196,9 +215,13 @@ impl App {
let header_style = Style::default()
.fg(self.colors.header_fg)
.bg(self.colors.header_bg);
let selected_style = Style::default()
let selected_row_style = Style::default()
.add_modifier(Modifier::REVERSED)
.fg(self.colors.selected_style_fg);
.fg(self.colors.selected_row_style_fg);
let selected_col_style = Style::default().fg(self.colors.selected_column_style_fg);
let selected_cell_style = Style::default()
.add_modifier(Modifier::REVERSED)
.fg(self.colors.selected_cell_style_fg);
let header = ["Name", "Address", "Email"]
.into_iter()
@ -229,7 +252,9 @@ impl App {
],
)
.header(header)
.highlight_style(selected_style)
.row_highlight_style(selected_row_style)
.column_highlight_style(selected_col_style)
.cell_highlight_style(selected_cell_style)
.highlight_symbol(Text::from(vec![
"".into(),
bar.into(),
@ -256,7 +281,7 @@ impl App {
}
fn render_footer(&self, frame: &mut Frame, area: Rect) {
let info_footer = Paragraph::new(Line::from(INFO_TEXT))
let info_footer = Paragraph::new(Text::from_iter(INFO_TEXT))
.style(
Style::new()
.fg(self.colors.row_fg)

View file

@ -30,10 +30,11 @@ use crate::{
///
/// [`Table`] is also a [`StatefulWidget`], which means you can use it with [`TableState`] to allow
/// the user to scroll through the rows and select one of them. When rendering a [`Table`] with a
/// [`TableState`], the selected row will be highlighted. If the selected row is not visible (based
/// on the offset), the table will be scrolled to make the selected row visible.
/// [`TableState`], the selected row, column and cell will be highlighted. If the selected row is
/// not visible (based on the offset), the table will be scrolled to make the selected row visible.
///
/// Note: if the `widths` field is empty, the table will be rendered with equal widths.
/// Note: Highlight styles are applied in the following order: Row, Column, Cell.
///
/// See the table example and the recipe and traceroute tabs in the demo2 example in the [Examples]
/// directory for a more in depth example of the various configuration options and for how to handle
@ -57,7 +58,9 @@ use crate::{
/// - [`Table::column_spacing`] sets the spacing between each column.
/// - [`Table::block`] wraps the table in a [`Block`] widget.
/// - [`Table::style`] sets the base style of the widget.
/// - [`Table::highlight_style`] sets the style of the selected row.
/// - [`Table::row_highlight_style`] sets the style of the selected row.
/// - [`Table::column_highlight_style`] sets the style of the selected column.
/// - [`Table::cell_highlight_style`] sets the style of the selected cell.
/// - [`Table::highlight_symbol`] sets the symbol to be displayed in front of the selected row.
/// - [`Table::highlight_spacing`] sets when to show the highlight spacing.
///
@ -93,8 +96,10 @@ use crate::{
/// .footer(Row::new(vec!["Updated on Dec 28"]))
/// // As any other widget, a Table can be wrapped in a Block.
/// .block(Block::new().title("Table"))
/// // The selected row and its content can also be styled.
/// .highlight_style(Style::new().reversed())
/// // The selected row, column, cell and its content can also be styled.
/// .row_highlight_style(Style::new().reversed())
/// .column_highlight_style(Style::new().red())
/// .cell_highlight_style(Style::new().blue())
/// // ...and potentially show a symbol in front of the selection.
/// .highlight_symbol(">>");
/// ```
@ -220,7 +225,7 @@ use crate::{
/// ];
/// let table = Table::new(rows, widths)
/// .block(Block::new().title("Table"))
/// .highlight_style(Style::new().reversed())
/// .row_highlight_style(Style::new().reversed())
/// .highlight_symbol(">>");
///
/// frame.render_stateful_widget(table, area, &mut table_state);
@ -253,7 +258,13 @@ pub struct Table<'a> {
style: Style,
/// Style used to render the selected row
highlight_style: Style,
row_highlight_style: Style,
/// Style used to render the selected column
column_highlight_style: Style,
/// Style used to render the selected cell
cell_highlight_style: Style,
/// Symbol in front of the selected row
highlight_symbol: Text<'a>,
@ -275,7 +286,9 @@ impl<'a> Default for Table<'a> {
column_spacing: 1,
block: None,
style: Style::new(),
highlight_style: Style::new(),
row_highlight_style: Style::new(),
column_highlight_style: Style::new(),
cell_highlight_style: Style::new(),
highlight_symbol: Text::default(),
highlight_spacing: HighlightSpacing::default(),
flex: Flex::Start,
@ -564,8 +577,83 @@ impl<'a> Table<'a> {
///
/// [`Color`]: crate::style::Color
#[must_use = "method moves the value of self and returns the modified value"]
pub fn highlight_style<S: Into<Style>>(mut self, highlight_style: S) -> Self {
self.highlight_style = highlight_style.into();
#[deprecated(note = "use `Table::row_highlight_style` instead")]
pub fn highlight_style<S: Into<Style>>(self, highlight_style: S) -> Self {
self.row_highlight_style(highlight_style)
}
/// Set the style of the selected row
///
/// `style` accepts any type that is convertible to [`Style`] (e.g. [`Style`], [`Color`], or
/// your own type that implements [`Into<Style>`]).
///
/// This style will be applied to the entire row, including the selection symbol if it is
/// displayed, and will override any style set on the row or on the individual cells.
///
/// 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).row_highlight_style(Style::new().red().italic());
/// ```
/// [`Color`]: crate::style::Color
#[must_use = "method moves the value of self and returns the modified value"]
pub fn row_highlight_style<S: Into<Style>>(mut self, highlight_style: S) -> Self {
self.row_highlight_style = highlight_style.into();
self
}
/// Set the style of the selected column
///
/// `style` accepts any type that is convertible to [`Style`] (e.g. [`Style`], [`Color`], or
/// your own type that implements [`Into<Style>`]).
///
/// This style will be applied to the entire column, and will override any style set on the
/// row or on the individual cells.
///
/// 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).column_highlight_style(Style::new().red().italic());
/// ```
/// [`Color`]: crate::style::Color
#[must_use = "method moves the value of self and returns the modified value"]
pub fn column_highlight_style<S: Into<Style>>(mut self, highlight_style: S) -> Self {
self.column_highlight_style = highlight_style.into();
self
}
/// Set the style of the selected cell
///
/// `style` accepts any type that is convertible to [`Style`] (e.g. [`Style`], [`Color`], or
/// your own type that implements [`Into<Style>`]).
///
/// This style will be applied to the selected cell, and will override any style set on the
/// row or on the individual cells.
///
/// 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).cell_highlight_style(Style::new().red().italic());
/// ```
/// [`Color`]: crate::style::Color
#[must_use = "method moves the value of self and returns the modified value"]
pub fn cell_highlight_style<S: Into<Style>>(mut self, highlight_style: S) -> Self {
self.cell_highlight_style = highlight_style.into();
self
}
@ -707,8 +795,17 @@ impl StatefulWidgetRef for Table<'_> {
state.select(None);
}
let column_count = self.column_count();
if state.selected_column.is_some_and(|s| s >= column_count) {
state.select_column(Some(column_count.saturating_sub(1)));
}
if column_count == 0 {
state.select_column(None);
}
let selection_width = self.selection_width(state);
let columns_widths = self.get_columns_widths(table_area.width, selection_width);
let columns_widths =
self.get_columns_widths(table_area.width, selection_width, column_count);
let (header_area, rows_area, footer_area) = self.layout(table_area);
self.render_header(header_area, buf, &columns_widths);
@ -786,6 +883,8 @@ impl Table<'_> {
state.offset = start_index;
let mut y_offset = 0;
let mut selected_row_area = None;
for (i, row) in self
.rows
.iter()
@ -801,7 +900,7 @@ impl Table<'_> {
);
buf.set_style(row_area, row.style);
let is_selected = state.selected().is_some_and(|index| index == i);
let is_selected = state.selected.is_some_and(|index| index == i);
if selection_width > 0 && is_selected {
let selection_area = Rect {
width: selection_width,
@ -817,26 +916,49 @@ impl Table<'_> {
);
}
if is_selected {
buf.set_style(row_area, self.highlight_style);
selected_row_area = Some(row_area);
}
y_offset += row.height_with_margin();
}
let selected_column_area = state.selected_column.and_then(|s| {
// The selection is clamped by the column count. Since a user can manually specify an
// incorrect number of widths, we should use panic free methods.
columns_widths.get(s).map(|(x, width)| Rect {
x: x + area.x,
width: *width,
..area
})
});
match (selected_row_area, selected_column_area) {
(Some(row_area), Some(col_area)) => {
buf.set_style(row_area, self.row_highlight_style);
buf.set_style(col_area, self.column_highlight_style);
let cell_area = row_area.intersection(col_area);
buf.set_style(cell_area, self.cell_highlight_style);
}
(Some(row_area), None) => {
buf.set_style(row_area, self.row_highlight_style);
}
(None, Some(col_area)) => {
buf.set_style(col_area, self.column_highlight_style);
}
(None, None) => (),
}
}
/// Get all offsets and widths of all user specified columns.
///
/// Returns (x, width). When self.widths is empty, it is assumed `.widths()` has not been called
/// and a default of equal widths is returned.
fn get_columns_widths(&self, max_width: u16, selection_width: u16) -> Vec<(u16, u16)> {
fn get_columns_widths(
&self,
max_width: u16,
selection_width: u16,
col_count: usize,
) -> Vec<(u16, u16)> {
let widths = if self.widths.is_empty() {
let col_count = self
.rows
.iter()
.chain(self.header.iter())
.chain(self.footer.iter())
.map(|r| r.cells.len())
.max()
.unwrap_or(0);
// Divide the space between each column equally
vec![Constraint::Length(max_width / col_count.max(1) as u16); col_count]
} else {
@ -900,10 +1022,20 @@ impl Table<'_> {
(start, end)
}
fn column_count(&self) -> usize {
self.rows
.iter()
.chain(self.footer.iter())
.chain(self.header.iter())
.map(|r| r.cells.len())
.max()
.unwrap_or_default()
}
/// 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();
let has_selection = state.selected.is_some();
if self.highlight_spacing.should_add(has_selection) {
self.highlight_symbol.width() as u16
} else {
@ -953,6 +1085,8 @@ where
mod tests {
use std::vec;
use rstest::{fixture, rstest};
use super::*;
use crate::{
layout::Constraint::*,
@ -973,7 +1107,7 @@ mod tests {
assert_eq!(table.column_spacing, 1);
assert_eq!(table.block, None);
assert_eq!(table.style, Style::default());
assert_eq!(table.highlight_style, Style::default());
assert_eq!(table.row_highlight_style, Style::default());
assert_eq!(table.highlight_symbol, Text::default());
assert_eq!(table.highlight_spacing, HighlightSpacing::WhenSelected);
assert_eq!(table.flex, Flex::Start);
@ -989,7 +1123,7 @@ mod tests {
assert_eq!(table.column_spacing, 1);
assert_eq!(table.block, None);
assert_eq!(table.style, Style::default());
assert_eq!(table.highlight_style, Style::default());
assert_eq!(table.row_highlight_style, Style::default());
assert_eq!(table.highlight_symbol, Text::default());
assert_eq!(table.highlight_spacing, HighlightSpacing::WhenSelected);
assert_eq!(table.flex, Flex::Start);
@ -1072,10 +1206,32 @@ mod tests {
}
#[test]
#[allow(deprecated)]
fn highlight_style() {
let style = Style::default().red().italic();
let table = Table::default().highlight_style(style);
assert_eq!(table.highlight_style, style);
assert_eq!(table.row_highlight_style, style);
}
#[test]
fn row_highlight_style() {
let style = Style::default().red().italic();
let table = Table::default().row_highlight_style(style);
assert_eq!(table.row_highlight_style, style);
}
#[test]
fn column_highlight_style() {
let style = Style::default().red().italic();
let table = Table::default().column_highlight_style(style);
assert_eq!(table.column_highlight_style, style);
}
#[test]
fn cell_highlight_style() {
let style = Style::default().red().italic();
let table = Table::default().cell_highlight_style(style);
assert_eq!(table.cell_highlight_style, style);
}
#[test]
@ -1122,8 +1278,8 @@ mod tests {
#[cfg(test)]
mod state {
use rstest::{fixture, rstest};
use super::*;
use crate::{
buffer::Buffer,
layout::{Constraint, Rect},
@ -1145,6 +1301,7 @@ mod tests {
state.select_first();
StatefulWidget::render(table, table_buf.area, &mut table_buf, &mut state);
assert_eq!(state.selected, None);
assert_eq!(state.selected_column, None);
}
#[rstest]
@ -1158,25 +1315,49 @@ mod tests {
state.select_first();
StatefulWidget::render(&table, table_buf.area, &mut table_buf, &mut state);
assert_eq!(state.selected, Some(0));
assert_eq!(state.selected_column, None);
state.select_last();
StatefulWidget::render(&table, table_buf.area, &mut table_buf, &mut state);
assert_eq!(state.selected, Some(0));
assert_eq!(state.selected_column, None);
state.select_previous();
StatefulWidget::render(&table, table_buf.area, &mut table_buf, &mut state);
assert_eq!(state.selected, Some(0));
assert_eq!(state.selected_column, None);
state.select_next();
StatefulWidget::render(&table, table_buf.area, &mut table_buf, &mut state);
assert_eq!(state.selected, Some(0));
assert_eq!(state.selected_column, None);
let mut state = TableState::default();
state.select_first_column();
StatefulWidget::render(&table, table_buf.area, &mut table_buf, &mut state);
assert_eq!(state.selected_column, Some(0));
assert_eq!(state.selected, None);
state.select_last_column();
StatefulWidget::render(&table, table_buf.area, &mut table_buf, &mut state);
assert_eq!(state.selected_column, Some(0));
assert_eq!(state.selected, None);
state.select_previous_column();
StatefulWidget::render(&table, table_buf.area, &mut table_buf, &mut state);
assert_eq!(state.selected_column, Some(0));
assert_eq!(state.selected, None);
state.select_next_column();
StatefulWidget::render(&table, table_buf.area, &mut table_buf, &mut state);
assert_eq!(state.selected_column, Some(0));
assert_eq!(state.selected, None);
}
}
#[cfg(test)]
mod render {
use rstest::rstest;
use super::*;
use crate::layout::Alignment;
@ -1349,6 +1530,17 @@ mod tests {
Widget::render(table, Rect::new(0, 0, 20, 3), &mut buf);
}
#[test]
fn render_with_selected_column_and_incorrect_width_count_does_not_panic() {
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 3));
let table = Table::new(
vec![Row::new(vec!["Row1", "Row2", "Row3"])],
[Constraint::Length(10); 1],
);
let mut state = TableState::new().with_selected_column(2);
StatefulWidget::render(table, Rect::new(0, 0, 20, 3), &mut buf, &mut state);
}
#[test]
fn render_with_selected() {
let mut buf = Buffer::empty(Rect::new(0, 0, 15, 3));
@ -1357,9 +1549,9 @@ mod tests {
Row::new(vec!["Cell3", "Cell4"]),
];
let table = Table::new(rows, [Constraint::Length(5); 2])
.highlight_style(Style::new().red())
.row_highlight_style(Style::new().red())
.highlight_symbol(">>");
let mut state = TableState::new().with_selected(0);
let mut state = TableState::new().with_selected(Some(0));
StatefulWidget::render(table, Rect::new(0, 0, 15, 3), &mut buf, &mut state);
let expected = Buffer::with_lines([
">>Cell1 Cell2 ".red(),
@ -1369,6 +1561,105 @@ mod tests {
assert_eq!(buf, expected);
}
#[test]
fn render_with_selected_column() {
let mut buf = Buffer::empty(Rect::new(0, 0, 15, 3));
let rows = vec![
Row::new(vec!["Cell1", "Cell2"]),
Row::new(vec!["Cell3", "Cell4"]),
];
let table = Table::new(rows, [Constraint::Length(5); 2])
.column_highlight_style(Style::new().blue())
.highlight_symbol(">>");
let mut state = TableState::new().with_selected_column(Some(1));
StatefulWidget::render(table, Rect::new(0, 0, 15, 3), &mut buf, &mut state);
let expected = Buffer::with_lines::<[Line; 3]>([
Line::from(vec![
"Cell1".into(),
" ".into(),
"Cell2".blue(),
" ".into(),
]),
Line::from(vec![
"Cell3".into(),
" ".into(),
"Cell4".blue(),
" ".into(),
]),
Line::from(vec![" ".into(), " ".blue(), " ".into()]),
]);
assert_eq!(buf, expected);
}
#[test]
fn render_with_selected_cell() {
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 4));
let rows = vec![
Row::new(vec!["Cell1", "Cell2", "Cell3"]),
Row::new(vec!["Cell4", "Cell5", "Cell6"]),
Row::new(vec!["Cell7", "Cell8", "Cell9"]),
];
let table = Table::new(rows, [Constraint::Length(5); 3])
.highlight_symbol(">>")
.cell_highlight_style(Style::new().green());
let mut state = TableState::new().with_selected_cell((1, 2));
StatefulWidget::render(table, Rect::new(0, 0, 20, 4), &mut buf, &mut state);
let expected = Buffer::with_lines::<[Line; 4]>([
Line::from(vec![" Cell1 ".into(), "Cell2 ".into(), "Cell3".into()]),
Line::from(vec![">>Cell4 Cell5 ".into(), "Cell6".green(), " ".into()]),
Line::from(vec![" Cell7 ".into(), "Cell8 ".into(), "Cell9".into()]),
Line::from(vec![" ".into()]),
]);
assert_eq!(buf, expected);
}
#[test]
fn render_with_selected_row_and_column() {
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 4));
let rows = vec![
Row::new(vec!["Cell1", "Cell2", "Cell3"]),
Row::new(vec!["Cell4", "Cell5", "Cell6"]),
Row::new(vec!["Cell7", "Cell8", "Cell9"]),
];
let table = Table::new(rows, [Constraint::Length(5); 3])
.highlight_symbol(">>")
.row_highlight_style(Style::new().red())
.column_highlight_style(Style::new().blue());
let mut state = TableState::new().with_selected(1).with_selected_column(2);
StatefulWidget::render(table, Rect::new(0, 0, 20, 4), &mut buf, &mut state);
let expected = Buffer::with_lines::<[Line; 4]>([
Line::from(vec![" Cell1 ".into(), "Cell2 ".into(), "Cell3".blue()]),
Line::from(vec![">>Cell4 Cell5 ".red(), "Cell6".blue(), " ".red()]),
Line::from(vec![" Cell7 ".into(), "Cell8 ".into(), "Cell9".blue()]),
Line::from(vec![" ".into(), " ".blue(), " ".into()]),
]);
assert_eq!(buf, expected);
}
#[test]
fn render_with_selected_row_and_column_and_cell() {
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 4));
let rows = vec![
Row::new(vec!["Cell1", "Cell2", "Cell3"]),
Row::new(vec!["Cell4", "Cell5", "Cell6"]),
Row::new(vec!["Cell7", "Cell8", "Cell9"]),
];
let table = Table::new(rows, [Constraint::Length(5); 3])
.highlight_symbol(">>")
.row_highlight_style(Style::new().red())
.column_highlight_style(Style::new().blue())
.cell_highlight_style(Style::new().green());
let mut state = TableState::new().with_selected(1).with_selected_column(2);
StatefulWidget::render(table, Rect::new(0, 0, 20, 4), &mut buf, &mut state);
let expected = Buffer::with_lines::<[Line; 4]>([
Line::from(vec![" Cell1 ".into(), "Cell2 ".into(), "Cell3".blue()]),
Line::from(vec![">>Cell4 Cell5 ".red(), "Cell6".green(), " ".red()]),
Line::from(vec![" Cell7 ".into(), "Cell8 ".into(), "Cell9".blue()]),
Line::from(vec![" ".into(), " ".blue(), " ".into()]),
]);
assert_eq!(buf, expected);
}
/// Note that this includes a regression test for a bug where the table would not render the
/// correct rows when there is no selection.
/// <https://github.com/ratatui/ratatui/issues/1179>
@ -1391,7 +1682,7 @@ mod tests {
let mut buf = Buffer::empty(Rect::new(0, 0, 2, 5));
let mut state = TableState::new()
.with_offset(50)
.with_selected(selected_row);
.with_selected(selected_row.into());
StatefulWidget::render(table.clone(), Rect::new(0, 0, 5, 5), &mut buf, &mut state);
@ -1408,15 +1699,15 @@ mod tests {
fn length_constraint() {
// without selection, more than needed width
let table = Table::default().widths([Length(4), Length(4)]);
assert_eq!(table.get_columns_widths(20, 0), [(0, 4), (5, 4)]);
assert_eq!(table.get_columns_widths(20, 0, 0), [(0, 4), (5, 4)]);
// with selection, more than needed width
let table = Table::default().widths([Length(4), Length(4)]);
assert_eq!(table.get_columns_widths(20, 3), [(3, 4), (8, 4)]);
assert_eq!(table.get_columns_widths(20, 3, 0), [(3, 4), (8, 4)]);
// without selection, less than needed width
let table = Table::default().widths([Length(4), Length(4)]);
assert_eq!(table.get_columns_widths(7, 0), [(0, 3), (4, 3)]);
assert_eq!(table.get_columns_widths(7, 0, 0), [(0, 3), (4, 3)]);
// with selection, less than needed width
// <--------7px-------->
@ -1425,26 +1716,26 @@ mod tests {
// └────────┘x└────────┘
// column spacing (i.e. `x`) is always prioritized
let table = Table::default().widths([Length(4), Length(4)]);
assert_eq!(table.get_columns_widths(7, 3), [(3, 2), (6, 1)]);
assert_eq!(table.get_columns_widths(7, 3, 0), [(3, 2), (6, 1)]);
}
#[test]
fn max_constraint() {
// without selection, more than needed width
let table = Table::default().widths([Max(4), Max(4)]);
assert_eq!(table.get_columns_widths(20, 0), [(0, 4), (5, 4)]);
assert_eq!(table.get_columns_widths(20, 0, 0), [(0, 4), (5, 4)]);
// with selection, more than needed width
let table = Table::default().widths([Max(4), Max(4)]);
assert_eq!(table.get_columns_widths(20, 3), [(3, 4), (8, 4)]);
assert_eq!(table.get_columns_widths(20, 3, 0), [(3, 4), (8, 4)]);
// without selection, less than needed width
let table = Table::default().widths([Max(4), Max(4)]);
assert_eq!(table.get_columns_widths(7, 0), [(0, 3), (4, 3)]);
assert_eq!(table.get_columns_widths(7, 0, 0), [(0, 3), (4, 3)]);
// with selection, less than needed width
let table = Table::default().widths([Max(4), Max(4)]);
assert_eq!(table.get_columns_widths(7, 3), [(3, 2), (6, 1)]);
assert_eq!(table.get_columns_widths(7, 3, 0), [(3, 2), (6, 1)]);
}
#[test]
@ -1455,42 +1746,42 @@ mod tests {
// without selection, more than needed width
let table = Table::default().widths([Min(4), Min(4)]);
assert_eq!(table.get_columns_widths(20, 0), [(0, 10), (11, 9)]);
assert_eq!(table.get_columns_widths(20, 0, 0), [(0, 10), (11, 9)]);
// with selection, more than needed width
let table = Table::default().widths([Min(4), Min(4)]);
assert_eq!(table.get_columns_widths(20, 3), [(3, 8), (12, 8)]);
assert_eq!(table.get_columns_widths(20, 3, 0), [(3, 8), (12, 8)]);
// without selection, less than needed width
// allocates spacer
let table = Table::default().widths([Min(4), Min(4)]);
assert_eq!(table.get_columns_widths(7, 0), [(0, 3), (4, 3)]);
assert_eq!(table.get_columns_widths(7, 0, 0), [(0, 3), (4, 3)]);
// with selection, less than needed width
// always allocates selection and spacer
let table = Table::default().widths([Min(4), Min(4)]);
assert_eq!(table.get_columns_widths(7, 3), [(3, 2), (6, 1)]);
assert_eq!(table.get_columns_widths(7, 3, 0), [(3, 2), (6, 1)]);
}
#[test]
fn percentage_constraint() {
// without selection, more than needed width
let table = Table::default().widths([Percentage(30), Percentage(30)]);
assert_eq!(table.get_columns_widths(20, 0), [(0, 6), (7, 6)]);
assert_eq!(table.get_columns_widths(20, 0, 0), [(0, 6), (7, 6)]);
// with selection, more than needed width
let table = Table::default().widths([Percentage(30), Percentage(30)]);
assert_eq!(table.get_columns_widths(20, 3), [(3, 5), (9, 5)]);
assert_eq!(table.get_columns_widths(20, 3, 0), [(3, 5), (9, 5)]);
// without selection, less than needed width
// rounds from positions: [0.0, 0.0, 2.1, 3.1, 5.2, 7.0]
let table = Table::default().widths([Percentage(30), Percentage(30)]);
assert_eq!(table.get_columns_widths(7, 0), [(0, 2), (3, 2)]);
assert_eq!(table.get_columns_widths(7, 0, 0), [(0, 2), (3, 2)]);
// with selection, less than needed width
// rounds from positions: [0.0, 3.0, 5.1, 6.1, 7.0, 7.0]
let table = Table::default().widths([Percentage(30), Percentage(30)]);
assert_eq!(table.get_columns_widths(7, 3), [(3, 1), (5, 1)]);
assert_eq!(table.get_columns_widths(7, 3, 0), [(3, 1), (5, 1)]);
}
#[test]
@ -1498,22 +1789,22 @@ mod tests {
// without selection, more than needed width
// rounds from positions: [0.00, 0.00, 6.67, 7.67, 14.33]
let table = Table::default().widths([Ratio(1, 3), Ratio(1, 3)]);
assert_eq!(table.get_columns_widths(20, 0), [(0, 7), (8, 6)]);
assert_eq!(table.get_columns_widths(20, 0, 0), [(0, 7), (8, 6)]);
// with selection, more than needed width
// rounds from positions: [0.00, 3.00, 10.67, 17.33, 20.00]
let table = Table::default().widths([Ratio(1, 3), Ratio(1, 3)]);
assert_eq!(table.get_columns_widths(20, 3), [(3, 6), (10, 5)]);
assert_eq!(table.get_columns_widths(20, 3, 0), [(3, 6), (10, 5)]);
// without selection, less than needed width
// rounds from positions: [0.00, 2.33, 3.33, 5.66, 7.00]
let table = Table::default().widths([Ratio(1, 3), Ratio(1, 3)]);
assert_eq!(table.get_columns_widths(7, 0), [(0, 2), (3, 3)]);
assert_eq!(table.get_columns_widths(7, 0, 0), [(0, 2), (3, 3)]);
// with selection, less than needed width
// rounds from positions: [0.00, 3.00, 5.33, 6.33, 7.00, 7.00]
let table = Table::default().widths([Ratio(1, 3), Ratio(1, 3)]);
assert_eq!(table.get_columns_widths(7, 3), [(3, 1), (5, 2)]);
assert_eq!(table.get_columns_widths(7, 3, 0), [(3, 1), (5, 2)]);
}
/// When more width is available than requested, the behavior is controlled by flex
@ -1521,7 +1812,7 @@ mod tests {
fn underconstrained_flex() {
let table = Table::default().widths([Min(10), Min(10), Min(1)]);
assert_eq!(
table.get_columns_widths(62, 0),
table.get_columns_widths(62, 0, 0),
&[(0, 20), (21, 20), (42, 20)]
);
@ -1529,7 +1820,7 @@ mod tests {
.widths([Min(10), Min(10), Min(1)])
.flex(Flex::Legacy);
assert_eq!(
table.get_columns_widths(62, 0),
table.get_columns_widths(62, 0, 0),
&[(0, 10), (11, 10), (22, 40)]
);
@ -1537,7 +1828,7 @@ mod tests {
.widths([Min(10), Min(10), Min(1)])
.flex(Flex::SpaceBetween);
assert_eq!(
table.get_columns_widths(62, 0),
table.get_columns_widths(62, 0, 0),
&[(0, 20), (21, 20), (42, 20)]
);
}
@ -1548,7 +1839,7 @@ mod tests {
fn underconstrained_segment_size() {
let table = Table::default().widths([Min(10), Min(10), Min(1)]);
assert_eq!(
table.get_columns_widths(62, 0),
table.get_columns_widths(62, 0, 0),
&[(0, 20), (21, 20), (42, 20)]
);
@ -1556,7 +1847,7 @@ mod tests {
.widths([Min(10), Min(10), Min(1)])
.flex(Flex::Legacy);
assert_eq!(
table.get_columns_widths(62, 0),
table.get_columns_widths(62, 0, 0),
&[(0, 10), (11, 10), (22, 40)]
);
}
@ -1573,7 +1864,7 @@ mod tests {
.footer(Row::new(vec!["h", "i"]))
.column_spacing(0);
assert_eq!(
table.get_columns_widths(30, 0),
table.get_columns_widths(30, 0, 3),
&[(0, 10), (10, 10), (20, 10)]
);
}
@ -1584,7 +1875,7 @@ mod tests {
.rows(vec![])
.header(Row::new(vec!["f", "g"]))
.column_spacing(0);
assert_eq!(table.get_columns_widths(10, 0), [(0, 5), (5, 5)]);
assert_eq!(table.get_columns_widths(10, 0, 2), [(0, 5), (5, 5)]);
}
#[test]
@ -1593,7 +1884,7 @@ mod tests {
.rows(vec![])
.footer(Row::new(vec!["h", "i"]))
.column_spacing(0);
assert_eq!(table.get_columns_widths(10, 0), [(0, 5), (5, 5)]);
assert_eq!(table.get_columns_widths(10, 0, 2), [(0, 5), (5, 5)]);
}
#[track_caller]
@ -1962,4 +2253,49 @@ mod tests {
.remove_modifier(Modifier::CROSSED_OUT)
);
}
#[rstest]
#[case::no_columns(vec![], vec![], vec![], 0)]
#[case::only_header(vec!["H1", "H2"], vec![], vec![], 2)]
#[case::only_rows(
vec![],
vec![vec!["C1", "C2"], vec!["C1", "C2", "C3"]],
vec![],
3
)]
#[case::only_footer(vec![], vec![], vec!["F1", "F2", "F3", "F4"], 4)]
#[case::rows_longer(
vec!["H1", "H2", "H3", "H4"],
vec![vec!["C1", "C2"],vec!["C1", "C2", "C3"]],
vec!["F1", "F2"],
4
)]
#[case::rows_longer(
vec!["H1", "H2"],
vec![vec!["C1", "C2"], vec!["C1", "C2", "C3", "C4"]],
vec!["F1", "F2"],
4
)]
#[case::footer_longer(
vec!["H1", "H2"],
vec![vec!["C1", "C2"], vec!["C1", "C2", "C3"]],
vec!["F1", "F2", "F3", "F4"],
4
)]
fn column_count(
#[case] header: Vec<&str>,
#[case] rows: Vec<Vec<&str>>,
#[case] footer: Vec<&str>,
#[case] expected: usize,
) {
let header = Row::new(header);
let footer = Row::new(footer);
let rows: Vec<Row> = rows.into_iter().map(Row::new).collect();
let table = Table::new(rows, Vec::<Constraint>::new())
.header(header)
.footer(footer);
let column_count = table.column_count();
assert_eq!(column_count, expected);
}
}

View file

@ -1,16 +1,19 @@
/// State of a [`Table`] widget
///
/// This state can be used to scroll through the rows and select one of them. When the table is
/// rendered as a stateful widget, the selected row will be highlighted and the table will be
/// shifted to ensure that the selected row is visible. This will modify the [`TableState`] object
/// passed to the [`Frame::render_stateful_widget`] method.
/// rendered as a stateful widget, the selected row, column and cell will be highlighted and the
/// table will be shifted to ensure that the selected row is visible. This will modify the
/// [`TableState`] object passed to the [`Frame::render_stateful_widget`] method.
///
/// The state consists of two fields:
/// - [`offset`]: the index of the first row to be displayed
/// - [`selected`]: the index of the selected row, which can be `None` if no row is selected
/// - [`selected_column`]: the index of the selected column, which can be `None` if no column is
/// selected
///
/// [`offset`]: TableState::offset()
/// [`selected`]: TableState::selected()
/// [`selected_column`]: TableState::selected_column()
///
/// See the `table` example and the `recipe` and `traceroute` tabs in the demo2 example in the
/// [Examples] directory for a more in depth example of the various configuration options and for
@ -38,6 +41,7 @@
/// let mut table_state = TableState::default();
/// *table_state.offset_mut() = 1; // display the second row and onwards
/// table_state.select(Some(3)); // select the forth row (0-indexed)
/// table_state.select_column(Some(2)); // select the third column (0-indexed)
///
/// frame.render_stateful_widget(table, area, &mut table_state);
/// # }
@ -54,6 +58,7 @@
pub struct TableState {
pub(crate) offset: usize,
pub(crate) selected: Option<usize>,
pub(crate) selected_column: Option<usize>,
}
impl TableState {
@ -70,6 +75,7 @@ impl TableState {
Self {
offset: 0,
selected: None,
selected_column: None,
}
}
@ -110,6 +116,51 @@ impl TableState {
self
}
/// Sets the index of the selected column
///
/// This is a fluent setter method which must be chained or used as it consumes self
///
/// # Examples
///
/// ```rust
/// # use ratatui::{prelude::*, widgets::*};
/// let state = TableState::new().with_selected_column(Some(1));
/// ```
#[must_use = "method moves the value of self and returns the modified value"]
pub fn with_selected_column<T>(mut self, selected: T) -> Self
where
T: Into<Option<usize>>,
{
self.selected_column = selected.into();
self
}
/// Sets the indexes of the selected cell
///
/// This is a fluent setter method which must be chained or used as it consumes self
///
/// # Examples
///
/// ```rust
/// # use ratatui::{prelude::*, widgets::*};
/// let state = TableState::new().with_selected_cell(Some((1, 5)));
/// ```
#[must_use = "method moves the value of self and returns the modified value"]
pub fn with_selected_cell<T>(mut self, selected: T) -> Self
where
T: Into<Option<(usize, usize)>>,
{
if let Some((r, c)) = selected.into() {
self.selected = Some(r);
self.selected_column = Some(c);
} else {
self.selected = None;
self.selected_column = None;
}
self
}
/// Index of the first row to be displayed
///
/// # Examples
@ -154,6 +205,39 @@ impl TableState {
self.selected
}
/// Index of the selected column
///
/// Returns `None` if no column is selected
///
/// # Examples
///
/// ```rust
/// # use ratatui::{prelude::*, widgets::*};
/// let state = TableState::new();
/// assert_eq!(state.selected_column(), None);
/// ```
pub const fn selected_column(&self) -> Option<usize> {
self.selected_column
}
/// Indexes of the selected cell
///
/// Returns `None` if no cell is selected
///
/// # Examples
///
/// ```rust
/// # use ratatui::{prelude::*, widgets::*};
/// let state = TableState::new();
/// assert_eq!(state.selected_cell(), None);
/// ```
pub const fn selected_cell(&self) -> Option<(usize, usize)> {
if let (Some(r), Some(c)) = (self.selected, self.selected_column) {
return Some((r, c));
}
None
}
/// Mutable reference to the index of the selected row
///
/// Returns `None` if no row is selected
@ -170,6 +254,21 @@ impl TableState {
&mut self.selected
}
/// Mutable reference to the index of the selected column
///
/// Returns `None` if no column is selected
///
/// # Examples
///
/// ```rust
/// # use ratatui::{prelude::*, widgets::*};
/// let mut state = TableState::default();
/// *state.selected_column_mut() = Some(1);
/// ```
pub fn selected_column_mut(&mut self) -> &mut Option<usize> {
&mut self.selected_column
}
/// Sets the index of the selected row
///
/// Set to `None` if no row is selected. This will also reset the offset to `0`.
@ -189,9 +288,44 @@ impl TableState {
}
}
/// Selects the next item or the first one if no item is selected
/// Sets the index of the selected column
///
/// Note: until the table is rendered, the number of items is not known, so the index is set to
/// # Examples
///
/// ```rust
/// # use ratatui::{prelude::*, widgets::*};
/// let mut state = TableState::default();
/// state.select_column(Some(1));
/// ```
pub fn select_column(&mut self, index: Option<usize>) {
self.selected_column = index;
}
/// Sets the indexes of the selected cell
///
/// Set to `None` if no cell is selected. This will also reset the row offset to `0`.
///
/// # Examples
///
/// ```rust
/// # use ratatui::{prelude::*, widgets::*};
/// let mut state = TableState::default();
/// state.select_cell(Some((1, 5)));
/// ```
pub fn select_cell(&mut self, indexes: Option<(usize, usize)>) {
if let Some((r, c)) = indexes {
self.selected = Some(r);
self.selected_column = Some(c);
} else {
self.offset = 0;
self.selected = None;
self.selected_column = None;
}
}
/// Selects the next row or the first one if no row is selected
///
/// Note: until the table is rendered, the number of rows is not known, so the index is set to
/// `0` and will be corrected when the table is rendered
///
/// # Examples
@ -207,9 +341,26 @@ impl TableState {
self.select(Some(next));
}
/// Selects the previous item or the last one if no item is selected
/// Selects the next column or the first one if no column is selected
///
/// Note: until the table is rendered, the number of items is not known, so the index is set to
/// Note: until the table is rendered, the number of columns is not known, so the index is set
/// to `0` and will be corrected when the table is rendered
///
/// # Examples
///
/// ```rust
/// # use ratatui::{prelude::*, widgets::*};
/// let mut state = TableState::default();
/// state.select_next_column();
/// ```
pub fn select_next_column(&mut self) {
let next = self.selected_column.map_or(0, |i| i.saturating_add(1));
self.select_column(Some(next));
}
/// Selects the previous row or the last one if no item is selected
///
/// Note: until the table is rendered, the number of rows is not known, so the index is set to
/// `usize::MAX` and will be corrected when the table is rendered
///
/// # Examples
@ -225,9 +376,28 @@ impl TableState {
self.select(Some(previous));
}
/// Selects the first item
/// Selects the previous column or the last one if no column is selected
///
/// Note: until the table is rendered, the number of items is not known, so the index is set to
/// Note: until the table is rendered, the number of columns is not known, so the index is set
/// to `usize::MAX` and will be corrected when the table is rendered
///
/// # Examples
///
/// ```rust
/// # use ratatui::{prelude::*, widgets::*};
/// let mut state = TableState::default();
/// state.select_previous_column();
/// ```
pub fn select_previous_column(&mut self) {
let previous = self
.selected_column
.map_or(usize::MAX, |i| i.saturating_sub(1));
self.select_column(Some(previous));
}
/// Selects the first row
///
/// Note: until the table is rendered, the number of rows is not known, so the index is set to
/// `0` and will be corrected when the table is rendered
///
/// # Examples
@ -242,9 +412,25 @@ impl TableState {
self.select(Some(0));
}
/// Selects the last item
/// Selects the first column
///
/// Note: until the table is rendered, the number of items is not known, so the index is set to
/// Note: until the table is rendered, the number of columns is not known, so the index is set
/// to `0` and will be corrected when the table is rendered
///
/// # Examples
///
/// ```rust
/// # use ratatui::{prelude::*, widgets::*};
/// let mut state = TableState::default();
/// state.select_first_column();
/// ```
pub fn select_first_column(&mut self) {
self.select_column(Some(0));
}
/// Selects the last row
///
/// Note: until the table is rendered, the number of rows is not known, so the index is set to
/// `usize::MAX` and will be corrected when the table is rendered
///
/// # Examples
@ -259,11 +445,27 @@ impl TableState {
self.select(Some(usize::MAX));
}
/// Selects the last column
///
/// Note: until the table is rendered, the number of columns is not known, so the index is set
/// to `usize::MAX` and will be corrected when the table is rendered
///
/// # Examples
///
/// ```rust
/// # use ratatui::{prelude::*, widgets::*};
/// let mut state = TableState::default();
/// state.select_last();
/// ```
pub fn select_last_column(&mut self) {
self.select_column(Some(usize::MAX));
}
/// Scrolls down by a specified `amount` in the table.
///
/// This method updates the selected index by moving it down by the given `amount`.
/// If the `amount` causes the index to go out of bounds (i.e., if the index is greater than
/// the length of the table), the last item in the table will be selected.
/// the number of rows in the table), the last row in the table will be selected.
///
/// # Examples
///
@ -282,7 +484,7 @@ impl TableState {
///
/// This method updates the selected index by moving it up by the given `amount`.
/// If the `amount` causes the index to go out of bounds (i.e., less than zero),
/// the first item in the table will be selected.
/// the first row in the table will be selected.
///
/// # Examples
///
@ -296,6 +498,42 @@ impl TableState {
let selected = self.selected.unwrap_or_default();
self.select(Some(selected.saturating_sub(amount as usize)));
}
/// Scrolls right by a specified `amount` in the table.
///
/// This method updates the selected index by moving it right by the given `amount`.
/// If the `amount` causes the index to go out of bounds (i.e., if the index is greater than
/// the number of columns in the table), the last column in the table will be selected.
///
/// # Examples
///
/// ```rust
/// # use ratatui::{prelude::*, widgets::*};
/// let mut state = TableState::default();
/// state.scroll_right_by(4);
/// ```
pub fn scroll_right_by(&mut self, amount: u16) {
let selected = self.selected_column.unwrap_or_default();
self.select_column(Some(selected.saturating_add(amount as usize)));
}
/// Scrolls left by a specified `amount` in the table.
///
/// This method updates the selected index by moving it left by the given `amount`.
/// If the `amount` causes the index to go out of bounds (i.e., less than zero),
/// the first item in the table will be selected.
///
/// # Examples
///
/// ```rust
/// # use ratatui::{prelude::*, widgets::*};
/// let mut state = TableState::default();
/// state.scroll_left_by(4);
/// ```
pub fn scroll_left_by(&mut self, amount: u16) {
let selected = self.selected_column.unwrap_or_default();
self.select_column(Some(selected.saturating_sub(amount as usize)));
}
}
#[cfg(test)]
@ -307,6 +545,7 @@ mod tests {
let state = TableState::new();
assert_eq!(state.offset, 0);
assert_eq!(state.selected, None);
assert_eq!(state.selected_column, None);
}
#[test]
@ -321,6 +560,19 @@ mod tests {
assert_eq!(state.selected, Some(1));
}
#[test]
fn with_selected_column() {
let state = TableState::new().with_selected_column(Some(1));
assert_eq!(state.selected_column, Some(1));
}
#[test]
fn with_selected_cell_none() {
let state = TableState::new().with_selected_cell(None);
assert_eq!(state.selected, None);
assert_eq!(state.selected_column, None);
}
#[test]
fn offset() {
let state = TableState::new();
@ -340,6 +592,18 @@ mod tests {
assert_eq!(state.selected(), None);
}
#[test]
fn selected_column() {
let state = TableState::new();
assert_eq!(state.selected_column(), None);
}
#[test]
fn selected_cell() {
let state = TableState::new();
assert_eq!(state.selected_cell(), None);
}
#[test]
fn selected_mut() {
let mut state = TableState::new();
@ -347,6 +611,13 @@ mod tests {
assert_eq!(state.selected, Some(1));
}
#[test]
fn selected_column_mut() {
let mut state = TableState::new();
*state.selected_column_mut() = Some(1);
assert_eq!(state.selected_column, Some(1));
}
#[test]
fn select() {
let mut state = TableState::new();
@ -361,6 +632,36 @@ mod tests {
assert_eq!(state.selected, None);
}
#[test]
fn select_column() {
let mut state = TableState::new();
state.select_column(Some(1));
assert_eq!(state.selected_column, Some(1));
}
#[test]
fn select_column_none() {
let mut state = TableState::new().with_selected_column(Some(1));
state.select_column(None);
assert_eq!(state.selected_column, None);
}
#[test]
fn select_cell() {
let mut state = TableState::new();
state.select_cell(Some((1, 5)));
assert_eq!(state.selected_cell(), Some((1, 5)));
}
#[test]
fn select_cell_none() {
let mut state = TableState::new().with_selected_cell(Some((1, 5)));
state.select_cell(None);
assert_eq!(state.selected, None);
assert_eq!(state.selected_column, None);
assert_eq!(state.selected_cell(), None);
}
#[test]
fn test_table_state_navigation() {
let mut state = TableState::default();
@ -411,5 +712,37 @@ mod tests {
state.scroll_up_by(4);
assert_eq!(state.selected, Some(0));
let mut state = TableState::default();
state.select_first_column();
assert_eq!(state.selected_column, Some(0));
state.select_previous_column();
assert_eq!(state.selected_column, Some(0));
state.select_next_column();
assert_eq!(state.selected_column, Some(1));
state.select_previous_column();
assert_eq!(state.selected_column, Some(0));
state.select_last_column();
assert_eq!(state.selected_column, Some(usize::MAX));
state.select_previous_column();
assert_eq!(state.selected_column, Some(usize::MAX - 1));
let mut state = TableState::default().with_selected_column(Some(12));
state.scroll_right_by(4);
assert_eq!(state.selected_column, Some(16));
state.scroll_left_by(20);
assert_eq!(state.selected_column, Some(0));
state.scroll_right_by(100);
assert_eq!(state.selected_column, Some(100));
state.scroll_left_by(20);
assert_eq!(state.selected_column, Some(80));
}
}

View file

@ -44,7 +44,7 @@ impl Default for AppState {
impl AppState {
fn select(&mut self, index: usize) {
self.list.select(Some(index));
self.table.select(Some(index));
self.table.select_cell(Some((index, index)));
self.scrollbar = self.scrollbar.position(index);
}
}
@ -107,7 +107,8 @@ const DEFAULT_STATE_REPR: &str = r#"{
},
"table": {
"offset": 0,
"selected": null
"selected": null,
"selected_column": null
},
"scrollbar": {
"content_length": 10,
@ -144,7 +145,8 @@ const SELECTED_STATE_REPR: &str = r#"{
},
"table": {
"offset": 0,
"selected": 1
"selected": 1,
"selected_column": 0
},
"scrollbar": {
"content_length": 10,
@ -183,7 +185,8 @@ const SCROLLED_STATE_REPR: &str = r#"{
},
"table": {
"offset": 4,
"selected": 8
"selected": 8,
"selected_column": 0
},
"scrollbar": {
"content_length": 10,
@ -206,3 +209,23 @@ fn scrolled_state_deserialize() {
let mut state: AppState = serde_json::from_str(SCROLLED_STATE_REPR).unwrap();
assert_buffer(&mut state, SCROLLED_STATE_BUFFER);
}
// For backwards compatibility these fields should be enough to deserialize the state.
const OLD_TABLE_DESERIALIZE: &str = r#"{
"offset": 0,
"selected": 1
}"#;
const NEW_TABLE_DESERIALIZE: &str = r#"{
"offset": 0,
"selected": 1,
"selected_column": null
}"#;
// This test is to check for backwards compatibility with the old states.
#[test]
fn table_state_backwards_compatibility() {
let old_state: TableState = serde_json::from_str(OLD_TABLE_DESERIALIZE).unwrap();
let new_state: TableState = serde_json::from_str(NEW_TABLE_DESERIALIZE).unwrap();
assert_eq!(old_state, new_state);
}

View file

@ -632,6 +632,7 @@ fn widgets_table_can_have_elements_styled_individually() {
let mut terminal = Terminal::new(backend).unwrap();
let mut state = TableState::default();
state.select(Some(0));
state.select_column(Some(1));
terminal
.draw(|f| {
let table = Table::new(
@ -658,7 +659,9 @@ fn widgets_table_can_have_elements_styled_individually() {
.header(Row::new(vec!["Head1", "Head2", "Head3"]).bottom_margin(1))
.block(Block::new().borders(Borders::LEFT | Borders::RIGHT))
.highlight_symbol(">> ")
.highlight_style(Style::default().add_modifier(Modifier::BOLD))
.row_highlight_style(Style::default().add_modifier(Modifier::BOLD))
.column_highlight_style(Style::default().add_modifier(Modifier::ITALIC))
.cell_highlight_style(Style::default().add_modifier(Modifier::DIM))
.column_spacing(1);
f.render_stateful_widget(table, f.area(), &mut state);
})
@ -678,6 +681,19 @@ fn widgets_table_can_have_elements_styled_individually() {
.add_modifier(Modifier::BOLD),
);
}
// Second column highlight style
for row in 2..=3 {
for col in 11..=16 {
expected[(col, row)].set_style(Style::default().add_modifier(Modifier::ITALIC));
}
}
// First row, second column highlight style (cell highlight)
for col in 11..=16 {
expected[(col, 2)].set_style(Style::default().add_modifier(Modifier::DIM));
}
// Second row:
// 1. row color
for col in 1..=28 {