Skip to main content

egui_macview/surfaces/
mod.rs

1//! macOS AppKit Panel, Popover & Card Surfaces.
2//!
3//! Grounded in macOS HIG and W3C DTCG tokens via [`MacTheme`].
4//! Fully data-driven: zero hardcoded colors or dimensions.
5
6pub mod card;
7pub mod hud;
8pub use card::MacCard;
9pub use hud::MacFloatingInfoBox;
10
11use crate::theme::MacTheme;
12use egui::{CornerRadius, Id, Pos2, Rect, Response, Stroke, StrokeKind, Ui, Vec2};
13use std::hash::Hash;
14
15/// Authentic macOS NSPanel floating surface container.
16///
17/// Features authentic window background fill, border hairline strokes, optional
18/// title bar with centered title text, and customizable corner rounding.
19///
20/// # Example
21///
22/// ```rust
23/// use egui_macview::{MacPanel, MacTheme};
24///
25/// # let ctx = egui::Context::default();
26/// # let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
27/// let theme = MacTheme::default();
28/// let panel_rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(200.0, 150.0));
29///
30/// MacPanel::new()
31///     .theme(&theme)
32///     .title("Inspector")
33///     .show(ui, panel_rect, |panel_ui| {
34///         panel_ui.label("Panel Content");
35///     });
36/// # });
37/// # output.textures_delta.clear();
38/// ```
39#[derive(Default)]
40pub struct MacPanel<'a> {
41    id: Option<Id>,
42    title: Option<String>,
43    theme: Option<&'a MacTheme>,
44    rounding: Option<u8>,
45}
46
47impl<'a> MacPanel<'a> {
48    /// Creates a new floating panel surface.
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    /// Sets an explicit widget `Id` for this panel.
54    pub fn id(mut self, id_source: impl Hash + std::fmt::Debug) -> Self {
55        self.id = Some(Id::NULL.with(id_source));
56        self
57    }
58
59    /// Injects dynamic theme to eliminate hardcoded metrics and colors.
60    pub fn theme(mut self, theme: &'a MacTheme) -> Self {
61        self.theme = Some(theme);
62        self
63    }
64
65    /// Sets optional panel title displayed in the header bar.
66    pub fn title(mut self, title: impl Into<String>) -> Self {
67        self.title = Some(title.into());
68        self
69    }
70
71    /// Overrides panel corner rounding in points.
72    pub fn rounding(mut self, r: u8) -> Self {
73        self.rounding = Some(r);
74        self
75    }
76
77    /// Renders the panel within `rect` and invokes `add_contents` for child widgets.
78    pub fn show<R>(
79        self,
80        ui: &mut Ui,
81        rect: Rect,
82        add_contents: impl FnOnce(&mut Ui) -> R,
83    ) -> (Response, R) {
84        let fallback_theme;
85        let theme = match self.theme {
86            Some(t) => t,
87            None => {
88                fallback_theme = MacTheme::from_ctx(ui.ctx());
89                &fallback_theme
90            }
91        };
92
93        let panel_id = self.id.unwrap_or_else(|| {
94            ui.scope_id().with((
95                "mac_panel",
96                self.title.as_deref().unwrap_or(""),
97                rect.min.x.to_bits(),
98                rect.min.y.to_bits(),
99            ))
100        });
101        let resp = ui.interact(rect, panel_id, egui::Sense::click_and_drag());
102        if ui.rect_contains_pointer(rect) {
103            ui.input_mut(|i| {
104                i.smooth_scroll_delta = Vec2::ZERO;
105            });
106        }
107
108        let popover_metrics = theme.popover_metrics();
109        let rounding = self
110            .rounding
111            .unwrap_or_else(|| popover_metrics.corner_radius.points() as u8);
112
113        let bg_color = theme.window_background();
114        let border_stroke = Stroke::new(popover_metrics.border_width.points(), theme.separator());
115
116        let painter = ui.painter_at(rect);
117        painter.rect(
118            rect,
119            CornerRadius::same(rounding),
120            bg_color,
121            border_stroke,
122            StrokeKind::Inside,
123        );
124
125        let header_height = popover_metrics.header_height.points();
126
127        let content_rect = if let Some(title) = self.title {
128            let title_rect = Rect::from_min_size(rect.min, Vec2::new(rect.width(), header_height));
129            painter.line_segment(
130                [
131                    Pos2::new(rect.min.x, rect.min.y + header_height),
132                    Pos2::new(rect.max.x, rect.min.y + header_height),
133                ],
134                border_stroke,
135            );
136            painter.text(
137                title_rect.center(),
138                egui::Align2::CENTER_CENTER,
139                title,
140                egui::FontId::proportional(popover_metrics.header_font_size.points()),
141                theme.label(),
142            );
143            Rect::from_min_max(Pos2::new(rect.min.x, rect.min.y + header_height), rect.max)
144        } else {
145            rect
146        };
147
148        let mut child_ui = ui.new_child(
149            egui::UiBuilder::new()
150                .max_rect(content_rect)
151                .layout(egui::Layout::top_down(egui::Align::Min)),
152        );
153        let ret = add_contents(&mut child_ui);
154        (resp, ret)
155    }
156}
157
158/// Direction that the popover arrow points towards the anchor element.
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
160pub enum MacPopoverArrowEdge {
161    /// Arrow emerges from the top edge pointing upwards.
162    #[default]
163    Top,
164    /// Arrow emerges from the bottom edge pointing downwards.
165    Bottom,
166    /// Arrow emerges from the left edge pointing leftwards.
167    Left,
168    /// Arrow emerges from the right edge pointing rightwards.
169    Right,
170    /// No arrow indicator (bubble only).
171    None,
172}
173
174/// Authentic macOS AppKit NSPopover floating surface.
175///
176/// Features authentic balloon bubble geometry, arrow pointer directed at anchor points,
177/// dynamic scaling and opacity transitions, and theme-driven background/border colors.
178///
179/// # Example
180///
181/// ```rust
182/// use egui_macview::{MacPopover, MacPopoverArrowEdge, MacTheme};
183///
184/// # let ctx = egui::Context::default();
185/// # let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
186/// let theme = MacTheme::default();
187/// let popover_rect = egui::Rect::from_min_size(egui::Pos2::new(50.0, 50.0), egui::vec2(160.0, 100.0));
188///
189/// MacPopover::new()
190///     .theme(&theme)
191///     .arrow_edge(MacPopoverArrowEdge::Top)
192///     .show(ui, popover_rect, |popover_ui| {
193///         popover_ui.label("Popover Content");
194///     });
195/// # });
196/// # output.textures_delta.clear();
197/// ```
198pub struct MacPopover<'a> {
199    id: Option<Id>,
200    theme: Option<&'a MacTheme>,
201    arrow_edge: MacPopoverArrowEdge,
202    arrow_offset: Option<f32>,
203    scale: f32,
204    alpha: f32,
205}
206
207impl<'a> Default for MacPopover<'a> {
208    fn default() -> Self {
209        Self {
210            id: None,
211            theme: None,
212            arrow_edge: MacPopoverArrowEdge::Top,
213            arrow_offset: None,
214            scale: 1.0,
215            alpha: 1.0,
216        }
217    }
218}
219
220impl<'a> MacPopover<'a> {
221    /// Creates a new popover surface.
222    pub fn new() -> Self {
223        Self::default()
224    }
225
226    /// Sets an explicit widget `Id` for this popover.
227    pub fn id(mut self, id_source: impl Hash + std::fmt::Debug) -> Self {
228        self.id = Some(Id::NULL.with(id_source));
229        self
230    }
231
232    /// Injects dynamic theme to eliminate hardcoded metrics and colors.
233    pub fn theme(mut self, theme: &'a MacTheme) -> Self {
234        self.theme = Some(theme);
235        self
236    }
237
238    /// Specifies the edge where the directional arrow emerges.
239    pub fn arrow_edge(mut self, edge: MacPopoverArrowEdge) -> Self {
240        self.arrow_edge = edge;
241        self
242    }
243
244    /// Sets custom offset along the arrow edge in points (defaults to center).
245    pub fn arrow_offset(mut self, offset: f32) -> Self {
246        self.arrow_offset = Some(offset);
247        self
248    }
249
250    /// Scales the popover geometry around its center (used during zoom transitions).
251    pub fn scale(mut self, scale: f32) -> Self {
252        self.scale = scale;
253        self
254    }
255
256    /// Sets opacity alpha multiplier in `[0.0, 1.0]` for fade transitions.
257    pub fn alpha(mut self, alpha: f32) -> Self {
258        self.alpha = alpha;
259        self
260    }
261
262    /// Renders the popover balloon and invokes `add_contents` for inner child widgets.
263    pub fn show<R>(
264        self,
265        ui: &mut Ui,
266        rect: Rect,
267        add_contents: impl FnOnce(&mut Ui) -> R,
268    ) -> (Response, R) {
269        let fallback_theme;
270        let theme = match self.theme {
271            Some(t) => t,
272            None => {
273                fallback_theme = MacTheme::from_ctx(ui.ctx());
274                &fallback_theme
275            }
276        };
277
278        let metrics = theme.popover_metrics();
279        let corner_radius = metrics.corner_radius.points();
280        let border_width = metrics.border_width.points();
281        let arrow_w = metrics.arrow_base_width.points();
282        let arrow_h = metrics.arrow_height.points();
283
284        // Apply scale transformation around rect center
285        let center = rect.center();
286        let scaled_size = rect.size() * self.scale.max(f32::EPSILON);
287        let active_rect = Rect::from_center_size(center, scaled_size);
288
289        let popover_id = self.id.unwrap_or_else(|| {
290            ui.scope_id().with((
291                "mac_popover",
292                rect.min.x.to_bits(),
293                rect.min.y.to_bits(),
294                self.arrow_edge as u8,
295            ))
296        });
297        let resp = ui.interact(active_rect, popover_id, egui::Sense::click_and_drag());
298        if ui.rect_contains_pointer(active_rect) {
299            ui.input_mut(|i| {
300                i.smooth_scroll_delta = Vec2::ZERO;
301            });
302        }
303
304        let bg_color = theme.window_background().gamma_multiply(self.alpha);
305        let border_color = theme.separator().gamma_multiply(self.alpha);
306        let stroke = Stroke::new(border_width, border_color);
307
308        let painter = ui.painter_at(active_rect);
309
310        // Body bubble
311        painter.rect(
312            active_rect,
313            CornerRadius::same(corner_radius as u8),
314            bg_color,
315            stroke,
316            StrokeKind::Inside,
317        );
318
319        // Draw arrow if applicable
320        if self.arrow_edge != MacPopoverArrowEdge::None {
321            let mut arrow_points = [Pos2::ZERO; 3];
322            match self.arrow_edge {
323                MacPopoverArrowEdge::Top => {
324                    let ax = self.arrow_offset.unwrap_or(active_rect.center().x);
325                    arrow_points = [
326                        Pos2::new(ax - arrow_w * 0.5, active_rect.min.y),
327                        Pos2::new(ax, active_rect.min.y - arrow_h),
328                        Pos2::new(ax + arrow_w * 0.5, active_rect.min.y),
329                    ];
330                }
331                MacPopoverArrowEdge::Bottom => {
332                    let ax = self.arrow_offset.unwrap_or(active_rect.center().x);
333                    arrow_points = [
334                        Pos2::new(ax - arrow_w * 0.5, active_rect.max.y),
335                        Pos2::new(ax, active_rect.max.y + arrow_h),
336                        Pos2::new(ax + arrow_w * 0.5, active_rect.max.y),
337                    ];
338                }
339                MacPopoverArrowEdge::Left => {
340                    let ay = self.arrow_offset.unwrap_or(active_rect.center().y);
341                    arrow_points = [
342                        Pos2::new(active_rect.min.x, ay - arrow_w * 0.5),
343                        Pos2::new(active_rect.min.x - arrow_h, ay),
344                        Pos2::new(active_rect.min.x, ay + arrow_w * 0.5),
345                    ];
346                }
347                MacPopoverArrowEdge::Right => {
348                    let ay = self.arrow_offset.unwrap_or(active_rect.center().y);
349                    arrow_points = [
350                        Pos2::new(active_rect.max.x, ay - arrow_w * 0.5),
351                        Pos2::new(active_rect.max.x + arrow_h, ay),
352                        Pos2::new(active_rect.max.x, ay + arrow_w * 0.5),
353                    ];
354                }
355                MacPopoverArrowEdge::None => {}
356            }
357
358            if self.arrow_edge != MacPopoverArrowEdge::None {
359                painter.add(egui::Shape::convex_polygon(
360                    arrow_points.to_vec(),
361                    bg_color,
362                    Stroke::NONE,
363                ));
364                // Draw the two exposed outer legs of the arrow
365                painter.line_segment([arrow_points[0], arrow_points[1]], stroke);
366                painter.line_segment([arrow_points[1], arrow_points[2]], stroke);
367                // Clear the body rectangle border along the arrow base for seamless interior continuity
368                painter.line_segment(
369                    [arrow_points[0], arrow_points[2]],
370                    Stroke::new(border_width * 2.0, bg_color),
371                );
372            }
373        }
374
375        let mut child_ui = ui.new_child(
376            egui::UiBuilder::new()
377                .max_rect(active_rect.shrink(corner_radius.min(metrics.inner_padding.points())))
378                .layout(egui::Layout::top_down(egui::Align::Min)),
379        );
380        let ret = add_contents(&mut child_ui);
381        (resp, ret)
382    }
383}