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    middle_dragging: bool,
300    middle_start_world: Point,
301    middle_start_v_offset: f64,
302    middle_start_h_offset: f64,
303}
304
305impl ScrollView {
306    pub fn new(content: Box<dyn Widget>) -> Self {
307        Self {
308            bounds: Rect::default(),
309            children: vec![content],
310            base: WidgetBase::new(),
311            v: ScrollbarAxis {
312                enabled: true,
313                ..ScrollbarAxis::default()
314            },
315            h: ScrollbarAxis::default(),
316            stick_to_bottom: false,
317            was_at_bottom: false,
318            bar_visibility: current_scroll_visibility(),
319            visibility_explicit: false,
320            style: current_scroll_style(),
321            style_explicit: false,
322            offset_cell: None,
323            max_scroll_cell: None,
324            h_offset_cell: None,
325            h_max_scroll_cell: None,
326            visibility_cell: None,
327            style_cell: None,
328            viewport_cell: None,
329            painted_style_epoch: Cell::new(0),
330            middle_dragging: false,
331            middle_start_world: Point::ORIGIN,
332            middle_start_v_offset: 0.0,
333            middle_start_h_offset: 0.0,
334        }
335    }
336
337    // ── Axis enable ───────────────────────────────────────────────────────────
338
339    pub fn horizontal(mut self, enabled: bool) -> Self {
340        self.h.enabled = enabled;
341        self
342    }
343    pub fn vertical(mut self, enabled: bool) -> Self {
344        self.v.enabled = enabled;
345        self
346    }
347
348    // ── Scroll offset API (vertical for back-compat) ─────────────────────────
349
350    pub fn scroll_offset(&self) -> f64 {
351        self.v.offset
352    }
353
354    pub fn set_scroll_offset(&mut self, offset: f64) {
355        self.v.offset = offset;
356        if let Some(c) = &self.offset_cell {
357            c.set(offset);
358        }
359    }
360
361    pub fn max_scroll_value(&self) -> f64 {
362        self.v.max_scroll(self.bounds.height)
363    }
364
365    pub fn with_offset_cell(mut self, cell: Rc<Cell<f64>>) -> Self {
366        self.offset_cell = Some(cell);
367        self
368    }
369
370    pub fn with_max_scroll_cell(mut self, cell: Rc<Cell<f64>>) -> Self {
371        self.max_scroll_cell = Some(cell);
372        self
373    }
374
375    pub fn with_h_offset_cell(mut self, cell: Rc<Cell<f64>>) -> Self {
376        self.h_offset_cell = Some(cell);
377        self
378    }
379
380    pub fn with_h_max_scroll_cell(mut self, cell: Rc<Cell<f64>>) -> Self {
381        self.h_max_scroll_cell = Some(cell);
382        self
383    }
384
385    pub fn with_stick_to_bottom(mut self, stick: bool) -> Self {
386        self.stick_to_bottom = stick;
387        self
388    }
389
390    pub fn with_bar_visibility(mut self, v: ScrollBarVisibility) -> Self {
391        self.bar_visibility = v;
392        self.visibility_explicit = true;
393        self
394    }
395
396    pub fn set_bar_visibility(&mut self, v: ScrollBarVisibility) {
397        self.bar_visibility = v;
398        self.visibility_explicit = true;
399    }
400
401    pub fn with_bar_visibility_cell(mut self, cell: Rc<Cell<ScrollBarVisibility>>) -> Self {
402        self.visibility_cell = Some(cell);
403        self
404    }
405
406    pub fn with_style(mut self, s: ScrollBarStyle) -> Self {
407        self.style = s;
408        self.style_explicit = true;
409        self
410    }
411
412    pub fn with_style_cell(mut self, cell: Rc<Cell<ScrollBarStyle>>) -> Self {
413        self.style_cell = Some(cell);
414        self
415    }
416
417    /// Bind a cell that receives the visible content-space viewport rect.
418    pub fn with_viewport_cell(mut self, cell: Rc<Cell<Rect>>) -> Self {
419        self.viewport_cell = Some(cell);
420        self
421    }
422
423    // ── Geometry helpers ──────────────────────────────────────────────────────
424
425    fn viewport(&self) -> (f64, f64) {
426        // Viewport inside the widget AFTER reserving space for Solid bars.
427        let (reserve_x, reserve_y) = self.bar_reserve();
428        let w = (self.bounds.width - reserve_x).max(0.0);
429        let h = (self.bounds.height - reserve_y).max(0.0);
430        (w, h)
431    }
432
433    /// Horizontal/vertical space reserved for Solid scrollbars (0 for Floating).
434    fn bar_reserve(&self) -> (f64, f64) {
435        if self.style.kind != ScrollBarKind::Solid {
436            return (0.0, 0.0);
437        }
438        let span = self.style.bar_width + self.style.outer_margin + self.style.inner_margin;
439        let rx = if self.h.enabled && self.h.content > self.bounds.width {
440            0.0
441        } else {
442            0.0
443        };
444        // We reserve vertical bar width on the right when vertical scrolling
445        // is potentially active (has content overflow).
446        let need_v = self.v.enabled && self.v.content > self.bounds.height - self.h_bar_thickness();
447        let need_h = self.h.enabled && self.h.content > self.bounds.width - self.v_bar_thickness();
448        let rx = rx + if need_v { span } else { 0.0 };
449        let ry = if need_h { span } else { 0.0 };
450        (rx, ry)
451    }
452
453    /// Just the bar width + margins (not conditional on overflow).  Used for
454    /// hover-zone/paint placement when visibility says "AlwaysVisible".
455    fn v_bar_thickness(&self) -> f64 {
456        self.style.bar_width + self.style.outer_margin + self.style.inner_margin
457    }
458    fn h_bar_thickness(&self) -> f64 {
459        self.style.bar_width + self.style.outer_margin + self.style.inner_margin
460    }
461
462    /// Right-edge X (exclusive) of the vertical scroll bar in local space.
463    fn v_bar_right(&self) -> f64 {
464        self.bounds.width - RIGHT_EDGE_GUARD - self.style.outer_margin
465    }
466    /// Bottom-edge Y (exclusive, Y-up) of the horizontal bar — i.e. the lower
467    /// edge of the bar stripe, which in Y-up = `outer_margin + BOTTOM_EDGE_GUARD`.
468    fn h_bar_bottom(&self) -> f64 {
469        BOTTOM_EDGE_GUARD + self.style.outer_margin
470    }
471
472    /// Vertical track range [lo, hi] in Y-up.  Accounts for the horizontal bar
473    /// reserving a sliver at the bottom when both axes scroll.
474    fn v_track_range(&self) -> (f64, f64) {
475        let (_, reserve_y) = self.bar_reserve();
476        let lo = self.style.inner_margin + reserve_y;
477        let hi = (self.bounds.height - self.style.inner_margin).max(lo);
478        (lo, hi)
479    }
480
481    fn h_track_range(&self) -> (f64, f64) {
482        let (reserve_x, _) = self.bar_reserve();
483        let lo = self.style.inner_margin;
484        let hi = (self.bounds.width - self.style.inner_margin - reserve_x).max(lo);
485        (lo, hi)
486    }
487
488    fn v_scrollbar_geometry(&self) -> ScrollbarGeometry {
489        let (lo, hi) = self.v_track_range();
490        ScrollbarGeometry {
491            orientation: ScrollbarOrientation::Vertical,
492            track_start: lo,
493            track_end: hi,
494            cross_end: self.v_bar_right(),
495            hit_margin: DEFAULT_GRAB_MARGIN,
496        }
497    }
498
499    fn h_scrollbar_geometry(&self) -> ScrollbarGeometry {
500        let (lo, hi) = self.h_track_range();
501        ScrollbarGeometry {
502            orientation: ScrollbarOrientation::Horizontal,
503            track_start: lo,
504            track_end: hi,
505            cross_end: self.h_bar_bottom(),
506            hit_margin: DEFAULT_GRAB_MARGIN,
507        }
508    }
509
510    fn pos_in_v_hover(&self, pos: Point) -> bool {
511        self.v
512            .pos_in_hover(pos, self.style, self.v_scrollbar_geometry())
513    }
514
515    fn pos_in_h_hover(&self, pos: Point) -> bool {
516        self.h
517            .pos_in_hover(pos, self.style, self.h_scrollbar_geometry())
518    }
519
520    fn clamp_offsets(&mut self) {
521        let (vw, vh) = self.viewport();
522        self.v.clamp_offset(vh);
523        self.h.clamp_offset(vw);
524    }
525
526    fn publish_offsets(&self) {
527        if let Some(c) = &self.offset_cell {
528            c.set(self.v.offset);
529        }
530        if let Some(c) = &self.h_offset_cell {
531            c.set(self.h.offset);
532        }
533    }
534
535    // ── Layout property forwarding ────────────────────────────────────────────
536
537    pub fn with_margin(mut self, m: Insets) -> Self {
538        self.base.margin = m;
539        self
540    }
541    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
542        self.base.h_anchor = h;
543        self
544    }
545    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
546        self.base.v_anchor = v;
547        self
548    }
549    pub fn with_min_size(mut self, s: Size) -> Self {
550        self.base.min_size = s;
551        self
552    }
553    pub fn with_max_size(mut self, s: Size) -> Self {
554        self.base.max_size = s;
555        self
556    }
557
558    // ── Visibility helper ────────────────────────────────────────────────────
559
560    fn scrollbar_animation_active(&self) -> bool {
561        self.v.animation_active() || self.h.animation_active()
562    }
563}
564
565mod widget_impl;