fix(list): Modify List and List example to support saving offsets. (#667)

The current `List` example will unselect and reset the position of a
list.

This PR will save the last selected item, and updates `List` to honor
its offset, preventing the list from resetting when the user
`unselect()`s a `StatefulList`.
This commit is contained in:
bblsh 2024-01-19 03:17:39 -05:00 committed by GitHub
parent 2819eea82b
commit b3a57f3dff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 83 additions and 29 deletions

View file

@ -14,6 +14,7 @@ use ratatui::{prelude::*, widgets::*};
struct StatefulList<T> {
state: ListState,
items: Vec<T>,
last_selected: Option<usize>,
}
impl<T> StatefulList<T> {
@ -21,6 +22,7 @@ impl<T> StatefulList<T> {
StatefulList {
state: ListState::default(),
items,
last_selected: None,
}
}
@ -33,7 +35,7 @@ impl<T> StatefulList<T> {
i + 1
}
}
None => 0,
None => self.last_selected.unwrap_or(0),
};
self.state.select(Some(i));
}
@ -47,13 +49,16 @@ impl<T> StatefulList<T> {
i - 1
}
}
None => 0,
None => self.last_selected.unwrap_or(0),
};
self.state.select(Some(i));
}
fn unselect(&mut self) {
let offset = self.state.offset();
self.last_selected = self.state.selected();
self.state.select(None);
*self.state.offset_mut() = offset;
}
}

View file

@ -738,6 +738,7 @@ impl<'a> List<'a> {
self.items.is_empty()
}
/// Given an offset, calculate which items can fit in a given area
fn get_items_bounds(
&self,
selected: Option<usize>,
@ -745,35 +746,69 @@ impl<'a> List<'a> {
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;
// Note: visible here implies visible in the given area
let mut first_visible_index = offset;
let mut last_visible_index = offset;
// Current height of all items in the list to render, beginning at the offset
let mut height_from_offset = 0;
// Calculate the last visible index and total height of the items
// that will fit in the available space
for item in self.items.iter().skip(offset) {
if height + item.height() > max_height {
if height_from_offset + item.height() > max_height {
break;
}
height += item.height();
end += 1;
height_from_offset += item.height();
last_visible_index += 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;
// Get the selected index, but still honor the offset if nothing is selected
// This allows for the list to stay at a position after select()ing None.
let index_to_display = selected.unwrap_or(offset).min(self.items.len() - 1);
// Recall that last_visible_index is the index of what we
// can render up to in the given space after the offset
// If we have an item selected that is out of the viewable area (or
// the offset is still set), we still need to show this item
while index_to_display >= last_visible_index {
height_from_offset =
height_from_offset.saturating_add(self.items[last_visible_index].height());
last_visible_index += 1;
// Now we need to hide previous items since we didn't have space
// for the selected/offset item
while height_from_offset > max_height {
height_from_offset =
height_from_offset.saturating_sub(self.items[first_visible_index].height());
// Remove this item to view by starting at the next item index
first_visible_index += 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());
// Here we're doing something similar to what we just did above
// If the selected item index is not in the viewable area, let's try to show the item
while index_to_display < first_visible_index {
first_visible_index -= 1;
height_from_offset =
height_from_offset.saturating_add(self.items[first_visible_index].height());
// Don't show an item if it is beyond our viewable height
while height_from_offset > max_height {
last_visible_index -= 1;
height_from_offset =
height_from_offset.saturating_sub(self.items[last_visible_index].height());
}
}
(start, end)
(first_visible_index, last_visible_index)
}
}
@ -791,18 +826,19 @@ impl<'a> StatefulWidget for List<'a> {
None => area,
};
if list_area.width < 1 || list_area.height < 1 {
if self.items.is_empty() || list_area.is_empty() {
return;
}
if self.items.is_empty() {
return;
}
let list_height = list_area.height as usize;
let (start, end) = self.get_items_bounds(state.selected, state.offset, list_height);
state.offset = start;
let (first_visible_index, last_visible_index) =
self.get_items_bounds(state.selected, state.offset, list_height);
// Important: this changes the state's offset to be the beginning of the now viewable items
state.offset = first_visible_index;
// Get our set highlighted symbol (if one was set)
let highlight_symbol = self.highlight_symbol.unwrap_or("");
let blank_symbol = " ".repeat(highlight_symbol.width());
@ -813,7 +849,7 @@ impl<'a> StatefulWidget for List<'a> {
.iter_mut()
.enumerate()
.skip(state.offset)
.take(end - start)
.take(last_visible_index - first_visible_index)
{
let (x, y) = if self.direction == ListDirection::BottomToTop {
current_height += item.height() as u16;
@ -1649,6 +1685,19 @@ mod tests {
assert_buffer_eq!(buffer, expected);
}
#[test]
fn test_offset_renders_shifted() {
let items = list_items(vec![
"Item 0", "Item 1", "Item 2", "Item 3", "Item 4", "Item 5", "Item 6",
]);
let list = List::new(items);
let mut state = ListState::default().with_offset(3);
let buffer = render_stateful_widget(list, &mut state, 6, 3);
let expected = Buffer::with_lines(vec!["Item 3", "Item 4", "Item 5"]);
assert_buffer_eq!(buffer, expected);
}
#[test]
fn test_list_long_lines() {
let items = list_items(vec![