trail/app/scroll.rs
1//! Navigation-panel scrolling: which row of the listing is at the top of the
2//! pane, and the scroll margin that keeps the selection off the edges.
3//!
4//! The offset has to outlive a frame. ratatui's `List` computes its window from
5//! the offset it is handed, and a `ListState` built fresh per frame always
6//! hands it `0` — from which the cheapest window containing selection *N* ends
7//! at *N*, so every selection past the first screenful was drawn on the bottom
8//! row and the list scrolled under a selection that never moved. Only the
9//! renderer knows the pane height, so the renderer calls [`NavScroll::update`]
10//! once per frame and the panel draws with what it returns.
11
12use std::path::{Path, PathBuf};
13
14/// The scroll position of the navigation panel, for the directory it was
15/// computed in.
16///
17/// Keyed by directory rather than reset by every code path that changes one:
18/// a position carried from one listing into another means nothing, and
19/// comparing the directory at render time catches navigation, tab switches and
20/// tab closes without any of them having to remember to clear it. A refresh of
21/// the same directory keeps it, which is what stops a filesystem-watch reload
22/// from jumping the view.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct NavScroll {
25 /// The directory `offset` belongs to.
26 dir: PathBuf,
27 /// Index of the entry drawn on the pane's first row.
28 offset: usize,
29}
30
31impl NavScroll {
32 /// Returns the offset to draw with this frame, and records it in `slot`.
33 ///
34 /// `selected` indexes the list being drawn, `len` is its length and
35 /// `height` the number of rows the pane shows. `margin` is the number of
36 /// rows kept between the selection and either edge — vim's `scrolloff` —
37 /// and is capped at what the pane can honour, so an oversized margin keeps
38 /// the selection centred rather than making the list jitter.
39 ///
40 /// The margin collapses at the true ends of the list: the first entry is
41 /// drawn on the first row and the last entry on the last row. Within the
42 /// margin the previous offset is kept, so the selection floats rather than
43 /// being pinned to either edge.
44 ///
45 /// When `slot` holds no offset for `dir` — a directory just entered, or a
46 /// tab just switched to — the selection is centred, which puts a
47 /// remembered selection in context instead of on an edge.
48 pub fn update(
49 slot: &mut Option<NavScroll>,
50 dir: &Path,
51 selected: usize,
52 len: usize,
53 height: usize,
54 margin: usize,
55 ) -> usize {
56 let previous = slot
57 .as_ref()
58 .filter(|s| s.dir == dir)
59 .map(|s| s.offset)
60 .unwrap_or_else(|| selected.saturating_sub(height / 2));
61 let offset = clamp(previous, selected, len, height, margin);
62 match slot {
63 Some(s) if s.dir == dir => s.offset = offset,
64 _ => {
65 *slot = Some(NavScroll {
66 dir: dir.to_owned(),
67 offset,
68 })
69 }
70 }
71 offset
72 }
73}
74
75/// Moves `offset` the least distance that keeps `selected` at least `margin`
76/// rows inside a `height`-row window over a `len`-entry list.
77fn clamp(offset: usize, selected: usize, len: usize, height: usize, margin: usize) -> usize {
78 if height == 0 || len <= height {
79 return 0;
80 }
81 // A margin of half the pane or more cannot be kept on both sides at once;
82 // capping it here is what makes the lower bound below never exceed the
83 // upper one.
84 let margin = margin.min(height.saturating_sub(1) / 2);
85 let selected = selected.min(len - 1);
86 let lowest = (selected + margin + 1).saturating_sub(height);
87 let highest = selected.saturating_sub(margin);
88 // The last clamp is the collapse at the bottom: the window never scrolls
89 // past the final entry, so near the end the selection walks down into the
90 // margin instead.
91 offset.clamp(lowest, highest).min(len - height)
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97
98 /// Row within the pane at which `selected` is drawn.
99 fn row(offset: usize, selected: usize) -> usize {
100 selected - offset
101 }
102
103 #[test]
104 fn a_list_that_fits_never_scrolls() {
105 assert_eq!(clamp(5, 9, 10, 10, 3), 0);
106 assert_eq!(clamp(0, 0, 0, 10, 3), 0);
107 }
108
109 #[test]
110 fn moving_down_stops_margin_rows_short_of_the_bottom() {
111 // 100 entries, 20-row pane, margin 3: walking down from the top, the
112 // selection stops at row 16 and the list scrolls under it.
113 let mut offset = 0;
114 for selected in 0..60 {
115 offset = clamp(offset, selected, 100, 20, 3);
116 assert!(row(offset, selected) <= 16, "selection {selected} too low");
117 }
118 assert_eq!(row(offset, 59), 16);
119 }
120
121 #[test]
122 fn moving_up_from_the_bottom_leaves_the_selection_where_it_is() {
123 // The reported bug: at the bottom, moving up re-pinned the selection to
124 // the last row. With a persisted offset the view holds still and the
125 // selection climbs until it reaches the top margin.
126 let mut offset = clamp(0, 59, 100, 20, 3);
127 let start = row(offset, 59);
128 for selected in (50..59).rev() {
129 offset = clamp(offset, selected, 100, 20, 3);
130 assert_eq!(
131 offset,
132 clamp(0, 59, 100, 20, 3),
133 "view scrolled at {selected}"
134 );
135 }
136 assert_eq!(row(offset, 50), start - 9);
137 }
138
139 #[test]
140 fn moving_up_stops_margin_rows_short_of_the_top() {
141 let mut offset = 80;
142 for selected in (10..99).rev() {
143 offset = clamp(offset, selected, 100, 20, 3);
144 assert!(row(offset, selected) >= 3, "selection {selected} too high");
145 }
146 }
147
148 #[test]
149 fn the_margin_collapses_at_the_ends_of_the_list() {
150 assert_eq!(row(clamp(40, 0, 100, 20, 3), 0), 0, "first entry on row 0");
151 assert_eq!(
152 row(clamp(0, 99, 100, 20, 3), 99),
153 19,
154 "last entry on the last row"
155 );
156 }
157
158 #[test]
159 fn a_zero_margin_lets_the_selection_reach_either_edge() {
160 assert_eq!(row(clamp(0, 19, 100, 20, 0), 19), 19);
161 assert_eq!(row(clamp(19, 19, 100, 20, 0), 19), 0);
162 }
163
164 #[test]
165 fn an_oversized_margin_centres_instead_of_jittering() {
166 // Margin 50 in a 21-row pane is capped at 10: the selection sits on the
167 // middle row whichever way it moves.
168 let mut offset = 0;
169 for selected in 10..80 {
170 offset = clamp(offset, selected, 100, 21, 50);
171 assert_eq!(row(offset, selected), 10);
172 }
173 }
174
175 #[test]
176 fn a_new_directory_centres_its_selection() {
177 let mut slot = None;
178 let offset = NavScroll::update(&mut slot, Path::new("/a"), 50, 100, 20, 3);
179 assert_eq!(row(offset, 50), 10);
180 }
181
182 #[test]
183 fn the_same_directory_keeps_its_offset_and_another_does_not() {
184 let mut slot = None;
185 let first = NavScroll::update(&mut slot, Path::new("/a"), 50, 100, 20, 3);
186 // One step down stays within the margin, so the view holds still.
187 assert_eq!(
188 NavScroll::update(&mut slot, Path::new("/a"), 51, 100, 20, 3),
189 first
190 );
191 // The same index in a different directory is re-centred, not carried.
192 let other = NavScroll::update(&mut slot, Path::new("/b"), 5, 100, 20, 3);
193 assert_eq!(other, 0);
194 }
195}