Skip to main content

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}