Skip to main content

qframe/widgets/
scroll_view.rs

1//! Vertical scrolling of any content.
2
3use crate::event::{Event, MouseButton, MouseKind};
4use crate::geometry::{Rect, Size, clamp_u16};
5use crate::keymap::Key;
6use crate::widget::{Axis, Container, EventCx, Flex, Length, MeasureCx, Node, PaintCx, Widget, WidgetId};
7
8use super::rows::WHEEL_ROWS;
9use super::scrollbar::{self, ScrollMetrics, ScrollbarStyle};
10
11/// Shows content taller than its area and scrolls it with the wheel, the scrollbar or the
12/// keyboard (↑/↓, PgUp/PgDn, Home/End while focused). When focus moves to a widget inside that
13/// is out of view, the view scrolls to it.
14///
15/// Style keys: `scrollbar` (`style`, `track`, `thumb`) with `hover`, and `scrollbar.<style>`.
16pub struct ScrollView<Msg> {
17    content: Vec<Node<Msg>>,
18    scrollbar: Option<ScrollbarStyle>,
19}
20
21#[derive(Debug, Default)]
22struct ScrollMemory {
23    offset: u16,
24    content_height: u16,
25    revealed: Option<WidgetId>,
26    dragging: bool,
27}
28
29impl<Msg: 'static> ScrollView<Msg> {
30    /// An empty scroll view; add content with [`View::add_with`](crate::widget::View::add_with).
31    #[must_use]
32    pub fn new() -> Self {
33        Self { content: vec![Node::new(Flex::new(Axis::Column, Vec::new()), 0)], scrollbar: None }
34    }
35
36    /// Draws the scrollbar in `style` whatever the theme chooses.
37    #[must_use]
38    pub fn scrollbar(mut self, style: ScrollbarStyle) -> Self {
39        self.scrollbar = Some(style);
40        self
41    }
42
43    fn metrics(memory: &ScrollMemory, area: Rect) -> ScrollMetrics {
44        ScrollMetrics {
45            total: usize::from(memory.content_height),
46            visible: usize::from(area.height),
47            offset: usize::from(memory.offset),
48        }
49    }
50
51    fn scroll_to(cx: &mut EventCx<'_, Msg>, offset: i32) {
52        let area = cx.area();
53        let memory = cx.memory::<ScrollMemory>();
54        let max = memory.content_height.saturating_sub(area.height);
55        memory.offset = clamp_u16(offset).min(max);
56    }
57}
58
59impl<Msg: 'static> Default for ScrollView<Msg> {
60    fn default() -> Self {
61        Self::new()
62    }
63}
64
65impl<Msg: 'static> Container<Msg> for ScrollView<Msg> {
66    fn set_children(&mut self, children: Vec<Node<Msg>>) {
67        let mut column = Node::new(Flex::new(Axis::Column, children), 0);
68        column.layout.width = Length::Fill(1);
69        self.content = vec![column];
70    }
71}
72
73impl<Msg: 'static> Widget<Msg> for ScrollView<Msg> {
74    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
75        let content = self.content.first().map_or(Size::default(), |c| cx.measure_child(c, available));
76        Size::new(content.width.saturating_add(1), content.height).min(available)
77    }
78
79    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
80        let Some(content) = self.content.first() else {
81            return;
82        };
83        cx.register_hit(area);
84        let full = cx.measure_child(content, Size::new(area.width, u16::MAX)).height;
85        let overflows = full > area.height;
86        let width = if overflows { area.width.saturating_sub(2) } else { area.width };
87        let height = if overflows { cx.measure_child(content, Size::new(width, u16::MAX)).height } else { full };
88        let offset = {
89            let memory = cx.memory::<ScrollMemory>();
90            memory.content_height = height;
91            memory.offset = memory.offset.min(height.saturating_sub(area.height));
92            memory.offset
93        };
94        let content_rect = Rect::new(area.x, area.y - i32::from(offset), width, height);
95        cx.with_clip(area, |cx| cx.paint_child(content, content_rect));
96
97        if let Some(focused) = cx.interaction.focused
98            && focused != cx.id()
99            && cx.frame.is_within(focused, cx.id())
100            && let Some(rect) = cx.frame.rects.get(&focused).copied()
101            && cx.memory::<ScrollMemory>().revealed != Some(focused)
102        {
103            let memory = cx.memory::<ScrollMemory>();
104            memory.revealed = Some(focused);
105            let top = rect.y - content_rect.y;
106            let bottom = top + i32::from(rect.height);
107            let new_offset = if top < i32::from(offset) {
108                clamp_u16(top)
109            } else if bottom > i32::from(offset) + i32::from(area.height) {
110                clamp_u16(bottom - i32::from(area.height))
111            } else {
112                offset
113            };
114            if new_offset != offset {
115                memory.offset = new_offset;
116                cx.request_frame_in(std::time::Duration::ZERO);
117            }
118        }
119
120        if overflows {
121            let metrics = Self::metrics(cx.memory::<ScrollMemory>(), area);
122            let active =
123                cx.memory::<ScrollMemory>().dragging || cx.pointer().is_some_and(|(x, _)| x >= area.right() - 1);
124            scrollbar::paint(cx, Rect::new(area.right() - 1, area.y, 1, area.height), metrics, active, self.scrollbar);
125        }
126    }
127
128    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
129        let area = cx.area();
130        let (offset, metrics) = {
131            let memory = cx.memory::<ScrollMemory>();
132            (i32::from(memory.offset), Self::metrics(memory, area))
133        };
134        let page = i32::from(area.height.saturating_sub(1).max(1));
135        match event {
136            Event::Key(key) => {
137                let target = if key.is_plain(Key::Up) {
138                    offset - 1
139                } else if key.is_plain(Key::Down) {
140                    offset + 1
141                } else if key.is_plain(Key::PageUp) {
142                    offset - page
143                } else if key.is_plain(Key::PageDown) {
144                    offset + page
145                } else if key.is_plain(Key::Home) {
146                    0
147                } else if key.is_plain(Key::End) {
148                    i32::MAX
149                } else {
150                    return false;
151                };
152                if !metrics.overflows() {
153                    return false;
154                }
155                Self::scroll_to(cx, target);
156                true
157            }
158            Event::Mouse(mouse) => {
159                let on_bar = metrics.overflows() && mouse.x == area.right() - 1;
160                match mouse.kind {
161                    MouseKind::ScrollUp if metrics.overflows() => {
162                        Self::scroll_to(cx, offset - i32::from(WHEEL_ROWS));
163                        true
164                    }
165                    MouseKind::ScrollDown if metrics.overflows() => {
166                        Self::scroll_to(cx, offset + i32::from(WHEEL_ROWS));
167                        true
168                    }
169                    MouseKind::Down(MouseButton::Left) if on_bar => {
170                        cx.capture_pointer();
171                        cx.memory::<ScrollMemory>().dragging = true;
172                        let target = metrics.offset_at(clamp_u16(mouse.y - area.y), area.height);
173                        Self::scroll_to(cx, i32::try_from(target).unwrap_or(i32::MAX));
174                        true
175                    }
176                    MouseKind::Drag(MouseButton::Left) if cx.memory::<ScrollMemory>().dragging => {
177                        let target = metrics.offset_at(clamp_u16(mouse.y - area.y), area.height);
178                        Self::scroll_to(cx, i32::try_from(target).unwrap_or(i32::MAX));
179                        true
180                    }
181                    MouseKind::Up(MouseButton::Left) if cx.memory::<ScrollMemory>().dragging => {
182                        cx.memory::<ScrollMemory>().dragging = false;
183                        true
184                    }
185                    _ => false,
186                }
187            }
188            _ => false,
189        }
190    }
191
192    fn focusable(&self) -> bool {
193        true
194    }
195
196    fn children(&self) -> &[Node<Msg>] {
197        &self.content
198    }
199
200    fn children_mut(&mut self) -> &mut [Node<Msg>] {
201        &mut self.content
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crate::runtime::{App, Command, Harness};
209    use crate::widget::View;
210    use crate::widgets::{Button, Text};
211
212    struct Demo;
213
214    impl App for Demo {
215        type Msg = ();
216        fn update(&mut self, _: ()) -> Command<()> {
217            Command::none()
218        }
219        fn view(&self, ui: &mut View<'_, ()>) {
220            ui.add_with(ScrollView::new(), |ui| {
221                for i in 0..20 {
222                    ui.add(Text::new(format!("line {i}")));
223                }
224                ui.add(Button::new("Bottom").on_press(())).id("bottom");
225            })
226            .fill();
227        }
228    }
229
230    #[test]
231    fn scrolls_with_keys_and_wheel() {
232        let mut h = Harness::new(Demo, 20, 5);
233        assert!(h.screen().starts_with("line 0"));
234        h.press("tab").press("pgdn");
235        assert!(h.screen().starts_with("line 4"), "{}", h.screen());
236        h.mouse(MouseKind::ScrollDown, 2, 2);
237        assert!(h.screen().starts_with("line 7"));
238        h.press("end");
239        assert!(h.screen().contains("Bottom"));
240        h.press("home");
241        assert!(h.screen().starts_with("line 0"));
242    }
243
244    #[test]
245    fn reveals_focused_widget() {
246        let mut h = Harness::new(Demo, 20, 5);
247        h.press("tab").press("tab");
248        assert!(h.is_focused("bottom"));
249        assert!(h.screen().contains("Bottom"), "{}", h.screen());
250    }
251
252    #[test]
253    fn draws_scrollbar_only_when_needed() {
254        // The default block style is colour only, so the scrollbar column is read by its colours.
255        let h = Harness::new(Demo, 20, 30);
256        let muted = h.env().theme().color("muted");
257        assert_ne!(h.bg(19, 0), muted);
258        let short = Harness::new(Demo, 20, 5);
259        assert_eq!(short.bg(19, 0), muted, "the thumb sits at the top");
260        assert_eq!(short.bg(19, 4), short.env().theme().color("raised"), "the track runs below it");
261    }
262
263    struct Pinned(Option<ScrollbarStyle>);
264
265    impl App for Pinned {
266        type Msg = ();
267        fn update(&mut self, _: ()) -> Command<()> {
268            Command::none()
269        }
270        fn view(&self, ui: &mut View<'_, ()>) {
271            let view = self.0.map_or_else(ScrollView::new, |style| ScrollView::new().scrollbar(style));
272            ui.add_with(view, |ui| {
273                for i in 0..20 {
274                    ui.add(Text::new(format!("line {i}")));
275                }
276            })
277            .fill();
278        }
279    }
280
281    /// The scrollbar column, top to bottom, with blank cells as spaces.
282    fn bar(h: &Harness<Pinned>) -> String {
283        h.screen().lines().map(|line| format!("{line:<10}").chars().nth(9).unwrap_or(' ')).collect()
284    }
285
286    #[test]
287    fn every_style_draws_its_own_column() {
288        let expected = [
289            (ScrollbarStyle::Block, "    "),
290            (ScrollbarStyle::Half, "▐▕▕▕"),
291            (ScrollbarStyle::Thin, "▕   "),
292            (ScrollbarStyle::Dots, "•···"),
293        ];
294        for (style, column) in expected {
295            let mut h = Harness::new(Pinned(Some(style)), 10, 4);
296            assert_eq!(bar(&h), column, "{style:?}");
297            let theme = h.env().theme();
298            let (raised, muted, canvas) = (theme.color("raised"), theme.color("muted"), theme.color("canvas"));
299            let thumb = if style == ScrollbarStyle::Dots { theme.color("dim") } else { muted };
300            match style {
301                ScrollbarStyle::Block => assert_eq!((h.bg(9, 0), h.bg(9, 3)), (muted, raised)),
302                ScrollbarStyle::Thin => assert_eq!(h.bg(9, 3), canvas, "thin draws no track"),
303                ScrollbarStyle::Half => assert_eq!(h.fg(9, 0), muted),
304                ScrollbarStyle::Dots => assert_eq!((h.fg(9, 0), h.fg(9, 3)), (theme.color("dim"), muted)),
305            }
306            h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
307            assert!(h.screen().is_ascii(), "{style:?}");
308            assert_eq!(h.bg(9, 0), thumb, "ASCII thumb is a coloured cell in {style:?}");
309        }
310    }
311
312    #[test]
313    fn theme_word_chooses_the_style_and_pinning_wins() {
314        let dir = std::env::temp_dir().join(format!("quvyta-scrollbar-{}", std::process::id()));
315        std::fs::create_dir_all(&dir).expect("temp dir");
316        let theme = "[meta]\nname = \"Dotted\"\nextends = \"monochrome\"\n[style.scrollbar]\nstyle = \"dots\"\n";
317        std::fs::write(dir.join("dotted.toml"), theme).expect("theme file");
318        let dirs = crate::env::AssetDirs { themes: Some(dir.clone()), ..Default::default() };
319        let env = crate::env::Env::load(&dirs).expect("loads");
320        let mut h = Harness::with_env(Pinned(None), env.clone(), 10, 4);
321        h.set_glyph_mode(crate::icons::GlyphMode::Unicode).set_theme("dotted");
322        assert_eq!(bar(&h), "•···");
323        let mut pinned = Harness::with_env(Pinned(Some(ScrollbarStyle::Thin)), env, 10, 4);
324        pinned.set_glyph_mode(crate::icons::GlyphMode::Unicode).set_theme("dotted");
325        assert_eq!(bar(&pinned), "▕   ");
326        std::fs::remove_dir_all(dir).ok();
327    }
328}