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::{MacAppearance, 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        #[allow(deprecated)]
71        let id = Id::new(id_source);
72        Self {
73            id,
74            theme: None,
75            horizontal: false,
76            vertical: true,
77            auto_hide: true,
78            max_height: None,
79            max_width: None,
80            min_scrolled_height: None,
81            min_scrolled_width: None,
82            offset_override: None,
83        }
84    }
85
86    /// Convenience constructor for both horizontal and vertical scrolling.
87    pub fn both(id_source: impl Hash + std::fmt::Debug) -> Self {
88        Self::new(id_source).horizontal(true).vertical(true)
89    }
90
91    /// Injects dynamic theme to eliminate hardcoded metrics and colors.
92    pub fn theme(mut self, theme: &'a MacTheme) -> Self {
93        self.theme = Some(theme);
94        self
95    }
96
97    /// Enables or disables horizontal scrolling (default: false).
98    pub fn horizontal(mut self, enabled: bool) -> Self {
99        self.horizontal = enabled;
100        self
101    }
102
103    /// Enables or disables vertical scrolling (default: true).
104    pub fn vertical(mut self, enabled: bool) -> Self {
105        self.vertical = enabled;
106        self
107    }
108
109    /// Controls whether overlay scrollbars fade out when inactive (default: true).
110    pub fn auto_hide(mut self, auto_hide: bool) -> Self {
111        self.auto_hide = auto_hide;
112        self
113    }
114
115    /// Sets maximum height constraint in points.
116    pub fn max_height(mut self, max_h: f32) -> Self {
117        self.max_height = Some(max_h);
118        self
119    }
120
121    /// Sets maximum width constraint in points.
122    pub fn max_width(mut self, max_w: f32) -> Self {
123        self.max_width = Some(max_w);
124        self
125    }
126
127    /// Sets minimum scrolled height in points.
128    pub fn min_scrolled_height(mut self, min_h: f32) -> Self {
129        self.min_scrolled_height = Some(min_h);
130        self
131    }
132
133    /// Sets minimum scrolled width in points.
134    pub fn min_scrolled_width(mut self, min_w: f32) -> Self {
135        self.min_scrolled_width = Some(min_w);
136        self
137    }
138
139    /// Binds directly to an external `&mut Vec2` offset variable for programmatic control.
140    pub fn with_offset(mut self, offset: &'a mut Vec2) -> Self {
141        self.offset_override = Some(offset);
142        self
143    }
144
145    /// Renders the scrollable container and child content, overlaying authentic floating scrollbars.
146    pub fn show<R>(
147        mut self,
148        ui: &mut Ui,
149        content: impl FnOnce(&mut Ui) -> R,
150    ) -> MacScrollViewOutput<R> {
151        let fallback_theme;
152        let theme = match self.theme {
153            Some(t) => t,
154            None => {
155                fallback_theme = MacTheme::default();
156                &fallback_theme
157            }
158        };
159
160        let metrics = theme.scroll_view();
161        let scrollbar_thickness = metrics.scrollbar_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 MacAppearance
275        let get_thumb_color = |hovered: bool, dragged: bool| -> Color32 {
276            let base_alpha: u8 = if dragged {
277                150
278            } else if hovered {
279                120
280            } else {
281                80
282            };
283            let effective_alpha = ((base_alpha as f32) * alpha) as u8;
284            match theme.appearance {
285                MacAppearance::Aqua => Color32::from_black_alpha(effective_alpha),
286                MacAppearance::DarkAqua => Color32::from_white_alpha(effective_alpha),
287            }
288        };
289
290        let has_v = self.vertical && max_scroll_y > 0.0;
291        let has_h = self.horizontal && max_scroll_x > 0.0;
292
293        // 4. Vertical Floating Overlay Scrollbar
294        if has_v {
295            let trail = if has_h {
296                edge_inset * 2.0 + scrollbar_thickness
297            } else {
298                edge_inset
299            };
300            if render_overlay_thumb(
301                ui,
302                &painter,
303                self.id.with("v_thumb"),
304                viewport_rect,
305                viewport_rect.height(),
306                content_size.y,
307                edge_inset,
308                trail,
309                edge_inset,
310                scrollbar_thickness,
311                corner_radius,
312                thumb_min_length,
313                min_visible_thumb_len,
314                &mut current_offset.y,
315                max_scroll_y,
316                true,
317                alpha,
318                &get_thumb_color,
319                last_active_id,
320                now,
321            ) && self.offset_override.is_none()
322            {
323                ui.data_mut(|d| d.insert_temp(self.id.with("drag_offset"), Some(current_offset)));
324            }
325        }
326
327        // 5. Horizontal Floating Overlay Scrollbar
328        if has_h {
329            let trail = if has_v {
330                edge_inset * 2.0 + scrollbar_thickness
331            } else {
332                edge_inset
333            };
334            if render_overlay_thumb(
335                ui,
336                &painter,
337                self.id.with("h_thumb"),
338                viewport_rect,
339                viewport_rect.width(),
340                content_size.x,
341                edge_inset,
342                trail,
343                edge_inset,
344                scrollbar_thickness,
345                corner_radius,
346                thumb_min_length,
347                min_visible_thumb_len,
348                &mut current_offset.x,
349                max_scroll_x,
350                false,
351                alpha,
352                &get_thumb_color,
353                last_active_id,
354                now,
355            ) && self.offset_override.is_none()
356            {
357                ui.data_mut(|d| d.insert_temp(self.id.with("drag_offset"), Some(current_offset)));
358            }
359        }
360
361        // 6. Persist updated offset
362        if let Some(ref mut o) = self.offset_override {
363            **o = current_offset;
364        }
365
366        MacScrollViewOutput {
367            inner: output.inner,
368            inner_rect: viewport_rect,
369            content_size,
370            offset: current_offset,
371        }
372    }
373}