Skip to main content

egui_macview/layout/
scroll_view.rs

1//! Authentic macOS AppKit Floating Overlay Scroll View (NSScrollView equivalent).
2//!
3//! Grounded in macOS HIG and W3C DTCG tokens via `MacTheme`.
4//! Fully data-driven: zero hardcoded dimensions or colors.
5//! Features zero-gutter content reservation (full viewport width/height),
6//! smooth hardware scroll clipping, virtual viewport computation,
7//! and interactive floating overlay scrollbars that smoothly fade out when inactive.
8
9use crate::layout::scrollbar::render_overlay_thumb;
10use crate::theme::MacTheme;
11use egui::scroll_area::ScrollBarVisibility;
12use egui::{Color32, Id, Pos2, Rect, ScrollArea, Ui, Vec2};
13use std::hash::Hash;
14
15/// Output returned by [`MacScrollView::show`].
16#[derive(Debug, Clone)]
17pub struct MacScrollViewOutput<R> {
18    /// Return value of the inner content closure.
19    pub inner: R,
20    /// Bounding rectangle of the visible viewport in logical points.
21    pub inner_rect: Rect,
22    /// Total dimensions of the scrollable content in points.
23    pub content_size: Vec2,
24    /// Current logical scroll offset in points.
25    pub offset: Vec2,
26}
27
28/// Authentic macOS AppKit Scroll View (`NSScrollView` equivalent).
29///
30/// Features zero-gutter content reservation (full viewport width/height),
31/// smooth hardware scroll clipping, virtual viewport computation,
32/// and interactive floating overlay scrollbars that smoothly fade out when inactive.
33///
34/// # Example
35///
36/// ```rust
37/// use egui_macview::{MacScrollView, MacTheme};
38///
39/// # let ctx = egui::Context::default();
40/// # let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
41/// let theme = MacTheme::default();
42///
43/// MacScrollView::new("log_scroll")
44///     .theme(&theme)
45///     .max_height(200.0)
46///     .show(ui, |content_ui| {
47///         for i in 0..50 {
48///             content_ui.label(format!("Log line {}", i));
49///         }
50///     });
51/// # });
52/// # output.textures_delta.clear();
53/// ```
54pub struct MacScrollView<'a> {
55    id: Id,
56    theme: Option<&'a MacTheme>,
57    horizontal: bool,
58    vertical: bool,
59    auto_hide: bool,
60    max_height: Option<f32>,
61    max_width: Option<f32>,
62    min_scrolled_height: Option<f32>,
63    min_scrolled_width: Option<f32>,
64    offset_override: Option<&'a mut Vec2>,
65}
66
67impl<'a> MacScrollView<'a> {
68    /// Creates a scroll view with state identified by `id_source`.
69    pub fn new(id_source: impl Hash + std::fmt::Debug) -> Self {
70        let id = Id::NULL.with(id_source);
71        Self {
72            id,
73            theme: None,
74            horizontal: false,
75            vertical: true,
76            auto_hide: true,
77            max_height: None,
78            max_width: None,
79            min_scrolled_height: None,
80            min_scrolled_width: None,
81            offset_override: None,
82        }
83    }
84
85    /// Convenience constructor for both horizontal and vertical scrolling.
86    pub fn both(id_source: impl Hash + std::fmt::Debug) -> Self {
87        Self::new(id_source).horizontal(true).vertical(true)
88    }
89
90    /// Injects dynamic theme to eliminate hardcoded metrics and colors.
91    pub fn theme(mut self, theme: &'a MacTheme) -> Self {
92        self.theme = Some(theme);
93        self
94    }
95
96    /// Enables or disables horizontal scrolling (default: false).
97    pub fn horizontal(mut self, enabled: bool) -> Self {
98        self.horizontal = enabled;
99        self
100    }
101
102    /// Enables or disables vertical scrolling (default: true).
103    pub fn vertical(mut self, enabled: bool) -> Self {
104        self.vertical = enabled;
105        self
106    }
107
108    /// Controls whether overlay scrollbars fade out when inactive (default: true).
109    pub fn auto_hide(mut self, auto_hide: bool) -> Self {
110        self.auto_hide = auto_hide;
111        self
112    }
113
114    /// Sets maximum height constraint in points.
115    pub fn max_height(mut self, max_h: f32) -> Self {
116        self.max_height = Some(max_h);
117        self
118    }
119
120    /// Sets maximum width constraint in points.
121    pub fn max_width(mut self, max_w: f32) -> Self {
122        self.max_width = Some(max_w);
123        self
124    }
125
126    /// Sets minimum scrolled height in points.
127    pub fn min_scrolled_height(mut self, min_h: f32) -> Self {
128        self.min_scrolled_height = Some(min_h);
129        self
130    }
131
132    /// Sets minimum scrolled width in points.
133    pub fn min_scrolled_width(mut self, min_w: f32) -> Self {
134        self.min_scrolled_width = Some(min_w);
135        self
136    }
137
138    /// Binds directly to an external `&mut Vec2` offset variable for programmatic control.
139    pub fn with_offset(mut self, offset: &'a mut Vec2) -> Self {
140        self.offset_override = Some(offset);
141        self
142    }
143
144    /// Renders the scrollable container and child content, overlaying authentic floating scrollbars.
145    pub fn show<R>(
146        mut self,
147        ui: &mut Ui,
148        content: impl FnOnce(&mut Ui) -> R,
149    ) -> MacScrollViewOutput<R> {
150        let fallback_theme;
151        let theme = match self.theme {
152            Some(t) => t,
153            None => {
154                fallback_theme = MacTheme::from_ctx(ui.ctx());
155                &fallback_theme
156            }
157        };
158
159        let metrics = theme.scroll_view();
160        let scrollbar_thickness = metrics.scrollbar_thickness.points();
161        let scrollbar_hover_thickness = metrics.scrollbar_hover_thickness.points();
162        let thumb_min_length = metrics.thumb_min_length.points();
163        let corner_radius = metrics.corner_radius.points();
164        let fade_timeout = metrics.fade_timeout.points();
165        let edge_inset = metrics.edge_inset.points();
166
167        let min_w = self.min_scrolled_width.unwrap_or(0.0);
168        let min_h = self.min_scrolled_height.unwrap_or(0.0);
169
170        // 1. Configure underlying ScrollArea with AlwaysHidden scrollbars to eliminate 16px gutter reservation
171        let mut area = ScrollArea::new([self.horizontal, self.vertical])
172            .id_salt(self.id.with("scroll_area"))
173            .scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden)
174            .auto_shrink([false, false])
175            .min_scrolled_width(min_w)
176            .min_scrolled_height(min_h);
177
178        if let Some(mh) = self.max_height {
179            area = area.max_height(mh);
180        }
181        if let Some(mw) = self.max_width {
182            area = area.max_width(mw);
183        }
184
185        if let Some(ref o) = self.offset_override {
186            area = area.scroll_offset(**o);
187        } else if let Some(drag_o) = ui.data_mut(|d| {
188            d.get_temp::<Option<Vec2>>(self.id.with("drag_offset"))
189                .flatten()
190        }) {
191            area = area.scroll_offset(drag_o);
192            ui.data_mut(|d| d.insert_temp(self.id.with("drag_offset"), None::<Vec2>));
193        }
194
195        let output = area.show(ui, content);
196        let viewport_rect = output.inner_rect;
197        let content_size = output.content_size;
198        let mut current_offset = output.state.clamped_offset();
199
200        // 2. Compute virtual viewport bounds and overflow
201        let max_scroll_x = (content_size.x - viewport_rect.width()).max(0.0);
202        let max_scroll_y = (content_size.y - viewport_rect.height()).max(0.0);
203        current_offset.x = current_offset.x.clamp(0.0, max_scroll_x);
204        current_offset.y = current_offset.y.clamp(0.0, max_scroll_y);
205
206        // 3. Inactivity & Fade computation
207        let prev_offset_id = self.id.with("prev_offset");
208        let prev_offset: Vec2 =
209            ui.data_mut(|d| *d.get_temp_mut_or_insert_with(prev_offset_id, || current_offset));
210        let scrolled_this_frame = (current_offset - prev_offset).length_sq() > f32::EPSILON;
211        ui.data_mut(|d| d.insert_temp(prev_offset_id, current_offset));
212
213        let last_active_id = self.id.with("last_active_time");
214        let now = ui.input(|i| i.time);
215        let mut last_active: f64 =
216            ui.data_mut(|d| *d.get_temp_mut_or_insert_with(last_active_id, || now));
217
218        let is_pointer_in_viewport = ui.rect_contains_pointer(viewport_rect);
219        let has_scroll_delta = ui.input(|i| {
220            i.smooth_scroll_delta != Vec2::ZERO
221                || i.raw
222                    .events
223                    .iter()
224                    .any(|e| matches!(e, egui::Event::MouseWheel { .. }))
225        });
226        let pointer_moving = ui.input(|i| i.pointer.delta() != Vec2::ZERO);
227
228        let hit_zone_padding = metrics.hit_zone_padding.points();
229        let min_visible_thumb_len = metrics.min_visible_thumb_length.points();
230        let zone_span = scrollbar_thickness + hit_zone_padding;
231        let v_zone = Rect::from_min_max(
232            Pos2::new(viewport_rect.right() - zone_span, viewport_rect.top()),
233            viewport_rect.max,
234        );
235        let h_zone = Rect::from_min_max(
236            Pos2::new(viewport_rect.left(), viewport_rect.bottom() - zone_span),
237            viewport_rect.max,
238        );
239        let in_scrollbar_zone = (self.vertical && ui.rect_contains_pointer(v_zone))
240            || (self.horizontal && ui.rect_contains_pointer(h_zone));
241
242        if scrolled_this_frame
243            || (is_pointer_in_viewport && has_scroll_delta)
244            || (in_scrollbar_zone && pointer_moving)
245        {
246            last_active = now;
247            ui.data_mut(|d| d.insert_temp(last_active_id, last_active));
248        }
249
250        let init_id = self.id.with("init_done");
251        let initialized: bool = ui.data_mut(|d| *d.get_temp_mut_or_insert_with(init_id, || false));
252        if !initialized {
253            ui.data_mut(|d| d.insert_temp(init_id, true));
254        }
255
256        let elapsed = (now - last_active).max(0.0);
257        let is_active = !self.auto_hide || !initialized || elapsed < (fade_timeout as f64);
258        let alpha = if self.auto_hide {
259            if !initialized {
260                1.0
261            } else {
262                let a = ui.ctx().animate_bool(self.id.with("fade_anim"), is_active);
263                if a > 0.0 && a < 1.0 {
264                    ui.ctx().request_repaint();
265                }
266                a
267            }
268        } else {
269            1.0
270        };
271
272        let painter = ui.painter_at(viewport_rect);
273
274        // Helper for thumb styling derived from MacTheme semantic tokens
275        let get_thumb_color = |hovered: bool, dragged: bool| -> Color32 {
276            theme.scrollbar_thumb_color(hovered || dragged, alpha)
277        };
278        let track_bg_color = theme.control().gamma_multiply(alpha * 0.5);
279
280        let has_v = self.vertical && max_scroll_y > 0.0;
281        let has_h = self.horizontal && max_scroll_x > 0.0;
282
283        // 4. Vertical Floating Overlay Scrollbar
284        if has_v {
285            let trail = if has_h {
286                edge_inset * 2.0 + scrollbar_thickness
287            } else {
288                edge_inset
289            };
290            if render_overlay_thumb(
291                ui,
292                &painter,
293                self.id.with("v_thumb"),
294                viewport_rect,
295                viewport_rect.height(),
296                content_size.y,
297                edge_inset,
298                trail,
299                edge_inset,
300                scrollbar_thickness,
301                scrollbar_hover_thickness,
302                corner_radius,
303                thumb_min_length,
304                min_visible_thumb_len,
305                &mut current_offset.y,
306                max_scroll_y,
307                true,
308                alpha,
309                track_bg_color,
310                &get_thumb_color,
311                last_active_id,
312                now,
313            ) && self.offset_override.is_none()
314            {
315                ui.data_mut(|d| d.insert_temp(self.id.with("drag_offset"), Some(current_offset)));
316            }
317        }
318
319        // 5. Horizontal Floating Overlay Scrollbar
320        if has_h {
321            let trail = if has_v {
322                edge_inset * 2.0 + scrollbar_thickness
323            } else {
324                edge_inset
325            };
326            if render_overlay_thumb(
327                ui,
328                &painter,
329                self.id.with("h_thumb"),
330                viewport_rect,
331                viewport_rect.width(),
332                content_size.x,
333                edge_inset,
334                trail,
335                edge_inset,
336                scrollbar_thickness,
337                scrollbar_hover_thickness,
338                corner_radius,
339                thumb_min_length,
340                min_visible_thumb_len,
341                &mut current_offset.x,
342                max_scroll_x,
343                false,
344                alpha,
345                track_bg_color,
346                &get_thumb_color,
347                last_active_id,
348                now,
349            ) && self.offset_override.is_none()
350            {
351                ui.data_mut(|d| d.insert_temp(self.id.with("drag_offset"), Some(current_offset)));
352            }
353        }
354
355        // 6. Persist updated offset
356        if let Some(ref mut o) = self.offset_override {
357            **o = current_offset;
358        }
359
360        MacScrollViewOutput {
361            inner: output.inner,
362            inner_rect: viewport_rect,
363            content_size,
364            offset: current_offset,
365        }
366    }
367}