smudge_widgets/scroll.rs
1//! The scroll view: a clipped viewport whose content moves with the wheel.
2//!
3//! The view is a [`ScrollView`] panel that clips its content, handles wheel
4//! events while it is hovered, and clamps its offset to what the content
5//! needs. The offset is the view's scroll offset, which the core applies after
6//! layout, so scrolling moves the content without a layout pass. The thumb
7//! moves the same way, through the scroll offset of a track it hangs from.
8//! Through [`ScrollExt::follow_end`] the view can also pin itself to the end
9//! of its content while a signal reads `true`, and release itself when a
10//! wheel step leaves the end.
11//!
12//! The view learns its own height and its content's height when the two
13//! paint, and writes them to signals its bindings read. A size-driven change,
14//! such as a thumb that follows a resize, therefore lands one frame after the
15//! resize that caused it, which keeps layout out of the paint pass. The
16//! follow pin is the one exception: the core applies it after each layout
17//! pass with the heights that pass just wrote, so a frame that grows the
18//! content never shows the end out of view.
19
20use std::cell::RefCell;
21use std::rc::Rc;
22
23use smudge_core::element::{Element, IntoElement, Value};
24use smudge_core::geometry::Point;
25use smudge_core::input::Handled;
26use smudge_core::paint::PaintOp;
27use smudge_core::reactive::{RwSignal, prelude::*};
28use smudge_core::semantics::{Role, Semantics};
29use smudge_core::style::{Edges, Length, Overflow, px};
30use smudge_core::theme::ColorToken;
31use smudge_core::tree::Dirty;
32use smudge_core::widget::{PaintCx, Panel, Widget, panel};
33
34/// The width of the scrollbar thumb, in logical pixels.
35const THUMB_WIDTH: f32 = 4.0;
36
37/// The gap between the thumb and the right edge of the viewport, in logical
38/// pixels.
39const THUMB_RIGHT_INSET: f32 = 2.0;
40
41/// The shortest the thumb is ever drawn, in logical pixels, so a long content
42/// keeps a thumb that can still be seen.
43const MIN_THUMB_LENGTH: f32 = 24.0;
44
45/// How far from the end of the content a wheel step down may land, in
46/// logical pixels, and still engage following.
47const FOLLOW_SLACK: f32 = 4.0;
48
49/// The heights a scroll view measures when it paints.
50#[derive(Clone, Copy, Debug, PartialEq)]
51struct Viewport {
52 /// The height of the view's content box, the part of the content that
53 /// fits on screen at once.
54 visible: f32,
55 /// The height inside the view's border, which the thumb travels along.
56 track: f32,
57}
58
59/// The reactive state one scroll view shares between its widgets, its wheel
60/// handler and its bindings.
61struct ScrollChrome {
62 /// The signal whose value is the scroll offset. It starts as one the view
63 /// created itself, and [`ScrollExt::offset`] swaps in one of the user's
64 /// while the view is built.
65 offset: RefCell<RwSignal<f32>>,
66 /// The signal that reports and sets whether the view follows the end of
67 /// its content, when [`ScrollExt::follow_end`] set one.
68 following: RefCell<Option<RwSignal<bool>>>,
69 /// The height of the content, once the content has painted.
70 content_height: RwSignal<Option<f32>>,
71 /// The height the content takes in the coming layout, when the view's
72 /// owner knows it before the content paints. It stands in for
73 /// `content_height`, which lags it by a frame.
74 content_extent: Option<RwSignal<f32>>,
75 /// The heights of the view, once the view has painted.
76 viewport: RwSignal<Option<Viewport>>,
77 /// The visible height of the view, once the view has painted, reported to
78 /// a virtual list that computes its rows from it.
79 visible_height: RwSignal<Option<f32>>,
80}
81
82impl ScrollChrome {
83 /// Returns the current offset signal, whichever one
84 /// [`ScrollExt::offset`] installed.
85 fn signal(&self) -> RwSignal<f32> {
86 self.offset.borrow().clone()
87 }
88
89 /// Returns the furthest the view can scroll, or `None` before both the
90 /// view and its content have painted. Reading it tracks both heights.
91 fn max_offset(&self) -> Option<f32> {
92 let content = self.content()?;
93 let viewport = self.viewport.get()?;
94 Some((content - viewport.visible).max(0.0))
95 }
96
97 /// Returns the height of the content: the extent the owner gave, or the
98 /// painted height, which is `None` before the content has painted.
99 /// Reading it tracks the signal it comes from.
100 fn content(&self) -> Option<f32> {
101 match &self.content_extent {
102 Some(extent) => Some(finite(extent.get()).max(0.0)),
103 None => self.content_height.get(),
104 }
105 }
106
107 /// Returns the follow signal, when [`ScrollExt::follow_end`] set one.
108 fn following(&self) -> Option<RwSignal<bool>> {
109 self.following.borrow().clone()
110 }
111}
112
113/// A panel that clips its content and scrolls it with the wheel.
114///
115/// It paints like a [`Panel`], so it takes a background and a border through
116/// the style builders. Its paint also reports the view's heights to its
117/// bindings, which is how the offset learns its range and the thumb its
118/// track.
119pub struct ScrollView {
120 /// The state shared with the wheel handler and the bindings.
121 chrome: Rc<ScrollChrome>,
122}
123
124impl Widget for ScrollView {
125 /// Reports the view as a scrollable region.
126 fn semantics(&self) -> Option<Semantics> {
127 Some(Semantics::new(Role::ScrollView))
128 }
129
130 fn paint(&self, cx: &PaintCx<'_>, out: &mut Vec<PaintOp>) {
131 let border = cx.style.layout.border_width;
132 let visible = finite(cx.content.height());
133 report(
134 &self.chrome.viewport,
135 Viewport {
136 visible,
137 track: finite(cx.rect.height() - border.top - border.bottom).max(0.0),
138 },
139 );
140 report(&self.chrome.visible_height, visible);
141 Panel.paint(cx, out);
142 }
143}
144
145/// The wrapper around the user's content, whose paint reports the content
146/// height.
147///
148/// It paints nothing: the content paints itself, one node below.
149struct ScrollContent {
150 /// The state shared with the wheel handler and the bindings.
151 chrome: Rc<ScrollChrome>,
152}
153
154impl Widget for ScrollContent {
155 fn paint(&self, cx: &PaintCx<'_>, _out: &mut Vec<PaintOp>) {
156 report(&self.chrome.content_height, finite(cx.rect.height()));
157 }
158}
159
160/// Returns `value`, or zero when it is not a number.
161fn finite(value: f32) -> f32 {
162 if value.is_finite() { value } else { 0.0 }
163}
164
165/// Writes a measured value into a geometry signal, when it changed.
166///
167/// An equal write would still wake every binding that reads the signal, so
168/// the comparison is what keeps an idle scroll view from asking for frames.
169fn report<T: PartialEq + 'static>(signal: &RwSignal<Option<T>>, value: T) {
170 let value = Some(value);
171 if signal.read(|current| current != &value) {
172 signal.set(value);
173 }
174}
175
176/// Clamps a desired offset to the range `0..=max`.
177///
178/// A desired offset that is not a number counts as zero, so a NaN written to
179/// the offset signal lands at the top instead of moving every rectangle out of
180/// the arithmetic.
181fn clamp_offset(desired: f32, max: f32) -> f32 {
182 finite(desired).clamp(0.0, max)
183}
184
185/// Creates a view that clips `content` and scrolls it with the wheel.
186///
187/// The view is a panel one viewport tall, so give it a height, either
188/// directly or through a parent that sizes it. The content is laid out at its
189/// full height, however tall that is. A wheel scroll moves the content up
190/// while the pointer is over the view, clamped so the content's bottom edge
191/// never rises above the bottom of the view's content box, and a thin thumb
192/// at the right edge shows the position while the content overflows. The
193/// thumb is a position indicator only: it has no handlers and no dragging.
194///
195/// The offset can be controlled from outside through a signal, with
196/// [`ScrollExt::offset`]:
197///
198/// ```
199/// use smudge_core::geometry::Color;
200/// use smudge_core::reactive::{RwSignal, prelude::*};
201/// use smudge_core::style::px;
202/// use smudge_core::widget::panel;
203/// use smudge_widgets::{ScrollExt, column, scroll};
204///
205/// let offset = RwSignal::new(0.0);
206/// let view = scroll(
207/// column().child(panel().height(px(400.0)).background(Color::WHITE)),
208/// )
209/// .offset(offset.clone())
210/// .height(px(120.0));
211///
212/// // Jump towards the bottom: the view clamps to what the content needs.
213/// offset.set(f32::MAX);
214/// ```
215///
216/// Once the view and its content have painted, it writes the clamped offset
217/// back to the signal, so the signal always reads the position on screen.
218/// Before that, it applies any offset that is not negative as it is, which
219/// lets a view mount at a remembered position.
220pub fn scroll(content: impl IntoElement) -> Element<ScrollView> {
221 scroll_with_visible_height(content, RwSignal::new(None), None)
222}
223
224/// Creates a view like [`scroll`] that reports the visible height of its
225/// content box to `visible_height` once it has painted, and again whenever
226/// that height changes.
227///
228/// The height is a measurement only the view has, because it follows the
229/// style and the layout around it, so the virtual list reads it from this
230/// signal instead of asking for a fixed viewport.
231///
232/// A list that sizes its content itself passes that size as
233/// `content_extent`, and the view clamps its offset with it instead of the
234/// content height it painted last frame. Otherwise an offset the list moves
235/// in the frame that grows the content would be clamped to the old end.
236pub(crate) fn scroll_with_visible_height(
237 content: impl IntoElement,
238 visible_height: RwSignal<Option<f32>>,
239 content_extent: Option<RwSignal<f32>>,
240) -> Element<ScrollView> {
241 let chrome = Rc::new(ScrollChrome {
242 offset: RefCell::new(RwSignal::new(0.0)),
243 following: RefCell::new(None),
244 content_height: RwSignal::new(None),
245 content_extent,
246 viewport: RwSignal::new(None),
247 visible_height,
248 });
249
250 let wheel_chrome = Rc::clone(&chrome);
251 let offset_chrome = Rc::clone(&chrome);
252 let content_chrome = Rc::clone(&chrome);
253 let track_chrome = Rc::clone(&chrome);
254
255 Element::new(ScrollView {
256 chrome: Rc::clone(&chrome),
257 })
258 .column()
259 .overflow(Overflow::Scroll)
260 // The view takes every wheel event over it, even one it cannot use, so
261 // a page around a view at its end does not scroll in its place.
262 .on_wheel(move |delta| {
263 let Some(max) = wheel_chrome.max_offset() else {
264 return Handled::Yes;
265 };
266 let follow_signal = wheel_chrome.following();
267 // The signal, not the offset the binding last applied, is where a
268 // step starts: several wheel events can arrive before the next flush,
269 // and each must add to the one before it. While the view follows it
270 // sits at the end, so the step starts there even when the signal has
271 // not caught up with the pin yet.
272 let following = follow_signal
273 .as_ref()
274 .is_some_and(|signal| signal.read(|active| *active));
275 let signal = wheel_chrome.signal();
276 let current = if following {
277 max
278 } else {
279 clamp_offset(signal.read(|offset| *offset), max)
280 };
281 let step = finite(delta.y);
282 let next = clamp_offset(current + step, max);
283 if next != current {
284 signal.set(next);
285 }
286 // Any step that moves the view up releases the pin, so small steps
287 // add up instead of each one snapping back. The slack applies only
288 // to a step down, which engages the pin when it lands near the end.
289 if let Some(signal) = &follow_signal {
290 if following && next < current {
291 signal.set(false);
292 } else if !following && step > 0.0 && next >= max - FOLLOW_SLACK {
293 signal.set(true);
294 }
295 }
296 Handled::Yes
297 })
298 .scroll_offset(move || {
299 let signal = offset_chrome.signal();
300 let following = offset_chrome.following().is_some_and(|signal| signal.get());
301 let applied = match offset_chrome.max_offset() {
302 // While the view follows, the end is the offset, whatever the
303 // signal held: the core pins the node with the heights the last
304 // layout pass wrote, which the painted ones the signal
305 // round-trips through lag by a frame. The read is tracked, so a
306 // write from the application is overwritten in the next flush.
307 Some(max) if following => {
308 if signal.get() != max {
309 signal.set(max);
310 }
311 max
312 }
313 Some(max) => {
314 let desired = signal.get();
315 let clamped = clamp_offset(desired, max);
316 if clamped != desired {
317 signal.set(clamped);
318 }
319 clamped
320 }
321 None => finite(signal.get()).max(0.0),
322 };
323 Point { x: 0.0, y: applied }
324 })
325 .child(
326 Element::new(ScrollContent {
327 chrome: content_chrome,
328 })
329 .column()
330 .shrink(0.0)
331 .child(content),
332 )
333 .child(thumb(track_chrome))
334}
335
336/// Controls the offset of a scroll view through a signal.
337///
338/// Import the trait to use it:
339///
340/// ```
341/// use smudge_core::reactive::RwSignal;
342/// use smudge_core::widget::panel;
343/// use smudge_widgets::{ScrollExt, column, scroll};
344///
345/// let offset = RwSignal::new(0.0);
346/// let view = scroll(column().child(panel())).offset(offset);
347/// ```
348pub trait ScrollExt: Sized {
349 /// Scrolls with `signal`: the view reads the signal for its offset, so a
350 /// write from anywhere moves the content, clamped to the content's size.
351 ///
352 /// The wheel writes the same signal, and the view writes the clamped
353 /// offset back to it, so the view, the thumb and the signal stay in step.
354 fn offset(self, signal: RwSignal<f32>) -> Self;
355
356 /// Keeps the view at the end of its content while `signal` reads `true`.
357 ///
358 /// While it follows, every frame that grows the content leaves the view
359 /// pinned to the end in the same frame, so a line appended to a log is
360 /// already in view. The core applies that pin after each layout pass,
361 /// where the painted heights the offset signal round-trips through are
362 /// one frame old. The view writes the painted end back to the offset
363 /// signal, so a signal set with [`ScrollExt::offset`] tracks the position
364 /// on screen and catches up one frame after a frame that grew the
365 /// content.
366 ///
367 /// The signal is also where the view reports where the user is: any
368 /// wheel step that moves the view up from the end writes `false`, and a
369 /// step down that lands within four logical pixels of the end writes
370 /// `true`. Writing `true` from the application jumps to the end.
371 /// A view that starts following with content shorter than itself stays
372 /// at offset zero until the content grows past it.
373 ///
374 /// Give the signal to one view. Two views that share it pin and release
375 /// together, and a wheel step over either one decides for both.
376 ///
377 /// While it follows, the offset signal is the view's to write: a write
378 /// from the application is overwritten, and the way to move the view is
379 /// to write `false` first.
380 ///
381 /// ```
382 /// use smudge_core::geometry::Color;
383 /// use smudge_core::reactive::{RwSignal, prelude::*};
384 /// use smudge_core::style::px;
385 /// use smudge_core::widget::panel;
386 /// use smudge_widgets::{ScrollExt, column, scroll};
387 ///
388 /// let following = RwSignal::new(true);
389 /// let view = scroll(
390 /// column().child(panel().height(px(400.0)).background(Color::WHITE)),
391 /// )
392 /// .follow_end(following.clone())
393 /// .height(px(120.0));
394 ///
395 /// // The view shows the end of the content from the first frame on,
396 /// // and stays there while the content grows.
397 /// following.set(false);
398 /// ```
399 fn follow_end(self, signal: RwSignal<bool>) -> Self;
400}
401
402/// The key of a view's offset signal binding.
403struct OffsetKey;
404
405/// The key of a view's follow signal binding.
406struct FollowKey;
407
408impl ScrollExt for Element<ScrollView> {
409 fn offset(self, signal: RwSignal<f32>) -> Self {
410 self.bind(OffsetKey, Value::Static(signal), use_offset_signal)
411 }
412
413 fn follow_end(self, signal: RwSignal<bool>) -> Self {
414 // `follow` is the core builder, named apart from this trait method so
415 // a call to `follow_end` on a view element reaches this one, which
416 // pairs the node's pin with the chrome's wheel handling.
417 self.follow(signal.clone())
418 .bind(FollowKey, Value::Static(signal), use_follow_signal)
419 }
420}
421
422/// Makes a scroll view read and write `signal` for its offset.
423///
424/// This runs while the view is built, before any of its bindings has read the
425/// signal it replaces.
426fn use_offset_signal(view: &mut ScrollView, signal: RwSignal<f32>) -> Dirty {
427 *view.chrome.offset.borrow_mut() = signal;
428 Dirty::Clean
429}
430
431/// Makes a scroll view turn `signal` with its wheel and pin its offset while
432/// it reads `true`.
433///
434/// This runs while the view is built, before any wheel event can arrive.
435fn use_follow_signal(view: &mut ScrollView, signal: RwSignal<bool>) -> Dirty {
436 *view.chrome.following.borrow_mut() = Some(signal);
437 Dirty::Clean
438}
439
440/// Builds the scrollbar of one scroll view: a track along the right edge, and
441/// the thumb that hangs from it.
442///
443/// The track is absolutely positioned and zero pixels tall, so it covers
444/// nothing a pointer could hit, and the thumb is its child in the flow. The
445/// track's scroll offset moves the thumb down to its position, so the thumb
446/// follows a scroll without a layout pass. The thumb's length is the visible
447/// share of the content, at least [`MIN_THUMB_LENGTH`], and it hides while
448/// the content fits.
449fn thumb(chrome: Rc<ScrollChrome>) -> Element<Panel> {
450 let hidden_chrome = Rc::clone(&chrome);
451 let length_chrome = Rc::clone(&chrome);
452 let top_chrome = chrome;
453 panel()
454 .absolute()
455 .inset(Edges {
456 top: Length::px(0.0),
457 right: Length::px(THUMB_RIGHT_INSET),
458 bottom: Length::Auto,
459 left: Length::Auto,
460 })
461 .width(px(THUMB_WIDTH))
462 .height(px(0.0))
463 .column()
464 .hidden(move || thumb_track(&hidden_chrome).is_none())
465 .scroll_offset(move || Point {
466 x: 0.0,
467 y: -thumb_top(&top_chrome),
468 })
469 .child(
470 panel()
471 .width(px(THUMB_WIDTH))
472 .height(move || Length::px(thumb_length(&length_chrome)))
473 .shrink(0.0)
474 .corner_radius(THUMB_WIDTH / 2.0)
475 .background(ColorToken::BORDER),
476 )
477}
478
479/// The geometry the thumb is drawn with.
480struct Track {
481 /// The length of the thumb.
482 length: f32,
483 /// The height the thumb travels along.
484 track: f32,
485 /// The furthest the view can scroll.
486 max_offset: f32,
487}
488
489/// Returns the thumb's geometry, or `None` while the content fits or before
490/// the view has painted, when the thumb is hidden.
491fn thumb_track(chrome: &ScrollChrome) -> Option<Track> {
492 let max_offset = chrome.max_offset()?;
493 let content = chrome.content()?;
494 let viewport = chrome.viewport.get()?;
495 if max_offset <= 0.0 || viewport.track <= 0.0 {
496 return None;
497 }
498 // The layout rounds the length to whole pixels, so the top is computed
499 // from the length the thumb is actually drawn with.
500 let length = (viewport.track * viewport.visible / content)
501 .clamp(MIN_THUMB_LENGTH.min(viewport.track), viewport.track)
502 .round();
503 Some(Track {
504 length,
505 track: viewport.track,
506 max_offset,
507 })
508}
509
510/// Returns the length the thumb is drawn with, or zero while it is hidden.
511fn thumb_length(chrome: &ScrollChrome) -> f32 {
512 thumb_track(chrome).map_or(0.0, |track| track.length)
513}
514
515/// Returns the thumb's distance from the top of the track, which maps the
516/// offset onto the part of the track the thumb does not cover, or zero while
517/// it is hidden. It is rounded to whole pixels, like every laid out edge, so
518/// the thumb's edges stay sharp.
519fn thumb_top(chrome: &ScrollChrome) -> f32 {
520 let Some(track) = thumb_track(chrome) else {
521 return 0.0;
522 };
523 let offset = clamp_offset(chrome.signal().get(), track.max_offset);
524 (offset / track.max_offset * (track.track - track.length)).round()
525}