Skip to main content

agg_gui/widgets/
scroll_view.rs

1//! `ScrollView` — scrolling container with egui-style scrollbars.
2//!
3//! Supports vertical, horizontal, or bidirectional scrolling.  The scrollbar
4//! can be styled in detail (bar width, margins, fade, solid vs floating) via
5//! [`ScrollBarStyle`] — set by value, by builder, or bound to an
6//! `Rc<Cell<ScrollBarStyle>>` for live tweaking (used by the demo Appearance
7//! tab).
8//!
9//! # Coordinate system
10//! All local coordinates are Y-up.  `scroll_offset` is "how far the user has
11//! scrolled down from the top" — `0` shows the TOP of the content,
12//! `max_scroll_y` shows the BOTTOM.  Same convention for horizontal:
13//! `h_scroll_offset = 0` shows the LEFT of the content.
14//!
15//! # Virtual rendering
16//! `with_viewport_cell(Rc<Cell<Rect>>)` publishes the currently-visible
17//! content-space rect each layout.  Children that want to cull off-viewport
18//! work (e.g. painting 10k row labels) read this cell and limit their paint.
19
20use std::cell::Cell;
21use std::rc::Rc;
22
23use crate::color::Color;
24use crate::draw_ctx::DrawCtx;
25use crate::event::{Event, EventResult, MouseButton};
26use crate::geometry::{Point, Rect, Size};
27use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
28use crate::widget::Widget;
29
30use super::scrollbar::{
31    paint_prepared_scrollbar, ScrollbarAxis, ScrollbarGeometry, ScrollbarOrientation,
32    DEFAULT_GRAB_MARGIN,
33};
34
35/// How the scrollbar is shown.  Matches egui's `ScrollBarVisibility`.
36///
37/// Hover-only behaviour is controlled by [`ScrollBarKind::Floating`] on the
38/// [`ScrollBarStyle`], not by this enum — a Floating bar with
39/// `VisibleWhenNeeded` only appears on hover; a Solid bar with
40/// `VisibleWhenNeeded` is always visible when content overflows.
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub enum ScrollBarVisibility {
43    /// Paint whenever content overflows, regardless of hover.
44    AlwaysVisible,
45    /// Paint when content overflows.  If the style is `Floating` the bar
46    /// additionally hides until the cursor enters the hover zone.
47    VisibleWhenNeeded,
48    /// Never paint — wheel/drag still work, but no visual indicator.
49    AlwaysHidden,
50}
51
52impl Default for ScrollBarVisibility {
53    fn default() -> Self {
54        Self::VisibleWhenNeeded
55    }
56}
57
58/// Whether the bar reserves layout space (Solid) or floats over content (Floating).
59#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub enum ScrollBarKind {
61    Solid,
62    Floating,
63}
64
65impl Default for ScrollBarKind {
66    fn default() -> Self {
67        Self::Floating
68    }
69}
70
71/// Which pair of colours is used for the track vs thumb.
72#[derive(Clone, Copy, Debug, PartialEq, Eq)]
73pub enum ScrollBarColor {
74    /// Track = neutral background; thumb = slightly brighter.  Default.
75    Background,
76    /// Track = transparent; thumb = accent-tinted foreground.
77    Foreground,
78}
79
80impl Default for ScrollBarColor {
81    fn default() -> Self {
82        Self::Background
83    }
84}
85
86/// Full scrollbar appearance configuration — mirrors egui's `style.spacing.scroll`.
87#[derive(Clone, Copy, Debug, PartialEq)]
88pub struct ScrollBarStyle {
89    /// Width of the full-size bar in pixels.  This is the bar width when the
90    /// user is hovering or interacting with it.
91    pub bar_width: f64,
92    /// Thin width shown when the bar is dormant (not hovered, not dragging).
93    /// Matches egui's `floating_width`.  On hover the bar grows from this to
94    /// [`Self::bar_width`].  Set equal to `bar_width` to disable the expand
95    /// effect.  Only takes effect when smaller than `bar_width`.
96    pub floating_width: f64,
97    /// Minimum length of the draggable thumb.
98    pub handle_min_length: f64,
99    /// Space between the bar and the panel's outer edge.
100    pub outer_margin: f64,
101    /// Space between the bar and the content area.
102    pub inner_margin: f64,
103    /// Space between sibling content and the bar area (applied when `kind = Solid`
104    /// and as a decorative inset when `Floating`).
105    pub content_margin: f64,
106    /// `true` = use one value for both axes; `false` = each axis may differ
107    /// (we keep a single value here for brevity and apply it to both).
108    pub margin_same: bool,
109    /// Bar kind — Solid reserves space in layout, Floating overlays content.
110    pub kind: ScrollBarKind,
111    /// Which colour role the bar uses.
112    pub color: ScrollBarColor,
113    /// Alpha of the fade-out region along the scroll-axis edges, 0..1.
114    pub fade_strength: f64,
115    /// Length of the fade region in pixels at each end.
116    pub fade_size: f64,
117}
118
119impl ScrollBarStyle {
120    /// Interpolated bar width for a hover-animation parameter `t` in `[0, 1]`.
121    /// `t = 0` returns [`Self::floating_width`] (dormant); `t = 1` returns
122    /// [`Self::bar_width`] (fully expanded).  Clamps `floating_width` so it
123    /// never exceeds `bar_width`, regardless of what the caller set.
124    ///
125    /// [`ScrollBarKind::Solid`] bars do not animate width — they always
126    /// render at `bar_width` so the "Full bar width" setting takes immediate
127    /// visible effect.  Only [`ScrollBarKind::Floating`] bars expand on hover.
128    pub fn bar_width_at(&self, t: f64) -> f64 {
129        if self.kind == ScrollBarKind::Solid {
130            return self.bar_width;
131        }
132        let from = self.floating_width.min(self.bar_width);
133        let t = t.clamp(0.0, 1.0);
134        from + (self.bar_width - from) * t
135    }
136}
137
138impl Default for ScrollBarStyle {
139    fn default() -> Self {
140        Self {
141            bar_width: 10.0,
142            floating_width: 2.0,
143            handle_min_length: 12.0,
144            outer_margin: 0.0,
145            inner_margin: 4.0,
146            content_margin: 0.0,
147            margin_same: true,
148            kind: ScrollBarKind::default(),
149            color: ScrollBarColor::Foreground,
150            fade_strength: 0.5,
151            fade_size: 20.0,
152        }
153    }
154}
155
156impl ScrollBarStyle {
157    /// Preset matching egui's `ScrollStyle::solid` — always-visible bar, solid
158    /// layout, fills reserved space.  Solid bars don't expand on hover so
159    /// `floating_width` equals `bar_width`.
160    pub fn solid() -> Self {
161        Self {
162            bar_width: 6.0,
163            floating_width: 2.0,
164            handle_min_length: 12.0,
165            outer_margin: 0.0,
166            inner_margin: 4.0,
167            content_margin: 0.0,
168            margin_same: true,
169            kind: ScrollBarKind::Solid,
170            color: ScrollBarColor::Background,
171            fade_strength: 0.5,
172            fade_size: 20.0,
173        }
174    }
175    /// Preset matching egui's `ScrollStyle::thin` — a narrow floating bar
176    /// that's always visible at its thin width and expands to full width when
177    /// hovered.  Callers should pair this with
178    /// [`ScrollBarVisibility::AlwaysVisible`] so the dormant thin bar is
179    /// rendered even when the cursor isn't over it (the appearance panel's
180    /// preset button does this).
181    pub fn thin() -> Self {
182        Self {
183            bar_width: 10.0,
184            floating_width: 2.0,
185            handle_min_length: 12.0,
186            outer_margin: 0.0,
187            inner_margin: 4.0,
188            content_margin: 0.0,
189            margin_same: true,
190            kind: ScrollBarKind::Floating,
191            color: ScrollBarColor::Background,
192            fade_strength: 0.5,
193            fade_size: 20.0,
194        }
195    }
196    /// Preset matching egui's `ScrollStyle::floating` — wide floating overlay
197    /// with fade gradient at the edges.
198    pub fn floating() -> Self {
199        Self::default()
200    }
201}
202
203// ── Global scroll style ─────────────────────────────────────────────────────
204//
205// Every `ScrollView` reads this value each layout unless the caller supplied
206// an explicit `with_style(...)` or `with_style_cell(...)`.  The Appearance
207// demo writes to this global so that "one slider affects every scroll bar in
208// the application" — matching egui's `all_styles_mut` behaviour.
209
210std::thread_local! {
211    static CURRENT_SCROLL_STYLE:      Cell<ScrollBarStyle>      = Cell::new(ScrollBarStyle::default());
212    static CURRENT_SCROLL_VISIBILITY: Cell<ScrollBarVisibility> = Cell::new(ScrollBarVisibility::VisibleWhenNeeded);
213    static SCROLL_STYLE_EPOCH:        Cell<u64>                 = Cell::new(1);
214}
215
216/// Read the current global scroll-bar style.
217pub fn current_scroll_style() -> ScrollBarStyle {
218    CURRENT_SCROLL_STYLE.with(|c| c.get())
219}
220
221/// Replace the global scroll-bar style.  All subsequent `ScrollView` layouts
222/// that don't have an explicit override pick this up.
223pub fn set_scroll_style(s: ScrollBarStyle) {
224    CURRENT_SCROLL_STYLE.with(|c| c.set(s));
225    SCROLL_STYLE_EPOCH.with(|c| c.set(c.get().wrapping_add(1)));
226    crate::animation::request_draw();
227}
228
229/// Read the current global scroll-bar visibility policy.
230pub fn current_scroll_visibility() -> ScrollBarVisibility {
231    CURRENT_SCROLL_VISIBILITY.with(|c| c.get())
232}
233
234/// Replace the global scroll-bar visibility policy.  Every `ScrollView` that
235/// doesn't bind its own `with_bar_visibility_cell(...)` or call
236/// `with_bar_visibility(...)` reads this value on each layout.
237pub fn set_scroll_visibility(v: ScrollBarVisibility) {
238    CURRENT_SCROLL_VISIBILITY.with(|c| c.set(v));
239    SCROLL_STYLE_EPOCH.with(|c| c.set(c.get().wrapping_add(1)));
240    crate::animation::request_draw();
241}
242
243fn current_scroll_style_epoch() -> u64 {
244    SCROLL_STYLE_EPOCH.with(|c| c.get())
245}
246
247// ── Helpers ──────────────────────────────────────────────────────────────────
248
249// ── Runtime constants ────────────────────────────────────────────────────────
250
251/// Pixels at the right edge reserved for the parent window's resize grip.
252const RIGHT_EDGE_GUARD: f64 = 4.0;
253/// Pixels at the bottom edge reserved for the parent window's resize grip.
254const BOTTOM_EDGE_GUARD: f64 = 4.0;
255
256// ── Per-axis state (vertical or horizontal) ──────────────────────────────────
257//
258// The vertical and horizontal scroll axes share the same computation — we
259// factor the state so both reuse `clamp_offset` / `thumb_metrics` logic.
260
261pub struct ScrollView {
262    bounds: Rect,
263    children: Vec<Box<dyn Widget>>, // always 0 or 1
264    base: WidgetBase,
265
266    v: ScrollbarAxis,
267    h: ScrollbarAxis,
268
269    /// Keep the scrollbar glued to the bottom as content grows (while the
270    /// user hasn't scrolled away from the end).
271    stick_to_bottom: bool,
272    was_at_bottom: bool,
273
274    /// How to render the scrollbar.
275    bar_visibility: ScrollBarVisibility,
276    /// `true` when the caller supplied an explicit per-instance visibility via
277    /// [`ScrollView::with_bar_visibility`].  When `false` and
278    /// `visibility_cell` is unset, the global visibility from
279    /// [`current_scroll_visibility`] is re-read each layout.
280    visibility_explicit: bool,
281    style: ScrollBarStyle,
282    /// `true` when the caller supplied an explicit per-instance style via
283    /// [`ScrollView::with_style`].  When `false` and `style_cell` is unset,
284    /// the global style from [`current_scroll_style`] is re-read each layout.
285    style_explicit: bool,
286
287    // ── External cell bindings ──
288    offset_cell: Option<Rc<Cell<f64>>>,
289    max_scroll_cell: Option<Rc<Cell<f64>>>,
290    h_offset_cell: Option<Rc<Cell<f64>>>,
291    h_max_scroll_cell: Option<Rc<Cell<f64>>>,
292    visibility_cell: Option<Rc<Cell<ScrollBarVisibility>>>,
293    style_cell: Option<Rc<Cell<ScrollBarStyle>>>,
294    /// Visible viewport rect in content-space Y-up coordinates, written each
295    /// layout.  Children doing virtual rendering read this cell.
296    viewport_cell: Option<Rc<Cell<Rect>>>,
297    painted_style_epoch: Cell<u64>,
298
299    /// Optional override for the edge-fade gradient colour.  When unset we
300    /// use `Visuals::window_fill`, which is correct for the **default**
301    /// case where the scroll view sits directly on a window.
302    ///
303    /// **TRAP:** when the `ScrollView` sits on top of a custom-coloured
304    /// container (e.g. a `FlexColumn::with_panel_bg()` panel, a
305    /// `Container::with_background(...)` card, a debug visualiser, or
306    /// any non-window background), the default fade looks like a bright
307    /// white halo against your panel.  Set this to the actual ancestor
308    /// background colour in that case.  See
309    /// [`ScrollView::with_fade_color`].
310    fade_color: Option<Color>,
311
312    middle_dragging: bool,
313    middle_start_world: Point,
314    middle_start_v_offset: f64,
315    middle_start_h_offset: f64,
316}
317
318impl ScrollView {
319    pub fn new(content: Box<dyn Widget>) -> Self {
320        Self {
321            bounds: Rect::default(),
322            children: vec![content],
323            base: WidgetBase::new(),
324            v: ScrollbarAxis {
325                enabled: true,
326                ..ScrollbarAxis::default()
327            },
328            h: ScrollbarAxis::default(),
329            stick_to_bottom: false,
330            was_at_bottom: false,
331            bar_visibility: current_scroll_visibility(),
332            visibility_explicit: false,
333            style: current_scroll_style(),
334            style_explicit: false,
335            offset_cell: None,
336            max_scroll_cell: None,
337            h_offset_cell: None,
338            h_max_scroll_cell: None,
339            visibility_cell: None,
340            style_cell: None,
341            viewport_cell: None,
342            painted_style_epoch: Cell::new(0),
343            middle_dragging: false,
344            middle_start_world: Point::ORIGIN,
345            middle_start_v_offset: 0.0,
346            middle_start_h_offset: 0.0,
347            fade_color: None,
348        }
349    }
350
351    /// Override the edge-fade colour the scrollbar gutter blends to.
352    ///
353    /// **READ THIS BEFORE PLACING A `ScrollView` ON ANY CUSTOM
354    /// BACKGROUND.**  The default fade colour is `Visuals::window_fill`
355    /// (the colour behind a plain window).  If the `ScrollView` sits
356    /// inside a `FlexColumn::with_panel_bg`, a coloured `Container`,
357    /// inside a tab body with a custom fill, or anywhere else where the
358    /// pixels behind the scrollbar are NOT `window_fill`, the fade
359    /// gradient will paint a bright halo of the WRONG colour because it
360    /// blends to the default rather than what's actually behind it.
361    ///
362    /// Pass the visible ancestor background here so the fade dissolves
363    /// invisibly into the panel.  Common idioms:
364    ///
365    /// ```ignore
366    /// // Sits on a panel:
367    /// ScrollView::new(child).with_fade_color(ctx.visuals().panel_fill)
368    /// // Sits on a coloured Container:
369    /// ScrollView::new(child).with_fade_color(my_container_bg)
370    /// ```
371    pub fn with_fade_color(mut self, c: Color) -> Self {
372        self.fade_color = Some(c);
373        self
374    }
375
376    // ── Axis enable ───────────────────────────────────────────────────────────
377
378    pub fn horizontal(mut self, enabled: bool) -> Self {
379        self.h.enabled = enabled;
380        self
381    }
382    pub fn vertical(mut self, enabled: bool) -> Self {
383        self.v.enabled = enabled;
384        self
385    }
386
387    // ── Scroll offset API (vertical for back-compat) ─────────────────────────
388
389    pub fn scroll_offset(&self) -> f64 {
390        self.v.offset
391    }
392
393    pub fn set_scroll_offset(&mut self, offset: f64) {
394        self.v.offset = offset;
395        if let Some(c) = &self.offset_cell {
396            c.set(offset);
397        }
398    }
399
400    pub fn max_scroll_value(&self) -> f64 {
401        self.v.max_scroll(self.bounds.height)
402    }
403
404    pub fn with_offset_cell(mut self, cell: Rc<Cell<f64>>) -> Self {
405        self.offset_cell = Some(cell);
406        self
407    }
408
409    pub fn with_max_scroll_cell(mut self, cell: Rc<Cell<f64>>) -> Self {
410        self.max_scroll_cell = Some(cell);
411        self
412    }
413
414    pub fn with_h_offset_cell(mut self, cell: Rc<Cell<f64>>) -> Self {
415        self.h_offset_cell = Some(cell);
416        self
417    }
418
419    pub fn with_h_max_scroll_cell(mut self, cell: Rc<Cell<f64>>) -> Self {
420        self.h_max_scroll_cell = Some(cell);
421        self
422    }
423
424    pub fn with_stick_to_bottom(mut self, stick: bool) -> Self {
425        self.stick_to_bottom = stick;
426        self
427    }
428
429    pub fn with_bar_visibility(mut self, v: ScrollBarVisibility) -> Self {
430        self.bar_visibility = v;
431        self.visibility_explicit = true;
432        self
433    }
434
435    pub fn set_bar_visibility(&mut self, v: ScrollBarVisibility) {
436        self.bar_visibility = v;
437        self.visibility_explicit = true;
438    }
439
440    pub fn with_bar_visibility_cell(mut self, cell: Rc<Cell<ScrollBarVisibility>>) -> Self {
441        self.visibility_cell = Some(cell);
442        self
443    }
444
445    pub fn with_style(mut self, s: ScrollBarStyle) -> Self {
446        self.style = s;
447        self.style_explicit = true;
448        self
449    }
450
451    pub fn with_style_cell(mut self, cell: Rc<Cell<ScrollBarStyle>>) -> Self {
452        self.style_cell = Some(cell);
453        self
454    }
455
456    /// Bind a cell that receives the visible content-space viewport rect.
457    pub fn with_viewport_cell(mut self, cell: Rc<Cell<Rect>>) -> Self {
458        self.viewport_cell = Some(cell);
459        self
460    }
461
462    // ── Geometry helpers ──────────────────────────────────────────────────────
463
464    fn viewport(&self) -> (f64, f64) {
465        // Viewport inside the widget AFTER reserving space for Solid bars.
466        let (reserve_x, reserve_y) = self.bar_reserve();
467        let w = (self.bounds.width - reserve_x).max(0.0);
468        let h = (self.bounds.height - reserve_y).max(0.0);
469        (w, h)
470    }
471
472    /// Horizontal/vertical space reserved for Solid scrollbars (0 for Floating).
473    fn bar_reserve(&self) -> (f64, f64) {
474        if self.style.kind != ScrollBarKind::Solid {
475            return (0.0, 0.0);
476        }
477        let span = self.style.bar_width + self.style.outer_margin + self.style.inner_margin;
478        let rx = if self.h.enabled && self.h.content > self.bounds.width {
479            0.0
480        } else {
481            0.0
482        };
483        // We reserve vertical bar width on the right when vertical scrolling
484        // is potentially active (has content overflow).
485        let need_v = self.v.enabled && self.v.content > self.bounds.height - self.h_bar_thickness();
486        let need_h = self.h.enabled && self.h.content > self.bounds.width - self.v_bar_thickness();
487        let rx = rx + if need_v { span } else { 0.0 };
488        let ry = if need_h { span } else { 0.0 };
489        (rx, ry)
490    }
491
492    /// Just the bar width + margins (not conditional on overflow).  Used for
493    /// hover-zone/paint placement when visibility says "AlwaysVisible".
494    fn v_bar_thickness(&self) -> f64 {
495        self.style.bar_width + self.style.outer_margin + self.style.inner_margin
496    }
497    fn h_bar_thickness(&self) -> f64 {
498        self.style.bar_width + self.style.outer_margin + self.style.inner_margin
499    }
500
501    /// Right-edge X (exclusive) of the vertical scroll bar in local space.
502    fn v_bar_right(&self) -> f64 {
503        self.bounds.width - RIGHT_EDGE_GUARD - self.style.outer_margin
504    }
505    /// Bottom-edge Y (exclusive, Y-up) of the horizontal bar — i.e. the lower
506    /// edge of the bar stripe, which in Y-up = `outer_margin + BOTTOM_EDGE_GUARD`.
507    fn h_bar_bottom(&self) -> f64 {
508        BOTTOM_EDGE_GUARD + self.style.outer_margin
509    }
510
511    /// Vertical track range [lo, hi] in Y-up.  Accounts for the horizontal bar
512    /// reserving a sliver at the bottom when both axes scroll.
513    fn v_track_range(&self) -> (f64, f64) {
514        let (_, reserve_y) = self.bar_reserve();
515        let lo = self.style.inner_margin + reserve_y;
516        let hi = (self.bounds.height - self.style.inner_margin).max(lo);
517        (lo, hi)
518    }
519
520    fn h_track_range(&self) -> (f64, f64) {
521        let (reserve_x, _) = self.bar_reserve();
522        let lo = self.style.inner_margin;
523        let hi = (self.bounds.width - self.style.inner_margin - reserve_x).max(lo);
524        (lo, hi)
525    }
526
527    fn v_scrollbar_geometry(&self) -> ScrollbarGeometry {
528        let (lo, hi) = self.v_track_range();
529        ScrollbarGeometry {
530            orientation: ScrollbarOrientation::Vertical,
531            track_start: lo,
532            track_end: hi,
533            cross_end: self.v_bar_right(),
534            hit_margin: DEFAULT_GRAB_MARGIN,
535        }
536    }
537
538    fn h_scrollbar_geometry(&self) -> ScrollbarGeometry {
539        let (lo, hi) = self.h_track_range();
540        ScrollbarGeometry {
541            orientation: ScrollbarOrientation::Horizontal,
542            track_start: lo,
543            track_end: hi,
544            cross_end: self.h_bar_bottom(),
545            hit_margin: DEFAULT_GRAB_MARGIN,
546        }
547    }
548
549    fn pos_in_v_hover(&self, pos: Point) -> bool {
550        self.v
551            .pos_in_hover(pos, self.style, self.v_scrollbar_geometry())
552    }
553
554    fn pos_in_h_hover(&self, pos: Point) -> bool {
555        self.h
556            .pos_in_hover(pos, self.style, self.h_scrollbar_geometry())
557    }
558
559    fn clamp_offsets(&mut self) {
560        let (vw, vh) = self.viewport();
561        self.v.clamp_offset(vh);
562        self.h.clamp_offset(vw);
563    }
564
565    fn publish_offsets(&self) {
566        if let Some(c) = &self.offset_cell {
567            c.set(self.v.offset);
568        }
569        if let Some(c) = &self.h_offset_cell {
570            c.set(self.h.offset);
571        }
572    }
573
574    // ── Layout property forwarding ────────────────────────────────────────────
575
576    pub fn with_margin(mut self, m: Insets) -> Self {
577        self.base.margin = m;
578        self
579    }
580    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
581        self.base.h_anchor = h;
582        self
583    }
584    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
585        self.base.v_anchor = v;
586        self
587    }
588    pub fn with_min_size(mut self, s: Size) -> Self {
589        self.base.min_size = s;
590        self
591    }
592    pub fn with_max_size(mut self, s: Size) -> Self {
593        self.base.max_size = s;
594        self
595    }
596
597    // ── Visibility helper ────────────────────────────────────────────────────
598
599    fn scrollbar_animation_active(&self) -> bool {
600        self.v.animation_active() || self.h.animation_active()
601    }
602}
603
604mod widget_impl;