From a7c21a972921d9a6008f59fc2bc8f7a08daec936 Mon Sep 17 00:00:00 2001 From: Florian Dehau Date: Sun, 1 Aug 2021 17:51:44 +0200 Subject: [PATCH] fix(widgets): avoid offset panic in `Table` and `List` when input changes --- src/widgets/list.rs | 67 +++++++++++++++++++++++---------------- src/widgets/table.rs | 1 + tests/widgets_list.rs | 40 +++++++++++++++++++++++ tests/widgets_table.rs | 72 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 152 insertions(+), 28 deletions(-) diff --git a/src/widgets/list.rs b/src/widgets/list.rs index d2fc3b17..47538192 100644 --- a/src/widgets/list.rs +++ b/src/widgets/list.rs @@ -128,6 +128,44 @@ impl<'a> List<'a> { self.start_corner = corner; self } + + fn get_items_bounds( + &self, + selected: Option, + offset: usize, + max_height: usize, + ) -> (usize, usize) { + let offset = offset.min(self.items.len().saturating_sub(1)); + let mut start = offset; + let mut end = offset; + let mut height = 0; + for item in self.items.iter().skip(offset) { + if height + item.height() > max_height { + break; + } + height += item.height(); + end += 1; + } + + let selected = selected.unwrap_or(0).min(self.items.len() - 1); + while selected >= end { + height = height.saturating_add(self.items[end].height()); + end += 1; + while height > max_height { + height = height.saturating_sub(self.items[start].height()); + start += 1; + } + } + while selected < start { + start -= 1; + height = height.saturating_add(self.items[start].height()); + while height > max_height { + end -= 1; + height = height.saturating_sub(self.items[end].height()); + } + } + (start, end) + } } impl<'a> StatefulWidget for List<'a> { @@ -153,34 +191,7 @@ impl<'a> StatefulWidget for List<'a> { } let list_height = list_area.height as usize; - let mut start = state.offset; - let mut end = state.offset; - let mut height = 0; - for item in self.items.iter().skip(state.offset) { - if height + item.height() > list_height { - break; - } - height += item.height(); - end += 1; - } - - let selected = state.selected.unwrap_or(0).min(self.items.len() - 1); - while selected >= end { - height = height.saturating_add(self.items[end].height()); - end += 1; - while height > list_height { - height = height.saturating_sub(self.items[start].height()); - start += 1; - } - } - while selected < start { - start -= 1; - height = height.saturating_add(self.items[start].height()); - while height > list_height { - end -= 1; - height = height.saturating_sub(self.items[end].height()); - } - } + let (start, end) = self.get_items_bounds(state.selected, state.offset, list_height); state.offset = start; let highlight_symbol = self.highlight_symbol.unwrap_or(""); diff --git a/src/widgets/table.rs b/src/widgets/table.rs index bb06f34f..fa75e77c 100644 --- a/src/widgets/table.rs +++ b/src/widgets/table.rs @@ -302,6 +302,7 @@ impl<'a> Table<'a> { offset: usize, max_height: u16, ) -> (usize, usize) { + let offset = offset.min(self.rows.len().saturating_sub(1)); let mut start = offset; let mut end = offset; let mut height = 0; diff --git a/tests/widgets_list.rs b/tests/widgets_list.rs index 70280318..1dd8fa8b 100644 --- a/tests/widgets_list.rs +++ b/tests/widgets_list.rs @@ -86,3 +86,43 @@ fn widgets_list_should_truncate_items() { terminal.backend().assert_buffer(&case.expected); } } + +#[test] +fn widgets_list_should_clamp_offset_if_items_are_removed() { + let backend = TestBackend::new(10, 4); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = ListState::default(); + + // render with 6 items => offset will be at 2 + state.select(Some(5)); + terminal + .draw(|f| { + let size = f.size(); + let items = vec![ + ListItem::new("Item 0"), + ListItem::new("Item 1"), + ListItem::new("Item 2"), + ListItem::new("Item 3"), + ListItem::new("Item 4"), + ListItem::new("Item 5"), + ]; + let list = List::new(items).highlight_symbol(">> "); + f.render_stateful_widget(list, size, &mut state); + }) + .unwrap(); + let expected = Buffer::with_lines(vec![" Item 2 ", " Item 3 ", " Item 4 ", ">> Item 5 "]); + terminal.backend().assert_buffer(&expected); + + // render again with 1 items => check offset is clamped to 1 + state.select(Some(1)); + terminal + .draw(|f| { + let size = f.size(); + let items = vec![ListItem::new("Item 3")]; + let list = List::new(items).highlight_symbol(">> "); + f.render_stateful_widget(list, size, &mut state); + }) + .unwrap(); + let expected = Buffer::with_lines(vec![" Item 3 ", " ", " ", " "]); + terminal.backend().assert_buffer(&expected); +} diff --git a/tests/widgets_table.rs b/tests/widgets_table.rs index a790a5a5..8f292d92 100644 --- a/tests/widgets_table.rs +++ b/tests/widgets_table.rs @@ -749,3 +749,75 @@ fn widgets_table_columns_dont_panic() { state.select(Some(0)); test_case(&mut state, table1.clone(), table1_width); } + +#[test] +fn widgets_table_should_clamp_offset_if_rows_are_removed() { + let backend = TestBackend::new(30, 8); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = TableState::default(); + + // render with 6 items => offset will be at 2 + state.select(Some(5)); + terminal + .draw(|f| { + let size = f.size(); + let table = Table::new(vec![ + Row::new(vec!["Row01", "Row02", "Row03"]), + Row::new(vec!["Row11", "Row12", "Row13"]), + Row::new(vec!["Row21", "Row22", "Row23"]), + Row::new(vec!["Row31", "Row32", "Row33"]), + Row::new(vec!["Row41", "Row42", "Row43"]), + Row::new(vec!["Row51", "Row52", "Row53"]), + ]) + .header(Row::new(vec!["Head1", "Head2", "Head3"]).bottom_margin(1)) + .block(Block::default().borders(Borders::ALL)) + .widths(&[ + Constraint::Length(5), + Constraint::Length(5), + Constraint::Length(5), + ]) + .column_spacing(1); + f.render_stateful_widget(table, size, &mut state); + }) + .unwrap(); + let expected = Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│Head1 Head2 Head3 │", + "│ │", + "│Row21 Row22 Row23 │", + "│Row31 Row32 Row33 │", + "│Row41 Row42 Row43 │", + "│Row51 Row52 Row53 │", + "└────────────────────────────┘", + ]); + terminal.backend().assert_buffer(&expected); + + // render with 1 item => offset will be at 1 + state.select(Some(1)); + terminal + .draw(|f| { + let size = f.size(); + let table = Table::new(vec![Row::new(vec!["Row31", "Row32", "Row33"])]) + .header(Row::new(vec!["Head1", "Head2", "Head3"]).bottom_margin(1)) + .block(Block::default().borders(Borders::ALL)) + .widths(&[ + Constraint::Length(5), + Constraint::Length(5), + Constraint::Length(5), + ]) + .column_spacing(1); + f.render_stateful_widget(table, size, &mut state); + }) + .unwrap(); + let expected = Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│Head1 Head2 Head3 │", + "│ │", + "│Row31 Row32 Row33 │", + "│ │", + "│ │", + "│ │", + "└────────────────────────────┘", + ]); + terminal.backend().assert_buffer(&expected); +}