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::event::{Event, EventResult, MouseButton};
25use crate::geometry::{Point, Rect, Size};
26use crate::draw_ctx::DrawCtx;
27use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
28use crate::widget::Widget;
29
30/// How the scrollbar is shown.  Matches egui's `ScrollBarVisibility`.
31///
32/// Hover-only behaviour is controlled by [`ScrollBarKind::Floating`] on the
33/// [`ScrollBarStyle`], not by this enum — a Floating bar with
34/// `VisibleWhenNeeded` only appears on hover; a Solid bar with
35/// `VisibleWhenNeeded` is always visible when content overflows.
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub enum ScrollBarVisibility {
38    /// Paint whenever content overflows, regardless of hover.
39    AlwaysVisible,
40    /// Paint when content overflows.  If the style is `Floating` the bar
41    /// additionally hides until the cursor enters the hover zone.
42    VisibleWhenNeeded,
43    /// Never paint — wheel/drag still work, but no visual indicator.
44    AlwaysHidden,
45}
46
47impl Default for ScrollBarVisibility {
48    fn default() -> Self { Self::VisibleWhenNeeded }
49}
50
51/// Whether the bar reserves layout space (Solid) or floats over content (Floating).
52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53pub enum ScrollBarKind { Solid, Floating }
54
55impl Default for ScrollBarKind {
56    fn default() -> Self { Self::Floating }
57}
58
59/// Which pair of colours is used for the track vs thumb.
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61pub enum ScrollBarColor {
62    /// Track = neutral background; thumb = slightly brighter.  Default.
63    Background,
64    /// Track = transparent; thumb = accent-tinted foreground.
65    Foreground,
66}
67
68impl Default for ScrollBarColor {
69    fn default() -> Self { Self::Background }
70}
71
72/// Full scrollbar appearance configuration — mirrors egui's `style.spacing.scroll`.
73#[derive(Clone, Copy, Debug, PartialEq)]
74pub struct ScrollBarStyle {
75    /// Width of the full-size bar in pixels.  This is the bar width when the
76    /// user is hovering or interacting with it.
77    pub bar_width:         f64,
78    /// Thin width shown when the bar is dormant (not hovered, not dragging).
79    /// Matches egui's `floating_width`.  On hover the bar grows from this to
80    /// [`Self::bar_width`].  Set equal to `bar_width` to disable the expand
81    /// effect.  Only takes effect when smaller than `bar_width`.
82    pub floating_width:    f64,
83    /// Minimum length of the draggable thumb.
84    pub handle_min_length: f64,
85    /// Space between the bar and the panel's outer edge.
86    pub outer_margin:      f64,
87    /// Space between the bar and the content area.
88    pub inner_margin:      f64,
89    /// Space between sibling content and the bar area (applied when `kind = Solid`
90    /// and as a decorative inset when `Floating`).
91    pub content_margin:    f64,
92    /// `true` = use one value for both axes; `false` = each axis may differ
93    /// (we keep a single value here for brevity and apply it to both).
94    pub margin_same:       bool,
95    /// Bar kind — Solid reserves space in layout, Floating overlays content.
96    pub kind:              ScrollBarKind,
97    /// Which colour role the bar uses.
98    pub color:             ScrollBarColor,
99    /// Alpha of the fade-out region along the scroll-axis edges, 0..1.
100    pub fade_strength:     f64,
101    /// Length of the fade region in pixels at each end.
102    pub fade_size:         f64,
103}
104
105impl ScrollBarStyle {
106    /// Interpolated bar width for a hover-animation parameter `t` in `[0, 1]`.
107    /// `t = 0` returns [`Self::floating_width`] (dormant); `t = 1` returns
108    /// [`Self::bar_width`] (fully expanded).  Clamps `floating_width` so it
109    /// never exceeds `bar_width`, regardless of what the caller set.
110    ///
111    /// [`ScrollBarKind::Solid`] bars do not animate width — they always
112    /// render at `bar_width` so the "Full bar width" setting takes immediate
113    /// visible effect.  Only [`ScrollBarKind::Floating`] bars expand on hover.
114    pub fn bar_width_at(&self, t: f64) -> f64 {
115        if self.kind == ScrollBarKind::Solid {
116            return self.bar_width;
117        }
118        let from = self.floating_width.min(self.bar_width);
119        let t    = t.clamp(0.0, 1.0);
120        from + (self.bar_width - from) * t
121    }
122}
123
124impl Default for ScrollBarStyle {
125    fn default() -> Self {
126        Self {
127            bar_width:         15.0,
128            floating_width:    15.0,
129            handle_min_length: 10.0,
130            outer_margin:       5.0,
131            inner_margin:       7.0,
132            content_margin:     5.0,
133            margin_same:        true,
134            kind:               ScrollBarKind::default(),
135            color:              ScrollBarColor::default(),
136            fade_strength:      1.0,
137            fade_size:         45.0,
138        }
139    }
140}
141
142impl ScrollBarStyle {
143    /// Preset matching egui's `ScrollStyle::solid` — always-visible bar, solid
144    /// layout, fills reserved space.  Solid bars don't expand on hover so
145    /// `floating_width` equals `bar_width`.
146    pub fn solid() -> Self {
147        Self {
148            bar_width:         8.0,
149            floating_width:    8.0,
150            handle_min_length: 12.0,
151            outer_margin:      0.0,
152            inner_margin:      4.0,
153            content_margin:    0.0,
154            margin_same:       true,
155            kind:              ScrollBarKind::Solid,
156            color:             ScrollBarColor::Foreground,
157            fade_strength:     0.0,
158            fade_size:         0.0,
159        }
160    }
161    /// Preset matching egui's `ScrollStyle::thin` — a narrow floating bar
162    /// that's always visible at its thin width and expands to full width when
163    /// hovered.  Callers should pair this with
164    /// [`ScrollBarVisibility::AlwaysVisible`] so the dormant thin bar is
165    /// rendered even when the cursor isn't over it (the appearance panel's
166    /// preset button does this).
167    pub fn thin() -> Self {
168        Self {
169            bar_width:         10.0,
170            floating_width:    4.0,
171            handle_min_length: 12.0,
172            outer_margin:      2.0,
173            inner_margin:      2.0,
174            content_margin:    0.0,
175            margin_same:       true,
176            kind:              ScrollBarKind::Floating,
177            color:             ScrollBarColor::Background,
178            fade_strength:     0.0,
179            fade_size:         0.0,
180        }
181    }
182    /// Preset matching egui's `ScrollStyle::floating` — wide floating overlay
183    /// with fade gradient at the edges.
184    pub fn floating() -> Self {
185        Self::default()
186    }
187}
188
189// ── Global scroll style ─────────────────────────────────────────────────────
190//
191// Every `ScrollView` reads this value each layout unless the caller supplied
192// an explicit `with_style(...)` or `with_style_cell(...)`.  The Appearance
193// demo writes to this global so that "one slider affects every scroll bar in
194// the application" — matching egui's `all_styles_mut` behaviour.
195
196std::thread_local! {
197    static CURRENT_SCROLL_STYLE:      Cell<ScrollBarStyle>      = Cell::new(ScrollBarStyle::default());
198    static CURRENT_SCROLL_VISIBILITY: Cell<ScrollBarVisibility> = Cell::new(ScrollBarVisibility::VisibleWhenNeeded);
199}
200
201/// Read the current global scroll-bar style.
202pub fn current_scroll_style() -> ScrollBarStyle {
203    CURRENT_SCROLL_STYLE.with(|c| c.get())
204}
205
206/// Replace the global scroll-bar style.  All subsequent `ScrollView` layouts
207/// that don't have an explicit override pick this up.
208pub fn set_scroll_style(s: ScrollBarStyle) {
209    CURRENT_SCROLL_STYLE.with(|c| c.set(s));
210}
211
212/// Read the current global scroll-bar visibility policy.
213pub fn current_scroll_visibility() -> ScrollBarVisibility {
214    CURRENT_SCROLL_VISIBILITY.with(|c| c.get())
215}
216
217/// Replace the global scroll-bar visibility policy.  Every `ScrollView` that
218/// doesn't bind its own `with_bar_visibility_cell(...)` or call
219/// `with_bar_visibility(...)` reads this value on each layout.
220pub fn set_scroll_visibility(v: ScrollBarVisibility) {
221    CURRENT_SCROLL_VISIBILITY.with(|c| c.set(v));
222}
223
224// ── Helpers ──────────────────────────────────────────────────────────────────
225
226/// Multiply the alpha channel of `c` by `a`.  Used to fade the track /
227/// thumb during the hover fade-in / fade-out animation — the colour stays
228/// its palette-defined hue and only transparency changes.
229fn scale_alpha(c: Color, a: f64) -> Color {
230    Color::rgba(c.r, c.g, c.b, c.a * (a as f32).clamp(0.0, 1.0))
231}
232
233// ── Runtime constants ────────────────────────────────────────────────────────
234
235/// Pixels at the right edge reserved for the parent window's resize grip.
236const RIGHT_EDGE_GUARD:  f64 = 4.0;
237/// Pixels at the bottom edge reserved for the parent window's resize grip.
238const BOTTOM_EDGE_GUARD: f64 = 4.0;
239/// Extra hit-margin around the bar so it's easy to grab even when dormant.
240const GRAB_MARGIN:       f64 = 6.0;
241
242// ── Per-axis state (vertical or horizontal) ──────────────────────────────────
243//
244// The vertical and horizontal scroll axes share the same computation — we
245// factor the state so both reuse `clamp_offset` / `thumb_metrics` logic.
246
247#[derive(Clone, Copy)]
248struct AxisState {
249    enabled:     bool,
250    offset:      f64,
251    content:     f64,
252    hovered_bar: bool,
253    hovered_thumb: bool,
254    dragging:    bool,
255    drag_thumb_offset: f64,
256    hover_anim:  crate::animation::Tween,
257    /// Alpha tween for the fade-in / fade-out animation when a
258    /// `Floating + VisibleWhenNeeded` bar appears on hover.  For every
259    /// other visibility/kind combination the bar is painted at full
260    /// opacity, so this tween stays at 1.0 and does nothing.
261    visibility_anim: crate::animation::Tween,
262}
263
264impl Default for AxisState {
265    fn default() -> Self {
266        Self {
267            enabled: false, offset: 0.0, content: 0.0,
268            hovered_bar: false, hovered_thumb: false, dragging: false,
269            drag_thumb_offset: 0.0,
270            hover_anim: crate::animation::Tween::new(0.0, 0.12),
271            visibility_anim: crate::animation::Tween::new(0.0, 0.18),
272        }
273    }
274}
275
276impl AxisState {
277    fn max_scroll(&self, viewport: f64) -> f64 {
278        (self.content - viewport).max(0.0)
279    }
280
281    /// Returns `true` when the bar is in the "expanded" interaction state.
282    fn interact(&self) -> bool {
283        self.hovered_bar || self.hovered_thumb || self.dragging
284    }
285}
286
287pub struct ScrollView {
288    bounds:   Rect,
289    children: Vec<Box<dyn Widget>>,  // always 0 or 1
290    base:     WidgetBase,
291
292    v: AxisState,
293    h: AxisState,
294
295    /// Keep the scrollbar glued to the bottom as content grows (while the
296    /// user hasn't scrolled away from the end).
297    stick_to_bottom: bool,
298    was_at_bottom:   bool,
299
300    /// How to render the scrollbar.
301    bar_visibility: ScrollBarVisibility,
302    /// `true` when the caller supplied an explicit per-instance visibility via
303    /// [`ScrollView::with_bar_visibility`].  When `false` and
304    /// `visibility_cell` is unset, the global visibility from
305    /// [`current_scroll_visibility`] is re-read each layout.
306    visibility_explicit: bool,
307    style:          ScrollBarStyle,
308    /// `true` when the caller supplied an explicit per-instance style via
309    /// [`ScrollView::with_style`].  When `false` and `style_cell` is unset,
310    /// the global style from [`current_scroll_style`] is re-read each layout.
311    style_explicit: bool,
312
313    // ── External cell bindings ──
314    offset_cell:      Option<Rc<Cell<f64>>>,
315    max_scroll_cell:  Option<Rc<Cell<f64>>>,
316    visibility_cell:  Option<Rc<Cell<ScrollBarVisibility>>>,
317    style_cell:       Option<Rc<Cell<ScrollBarStyle>>>,
318    /// Visible viewport rect in content-space Y-up coordinates, written each
319    /// layout.  Children doing virtual rendering read this cell.
320    viewport_cell:    Option<Rc<Cell<Rect>>>,
321}
322
323impl ScrollView {
324    pub fn new(content: Box<dyn Widget>) -> Self {
325        Self {
326            bounds:            Rect::default(),
327            children:          vec![content],
328            base:              WidgetBase::new(),
329            v:                 AxisState { enabled: true, ..AxisState::default() },
330            h:                 AxisState::default(),
331            stick_to_bottom:   false,
332            was_at_bottom:     false,
333            bar_visibility:    current_scroll_visibility(),
334            visibility_explicit: false,
335            style:             current_scroll_style(),
336            style_explicit:    false,
337            offset_cell:       None,
338            max_scroll_cell:   None,
339            visibility_cell:   None,
340            style_cell:        None,
341            viewport_cell:     None,
342        }
343    }
344
345    // ── Axis enable ───────────────────────────────────────────────────────────
346
347    pub fn horizontal(mut self, enabled: bool) -> Self {
348        self.h.enabled = enabled; self
349    }
350    pub fn vertical(mut self, enabled: bool) -> Self {
351        self.v.enabled = enabled; self
352    }
353
354    // ── Scroll offset API (vertical for back-compat) ─────────────────────────
355
356    pub fn scroll_offset(&self) -> f64 { self.v.offset }
357
358    pub fn set_scroll_offset(&mut self, offset: f64) {
359        self.v.offset = offset;
360        if let Some(c) = &self.offset_cell { c.set(offset); }
361    }
362
363    pub fn max_scroll_value(&self) -> f64 { self.v.max_scroll(self.bounds.height) }
364
365    pub fn with_offset_cell(mut self, cell: Rc<Cell<f64>>) -> Self {
366        self.offset_cell = Some(cell); self
367    }
368
369    pub fn with_max_scroll_cell(mut self, cell: Rc<Cell<f64>>) -> Self {
370        self.max_scroll_cell = Some(cell); self
371    }
372
373    pub fn with_stick_to_bottom(mut self, stick: bool) -> Self {
374        self.stick_to_bottom = stick; self
375    }
376
377    pub fn with_bar_visibility(mut self, v: ScrollBarVisibility) -> Self {
378        self.bar_visibility = v;
379        self.visibility_explicit = true;
380        self
381    }
382
383    pub fn set_bar_visibility(&mut self, v: ScrollBarVisibility) {
384        self.bar_visibility = v;
385        self.visibility_explicit = true;
386    }
387
388    pub fn with_bar_visibility_cell(mut self, cell: Rc<Cell<ScrollBarVisibility>>) -> Self {
389        self.visibility_cell = Some(cell); self
390    }
391
392    pub fn with_style(mut self, s: ScrollBarStyle) -> Self {
393        self.style = s;
394        self.style_explicit = true;
395        self
396    }
397
398    pub fn with_style_cell(mut self, cell: Rc<Cell<ScrollBarStyle>>) -> Self {
399        self.style_cell = Some(cell); self
400    }
401
402    /// Bind a cell that receives the visible content-space viewport rect.
403    pub fn with_viewport_cell(mut self, cell: Rc<Cell<Rect>>) -> Self {
404        self.viewport_cell = Some(cell); self
405    }
406
407    // ── Geometry helpers ──────────────────────────────────────────────────────
408
409    fn viewport(&self) -> (f64, f64) {
410        // Viewport inside the widget AFTER reserving space for Solid bars.
411        let (reserve_x, reserve_y) = self.bar_reserve();
412        let w = (self.bounds.width  - reserve_x).max(0.0);
413        let h = (self.bounds.height - reserve_y).max(0.0);
414        (w, h)
415    }
416
417    /// Horizontal/vertical space reserved for Solid scrollbars (0 for Floating).
418    fn bar_reserve(&self) -> (f64, f64) {
419        if self.style.kind != ScrollBarKind::Solid {
420            return (0.0, 0.0);
421        }
422        let span = self.style.bar_width
423            + self.style.outer_margin
424            + self.style.inner_margin;
425        let rx = if self.h.enabled && self.h.content > self.bounds.width  { 0.0 } else { 0.0 };
426        // We reserve vertical bar width on the right when vertical scrolling
427        // is potentially active (has content overflow).
428        let need_v = self.v.enabled && self.v.content > self.bounds.height - self.h_bar_thickness();
429        let need_h = self.h.enabled && self.h.content > self.bounds.width  - self.v_bar_thickness();
430        let rx = rx + if need_v { span } else { 0.0 };
431        let ry = if need_h { span } else { 0.0 };
432        (rx, ry)
433    }
434
435    /// Just the bar width + margins (not conditional on overflow).  Used for
436    /// hover-zone/paint placement when visibility says "AlwaysVisible".
437    fn v_bar_thickness(&self) -> f64 {
438        self.style.bar_width + self.style.outer_margin + self.style.inner_margin
439    }
440    fn h_bar_thickness(&self) -> f64 {
441        self.style.bar_width + self.style.outer_margin + self.style.inner_margin
442    }
443
444    /// Right-edge X (exclusive) of the vertical scroll bar in local space.
445    fn v_bar_right(&self) -> f64 {
446        self.bounds.width - RIGHT_EDGE_GUARD - self.style.outer_margin
447    }
448    /// Bottom-edge Y (exclusive, Y-up) of the horizontal bar — i.e. the lower
449    /// edge of the bar stripe, which in Y-up = `outer_margin + BOTTOM_EDGE_GUARD`.
450    fn h_bar_bottom(&self) -> f64 {
451        BOTTOM_EDGE_GUARD + self.style.outer_margin
452    }
453
454    /// Vertical track range [lo, hi] in Y-up.  Accounts for the horizontal bar
455    /// reserving a sliver at the bottom when both axes scroll.
456    fn v_track_range(&self) -> (f64, f64) {
457        let (_, reserve_y) = self.bar_reserve();
458        let lo = self.style.inner_margin + reserve_y;
459        let hi = (self.bounds.height - self.style.inner_margin).max(lo);
460        (lo, hi)
461    }
462
463    fn h_track_range(&self) -> (f64, f64) {
464        let (reserve_x, _) = self.bar_reserve();
465        let lo = self.style.inner_margin;
466        let hi = (self.bounds.width - self.style.inner_margin - reserve_x).max(lo);
467        (lo, hi)
468    }
469
470    /// Vertical thumb `(y_bottom, height)` in local Y-up or `None` if no overflow.
471    fn v_thumb_metrics(&self) -> Option<(f64, f64)> {
472        let (_, vh) = self.viewport();
473        if self.v.content <= vh { return None; }
474        let (lo, hi) = self.v_track_range();
475        let track_h  = hi - lo;
476        let ratio    = vh / self.v.content;
477        let thumb_h  = (track_h * ratio).max(self.style.handle_min_length);
478        let travel   = (track_h - thumb_h).max(0.0);
479        let max_s    = self.v.max_scroll(vh);
480        let thumb_y  = if max_s > 0.0 {
481            lo + travel * (1.0 - self.v.offset / max_s)
482        } else { lo + travel };
483        Some((thumb_y, thumb_h))
484    }
485
486    /// Horizontal thumb `(x_left, width)` in local X.
487    fn h_thumb_metrics(&self) -> Option<(f64, f64)> {
488        let (vw, _) = self.viewport();
489        if self.h.content <= vw { return None; }
490        let (lo, hi) = self.h_track_range();
491        let track_w  = hi - lo;
492        let ratio    = vw / self.h.content;
493        let thumb_w  = (track_w * ratio).max(self.style.handle_min_length);
494        let travel   = (track_w - thumb_w).max(0.0);
495        let max_s    = self.h.max_scroll(vw);
496        let thumb_x  = if max_s > 0.0 {
497            lo + travel * (self.h.offset / max_s)
498        } else { lo };
499        Some((thumb_x, thumb_w))
500    }
501
502    fn pos_on_v_thumb(&self, pos: Point) -> bool {
503        let bar_right = self.v_bar_right();
504        let bar_left  = bar_right - self.style.bar_width;
505        let hit_left  = bar_left - GRAB_MARGIN;
506        if pos.x < hit_left || pos.x >= bar_right { return false; }
507        if let Some((ty, th)) = self.v_thumb_metrics() {
508            pos.y >= ty && pos.y <= ty + th
509        } else { false }
510    }
511
512    fn pos_on_h_thumb(&self, pos: Point) -> bool {
513        let bar_bottom = self.h_bar_bottom();
514        let bar_top    = bar_bottom + self.style.bar_width;
515        let hit_top    = bar_top + GRAB_MARGIN;
516        if pos.y < bar_bottom || pos.y >= hit_top { return false; }
517        if let Some((tx, tw)) = self.h_thumb_metrics() {
518            pos.x >= tx && pos.x <= tx + tw
519        } else { false }
520    }
521
522    fn pos_in_v_hover(&self, pos: Point) -> bool {
523        let bar_right = self.v_bar_right();
524        let bar_left  = bar_right - self.style.bar_width - GRAB_MARGIN;
525        pos.x >= bar_left && pos.x < bar_right
526    }
527
528    fn pos_in_h_hover(&self, pos: Point) -> bool {
529        let bar_bottom = self.h_bar_bottom();
530        let bar_top    = bar_bottom + self.style.bar_width + GRAB_MARGIN;
531        pos.y >= bar_bottom && pos.y < bar_top
532    }
533
534    fn clamp_offsets(&mut self) {
535        let (vw, vh) = self.viewport();
536        self.v.offset = self.v.offset.clamp(0.0, self.v.max_scroll(vh)).round();
537        self.h.offset = self.h.offset.clamp(0.0, self.h.max_scroll(vw)).round();
538    }
539
540    // ── Layout property forwarding ────────────────────────────────────────────
541
542    pub fn with_margin(mut self, m: Insets)    -> Self { self.base.margin   = m; self }
543    pub fn with_h_anchor(mut self, h: HAnchor) -> Self { self.base.h_anchor = h; self }
544    pub fn with_v_anchor(mut self, v: VAnchor) -> Self { self.base.v_anchor = v; self }
545    pub fn with_min_size(mut self, s: Size)    -> Self { self.base.min_size = s; self }
546    pub fn with_max_size(mut self, s: Size)    -> Self { self.base.max_size = s; self }
547
548    // ── Visibility helper ────────────────────────────────────────────────────
549
550    fn should_paint_v(&self) -> bool {
551        let (_, vh) = self.viewport();
552        if self.v.content <= vh { return false; }
553        let floating = self.style.kind == ScrollBarKind::Floating;
554        match self.bar_visibility {
555            ScrollBarVisibility::AlwaysHidden      => false,
556            ScrollBarVisibility::AlwaysVisible     => true,
557            // With Floating kind, VisibleWhenNeeded hides until hover/drag —
558            // matches egui's floating style.  Solid kind shows unconditionally
559            // when content overflows.
560            ScrollBarVisibility::VisibleWhenNeeded =>
561                !floating || self.v.hovered_bar || self.v.dragging,
562        }
563    }
564
565    fn should_paint_h(&self) -> bool {
566        let (vw, _) = self.viewport();
567        if self.h.content <= vw { return false; }
568        let floating = self.style.kind == ScrollBarKind::Floating;
569        match self.bar_visibility {
570            ScrollBarVisibility::AlwaysHidden      => false,
571            ScrollBarVisibility::AlwaysVisible     => true,
572            ScrollBarVisibility::VisibleWhenNeeded =>
573                !floating || self.h.hovered_bar || self.h.dragging,
574        }
575    }
576}
577
578impl Widget for ScrollView {
579    fn type_name(&self) -> &'static str { "ScrollView" }
580    fn bounds(&self) -> Rect { self.bounds }
581    fn set_bounds(&mut self, b: Rect) { self.bounds = b; }
582    fn children(&self) -> &[Box<dyn Widget>] { &self.children }
583    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> { &mut self.children }
584
585    fn margin(&self)   -> Insets  { self.base.margin }
586    fn h_anchor(&self) -> HAnchor { self.base.h_anchor }
587    fn v_anchor(&self) -> VAnchor { self.base.v_anchor }
588    fn min_size(&self) -> Size    { self.base.min_size }
589    fn max_size(&self) -> Size    { self.base.max_size }
590
591    fn hit_test(&self, local_pos: Point) -> bool {
592        if self.v.dragging || self.h.dragging { return true; }
593        let b = self.bounds();
594        local_pos.x >= 0.0 && local_pos.x <= b.width
595            && local_pos.y >= 0.0 && local_pos.y <= b.height
596    }
597
598    fn claims_pointer_exclusively(&self, local_pos: Point) -> bool {
599        if self.v.dragging || self.h.dragging { return true; }
600        let (vw, vh) = self.viewport();
601        if self.v.enabled && self.v.content > vh && self.pos_in_v_hover(local_pos) { return true; }
602        if self.h.enabled && self.h.content > vw && self.pos_in_h_hover(local_pos) { return true; }
603        false
604    }
605
606    fn layout(&mut self, available: Size) -> Size {
607        // Pull live state from external cells first.
608        if let Some(c) = &self.offset_cell     { self.v.offset = c.get(); }
609        if let Some(c) = &self.visibility_cell {
610            self.bar_visibility = c.get();
611        } else if !self.visibility_explicit {
612            self.bar_visibility = current_scroll_visibility();
613        }
614        if let Some(c) = &self.style_cell {
615            self.style = c.get();
616        } else if !self.style_explicit {
617            // No explicit override → follow the global scroll-bar style so
618            // the Appearance demo restyles every `ScrollView` in the app.
619            self.style = current_scroll_style();
620        }
621
622        self.bounds = Rect::new(0.0, 0.0, available.width, available.height);
623
624        // For horizontal scrolling, content width is unconstrained (the child
625        // may return a width larger than our viewport).  For vertical-only, we
626        // pin child to the viewport width so wrapping widgets behave.
627        let (vw_guess, _vh_guess) = self.viewport();
628        let child_in_w = if self.h.enabled { f64::MAX / 2.0 } else { vw_guess };
629        let child_in_h = f64::MAX / 2.0;
630
631        if let Some(child) = self.children.first_mut() {
632            let natural = child.layout(Size::new(child_in_w, child_in_h));
633            self.v.content = natural.height;
634            self.h.content = if self.h.enabled { natural.width } else { vw_guess };
635        }
636
637        // Re-query viewport now that content dimensions are known (Solid bars
638        // may reserve different space once we know overflow).
639        let (vw, vh) = self.viewport();
640
641        if self.stick_to_bottom && self.was_at_bottom {
642            self.v.offset = self.v.max_scroll(vh);
643        }
644        self.clamp_offsets();
645        self.was_at_bottom = (self.v.max_scroll(vh) - self.v.offset).abs() < 0.5;
646
647        // Publish offsets / max / viewport.
648        if let Some(c) = &self.offset_cell     { c.set(self.v.offset); }
649        if let Some(c) = &self.max_scroll_cell { c.set(self.v.max_scroll(vh)); }
650        if let Some(c) = &self.viewport_cell {
651            // Content-space viewport rect in Y-UP content coords:
652            //   x = h_offset  (left edge of visible region)
653            //   y = (v_content_height - vh - v_offset) if inverting, but we
654            //       expose TOP-DOWN coords for easier row math: y = v_offset.
655            // We output a rect where (x, y) is the TOP-LEFT of visible content
656            // in a conventional top-down space, and (width, height) = viewport.
657            c.set(Rect::new(self.h.offset, self.v.offset, vw, vh));
658        }
659
660        // Position child inside the widget.
661        if let Some(child) = self.children.first_mut() {
662            let child_y = vh - self.v.content + self.v.offset;
663            let child_x = -self.h.offset;
664            child.set_bounds(Rect::new(
665                child_x.round(), child_y.round(),
666                if self.h.enabled { self.h.content } else { vw },
667                self.v.content,
668            ));
669        }
670
671        available
672    }
673
674    fn paint(&mut self, _ctx: &mut dyn DrawCtx) {}
675
676    fn clip_children_rect(&self) -> Option<(f64, f64, f64, f64)> {
677        // Clip children to the VIEWPORT so the content never overpaints the
678        // scrollbar gutter or the edge guards.
679        let (vw, vh) = self.viewport();
680        Some((0.0, self.bounds.height - vh, vw, vh))
681    }
682
683    fn paint_overlay(&mut self, ctx: &mut dyn DrawCtx) {
684        let v = ctx.visuals();
685
686        // Drive the fade-in / fade-out alpha animation.  `should_paint_*`
687        // returns true exactly when the bar would be shown in the old
688        // pop-in behaviour; the tween now smooths that transition so a
689        // `Floating + VisibleWhenNeeded` bar dissolves in instead of
690        // snapping.  For non-animating combinations the target stays
691        // pinned at its terminal value, so the tween is a no-op.
692        self.v.visibility_anim.set_target(if self.should_paint_v() { 1.0 } else { 0.0 });
693        self.h.visibility_anim.set_target(if self.should_paint_h() { 1.0 } else { 0.0 });
694        let v_alpha = self.v.visibility_anim.tick();
695        let h_alpha = self.h.visibility_anim.tick();
696
697        // Paint whenever alpha is visible — including the tail of a
698        // fade-out after the cursor leaves, so the bar smoothly dissolves
699        // instead of vanishing.
700        let paint_v = self.v.enabled && self.v.content > self.viewport().1 && v_alpha > 0.001;
701        let paint_h = self.h.enabled && self.h.content > self.viewport().0 && h_alpha > 0.001;
702
703        let track_color_base = match self.style.color {
704            ScrollBarColor::Background => v.scroll_track,
705            ScrollBarColor::Foreground => Color::rgba(
706                v.accent.r, v.accent.g, v.accent.b, 0.08),
707        };
708        let thumb_idle = match self.style.color {
709            ScrollBarColor::Background => v.scroll_thumb,
710            ScrollBarColor::Foreground => v.accent,
711        };
712
713        // ── Vertical bar ──
714        if paint_v {
715            if let Some((ty, th)) = self.v_thumb_metrics() {
716                let bar_right = self.v_bar_right();
717                self.v.hover_anim.set_target(if self.v.interact() { 1.0 } else { 0.0 });
718                let t         = self.v.hover_anim.tick();
719                let bar_w     = self.style.bar_width_at(t);
720                let bar_x     = bar_right - bar_w;
721                let r         = bar_w * 0.5;
722
723                let (lo, hi) = self.v_track_range();
724                ctx.set_fill_color(scale_alpha(track_color_base, v_alpha));
725                ctx.begin_path();
726                ctx.rounded_rect(bar_x, lo, bar_w, hi - lo, r);
727                ctx.fill();
728
729                let tc = if self.v.dragging {
730                    v.scroll_thumb_dragging
731                } else if self.v.hovered_thumb {
732                    v.scroll_thumb_hovered
733                } else { thumb_idle };
734                ctx.set_fill_color(scale_alpha(tc, v_alpha));
735                ctx.begin_path();
736                ctx.rounded_rect(bar_x, ty, bar_w, th, r);
737                ctx.fill();
738            }
739        }
740
741        // ── Horizontal bar ──
742        if paint_h {
743            if let Some((tx, tw)) = self.h_thumb_metrics() {
744                let bar_bottom = self.h_bar_bottom();
745                self.h.hover_anim.set_target(if self.h.interact() { 1.0 } else { 0.0 });
746                let t          = self.h.hover_anim.tick();
747                let bar_h      = self.style.bar_width_at(t);
748                let r          = bar_h * 0.5;
749
750                let (lo, hi) = self.h_track_range();
751                ctx.set_fill_color(scale_alpha(track_color_base, h_alpha));
752                ctx.begin_path();
753                ctx.rounded_rect(lo, bar_bottom, hi - lo, bar_h, r);
754                ctx.fill();
755
756                let tc = if self.h.dragging {
757                    v.scroll_thumb_dragging
758                } else if self.h.hovered_thumb {
759                    v.scroll_thumb_hovered
760                } else { thumb_idle };
761                ctx.set_fill_color(scale_alpha(tc, h_alpha));
762                ctx.begin_path();
763                ctx.rounded_rect(tx, bar_bottom, tw, bar_h, r);
764                ctx.fill();
765            }
766        }
767
768        // ── Fade gradient overlay at the scroll-axis edges ──
769        //
770        // Approximation of egui's fade: draw a translucent stripe of the
771        // panel_fill colour at each edge where content is clipped.  Strength
772        // * 1.0 = fully opaque at the edge.
773        if self.style.fade_strength > 0.001 && self.style.fade_size > 0.5 {
774            self.paint_fade(ctx);
775        }
776    }
777
778    fn on_event(&mut self, event: &Event) -> EventResult {
779        match event {
780            // ── Mouse wheel ───────────────────────────────────────────────────
781            Event::MouseWheel { delta_y, delta_x, .. } => {
782                let mut consumed = false;
783                if self.v.enabled {
784                    self.v.offset = self.v.offset + delta_y * 40.0;
785                    consumed = true;
786                }
787                if self.h.enabled {
788                    self.h.offset = self.h.offset + delta_x * 40.0;
789                    consumed = true;
790                }
791                self.clamp_offsets();
792                let (_, vh) = self.viewport();
793                self.was_at_bottom = (self.v.max_scroll(vh) - self.v.offset).abs() < 0.5;
794                if let Some(c) = &self.offset_cell { c.set(self.v.offset); }
795                if consumed {
796                    crate::animation::request_tick();
797                    EventResult::Consumed
798                } else {
799                    EventResult::Ignored
800                }
801            }
802
803            // ── Mouse move ────────────────────────────────────────────────────
804            Event::MouseMove { pos } => {
805                let (vw, vh) = self.viewport();
806                let v_scroll = self.v.enabled && self.v.content > vh;
807                let h_scroll = self.h.enabled && self.h.content > vw;
808                let was_vb = self.v.hovered_bar;
809                let was_vt = self.v.hovered_thumb;
810                let was_hb = self.h.hovered_bar;
811                let was_ht = self.h.hovered_thumb;
812                self.v.hovered_bar   = v_scroll && self.pos_in_v_hover(*pos);
813                self.v.hovered_thumb = v_scroll && self.pos_on_v_thumb(*pos);
814                self.h.hovered_bar   = h_scroll && self.pos_in_h_hover(*pos);
815                self.h.hovered_thumb = h_scroll && self.pos_on_h_thumb(*pos);
816                if was_vb != self.v.hovered_bar || was_vt != self.v.hovered_thumb
817                    || was_hb != self.h.hovered_bar || was_ht != self.h.hovered_thumb
818                {
819                    crate::animation::request_tick();
820                }
821
822                if self.v.dragging {
823                    if let Some((_, th)) = self.v_thumb_metrics() {
824                        let (lo, hi) = self.v_track_range();
825                        let travel = (hi - lo - th).max(1.0);
826                        let new_ty = (pos.y - self.v.drag_thumb_offset)
827                            .clamp(lo, lo + travel);
828                        let frac = 1.0 - (new_ty - lo) / travel;
829                        self.v.offset = (frac * self.v.max_scroll(vh)).max(0.0);
830                        self.clamp_offsets();
831                        self.was_at_bottom =
832                            (self.v.max_scroll(vh) - self.v.offset).abs() < 0.5;
833                        if let Some(c) = &self.offset_cell { c.set(self.v.offset); }
834                    }
835                    crate::animation::request_tick();
836                    return EventResult::Consumed;
837                }
838                if self.h.dragging {
839                    if let Some((_, tw)) = self.h_thumb_metrics() {
840                        let (lo, hi) = self.h_track_range();
841                        let travel = (hi - lo - tw).max(1.0);
842                        let new_tx = (pos.x - self.h.drag_thumb_offset)
843                            .clamp(lo, lo + travel);
844                        let frac = (new_tx - lo) / travel;
845                        self.h.offset = (frac * self.h.max_scroll(vw)).max(0.0);
846                        self.clamp_offsets();
847                    }
848                    crate::animation::request_tick();
849                    return EventResult::Consumed;
850                }
851                EventResult::Ignored
852            }
853
854            // ── Mouse down ────────────────────────────────────────────────────
855            Event::MouseDown { pos, button: MouseButton::Left, .. } => {
856                let (vw, vh) = self.viewport();
857                let v_scroll = self.v.enabled && self.v.content > vh;
858                let h_scroll = self.h.enabled && self.h.content > vw;
859
860                if v_scroll && self.pos_in_v_hover(*pos) {
861                    if self.pos_on_v_thumb(*pos) {
862                        let ty = self.v_thumb_metrics().map(|(y, _)| y).unwrap_or(0.0);
863                        self.v.dragging = true;
864                        self.v.drag_thumb_offset = pos.y - ty;
865                        // No tick: thumb grab has no visible effect until
866                        // the cursor actually moves.
867                    } else if let Some((ty, th)) = self.v_thumb_metrics() {
868                        // Page step on track click (matches Windows / macOS).
869                        // Y-up: cursor ABOVE thumb (higher y) → scroll UP,
870                        // cursor BELOW thumb → scroll DOWN.  Step by one
871                        // viewport minus a small overlap for continuity.
872                        let page = (vh - 16.0).max(20.0);
873                        if pos.y > ty + th {
874                            self.v.offset = (self.v.offset - page).max(0.0);
875                        } else if pos.y < ty {
876                            self.v.offset = (self.v.offset + page).min(self.v.max_scroll(vh));
877                        }
878                        self.clamp_offsets();
879                        if let Some(c) = &self.offset_cell { c.set(self.v.offset); }
880                        // Offset changed — visible scroll.
881                        crate::animation::request_tick();
882                    }
883                    return EventResult::Consumed;
884                }
885                if h_scroll && self.pos_in_h_hover(*pos) {
886                    if self.pos_on_h_thumb(*pos) {
887                        let tx = self.h_thumb_metrics().map(|(x, _)| x).unwrap_or(0.0);
888                        self.h.dragging = true;
889                        self.h.drag_thumb_offset = pos.x - tx;
890                        // No tick — see v-axis thumb grab comment above.
891                    } else if let Some((tx, tw)) = self.h_thumb_metrics() {
892                        let page = (vw - 16.0).max(20.0);
893                        if pos.x < tx {
894                            self.h.offset = (self.h.offset - page).max(0.0);
895                        } else if pos.x > tx + tw {
896                            self.h.offset = (self.h.offset + page).min(self.h.max_scroll(vw));
897                        }
898                        self.clamp_offsets();
899                        crate::animation::request_tick();
900                    }
901                    return EventResult::Consumed;
902                }
903                EventResult::Ignored
904            }
905
906            // ── Mouse up ──────────────────────────────────────────────────────
907            Event::MouseUp { button: MouseButton::Left, .. } => {
908                let was = self.v.dragging || self.h.dragging;
909                self.v.dragging = false;
910                self.h.dragging = false;
911                if was {
912                    crate::animation::request_tick();
913                    EventResult::Consumed
914                } else {
915                    EventResult::Ignored
916                }
917            }
918
919            _ => EventResult::Ignored,
920        }
921    }
922}
923
924impl ScrollView {
925    /// Paint a gradient fade at the scroll-axis edges using thin horizontal or
926    /// vertical strips with linearly interpolated alpha.  The strip closest to
927    /// the clip edge is fully opaque; the strip furthest inside the viewport is
928    /// fully transparent — giving a smooth dissolve into the background colour.
929    fn paint_fade(&self, ctx: &mut dyn DrawCtx) {
930        let v        = ctx.visuals();
931        let c        = v.panel_fill;
932        let (vw, vh) = self.viewport();
933        let strength = self.style.fade_strength.clamp(0.0, 1.0) as f32;
934        let size     = self.style.fade_size.max(0.0);
935        let max_a    = strength;
936
937        // Fade appears only near edges where content is clipped.
938        if self.v.enabled {
939            if self.v.offset > 0.5 {
940                // Top edge (Y-up: high Y).  Gradient transparent→opaque going up.
941                Self::fill_v_gradient(ctx, c, max_a, 0.0, self.bounds.height - size, vw, size, false);
942            }
943            if (self.v.max_scroll(vh) - self.v.offset) > 0.5 {
944                // Bottom edge.  Gradient transparent→opaque going down.
945                let y_bottom = self.bounds.height - vh;
946                Self::fill_v_gradient(ctx, c, max_a, 0.0, y_bottom, vw, size, true);
947            }
948        }
949        if self.h.enabled {
950            if self.h.offset > 0.5 {
951                // Left edge.  Gradient transparent→opaque going left.
952                Self::fill_h_gradient(ctx, c, max_a, 0.0, self.bounds.height - vh, size, vh, true);
953            }
954            if (self.h.max_scroll(vw) - self.h.offset) > 0.5 {
955                // Right edge.  Gradient transparent→opaque going right.
956                Self::fill_h_gradient(ctx, c, max_a, vw - size, self.bounds.height - vh, size, vh, false);
957            }
958        }
959    }
960
961    /// Draw a vertical gradient rect using `STEPS` thin strips.
962    ///
963    /// When `opaque_at_bottom` is `true` the gradient runs opaque→transparent
964    /// bottom-to-top (bottom edge fade); when `false` it runs
965    /// transparent→opaque bottom-to-top (top edge fade).
966    fn fill_v_gradient(
967        ctx:             &mut dyn DrawCtx,
968        c:               Color,
969        max_alpha:       f32,
970        x:               f64,
971        y:               f64,
972        w:               f64,
973        h:               f64,
974        opaque_at_bottom: bool,
975    ) {
976        const STEPS: usize = 64;
977        let strip_h = h / STEPS as f64;
978        for i in 0..STEPS {
979            // t = 0 at the transparent end, 1 at the opaque end.
980            let t = (i as f32 + 0.5) / STEPS as f32;
981            let a = if opaque_at_bottom { 1.0 - t } else { t };
982            ctx.set_fill_color(Color::rgba(c.r, c.g, c.b, a * max_alpha));
983            ctx.begin_path();
984            ctx.rect(x, y + i as f64 * strip_h, w, strip_h + 0.5);
985            ctx.fill();
986        }
987    }
988
989    /// Draw a horizontal gradient rect using `STEPS` thin strips.
990    ///
991    /// When `opaque_at_left` is `true` the gradient runs opaque→transparent
992    /// left-to-right (left edge fade); when `false` it runs
993    /// transparent→opaque left-to-right (right edge fade).
994    fn fill_h_gradient(
995        ctx:           &mut dyn DrawCtx,
996        c:             Color,
997        max_alpha:     f32,
998        x:             f64,
999        y:             f64,
1000        w:             f64,
1001        h:             f64,
1002        opaque_at_left: bool,
1003    ) {
1004        const STEPS: usize = 64;
1005        let strip_w = w / STEPS as f64;
1006        for i in 0..STEPS {
1007            let t = (i as f32 + 0.5) / STEPS as f32;
1008            let a = if opaque_at_left { 1.0 - t } else { t };
1009            ctx.set_fill_color(Color::rgba(c.r, c.g, c.b, a * max_alpha));
1010            ctx.begin_path();
1011            ctx.rect(x + i as f64 * strip_w, y, strip_w + 0.5, h);
1012            ctx.fill();
1013        }
1014    }
1015}