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::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/// Style keys: `scrollbar` (`style`, `track`, `thumb`) with `hover`, and `scrollbar.<style>`.
21pub struct ScrollView<Msg> {
22    content: Vec<Node<Msg>>,
23    scrollbar: Option<ScrollbarStyle>,
24}
25
26#[derive(Debug, Default)]
27struct ScrollMemory {
28    offset: u16,
29    content_height: u16,
30    revealed: Option<WidgetId>,
31    dragging: bool,
32    /// A move towards an area a widget asked to reveal, while it runs.
33    glide: Option<Glide>,
34}
35
36/// A scroll from one offset to another that started at `start`.
37#[derive(Debug, Clone, Copy)]
38struct Glide {
39    from: u16,
40    to: u16,
41    start: Duration,
42}
43
44/// The offset that shows the content rows `top..bottom` in a view `height` rows tall, moving
45/// the least from `offset`: an area already in view, or covering the whole view, stays put.
46fn offset_showing(top: i32, bottom: i32, offset: u16, height: u16) -> u16 {
47    let (start, end) = (i32::from(offset), i32::from(offset) + i32::from(height));
48    if top >= start && bottom <= end || top <= start && bottom >= end {
49        offset
50    } else if top < start || bottom - top > i32::from(height) {
51        clamp_u16(top)
52    } else {
53        clamp_u16(bottom - i32::from(height))
54    }
55}
56
57impl<Msg: 'static> ScrollView<Msg> {
58    /// An empty scroll view; add content with [`View::add_with`](crate::widget::View::add_with).
59    #[must_use]
60    pub fn new() -> Self {
61        Self { content: vec![Node::new(Flex::new(Axis::Column, Vec::new()), 0)], scrollbar: None }
62    }
63
64    /// Draws the scrollbar in `style` whatever the theme chooses.
65    #[must_use]
66    pub fn scrollbar(mut self, style: ScrollbarStyle) -> Self {
67        self.scrollbar = Some(style);
68        self
69    }
70
71    fn metrics(memory: &ScrollMemory, area: Rect) -> ScrollMetrics {
72        ScrollMetrics {
73            total: usize::from(memory.content_height),
74            visible: usize::from(area.height),
75            offset: usize::from(memory.offset),
76        }
77    }
78
79    fn scroll_to(cx: &mut EventCx<'_, Msg>, offset: i32) {
80        let area = cx.area();
81        let memory = cx.memory::<ScrollMemory>();
82        let max = memory.content_height.saturating_sub(area.height);
83        memory.offset = clamp_u16(offset).min(max);
84        memory.glide = None;
85    }
86
87    /// Moves along a running glide and returns the offset to paint at.
88    fn glide(cx: &mut PaintCx<'_>, offset: u16, max: u16) -> u16 {
89        let Some(glide) = cx.memory::<ScrollMemory>().glide else {
90            return offset;
91        };
92        let duration = cx.env().theme().motion().page;
93        let progress = cx.progress_since(glide.start, duration, Easing::EaseOut);
94        let (from, to) = (f32::from(glide.from), f32::from(glide.to));
95        // The offsets are u16, so the rounded value between them fits.
96        let now = (from + (to - from) * progress).round() as u16;
97        let memory = cx.memory::<ScrollMemory>();
98        memory.offset = now.min(max);
99        if progress >= 1.0 {
100            memory.glide = None;
101        }
102        memory.offset
103    }
104
105    /// Takes the last area a widget inside asked to reveal this frame and scrolls to it.
106    fn take_reveal(cx: &mut PaintCx<'_>, content: Rect, area: Rect, offset: u16) {
107        let id = cx.id();
108        let mut wanted = None;
109        for (asker, rect) in std::mem::take(&mut cx.frame.reveals) {
110            if asker != id && cx.frame.is_within(asker, id) {
111                wanted = Some(rect);
112            } else {
113                cx.frame.reveals.push((asker, rect));
114            }
115        }
116        let Some(rect) = wanted else { return };
117        let top = rect.y - content.y;
118        let max = content.height.saturating_sub(area.height);
119        let target = offset_showing(top, top + i32::from(rect.height), offset, area.height).min(max);
120        let reduced = cx.reduced_motion();
121        let now = cx.now();
122        let memory = cx.memory::<ScrollMemory>();
123        if reduced {
124            memory.offset = target;
125            memory.glide = None;
126        } else {
127            memory.glide = (target != offset).then_some(Glide { from: offset, to: target, start: now });
128        }
129        if target != offset {
130            cx.request_frame_in(Duration::ZERO);
131        }
132    }
133}
134
135impl<Msg: 'static> Default for ScrollView<Msg> {
136    fn default() -> Self {
137        Self::new()
138    }
139}
140
141impl<Msg: 'static> Container<Msg> for ScrollView<Msg> {
142    fn set_children(&mut self, children: Vec<Node<Msg>>) {
143        let mut column = Node::new(Flex::new(Axis::Column, children), 0);
144        column.layout.width = Length::Fill(1);
145        self.content = vec![column];
146    }
147}
148
149impl<Msg: 'static> Widget<Msg> for ScrollView<Msg> {
150    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
151        let content = self.content.first().map_or(Size::default(), |c| cx.measure_child(c, available));
152        Size::new(content.width.saturating_add(1), content.height).min(available)
153    }
154
155    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
156        let Some(content) = self.content.first() else {
157            return;
158        };
159        cx.register_hit(area);
160        let full = cx.measure_child(content, Size::new(area.width, u16::MAX)).height;
161        let overflows = full > area.height;
162        let width = if overflows { area.width.saturating_sub(2) } else { area.width };
163        let height = if overflows { cx.measure_child(content, Size::new(width, u16::MAX)).height } else { full };
164        let max = height.saturating_sub(area.height);
165        let offset = {
166            let memory = cx.memory::<ScrollMemory>();
167            memory.content_height = height;
168            memory.offset = memory.offset.min(max);
169            memory.offset
170        };
171        let offset = Self::glide(cx, offset, max);
172        let content_rect = Rect::new(area.x, area.y - i32::from(offset), width, height);
173        cx.with_clip(area, |cx| cx.paint_child(content, content_rect));
174        Self::take_reveal(cx, content_rect, area, offset);
175
176        if let Some(focused) = cx.interaction.focused
177            && focused != cx.id()
178            && cx.frame.is_within(focused, cx.id())
179            && let Some(rect) = cx.frame.rects.get(&focused).copied()
180            && cx.memory::<ScrollMemory>().revealed != Some(focused)
181        {
182            let memory = cx.memory::<ScrollMemory>();
183            memory.revealed = Some(focused);
184            let top = rect.y - content_rect.y;
185            let new_offset = offset_showing(top, top + i32::from(rect.height), offset, area.height);
186            if new_offset != offset {
187                memory.offset = new_offset;
188                memory.glide = None;
189                cx.request_frame_in(Duration::ZERO);
190            }
191        }
192
193        if overflows {
194            let metrics = Self::metrics(cx.memory::<ScrollMemory>(), area);
195            let active =
196                cx.memory::<ScrollMemory>().dragging || cx.pointer().is_some_and(|(x, _)| x >= area.right() - 1);
197            scrollbar::paint(cx, Rect::new(area.right() - 1, area.y, 1, area.height), metrics, active, self.scrollbar);
198        }
199    }
200
201    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
202        let area = cx.area();
203        let (offset, metrics) = {
204            let memory = cx.memory::<ScrollMemory>();
205            (i32::from(memory.offset), Self::metrics(memory, area))
206        };
207        let page = i32::from(area.height.saturating_sub(1).max(1));
208        match event {
209            Event::Key(key) => {
210                let target = if key.is_plain(Key::Up) {
211                    offset - 1
212                } else if key.is_plain(Key::Down) {
213                    offset + 1
214                } else if key.is_plain(Key::PageUp) {
215                    offset - page
216                } else if key.is_plain(Key::PageDown) {
217                    offset + page
218                } else if key.is_plain(Key::Home) {
219                    0
220                } else if key.is_plain(Key::End) {
221                    i32::MAX
222                } else {
223                    return false;
224                };
225                if !metrics.overflows() {
226                    return false;
227                }
228                Self::scroll_to(cx, target);
229                true
230            }
231            Event::Mouse(mouse) => {
232                let on_bar = metrics.overflows() && mouse.x == area.right() - 1;
233                match mouse.kind {
234                    MouseKind::ScrollUp if metrics.overflows() => {
235                        Self::scroll_to(cx, offset - i32::from(WHEEL_ROWS));
236                        true
237                    }
238                    MouseKind::ScrollDown if metrics.overflows() => {
239                        Self::scroll_to(cx, offset + i32::from(WHEEL_ROWS));
240                        true
241                    }
242                    MouseKind::Down(MouseButton::Left) if on_bar => {
243                        cx.capture_pointer();
244                        cx.memory::<ScrollMemory>().dragging = true;
245                        let target = metrics.offset_at(clamp_u16(mouse.y - area.y), area.height);
246                        Self::scroll_to(cx, i32::try_from(target).unwrap_or(i32::MAX));
247                        true
248                    }
249                    MouseKind::Drag(MouseButton::Left) if cx.memory::<ScrollMemory>().dragging => {
250                        let target = metrics.offset_at(clamp_u16(mouse.y - area.y), area.height);
251                        Self::scroll_to(cx, i32::try_from(target).unwrap_or(i32::MAX));
252                        true
253                    }
254                    MouseKind::Up(MouseButton::Left) if cx.memory::<ScrollMemory>().dragging => {
255                        cx.memory::<ScrollMemory>().dragging = false;
256                        true
257                    }
258                    _ => false,
259                }
260            }
261            _ => false,
262        }
263    }
264
265    fn focusable(&self) -> bool {
266        true
267    }
268
269    fn children(&self) -> &[Node<Msg>] {
270        &self.content
271    }
272
273    fn children_mut(&mut self) -> &mut [Node<Msg>] {
274        &mut self.content
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use crate::runtime::{App, Command, Harness};
282    use crate::widget::View;
283    use crate::widgets::{Button, Text};
284
285    struct Demo;
286
287    impl App for Demo {
288        type Msg = ();
289        fn update(&mut self, _: ()) -> Command<()> {
290            Command::none()
291        }
292        fn view(&self, ui: &mut View<'_, ()>) {
293            ui.add_with(ScrollView::new(), |ui| {
294                for i in 0..20 {
295                    ui.add(Text::new(format!("line {i}")));
296                }
297                ui.add(Button::new("Bottom").on_press(())).id("bottom");
298            })
299            .fill();
300        }
301    }
302
303    #[test]
304    fn scrolls_with_keys_and_wheel() {
305        let mut h = Harness::new(Demo, 20, 5);
306        assert!(h.screen().starts_with("line 0"));
307        h.press("tab").press("pgdn");
308        assert!(h.screen().starts_with("line 4"), "{}", h.screen());
309        h.mouse(MouseKind::ScrollDown, 2, 2);
310        assert!(h.screen().starts_with("line 7"));
311        h.press("end");
312        assert!(h.screen().contains("Bottom"));
313        h.press("home");
314        assert!(h.screen().starts_with("line 0"));
315    }
316
317    #[test]
318    fn reveals_focused_widget() {
319        let mut h = Harness::new(Demo, 20, 5);
320        h.press("tab").press("tab");
321        assert!(h.is_focused("bottom"));
322        assert!(h.screen().contains("Bottom"), "{}", h.screen());
323    }
324
325    #[test]
326    fn draws_scrollbar_only_when_needed() {
327        // The default block style is colour only, so the scrollbar column is read by its colours.
328        let h = Harness::new(Demo, 20, 30);
329        let muted = h.env().theme().color("muted");
330        assert_ne!(h.bg(19, 0), muted);
331        let short = Harness::new(Demo, 20, 5);
332        assert_eq!(short.bg(19, 0), muted, "the thumb sits at the top");
333        assert_eq!(short.bg(19, 4), short.env().theme().color("raised"), "the track runs below it");
334    }
335
336    struct Pinned(Option<ScrollbarStyle>);
337
338    impl App for Pinned {
339        type Msg = ();
340        fn update(&mut self, _: ()) -> Command<()> {
341            Command::none()
342        }
343        fn view(&self, ui: &mut View<'_, ()>) {
344            let view = self.0.map_or_else(ScrollView::new, |style| ScrollView::new().scrollbar(style));
345            ui.add_with(view, |ui| {
346                for i in 0..20 {
347                    ui.add(Text::new(format!("line {i}")));
348                }
349            })
350            .fill();
351        }
352    }
353
354    /// The scrollbar column, top to bottom, with blank cells as spaces.
355    fn bar(h: &Harness<Pinned>) -> String {
356        h.screen().lines().map(|line| format!("{line:<10}").chars().nth(9).unwrap_or(' ')).collect()
357    }
358
359    #[test]
360    fn every_style_draws_its_own_column() {
361        let expected = [
362            (ScrollbarStyle::Block, "    "),
363            (ScrollbarStyle::Half, "▐▕▕▕"),
364            (ScrollbarStyle::Thin, "▕   "),
365            (ScrollbarStyle::Dots, "•···"),
366        ];
367        for (style, column) in expected {
368            let mut h = Harness::new(Pinned(Some(style)), 10, 4);
369            assert_eq!(bar(&h), column, "{style:?}");
370            let theme = h.env().theme();
371            let (raised, muted, canvas) = (theme.color("raised"), theme.color("muted"), theme.color("canvas"));
372            let thumb = if style == ScrollbarStyle::Dots { theme.color("dim") } else { muted };
373            match style {
374                ScrollbarStyle::Block => assert_eq!((h.bg(9, 0), h.bg(9, 3)), (muted, raised)),
375                ScrollbarStyle::Thin => assert_eq!(h.bg(9, 3), canvas, "thin draws no track"),
376                ScrollbarStyle::Half => assert_eq!(h.fg(9, 0), muted),
377                ScrollbarStyle::Dots => assert_eq!((h.fg(9, 0), h.fg(9, 3)), (theme.color("dim"), muted)),
378            }
379            h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
380            assert!(h.screen().is_ascii(), "{style:?}");
381            assert_eq!(h.bg(9, 0), thumb, "ASCII thumb is a coloured cell in {style:?}");
382        }
383    }
384
385    #[test]
386    fn theme_word_chooses_the_style_and_pinning_wins() {
387        let dir = std::env::temp_dir().join(format!("quvyta-scrollbar-{}", std::process::id()));
388        std::fs::create_dir_all(&dir).expect("temp dir");
389        let theme = "[meta]\nname = \"Dotted\"\nextends = \"monochrome\"\n[style.scrollbar]\nstyle = \"dots\"\n";
390        std::fs::write(dir.join("dotted.toml"), theme).expect("theme file");
391        let dirs = crate::env::AssetDirs { themes: Some(dir.clone()), ..Default::default() };
392        let env = crate::env::Env::load(&dirs).expect("loads");
393        let mut h = Harness::with_env(Pinned(None), env.clone(), 10, 4);
394        h.set_glyph_mode(crate::icons::GlyphMode::Unicode).set_theme("dotted");
395        assert_eq!(bar(&h), "•···");
396        let mut pinned = Harness::with_env(Pinned(Some(ScrollbarStyle::Thin)), env, 10, 4);
397        pinned.set_glyph_mode(crate::icons::GlyphMode::Unicode).set_theme("dotted");
398        assert_eq!(bar(&pinned), "▕   ");
399        std::fs::remove_dir_all(dir).ok();
400    }
401}