Skip to main content

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}