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