Skip to main content

qframe/widgets/
scroll_view.rs

1//! Vertical scrolling of any content.
2
3use std::time::Duration;
4
5use crate::event::{Event, MouseButton, MouseKind};
6use crate::geometry::{Rect, Size, clamp_u16};
7use crate::keymap::Key;
8use crate::motion::Easing;
9use crate::widget::{Axis, Container, EventCx, Flex, Length, MeasureCx, Node, PaintCx, Widget, WidgetId};
10
11use super::rows::{self, WHEEL_ROWS};
12use super::scrollbar::{self, ScrollMetrics, ScrollbarStyle};
13
14/// Shows content taller than its area and scrolls it with the wheel, the scrollbar or the
15/// keyboard (↑/↓, PgUp/PgDn, Home/End while focused). When focus moves to a widget inside that
16/// is out of view, the view scrolls to it. When a widget inside asks to show a part of itself
17/// with [`PaintCx::reveal`], such as a code view going to a line, the view glides just far
18/// enough to show it, or jumps there when motion is reduced.
19///
20/// With [`ScrollView::follow_end`] the view keeps the end of growing content in view, for a
21/// conversation or command output that arrives while the person watches.
22///
23/// Style keys: `scrollbar` (`style`, `track`, `thumb`) with `hover`, and `scrollbar.<style>`;
24/// `log-more` for the rows-below note of a view that stopped following. Framework string:
25/// `quvyta.log.below`.
26pub struct ScrollView<Msg> {
27    content: Vec<Node<Msg>>,
28    scrollbar: Option<ScrollbarStyle>,
29    follow_end: bool,
30}
31
32#[derive(Debug, Default)]
33struct ScrollMemory {
34    offset: u16,
35    content_height: u16,
36    revealed: Option<WidgetId>,
37    dragging: bool,
38    /// A move towards an area a widget asked to reveal, or towards the end, while it runs.
39    glide: Option<Glide>,
40    /// Whether the person moved away from the end, so growth no longer follows it.
41    detached: bool,
42    /// Whether a frame was painted yet: the first one opens at the end without gliding.
43    painted: bool,
44    /// Where the rows-below note was painted in the last frame; a click on it follows the end.
45    note: Option<Rect>,
46}
47
48impl ScrollMemory {
49    /// Records a move from offset `from` to offset `to`, where `max` is the end: reaching the
50    /// end follows it again, and any move up from `from` stops following. A move down that
51    /// stops short of the end changes nothing, so a key pressed while the view glides to the
52    /// end keeps following.
53    fn moved(&mut self, from: u16, to: u16, max: u16) {
54        if to >= max {
55            self.detached = false;
56        } else if to < from {
57            self.detached = true;
58        }
59    }
60}
61
62/// A scroll from one offset to another that started at `start`.
63#[derive(Debug, Clone, Copy)]
64struct Glide {
65    from: u16,
66    to: u16,
67    start: Duration,
68}
69
70/// The offset that shows the content rows `top..bottom` in a view `height` rows tall, moving
71/// the least from `offset`: an area already in view, or covering the whole view, stays put.
72fn offset_showing(top: i32, bottom: i32, offset: u16, height: u16) -> u16 {
73    let (start, end) = (i32::from(offset), i32::from(offset) + i32::from(height));
74    if top >= start && bottom <= end || top <= start && bottom >= end {
75        offset
76    } else if top < start || bottom - top > i32::from(height) {
77        clamp_u16(top)
78    } else {
79        clamp_u16(bottom - i32::from(height))
80    }
81}
82
83impl<Msg: 'static> ScrollView<Msg> {
84    /// An empty scroll view; add content with [`View::add_with`](crate::widget::View::add_with).
85    #[must_use]
86    pub fn new() -> Self {
87        Self { content: vec![Node::new(Flex::new(Axis::Column, Vec::new()), 0)], scrollbar: None, follow_end: false }
88    }
89
90    /// Draws the scrollbar in `style` whatever the theme chooses.
91    #[must_use]
92    pub fn scrollbar(mut self, style: ScrollbarStyle) -> Self {
93        self.scrollbar = Some(style);
94        self
95    }
96
97    /// Keeps the end of the content in view while it grows, as long as the person is at the end.
98    ///
99    /// The first frame opens at the end. When the content grows, the view glides to the new end
100    /// over the theme's `page` duration, or jumps there when motion is reduced. Scrolling up with
101    /// the wheel, the keys or the scrollbar stops following, and a faint note at the bottom
102    /// counts the rows below; End, scrolling back to the bottom or a click on the note follows
103    /// again. Content that fits the view always counts as at the end.
104    ///
105    /// Focus keeps its usual pull: a widget inside that takes focus is scrolled into view. When
106    /// that move leaves the end, it stops following just as scrolling up does, so focusing
107    /// something earlier in the content holds it in view; when the focused widget sits at the
108    /// end, such as a reply field below a conversation, the view keeps following and the field
109    /// stays in view as the content grows. An area a widget asks to reveal follows the same rule.
110    ///
111    /// ```
112    /// use qframe::prelude::*;
113    /// use qframe::widgets::ScrollView;
114    ///
115    /// struct Chat(Vec<String>);
116    ///
117    /// impl App for Chat {
118    ///     type Msg = String;
119    ///     fn update(&mut self, line: String) -> Command<String> {
120    ///         self.0.push(line);
121    ///         Command::none()
122    ///     }
123    ///     fn view(&self, ui: &mut View<'_, String>) {
124    ///         ui.add_with(ScrollView::new().follow_end(true), |ui| {
125    ///             for line in &self.0 {
126    ///                 ui.add(Text::new(line.clone()));
127    ///             }
128    ///         })
129    ///         .fill();
130    ///     }
131    /// }
132    ///
133    /// let lines = (1..=9).map(|n| format!("line {n}")).collect();
134    /// let mut h = Harness::new(Chat(lines), 20, 3);
135    /// assert!(h.screen().contains("line 9"), "the first frame opens at the end");
136    /// h.set_reduced_motion(true).send("line 10".to_owned());
137    /// assert!(h.screen().contains("line 10"));
138    /// ```
139    #[must_use]
140    pub fn follow_end(mut self, follow: bool) -> Self {
141        self.follow_end = follow;
142        self
143    }
144
145    fn metrics(memory: &ScrollMemory, area: Rect) -> ScrollMetrics {
146        ScrollMetrics {
147            total: usize::from(memory.content_height),
148            visible: usize::from(area.height),
149            offset: usize::from(memory.offset),
150        }
151    }
152
153    fn scroll_to(cx: &mut EventCx<'_, Msg>, offset: i32) {
154        let area = cx.area();
155        let memory = cx.memory::<ScrollMemory>();
156        let max = memory.content_height.saturating_sub(area.height);
157        let from = memory.offset;
158        memory.offset = clamp_u16(offset).min(max);
159        memory.glide = None;
160        memory.moved(from, memory.offset, max);
161    }
162
163    /// Heads for the end `max` unless the person moved away from it: at once on the first frame
164    /// or with reduced motion, otherwise as a glide from where the view is.
165    fn follow(cx: &mut PaintCx<'_>, max: u16) {
166        let reduced = cx.reduced_motion();
167        let now = cx.now();
168        let memory = cx.memory::<ScrollMemory>();
169        let first = !std::mem::replace(&mut memory.painted, true);
170        if max == 0 || memory.glide.is_none() && memory.offset >= max {
171            memory.detached = false;
172        }
173        let heading = memory.glide.map_or(memory.offset, |glide| glide.to);
174        if memory.detached || heading == max {
175            return;
176        }
177        if first || reduced {
178            memory.offset = max;
179            memory.glide = None;
180        } else {
181            memory.glide = Some(Glide { from: memory.offset, to: max, start: now });
182        }
183    }
184
185    /// Moves along a running glide and returns the offset to paint at.
186    fn glide(cx: &mut PaintCx<'_>, offset: u16, max: u16) -> u16 {
187        let Some(glide) = cx.memory::<ScrollMemory>().glide else {
188            return offset;
189        };
190        let duration = cx.env().theme().motion().page;
191        let progress = cx.progress_since(glide.start, duration, Easing::EaseOut);
192        let (from, to) = (f32::from(glide.from), f32::from(glide.to));
193        // The offsets are u16, so the rounded value between them fits.
194        let now = (from + (to - from) * progress).round() as u16;
195        let memory = cx.memory::<ScrollMemory>();
196        memory.offset = now.min(max);
197        if progress >= 1.0 {
198            memory.glide = None;
199        }
200        memory.offset
201    }
202
203    /// Takes the last area a widget inside asked to reveal this frame and scrolls to it.
204    fn take_reveal(cx: &mut PaintCx<'_>, content: Rect, area: Rect, offset: u16) {
205        let id = cx.id();
206        let mut wanted = None;
207        for (asker, rect) in std::mem::take(&mut cx.frame.reveals) {
208            if asker != id && cx.frame.is_within(asker, id) {
209                wanted = Some(rect);
210            } else {
211                cx.frame.reveals.push((asker, rect));
212            }
213        }
214        let Some(rect) = wanted else { return };
215        let top = rect.y - content.y;
216        let max = content.height.saturating_sub(area.height);
217        let target = offset_showing(top, top + i32::from(rect.height), offset, area.height).min(max);
218        let reduced = cx.reduced_motion();
219        let now = cx.now();
220        let memory = cx.memory::<ScrollMemory>();
221        memory.moved(offset, target, max);
222        if reduced {
223            memory.offset = target;
224            memory.glide = None;
225        } else {
226            memory.glide = (target != offset).then_some(Glide { from: offset, to: target, start: now });
227        }
228        if target != offset {
229            cx.request_frame_in(Duration::ZERO);
230        }
231    }
232}
233
234impl<Msg: 'static> Default for ScrollView<Msg> {
235    fn default() -> Self {
236        Self::new()
237    }
238}
239
240impl<Msg: 'static> Container<Msg> for ScrollView<Msg> {
241    fn set_children(&mut self, children: Vec<Node<Msg>>) {
242        let mut column = Node::new(Flex::new(Axis::Column, children), 0);
243        column.layout.width = Length::Fill(1);
244        self.content = vec![column];
245    }
246}
247
248impl<Msg: 'static> Widget<Msg> for ScrollView<Msg> {
249    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
250        let content = self.content.first().map_or(Size::default(), |c| cx.measure_child(c, available));
251        Size::new(content.width.saturating_add(1), content.height).min(available)
252    }
253
254    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
255        let Some(content) = self.content.first() else {
256            return;
257        };
258        cx.register_hit(area);
259        let full = cx.measure_child(content, Size::new(area.width, u16::MAX)).height;
260        let overflows = full > area.height;
261        let width = if overflows { area.width.saturating_sub(2) } else { area.width };
262        let height = if overflows { cx.measure_child(content, Size::new(width, u16::MAX)).height } else { full };
263        let max = height.saturating_sub(area.height);
264        let offset = {
265            let memory = cx.memory::<ScrollMemory>();
266            memory.content_height = height;
267            memory.offset = memory.offset.min(max);
268            memory.offset
269        };
270        let offset = if self.follow_end {
271            Self::follow(cx, max);
272            cx.memory::<ScrollMemory>().offset
273        } else {
274            offset
275        };
276        let offset = Self::glide(cx, offset, max);
277        let content_rect = Rect::new(area.x, area.y - i32::from(offset), width, height);
278        cx.with_clip(area, |cx| cx.paint_child(content, content_rect));
279        Self::take_reveal(cx, content_rect, area, offset);
280
281        if let Some(focused) = cx.interaction.focused
282            && focused != cx.id()
283            && cx.frame.is_within(focused, cx.id())
284            && let Some(rect) = cx.frame.rects.get(&focused).copied()
285            && cx.memory::<ScrollMemory>().revealed != Some(focused)
286        {
287            let memory = cx.memory::<ScrollMemory>();
288            memory.revealed = Some(focused);
289            let top = rect.y - content_rect.y;
290            let new_offset = offset_showing(top, top + i32::from(rect.height), offset, area.height);
291            if new_offset != offset {
292                memory.moved(offset, new_offset, max);
293                memory.offset = new_offset;
294                memory.glide = None;
295                cx.request_frame_in(Duration::ZERO);
296            }
297        }
298
299        let note = {
300            let memory = cx.memory::<ScrollMemory>();
301            let below = max.saturating_sub(memory.offset);
302            (self.follow_end && memory.detached && below > 0).then_some(below)
303        };
304        let note = note.map(|below| {
305            let note = rows::paint_below_note(cx, Rect::new(area.x, area.y, width, area.height), below.into());
306            cx.register_hit(note);
307            note
308        });
309        cx.memory::<ScrollMemory>().note = note;
310
311        if overflows {
312            let metrics = Self::metrics(cx.memory::<ScrollMemory>(), area);
313            let active =
314                cx.memory::<ScrollMemory>().dragging || cx.pointer().is_some_and(|(x, _)| x >= area.right() - 1);
315            scrollbar::paint(cx, Rect::new(area.right() - 1, area.y, 1, area.height), metrics, active, self.scrollbar);
316        }
317    }
318
319    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
320        let area = cx.area();
321        let (offset, metrics) = {
322            let memory = cx.memory::<ScrollMemory>();
323            (i32::from(memory.offset), Self::metrics(memory, area))
324        };
325        let page = i32::from(area.height.saturating_sub(1).max(1));
326        match event {
327            Event::Key(key) => {
328                let target = if key.is_plain(Key::Up) {
329                    offset - 1
330                } else if key.is_plain(Key::Down) {
331                    offset + 1
332                } else if key.is_plain(Key::PageUp) {
333                    offset - page
334                } else if key.is_plain(Key::PageDown) {
335                    offset + page
336                } else if key.is_plain(Key::Home) {
337                    0
338                } else if key.is_plain(Key::End) {
339                    i32::MAX
340                } else {
341                    return false;
342                };
343                if !metrics.overflows() {
344                    return false;
345                }
346                Self::scroll_to(cx, target);
347                true
348            }
349            Event::Mouse(mouse) => {
350                let on_bar = metrics.overflows() && mouse.x == area.right() - 1;
351                let on_note = cx.memory::<ScrollMemory>().note.is_some_and(|note| note.contains(mouse.x, mouse.y));
352                match mouse.kind {
353                    MouseKind::Down(MouseButton::Left) if on_note => {
354                        Self::scroll_to(cx, i32::MAX);
355                        true
356                    }
357                    MouseKind::ScrollUp if metrics.overflows() => {
358                        Self::scroll_to(cx, offset - i32::from(WHEEL_ROWS));
359                        true
360                    }
361                    MouseKind::ScrollDown if metrics.overflows() => {
362                        Self::scroll_to(cx, offset + i32::from(WHEEL_ROWS));
363                        true
364                    }
365                    MouseKind::Down(MouseButton::Left) if on_bar => {
366                        cx.capture_pointer();
367                        cx.memory::<ScrollMemory>().dragging = true;
368                        let target = metrics.offset_at(clamp_u16(mouse.y - area.y), area.height);
369                        Self::scroll_to(cx, i32::try_from(target).unwrap_or(i32::MAX));
370                        true
371                    }
372                    MouseKind::Drag(MouseButton::Left) if cx.memory::<ScrollMemory>().dragging => {
373                        let target = metrics.offset_at(clamp_u16(mouse.y - area.y), area.height);
374                        Self::scroll_to(cx, i32::try_from(target).unwrap_or(i32::MAX));
375                        true
376                    }
377                    MouseKind::Up(MouseButton::Left) if cx.memory::<ScrollMemory>().dragging => {
378                        cx.memory::<ScrollMemory>().dragging = false;
379                        true
380                    }
381                    _ => false,
382                }
383            }
384            _ => false,
385        }
386    }
387
388    fn focusable(&self) -> bool {
389        true
390    }
391
392    fn children(&self) -> &[Node<Msg>] {
393        &self.content
394    }
395
396    fn children_mut(&mut self) -> &mut [Node<Msg>] {
397        &mut self.content
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404    use crate::runtime::{App, Command, Harness};
405    use crate::widget::View;
406    use crate::widgets::{Button, Text};
407
408    struct Demo;
409
410    impl App for Demo {
411        type Msg = ();
412        fn update(&mut self, _: ()) -> Command<()> {
413            Command::none()
414        }
415        fn view(&self, ui: &mut View<'_, ()>) {
416            ui.add_with(ScrollView::new(), |ui| {
417                for i in 0..20 {
418                    ui.add(Text::new(format!("line {i}")));
419                }
420                ui.add(Button::new("Bottom").on_press(())).id("bottom");
421            })
422            .fill();
423        }
424    }
425
426    #[test]
427    fn scrolls_with_keys_and_wheel() {
428        let mut h = Harness::new(Demo, 20, 5);
429        assert!(h.screen().starts_with("line 0"));
430        h.press("tab").press("pgdn");
431        assert!(h.screen().starts_with("line 4"), "{}", h.screen());
432        h.mouse(MouseKind::ScrollDown, 2, 2);
433        assert!(h.screen().starts_with("line 7"));
434        h.press("end");
435        assert!(h.screen().contains("Bottom"));
436        h.press("home");
437        assert!(h.screen().starts_with("line 0"));
438    }
439
440    #[test]
441    fn reveals_focused_widget() {
442        let mut h = Harness::new(Demo, 20, 5);
443        h.press("tab").press("tab");
444        assert!(h.is_focused("bottom"));
445        assert!(h.screen().contains("Bottom"), "{}", h.screen());
446    }
447
448    #[test]
449    fn draws_scrollbar_only_when_needed() {
450        // The default block style is colour only, so the scrollbar column is read by its colours.
451        let h = Harness::new(Demo, 20, 30);
452        let muted = h.env().theme().color("muted");
453        assert_ne!(h.bg(19, 0), muted);
454        let short = Harness::new(Demo, 20, 5);
455        assert_eq!(short.bg(19, 0), muted, "the thumb sits at the top");
456        assert_eq!(short.bg(19, 4), short.env().theme().color("raised"), "the track runs below it");
457    }
458
459    struct Pinned(Option<ScrollbarStyle>);
460
461    impl App for Pinned {
462        type Msg = ();
463        fn update(&mut self, _: ()) -> Command<()> {
464            Command::none()
465        }
466        fn view(&self, ui: &mut View<'_, ()>) {
467            let view = self.0.map_or_else(ScrollView::new, |style| ScrollView::new().scrollbar(style));
468            ui.add_with(view, |ui| {
469                for i in 0..20 {
470                    ui.add(Text::new(format!("line {i}")));
471                }
472            })
473            .fill();
474        }
475    }
476
477    /// The scrollbar column, top to bottom, with blank cells as spaces.
478    fn bar(h: &Harness<Pinned>) -> String {
479        h.screen().lines().map(|line| format!("{line:<10}").chars().nth(9).unwrap_or(' ')).collect()
480    }
481
482    #[test]
483    fn every_style_draws_its_own_column() {
484        let expected = [
485            (ScrollbarStyle::Block, "    "),
486            (ScrollbarStyle::Half, "▐▕▕▕"),
487            (ScrollbarStyle::Thin, "▕   "),
488            (ScrollbarStyle::Dots, "•···"),
489        ];
490        for (style, column) in expected {
491            let mut h = Harness::new(Pinned(Some(style)), 10, 4);
492            assert_eq!(bar(&h), column, "{style:?}");
493            let theme = h.env().theme();
494            let (raised, muted, canvas) = (theme.color("raised"), theme.color("muted"), theme.color("canvas"));
495            let thumb = if style == ScrollbarStyle::Dots { theme.color("dim") } else { muted };
496            match style {
497                ScrollbarStyle::Block => assert_eq!((h.bg(9, 0), h.bg(9, 3)), (muted, raised)),
498                ScrollbarStyle::Thin => assert_eq!(h.bg(9, 3), canvas, "thin draws no track"),
499                ScrollbarStyle::Half => assert_eq!(h.fg(9, 0), muted),
500                ScrollbarStyle::Dots => assert_eq!((h.fg(9, 0), h.fg(9, 3)), (theme.color("dim"), muted)),
501            }
502            h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
503            assert!(h.screen().is_ascii(), "{style:?}");
504            assert_eq!(h.bg(9, 0), thumb, "ASCII thumb is a coloured cell in {style:?}");
505        }
506    }
507
508    #[test]
509    fn theme_word_chooses_the_style_and_pinning_wins() {
510        let dir = std::env::temp_dir().join(format!("quvyta-scrollbar-{}", std::process::id()));
511        std::fs::create_dir_all(&dir).expect("temp dir");
512        let theme = "[meta]\nname = \"Dotted\"\nextends = \"monochrome\"\n[style.scrollbar]\nstyle = \"dots\"\n";
513        std::fs::write(dir.join("dotted.toml"), theme).expect("theme file");
514        let dirs = crate::env::AssetDirs { themes: Some(dir.clone()), ..Default::default() };
515        let env = crate::env::Env::load(&dirs).expect("loads");
516        let mut h = Harness::with_env(Pinned(None), env.clone(), 10, 4);
517        h.set_glyph_mode(crate::icons::GlyphMode::Unicode).set_theme("dotted");
518        assert_eq!(bar(&h), "•···");
519        let mut pinned = Harness::with_env(Pinned(Some(ScrollbarStyle::Thin)), env, 10, 4);
520        pinned.set_glyph_mode(crate::icons::GlyphMode::Unicode).set_theme("dotted");
521        assert_eq!(bar(&pinned), "▕   ");
522        std::fs::remove_dir_all(dir).ok();
523    }
524}
525
526#[cfg(test)]
527#[path = "scroll_view_follow_tests.rs"]
528mod follow_tests;