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;