Skip to content

Commit 654949b

Browse files
feat(list): Add Scroll Padding to Lists (#958)
Introduces scroll padding, which allows the api user to request that a certain number of ListItems be kept visible above and below the currently selected item while scrolling. ```rust let list = List::new(items).scroll_padding(1); ``` Fixes: #955
1 parent 943c043 commit 654949b

File tree

1 file changed

+279
-3
lines changed

1 file changed

+279
-3
lines changed

src/widgets/list.rs

+279-3
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,8 @@ pub struct List<'a> {
438438
repeat_highlight_symbol: bool,
439439
/// Decides when to allocate spacing for the selection symbol
440440
highlight_spacing: HighlightSpacing,
441+
/// How many items to try to keep visible before and after the selected item
442+
scroll_padding: usize,
441443
}
442444

443445
/// Defines the direction in which the list will be rendered.
@@ -685,6 +687,25 @@ impl<'a> List<'a> {
685687
self
686688
}
687689

690+
/// Sets the number of items around the currently selected item that should be kept visible
691+
///
692+
/// This is a fluent setter method which must be chained or used as it consumes self
693+
///
694+
/// # Example
695+
///
696+
/// A padding value of 1 will keep 1 item above and 1 item bellow visible if possible
697+
///
698+
/// ```rust
699+
/// # use ratatui::{prelude::*, widgets::*};
700+
/// # let items = vec!["Item 1"];
701+
/// let list = List::new(items).scroll_padding(1);
702+
/// ```
703+
#[must_use = "method moves the value of self and returns the modified value"]
704+
pub fn scroll_padding(mut self, padding: usize) -> List<'a> {
705+
self.scroll_padding = padding;
706+
self
707+
}
708+
688709
/// Defines the list direction (up or down)
689710
///
690711
/// Defines if the `List` is displayed *top to bottom* (default) or *bottom to top*. Use
@@ -737,6 +758,52 @@ impl<'a> List<'a> {
737758
self.items.is_empty()
738759
}
739760

761+
/// Applies scroll padding to the selected index, reducing the padding value to keep the
762+
/// selected item on screen even with items of inconsistent sizes
763+
///
764+
/// This function is sensitive to how the bounds checking function handles item height
765+
fn apply_scroll_padding_to_selected_index(
766+
&self,
767+
selected: Option<usize>,
768+
max_height: usize,
769+
first_visible_index: usize,
770+
last_visible_index: usize,
771+
) -> Option<usize> {
772+
let last_valid_index = self.items.len().saturating_sub(1);
773+
let selected = selected?.min(last_valid_index);
774+
775+
// The bellow loop handles situations where the list item sizes may not be consistent,
776+
// where the offset would have excluded some items that we want to include, or could
777+
// cause the offset value to be set to an inconsistent value each time we render.
778+
// The padding value will be reduced in case any of these issues would occur
779+
let mut scroll_padding = self.scroll_padding;
780+
while scroll_padding > 0 {
781+
let mut height_around_selected = 0;
782+
for index in selected.saturating_sub(scroll_padding)
783+
..=selected
784+
.saturating_add(scroll_padding)
785+
.min(last_valid_index)
786+
{
787+
height_around_selected += self.items[index].height();
788+
}
789+
if height_around_selected <= max_height {
790+
break;
791+
}
792+
scroll_padding -= 1;
793+
}
794+
795+
Some(
796+
if (selected + scroll_padding).min(last_valid_index) >= last_visible_index {
797+
selected + scroll_padding
798+
} else if selected.saturating_sub(scroll_padding) < first_visible_index {
799+
selected.saturating_sub(scroll_padding)
800+
} else {
801+
selected
802+
}
803+
.min(last_valid_index),
804+
)
805+
}
806+
740807
/// Given an offset, calculate which items can fit in a given area
741808
fn get_items_bounds(
742809
&self,
@@ -765,9 +832,17 @@ impl<'a> List<'a> {
765832
last_visible_index += 1;
766833
}
767834

768-
// Get the selected index, but still honor the offset if nothing is selected
769-
// This allows for the list to stay at a position after select()ing None.
770-
let index_to_display = selected.unwrap_or(offset).min(self.items.len() - 1);
835+
// Get the selected index and apply scroll_padding to it, but still honor the offset if
836+
// nothing is selected. This allows for the list to stay at a position after select()ing
837+
// None.
838+
let index_to_display = self
839+
.apply_scroll_padding_to_selected_index(
840+
selected,
841+
max_height,
842+
first_visible_index,
843+
last_visible_index,
844+
)
845+
.unwrap_or(offset);
771846

772847
// Recall that last_visible_index is the index of what we
773848
// can render up to in the given space after the offset
@@ -970,6 +1045,9 @@ where
9701045
mod tests {
9711046
use std::borrow::Cow;
9721047

1048+
use pretty_assertions::assert_eq;
1049+
use rstest::rstest;
1050+
9731051
use super::*;
9741052
use crate::{
9751053
assert_buffer_eq,
@@ -1963,4 +2041,202 @@ mod tests {
19632041
let expected = Buffer::with_lines(vec!["Large", " ", " "]);
19642042
assert_buffer_eq!(buffer, expected);
19652043
}
2044+
2045+
#[rstest]
2046+
#[case::no_padding(
2047+
4,
2048+
2, // Offset
2049+
0, // Padding
2050+
Some(2), // Selected
2051+
Buffer::with_lines(vec![">> Item 2 ", " Item 3 ", " Item 4 ", " Item 5 "])
2052+
)]
2053+
#[case::one_before(
2054+
4,
2055+
2, // Offset
2056+
1, // Padding
2057+
Some(2), // Selected
2058+
Buffer::with_lines(vec![" Item 1 ", ">> Item 2 ", " Item 3 ", " Item 4 "])
2059+
)]
2060+
#[case::one_after(
2061+
4,
2062+
1, // Offset
2063+
1, // Padding
2064+
Some(4), // Selected
2065+
Buffer::with_lines(vec![" Item 2 ", " Item 3 ", ">> Item 4 ", " Item 5 "])
2066+
)]
2067+
#[case::check_padding_overflow(
2068+
4,
2069+
1, // Offset
2070+
2, // Padding
2071+
Some(4), // Selected
2072+
Buffer::with_lines(vec![" Item 2 ", " Item 3 ", ">> Item 4 ", " Item 5 "])
2073+
)]
2074+
#[case::no_padding_offset_behavior(
2075+
5, // Render Area Height
2076+
2, // Offset
2077+
0, // Padding
2078+
Some(3), // Selected
2079+
Buffer::with_lines(
2080+
vec![" Item 2 ", ">> Item 3 ", " Item 4 ", " Item 5 ", " "]
2081+
)
2082+
)]
2083+
#[case::two_before(
2084+
5, // Render Area Height
2085+
2, // Offset
2086+
2, // Padding
2087+
Some(3), // Selected
2088+
Buffer::with_lines(
2089+
vec![" Item 1 ", " Item 2 ", ">> Item 3 ", " Item 4 ", " Item 5 "]
2090+
)
2091+
)]
2092+
#[case::keep_selected_visible(
2093+
4,
2094+
0, // Offset
2095+
4, // Padding
2096+
Some(1), // Selected
2097+
Buffer::with_lines(vec![" Item 0 ", ">> Item 1 ", " Item 2 ", " Item 3 "])
2098+
)]
2099+
fn test_padding(
2100+
#[case] render_height: u16,
2101+
#[case] offset: usize,
2102+
#[case] padding: usize,
2103+
#[case] selected: Option<usize>,
2104+
#[case] expected: Buffer,
2105+
) {
2106+
let backend = backend::TestBackend::new(10, render_height);
2107+
let mut terminal = Terminal::new(backend).unwrap();
2108+
let mut state = ListState::default();
2109+
2110+
*state.offset_mut() = offset;
2111+
state.select(selected);
2112+
2113+
let items = vec![
2114+
ListItem::new("Item 0"),
2115+
ListItem::new("Item 1"),
2116+
ListItem::new("Item 2"),
2117+
ListItem::new("Item 3"),
2118+
ListItem::new("Item 4"),
2119+
ListItem::new("Item 5"),
2120+
];
2121+
let list = List::new(items)
2122+
.scroll_padding(padding)
2123+
.highlight_symbol(">> ");
2124+
2125+
terminal
2126+
.draw(|f| {
2127+
let size = f.size();
2128+
f.render_stateful_widget(list, size, &mut state);
2129+
})
2130+
.unwrap();
2131+
2132+
terminal.backend().assert_buffer(&expected);
2133+
}
2134+
2135+
/// If there isnt enough room for the selected item and the requested padding the list can jump
2136+
/// up and down every frame if something isnt done about it. This code tests to make sure that
2137+
/// isnt currently happening
2138+
#[test]
2139+
fn test_padding_flicker() {
2140+
let backend = backend::TestBackend::new(10, 5);
2141+
let mut terminal = Terminal::new(backend).unwrap();
2142+
let mut state = ListState::default();
2143+
2144+
*state.offset_mut() = 2;
2145+
state.select(Some(4));
2146+
2147+
let items = vec![
2148+
ListItem::new("Item 0"),
2149+
ListItem::new("Item 1"),
2150+
ListItem::new("Item 2"),
2151+
ListItem::new("Item 3"),
2152+
ListItem::new("Item 4"),
2153+
ListItem::new("Item 5"),
2154+
ListItem::new("Item 6"),
2155+
ListItem::new("Item 7"),
2156+
];
2157+
let list = List::new(items).scroll_padding(3).highlight_symbol(">> ");
2158+
2159+
terminal
2160+
.draw(|f| {
2161+
let size = f.size();
2162+
f.render_stateful_widget(&list, size, &mut state);
2163+
})
2164+
.unwrap();
2165+
2166+
let offset_after_render = state.offset();
2167+
2168+
terminal
2169+
.draw(|f| {
2170+
let size = f.size();
2171+
f.render_stateful_widget(&list, size, &mut state);
2172+
})
2173+
.unwrap();
2174+
2175+
// Offset after rendering twice should remain the same as after once
2176+
assert_eq!(offset_after_render, state.offset());
2177+
}
2178+
2179+
#[test]
2180+
fn test_padding_inconsistent_item_sizes() {
2181+
let backend = backend::TestBackend::new(10, 3);
2182+
let mut terminal = Terminal::new(backend).unwrap();
2183+
let mut state = ListState::default().with_offset(0).with_selected(Some(3));
2184+
2185+
let items = vec![
2186+
ListItem::new("Item 0"),
2187+
ListItem::new("Item 1"),
2188+
ListItem::new("Item 2"),
2189+
ListItem::new("Item 3"),
2190+
ListItem::new("Item 4\nTest\nTest"),
2191+
ListItem::new("Item 5"),
2192+
];
2193+
let list = List::new(items).scroll_padding(1).highlight_symbol(">> ");
2194+
2195+
terminal
2196+
.draw(|f| {
2197+
let size = f.size();
2198+
f.render_stateful_widget(list, size, &mut state);
2199+
})
2200+
.unwrap();
2201+
2202+
terminal.backend().assert_buffer(&Buffer::with_lines(vec![
2203+
" Item 1 ",
2204+
" Item 2 ",
2205+
">> Item 3 ",
2206+
]));
2207+
}
2208+
2209+
// Tests to make sure when it's pushing back the first visible index value that it doesnt
2210+
// include an item that's too large
2211+
#[test]
2212+
fn test_padding_offset_pushback_break() {
2213+
let backend = backend::TestBackend::new(10, 4);
2214+
let mut terminal = Terminal::new(backend).unwrap();
2215+
let mut state = ListState::default();
2216+
2217+
*state.offset_mut() = 1;
2218+
state.select(Some(2));
2219+
2220+
let items = vec![
2221+
ListItem::new("Item 0\nTest\nTest"),
2222+
ListItem::new("Item 1"),
2223+
ListItem::new("Item 2"),
2224+
ListItem::new("Item 3"),
2225+
];
2226+
let list = List::new(items).scroll_padding(2).highlight_symbol(">> ");
2227+
2228+
terminal
2229+
.draw(|f| {
2230+
let size = f.size();
2231+
f.render_stateful_widget(list, size, &mut state);
2232+
})
2233+
.unwrap();
2234+
2235+
terminal.backend().assert_buffer(&Buffer::with_lines(vec![
2236+
" Item 1 ",
2237+
">> Item 2 ",
2238+
" Item 3 ",
2239+
" ",
2240+
]));
2241+
}
19662242
}

0 commit comments

Comments
 (0)