Skip to main content

rich_interact/
viewport.rs

1//! A scrollable window over rendered lines (#495).
2//!
3//! Components that show more than fits (a pager, a preview pane, a long
4//! list) keep a [`Viewport`] and render its visible lines; the painter
5//! repaints only the cells that changed, so scrolling one line rewrites what
6//! moved, not the whole screen. A viewport is also a [`Component`] on its
7//! own: a minimal pager that returns where it was left.
8//!
9//! Since 0.0.7 its first lines can be sticky (#716): a table's header
10//! stays on top while the rows under it scroll. On a page too short for
11//! them all, the last that fit stay (a table's column names) and the ones
12//! before scroll away with the rest.
13
14use rich::{Segment, Style};
15
16use crate::component::{Component, Context, Flow, View};
17use crate::event::{Event, Key};
18use crate::keymap::{Keymap, keys};
19use crate::kit::ScrollState;
20
21/// The keys of a [`Viewport`] run as a component, in context `viewport`:
22/// [`ScrollState::keymap`]'s and `done` and `quit`.
23pub fn viewport_keymap() -> Keymap {
24    let mut keymap = Keymap::new("viewport")
25        .bind("done", keys("enter"), "finish here")
26        .bind("quit", keys("q escape"), "quit");
27    keymap.extend(ScrollState::keymap("viewport"));
28    keymap
29}
30
31/// Lines, and the first one shown: a [`ScrollState`] over them.
32#[derive(Clone, Debug)]
33pub struct Viewport {
34    lines: Vec<Vec<Segment>>,
35    scroll: ScrollState,
36    /// Rows shown; `None` fills the context's height (less the status line
37    /// when the viewport runs as a component).
38    height: Option<usize>,
39    keymap: Keymap,
40    /// The first lines, shown on every page above the lines scrolled to.
41    sticky: usize,
42}
43
44impl Default for Viewport {
45    fn default() -> Self {
46        Viewport::new(Vec::new())
47    }
48}
49
50impl Viewport {
51    pub fn new(lines: Vec<Vec<Segment>>) -> Viewport {
52        Viewport {
53            scroll: ScrollState::new(lines.len()),
54            lines,
55            height: None,
56            keymap: viewport_keymap(),
57            sticky: 0,
58        }
59    }
60
61    /// Keep the first `rows` lines on every page, above the lines that
62    /// scroll: a table's header (#716). The scroll position
63    /// ([`offset`](Self::offset), [`scroll_to`](Self::scroll_to)) then
64    /// counts the lines under them. A page always keeps one row to scroll:
65    /// on one too short for every sticky line, the last that fit stay, and
66    /// the ones before them scroll away first, so every line is shown.
67    #[must_use]
68    pub fn sticky(mut self, rows: usize) -> Viewport {
69        self.set_sticky(rows);
70        self
71    }
72
73    /// Change the sticky lines (see [`sticky`](Self::sticky)), keeping the
74    /// offset where it still fits.
75    pub fn set_sticky(&mut self, rows: usize) {
76        self.sticky = rows;
77        self.scroll.set_len(self.lines.len() - self.stuck());
78    }
79
80    /// The sticky lines asked for.
81    pub fn sticky_rows(&self) -> usize {
82        self.sticky
83    }
84
85    /// The sticky lines there are.
86    fn stuck(&self) -> usize {
87        self.sticky.min(self.lines.len())
88    }
89
90    /// The sticky lines that stay on a page of `page` rows, the last of
91    /// them: one row at least is left to scroll.
92    fn pinned(&self, page: usize) -> usize {
93        self.stuck().min(page.saturating_sub(1))
94    }
95
96    /// The sticky lines that do not fit on a page of `page` rows: they
97    /// scroll away first, before the others stay.
98    fn unpinned(&self, page: usize) -> usize {
99        self.stuck() - self.pinned(page)
100    }
101
102    /// The rows of a page of `page` that scroll.
103    fn body(&self, page: usize) -> usize {
104        page - self.pinned(page)
105    }
106
107    /// The scroll state for a page of `page` rows: its length is the lines
108    /// less those that stay, so the last page ends with the last line.
109    fn scroll_for(&mut self, page: usize) -> (&mut ScrollState, usize) {
110        let (len, body) = (self.lines.len() - self.pinned(page), self.body(page));
111        self.scroll.set_len(len);
112        (&mut self.scroll, body)
113    }
114
115    /// Make `keys` do `action` (see [`viewport_keymap`]) when the viewport
116    /// runs as a component. Installed overrides (`viewport.page-down = n`)
117    /// apply too.
118    #[must_use]
119    pub fn rebind(mut self, action: &str, keys: impl IntoIterator<Item = Key>) -> Viewport {
120        self.keymap.rebind(action, keys);
121        self
122    }
123
124    /// Show `height` rows instead of filling the space.
125    #[must_use]
126    pub fn with_height(mut self, height: usize) -> Viewport {
127        self.height = Some(height);
128        self
129    }
130
131    /// Replace the lines, keeping the offset where it still fits.
132    pub fn set_lines(&mut self, lines: Vec<Vec<Segment>>) {
133        self.lines = lines;
134        self.scroll.set_len(self.lines.len() - self.stuck());
135    }
136
137    pub fn lines(&self) -> &[Vec<Segment>] {
138        &self.lines
139    }
140
141    pub fn len(&self) -> usize {
142        self.lines.len()
143    }
144
145    pub fn is_empty(&self) -> bool {
146        self.lines.is_empty()
147    }
148
149    /// The first line shown: with [sticky](Self::sticky) lines, the
150    /// number of lines scrolled past under them.
151    pub fn offset(&self) -> usize {
152        self.scroll.offset()
153    }
154
155    /// The scroll position.
156    pub fn scroll_state(&self) -> &ScrollState {
157        &self.scroll
158    }
159
160    /// Rows shown, given `available` rows of space.
161    pub fn page(&self, available: usize) -> usize {
162        self.height.unwrap_or(available).max(1)
163    }
164
165    /// Scroll by `delta` lines (negative: up), within the lines.
166    pub fn scroll(&mut self, delta: isize, page: usize) -> bool {
167        let (scroll, body) = self.scroll_for(page);
168        scroll.scroll(delta, body)
169    }
170
171    /// Scroll to the [offset](Self::offset) `line`, or as near as the last
172    /// page allows.
173    pub fn scroll_to(&mut self, line: usize, page: usize) -> bool {
174        let (scroll, body) = self.scroll_for(page);
175        scroll.scroll_to(line, body)
176    }
177
178    /// Scroll just enough that `line` (counted from the first line, sticky
179    /// lines included) is shown. A sticky line that stays needs no scroll.
180    pub fn show(&mut self, line: usize, page: usize) -> bool {
181        let (unpinned, pinned) = (self.unpinned(page), self.pinned(page));
182        // The offsets that show it: from the one that puts it last on the
183        // page, to the one that puts it first under the lines that stay
184        // (a sticky line that scrolls away: first on the page).
185        let first = (line + 1).saturating_sub(page);
186        let last = if line < unpinned {
187            line
188        } else if line < self.stuck() {
189            usize::MAX
190        } else {
191            line - pinned
192        };
193        let offset = self.offset();
194        if offset < first {
195            self.scroll_to(first, page)
196        } else if offset > last {
197            self.scroll_to(last, page)
198        } else {
199            false
200        }
201    }
202
203    /// Do one of [`ScrollState::keymap`]'s actions (`scroll-up`,
204    /// `page-down`, ...), for a component that looks its keys up in a
205    /// keymap. Returns whether `action` is a scroll action.
206    pub fn act(&mut self, action: &str, page: usize) -> bool {
207        let (scroll, body) = self.scroll_for(page);
208        scroll.act(action, body)
209    }
210
211    /// Move with the usual keys and the mouse wheel: arrows and `j`/`k` by a
212    /// line, PageUp/PageDown and Space by a page, Home/End and `g`/`G` to
213    /// the ends. Returns whether the event was a scroll key (moved or not).
214    pub fn handle_scroll(&mut self, event: &Event, page: usize) -> bool {
215        let (scroll, body) = self.scroll_for(page);
216        scroll.handle(event, body)
217    }
218
219    /// Whether the sticky lines that fit stay on a page of `page` rows:
220    /// once those that do not fit have scrolled away.
221    fn stays(&self, page: usize) -> bool {
222        self.offset() >= self.unpinned(page)
223    }
224
225    /// The index of the first line that scrolls on a page of `page` rows:
226    /// the first shown under the sticky lines that stay, or the first shown
227    /// when none stay yet.
228    pub fn top(&self, page: usize) -> usize {
229        let offset = self.offset();
230        if self.stays(page) {
231            offset + self.pinned(page)
232        } else {
233            offset
234        }
235    }
236
237    /// The lines that scroll, as shown on a page of `page` rows: every
238    /// line shown when none are [sticky](Self::sticky).
239    pub fn visible(&self, page: usize) -> &[Vec<Segment>] {
240        let end = (self.offset() + page).min(self.lines.len());
241        &self.lines[self.top(page).min(end)..end]
242    }
243
244    /// The lines shown on a page of `page` rows, each with its index: the
245    /// sticky lines that stay, then those scrolled to.
246    pub fn rows(&self, page: usize) -> Vec<(usize, &[Segment])> {
247        let staying = if self.stays(page) {
248            self.unpinned(page)..self.stuck()
249        } else {
250            0..0
251        };
252        let end = (self.offset() + page).min(self.lines.len());
253        staying
254            .chain(self.top(page).min(end)..end)
255            .map(|index| (index, self.lines[index].as_slice()))
256            .collect()
257    }
258
259    /// "lines 1–20 of 240", or "all 12 lines" when everything fits. With
260    /// sticky lines, the range is that of the lines under them, once
261    /// scrolled.
262    pub fn status(&self, page: usize) -> String {
263        let total = self.lines.len();
264        if total <= page {
265            return format!("all {total} lines");
266        }
267        let first = match self.offset() {
268            0 => 0,
269            _ => self.top(page),
270        };
271        let last = (self.offset() + page).min(total);
272        format!("lines {}–{last} of {total}", first + 1)
273    }
274}
275
276/// As a component: a pager over its lines with a status line. Enter
277/// finishes with the offset reached; `q` and Escape cancel. Its keys come
278/// from its [keymap](viewport_keymap), so they can be rebound.
279impl Component for Viewport {
280    type Output = usize;
281
282    fn handle(&mut self, event: &Event, context: &Context<'_>) -> Flow<usize> {
283        let page = self.page(context.height.saturating_sub(1));
284        let Some(key) = event.key() else {
285            // The wheel.
286            return if self.handle_scroll(event, page) {
287                Flow::Continue
288            } else {
289                Flow::Ignored
290            };
291        };
292        match self.keymap.action(key).map(str::to_string).as_deref() {
293            Some("done") => Flow::Done(self.offset()),
294            Some("quit") => Flow::Cancel,
295            Some(action) if self.act(action, page) => Flow::Continue,
296            _ => Flow::Ignored,
297        }
298    }
299
300    fn render(&self, context: &Context<'_>) -> View {
301        let page = self.page(context.height.saturating_sub(1));
302        let mut lines: Vec<Vec<Segment>> = self
303            .rows(page)
304            .into_iter()
305            .map(|(_, line)| line.to_vec())
306            .collect();
307        let status = format!("{} · ↑↓ PgUp PgDn · q quits", self.status(page));
308        lines.push(vec![Segment::new(
309            status,
310            Some(Style::parse("dim").expect("style")),
311        )]);
312        View::new(lines)
313    }
314
315    fn keymap(&self) -> Keymap {
316        self.keymap.clone()
317    }
318
319    fn default_value(&self) -> Option<usize> {
320        Some(self.offset())
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use crate::event::{Key, MouseKind};
328
329    fn lines(n: usize) -> Vec<Vec<Segment>> {
330        (1..=n)
331            .map(|i| vec![Segment::new(format!("line {i}"), None)])
332            .collect()
333    }
334
335    #[test]
336    fn scrolls_within_bounds() {
337        let mut viewport = Viewport::new(lines(10));
338        assert!(!viewport.scroll(-1, 4));
339        assert!(viewport.scroll(3, 4));
340        assert_eq!(viewport.offset(), 3);
341        assert!(viewport.scroll(100, 4));
342        assert_eq!(viewport.offset(), 6, "the last page is full");
343        assert_eq!(viewport.visible(4).len(), 4);
344        assert_eq!(viewport.status(4), "lines 7–10 of 10");
345        viewport.show(1, 4);
346        assert_eq!(viewport.offset(), 1);
347        viewport.show(8, 4);
348        assert_eq!(viewport.offset(), 5);
349        assert_eq!(Viewport::new(lines(3)).status(4), "all 3 lines");
350    }
351
352    #[test]
353    fn keys_and_wheel_move_it() {
354        let mut viewport = Viewport::new(lines(50));
355        let key = |name: &str| Event::Key(Key::parse(name).unwrap());
356        assert!(viewport.handle_scroll(&key("pagedown"), 10));
357        assert_eq!(viewport.offset(), 10);
358        assert!(viewport.handle_scroll(&key("j"), 10));
359        assert_eq!(viewport.offset(), 11);
360        assert!(viewport.handle_scroll(&key("G"), 10));
361        assert_eq!(viewport.offset(), 40);
362        assert!(viewport.handle_scroll(&key("home"), 10));
363        assert_eq!(viewport.offset(), 0);
364        assert!(!viewport.handle_scroll(&key("x"), 10));
365        let wheel = Event::Mouse(crate::event::Mouse {
366            kind: MouseKind::ScrollDown,
367            column: 0,
368            row: 0,
369            modifiers: Default::default(),
370            pixel: None,
371        });
372        assert!(viewport.handle_scroll(&wheel, 10));
373        assert_eq!(viewport.offset(), 3);
374    }
375
376    /// Run as a component, a viewport's keys follow its keymap, so
377    /// rebinding them works (0.0.14 release-test audit B).
378    #[test]
379    fn rebound_keys_scroll_it() {
380        use crate::headless::{self, Script};
381
382        let viewport = Viewport::new(lines(50)).rebind("scroll-down", [Key::char('n')]);
383        let (outcome, _) = headless::run(viewport, Script::new().keys("n n j enter"), 20, 6);
384        assert_eq!(outcome.unwrap(), crate::Outcome::Done(2));
385        let viewport = Viewport::new(lines(50)).rebind("done", [Key::char('x')]);
386        let (outcome, _) = headless::run(viewport, Script::new().keys("end x"), 20, 6);
387        assert_eq!(outcome.unwrap(), crate::Outcome::Done(45));
388    }
389
390    #[test]
391    fn replacing_lines_keeps_a_valid_offset() {
392        let mut viewport = Viewport::new(lines(50));
393        viewport.scroll_to(40, 10);
394        viewport.set_lines(lines(5));
395        assert_eq!(viewport.offset(), 4);
396        assert_eq!(viewport.visible(10).len(), 1);
397    }
398}