@@ -438,6 +438,8 @@ pub struct List<'a> {
438
438
repeat_highlight_symbol : bool ,
439
439
/// Decides when to allocate spacing for the selection symbol
440
440
highlight_spacing : HighlightSpacing ,
441
+ /// How many items to try to keep visible before and after the selected item
442
+ scroll_padding : usize ,
441
443
}
442
444
443
445
/// Defines the direction in which the list will be rendered.
@@ -685,6 +687,25 @@ impl<'a> List<'a> {
685
687
self
686
688
}
687
689
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
+
688
709
/// Defines the list direction (up or down)
689
710
///
690
711
/// Defines if the `List` is displayed *top to bottom* (default) or *bottom to top*. Use
@@ -737,6 +758,52 @@ impl<'a> List<'a> {
737
758
self . items . is_empty ( )
738
759
}
739
760
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
+
740
807
/// Given an offset, calculate which items can fit in a given area
741
808
fn get_items_bounds (
742
809
& self ,
@@ -765,9 +832,17 @@ impl<'a> List<'a> {
765
832
last_visible_index += 1 ;
766
833
}
767
834
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) ;
771
846
772
847
// Recall that last_visible_index is the index of what we
773
848
// can render up to in the given space after the offset
@@ -970,6 +1045,9 @@ where
970
1045
mod tests {
971
1046
use std:: borrow:: Cow ;
972
1047
1048
+ use pretty_assertions:: assert_eq;
1049
+ use rstest:: rstest;
1050
+
973
1051
use super :: * ;
974
1052
use crate :: {
975
1053
assert_buffer_eq,
@@ -1963,4 +2041,202 @@ mod tests {
1963
2041
let expected = Buffer :: with_lines ( vec ! [ "Large" , " " , " " ] ) ;
1964
2042
assert_buffer_eq ! ( buffer, expected) ;
1965
2043
}
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\n Test\n Test" ) ,
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\n Test\n Test" ) ,
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
+ }
1966
2242
}
0 commit comments