1use 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
14pub 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 glide: Option<Glide>,
34}
35
36#[derive(Debug, Clone, Copy)]
38struct Glide {
39 from: u16,
40 to: u16,
41 start: Duration,
42}
43
44fn 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 #[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 #[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 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 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 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 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 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}