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::{self, WHEEL_ROWS};
12use super::scrollbar::{self, ScrollMetrics, ScrollbarStyle};
13
14pub 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 glide: Option<Glide>,
40 detached: bool,
42 painted: bool,
44 note: Option<Rect>,
46}
47
48impl ScrollMemory {
49 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#[derive(Debug, Clone, Copy)]
64struct Glide {
65 from: u16,
66 to: u16,
67 start: Duration,
68}
69
70fn 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 #[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 #[must_use]
92 pub fn scrollbar(mut self, style: ScrollbarStyle) -> Self {
93 self.scrollbar = Some(style);
94 self
95 }
96
97 #[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 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 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 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 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 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 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 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;