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