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    /// The offset that shows `focused`, whose area is `rect`, having just taken the focus. A
204    /// widget clicked into is where the person already looks, so a click never scrolls. A widget
205    /// that names where the keyboard is inside it ([`PaintCx::focus_spot`]) is shown there; one
206    /// taller than the view that is already partly on screen stays where it is, rather than the
207    /// view jumping to its top and hiding what is above it.
208    fn focus_offset(cx: &PaintCx<'_>, focused: WidgetId, rect: Rect, content: Rect, area: Rect, offset: u16) -> u16 {
209        let clicked =
210            cx.interaction.focus_by_pointer && cx.interaction.pointer.is_some_and(|(x, y)| rect.contains(x, y));
211        if clicked {
212            return offset;
213        }
214        let spot = cx.frame.focus_spots.get(&focused).copied();
215        let target = spot.unwrap_or(rect);
216        let visible = target.y < area.bottom() && target.bottom() > area.y;
217        if spot.is_none() && target.height > area.height && visible {
218            return offset;
219        }
220        let top = target.y - content.y;
221        offset_showing(top, top + i32::from(target.height), offset, area.height)
222    }
223
224    /// Takes the last area a widget inside asked to reveal this frame and scrolls to it.
225    fn take_reveal(cx: &mut PaintCx<'_>, content: Rect, area: Rect, offset: u16) {
226        let id = cx.id();
227        let mut wanted = None;
228        for (asker, rect) in std::mem::take(&mut cx.frame.reveals) {
229            if asker != id && cx.frame.is_within(asker, id) {
230                wanted = Some(rect);
231            } else {
232                cx.frame.reveals.push((asker, rect));
233            }
234        }
235        let Some(rect) = wanted else { return };
236        let top = rect.y - content.y;
237        let max = content.height.saturating_sub(area.height);
238        let target = offset_showing(top, top + i32::from(rect.height), offset, area.height).min(max);
239        let reduced = cx.reduced_motion();
240        let now = cx.now();
241        let memory = cx.memory::<ScrollMemory>();
242        memory.moved(offset, target, max);
243        if reduced {
244            memory.offset = target;
245            memory.glide = None;
246        } else {
247            memory.glide = (target != offset).then_some(Glide { from: offset, to: target, start: now });
248        }
249        if target != offset {
250            cx.request_frame_in(Duration::ZERO);
251        }
252    }
253}
254
255impl<Msg: 'static> Default for ScrollView<Msg> {
256    fn default() -> Self {
257        Self::new()
258    }
259}
260
261impl<Msg: 'static> Container<Msg> for ScrollView<Msg> {
262    fn set_children(&mut self, children: Vec<Node<Msg>>) {
263        let mut column = Node::new(Flex::new(Axis::Column, children), 0);
264        column.layout.width = Length::Fill(1);
265        self.content = vec![column];
266    }
267}
268
269impl<Msg: 'static> Widget<Msg> for ScrollView<Msg> {
270    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
271        let content = self.content.first().map_or(Size::default(), |c| cx.measure_child(c, available));
272        Size::new(content.width.saturating_add(1), content.height).min(available)
273    }
274
275    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
276        let Some(content) = self.content.first() else {
277            return;
278        };
279        cx.register_hit(area);
280        let full = cx.measure_child(content, Size::new(area.width, u16::MAX)).height;
281        let overflows = full > area.height;
282        let width = if overflows { area.width.saturating_sub(2) } else { area.width };
283        let height = if overflows { cx.measure_child(content, Size::new(width, u16::MAX)).height } else { full };
284        let max = height.saturating_sub(area.height);
285        let offset = {
286            let memory = cx.memory::<ScrollMemory>();
287            memory.content_height = height;
288            memory.offset = memory.offset.min(max);
289            memory.offset
290        };
291        let offset = if self.follow_end {
292            Self::follow(cx, max);
293            cx.memory::<ScrollMemory>().offset
294        } else {
295            offset
296        };
297        let offset = Self::glide(cx, offset, max);
298        let content_rect = Rect::new(area.x, area.y - i32::from(offset), width, height);
299        cx.with_clip(area, |cx| cx.paint_child(content, content_rect));
300        Self::take_reveal(cx, content_rect, area, offset);
301
302        if let Some(focused) = cx.interaction.focused
303            && focused != cx.id()
304            && cx.frame.is_within(focused, cx.id())
305            && let Some(rect) = cx.frame.rects.get(&focused).copied()
306            && cx.memory::<ScrollMemory>().revealed != Some(focused)
307        {
308            cx.memory::<ScrollMemory>().revealed = Some(focused);
309            let new_offset = Self::focus_offset(cx, focused, rect, content_rect, area, offset);
310            let memory = cx.memory::<ScrollMemory>();
311            if new_offset != offset {
312                memory.moved(offset, new_offset, max);
313                memory.offset = new_offset;
314                memory.glide = None;
315                cx.request_frame_in(Duration::ZERO);
316            }
317        }
318
319        let note = {
320            let memory = cx.memory::<ScrollMemory>();
321            let below = max.saturating_sub(memory.offset);
322            (self.follow_end && memory.detached && below > 0).then_some(below)
323        };
324        let note = note.map(|below| {
325            let note = rows::paint_below_note(cx, Rect::new(area.x, area.y, width, area.height), below.into());
326            cx.register_hit(note);
327            note
328        });
329        cx.memory::<ScrollMemory>().note = note;
330
331        if overflows {
332            let metrics = Self::metrics(cx.memory::<ScrollMemory>(), area);
333            let active =
334                cx.memory::<ScrollMemory>().dragging || cx.pointer().is_some_and(|(x, _)| x >= area.right() - 1);
335            scrollbar::paint(cx, Rect::new(area.right() - 1, area.y, 1, area.height), metrics, active, self.scrollbar);
336        }
337    }
338
339    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
340        let area = cx.area();
341        let (offset, metrics) = {
342            let memory = cx.memory::<ScrollMemory>();
343            (i32::from(memory.offset), Self::metrics(memory, area))
344        };
345        let page = i32::from(area.height.saturating_sub(1).max(1));
346        match event {
347            Event::Key(key) => {
348                let target = if key.is_plain(Key::Up) {
349                    offset - 1
350                } else if key.is_plain(Key::Down) {
351                    offset + 1
352                } else if key.is_plain(Key::PageUp) {
353                    offset - page
354                } else if key.is_plain(Key::PageDown) {
355                    offset + page
356                } else if key.is_plain(Key::Home) {
357                    0
358                } else if key.is_plain(Key::End) {
359                    i32::MAX
360                } else {
361                    return false;
362                };
363                if !metrics.overflows() {
364                    return false;
365                }
366                Self::scroll_to(cx, target);
367                true
368            }
369            Event::Mouse(mouse) => {
370                let on_bar = metrics.overflows() && mouse.x == area.right() - 1;
371                let on_note = cx.memory::<ScrollMemory>().note.is_some_and(|note| note.contains(mouse.x, mouse.y));
372                match mouse.kind {
373                    MouseKind::Down(MouseButton::Left) if on_note => {
374                        Self::scroll_to(cx, i32::MAX);
375                        true
376                    }
377                    MouseKind::ScrollUp if metrics.overflows() => {
378                        Self::scroll_to(cx, offset - i32::from(WHEEL_ROWS));
379                        true
380                    }
381                    MouseKind::ScrollDown if metrics.overflows() => {
382                        Self::scroll_to(cx, offset + i32::from(WHEEL_ROWS));
383                        true
384                    }
385                    MouseKind::Down(MouseButton::Left) if on_bar => {
386                        cx.capture_pointer();
387                        cx.memory::<ScrollMemory>().dragging = true;
388                        let target = metrics.offset_at(clamp_u16(mouse.y - area.y), area.height);
389                        Self::scroll_to(cx, i32::try_from(target).unwrap_or(i32::MAX));
390                        true
391                    }
392                    MouseKind::Drag(MouseButton::Left) if cx.memory::<ScrollMemory>().dragging => {
393                        let target = metrics.offset_at(clamp_u16(mouse.y - area.y), area.height);
394                        Self::scroll_to(cx, i32::try_from(target).unwrap_or(i32::MAX));
395                        true
396                    }
397                    MouseKind::Up(MouseButton::Left) if cx.memory::<ScrollMemory>().dragging => {
398                        cx.memory::<ScrollMemory>().dragging = false;
399                        true
400                    }
401                    _ => false,
402                }
403            }
404            _ => false,
405        }
406    }
407
408    fn focusable(&self) -> bool {
409        true
410    }
411
412    fn children(&self) -> &[Node<Msg>] {
413        &self.content
414    }
415
416    fn children_mut(&mut self) -> &mut [Node<Msg>] {
417        &mut self.content
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424    use crate::runtime::{App, Command, Harness};
425    use crate::widget::View;
426    use crate::widgets::{Button, Text};
427
428    struct Demo;
429
430    impl App for Demo {
431        type Msg = ();
432        fn update(&mut self, _: ()) -> Command<()> {
433            Command::none()
434        }
435        fn view(&self, ui: &mut View<'_, ()>) {
436            ui.add_with(ScrollView::new(), |ui| {
437                for i in 0..20 {
438                    ui.add(Text::new(format!("line {i}")));
439                }
440                ui.add(Button::new("Bottom").on_press(())).id("bottom");
441            })
442            .fill();
443        }
444    }
445
446    #[test]
447    fn scrolls_with_keys_and_wheel() {
448        let mut h = Harness::new(Demo, 20, 5);
449        assert!(h.screen().starts_with("line 0"));
450        h.press("tab").press("pgdn");
451        assert!(h.screen().starts_with("line 4"), "{}", h.screen());
452        h.mouse(MouseKind::ScrollDown, 2, 2);
453        assert!(h.screen().starts_with("line 7"));
454        h.press("end");
455        assert!(h.screen().contains("Bottom"));
456        h.press("home");
457        assert!(h.screen().starts_with("line 0"));
458    }
459
460    #[test]
461    fn reveals_focused_widget() {
462        let mut h = Harness::new(Demo, 20, 5);
463        h.press("tab").press("tab");
464        assert!(h.is_focused("bottom"));
465        assert!(h.screen().contains("Bottom"), "{}", h.screen());
466    }
467
468    #[test]
469    fn draws_scrollbar_only_when_needed() {
470        // The default block style is colour only, so the scrollbar column is read by its colours.
471        let h = Harness::new(Demo, 20, 30);
472        let muted = h.env().theme().color("muted");
473        assert_ne!(h.bg(19, 0), muted);
474        let short = Harness::new(Demo, 20, 5);
475        assert_eq!(short.bg(19, 0), muted, "the thumb sits at the top");
476        assert_eq!(short.bg(19, 4), short.env().theme().color("raised"), "the track runs below it");
477    }
478
479    struct Pinned(Option<ScrollbarStyle>);
480
481    impl App for Pinned {
482        type Msg = ();
483        fn update(&mut self, _: ()) -> Command<()> {
484            Command::none()
485        }
486        fn view(&self, ui: &mut View<'_, ()>) {
487            let view = self.0.map_or_else(ScrollView::new, |style| ScrollView::new().scrollbar(style));
488            ui.add_with(view, |ui| {
489                for i in 0..20 {
490                    ui.add(Text::new(format!("line {i}")));
491                }
492            })
493            .fill();
494        }
495    }
496
497    /// The scrollbar column, top to bottom, with blank cells as spaces.
498    fn bar(h: &Harness<Pinned>) -> String {
499        h.screen().lines().map(|line| format!("{line:<10}").chars().nth(9).unwrap_or(' ')).collect()
500    }
501
502    #[test]
503    fn every_style_draws_its_own_column() {
504        let expected = [
505            (ScrollbarStyle::Block, "    "),
506            (ScrollbarStyle::Half, "▐▕▕▕"),
507            (ScrollbarStyle::Thin, "▕   "),
508            (ScrollbarStyle::Dots, "•···"),
509        ];
510        for (style, column) in expected {
511            let mut h = Harness::new(Pinned(Some(style)), 10, 4);
512            assert_eq!(bar(&h), column, "{style:?}");
513            let theme = h.env().theme();
514            let (raised, muted, canvas) = (theme.color("raised"), theme.color("muted"), theme.color("canvas"));
515            let thumb = if style == ScrollbarStyle::Dots { theme.color("dim") } else { muted };
516            match style {
517                ScrollbarStyle::Block => assert_eq!((h.bg(9, 0), h.bg(9, 3)), (muted, raised)),
518                ScrollbarStyle::Thin => assert_eq!(h.bg(9, 3), canvas, "thin draws no track"),
519                ScrollbarStyle::Half => assert_eq!(h.fg(9, 0), muted),
520                ScrollbarStyle::Dots => assert_eq!((h.fg(9, 0), h.fg(9, 3)), (theme.color("dim"), muted)),
521            }
522            h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
523            assert!(h.screen().is_ascii(), "{style:?}");
524            assert_eq!(h.bg(9, 0), thumb, "ASCII thumb is a coloured cell in {style:?}");
525        }
526    }
527
528    #[test]
529    fn theme_word_chooses_the_style_and_pinning_wins() {
530        let dir = std::env::temp_dir().join(format!("quvyta-scrollbar-{}", std::process::id()));
531        std::fs::create_dir_all(&dir).expect("temp dir");
532        let theme = "[meta]\nname = \"Dotted\"\nextends = \"monochrome\"\n[style.scrollbar]\nstyle = \"dots\"\n";
533        std::fs::write(dir.join("dotted.toml"), theme).expect("theme file");
534        let dirs = crate::env::AssetDirs { themes: Some(dir.clone()), ..Default::default() };
535        let env = crate::env::Env::load(&dirs).expect("loads");
536        let mut h = Harness::with_env(Pinned(None), env.clone(), 10, 4);
537        h.set_glyph_mode(crate::icons::GlyphMode::Unicode).set_theme("dotted");
538        assert_eq!(bar(&h), "•···");
539        let mut pinned = Harness::with_env(Pinned(Some(ScrollbarStyle::Thin)), env, 10, 4);
540        pinned.set_glyph_mode(crate::icons::GlyphMode::Unicode).set_theme("dotted");
541        assert_eq!(bar(&pinned), "▕   ");
542        std::fs::remove_dir_all(dir).ok();
543    }
544}
545
546#[cfg(test)]
547#[path = "scroll_view_follow_tests.rs"]
548mod follow_tests;