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 theme_cow = MacTheme::resolve(self.theme, ui.ctx());
85        let theme: &MacTheme = &theme_cow;
86
87        let panel_id = self.id.unwrap_or_else(|| {
88            ui.scope_id().with((
89                "mac_panel",
90                self.title.as_deref().unwrap_or(""),
91                rect.min.x.to_bits(),
92                rect.min.y.to_bits(),
93            ))
94        });
95        let resp = ui.interact(rect, panel_id, egui::Sense::click_and_drag());
96        if ui.rect_contains_pointer(rect) {
97            ui.input_mut(|i| {
98                i.smooth_scroll_delta = Vec2::ZERO;
99            });
100        }
101
102        let popover_metrics = theme.popover_metrics();
103        let rounding = self
104            .rounding
105            .unwrap_or_else(|| popover_metrics.corner_radius.points() as u8);
106
107        let bg_color = theme.window_background();
108        let border_stroke = Stroke::new(popover_metrics.border_width.points(), theme.separator());
109
110        let painter = ui.painter_at(rect);
111        painter.rect(
112            rect,
113            CornerRadius::same(rounding),
114            bg_color,
115            border_stroke,
116            StrokeKind::Inside,
117        );
118
119        let header_height = popover_metrics.header_height.points();
120
121        let content_rect = if let Some(title) = self.title {
122            let title_rect = Rect::from_min_size(rect.min, Vec2::new(rect.width(), header_height));
123            painter.line_segment(
124                [
125                    Pos2::new(rect.min.x, rect.min.y + header_height),
126                    Pos2::new(rect.max.x, rect.min.y + header_height),
127                ],
128                border_stroke,
129            );
130            painter.text(
131                title_rect.center(),
132                egui::Align2::CENTER_CENTER,
133                title,
134                egui::FontId::proportional(popover_metrics.header_font_size.points()),
135                theme.label(),
136            );
137            Rect::from_min_max(Pos2::new(rect.min.x, rect.min.y + header_height), rect.max)
138        } else {
139            rect
140        };
141
142        let mut child_ui = ui.new_child(
143            egui::UiBuilder::new()
144                .id_salt((panel_id, "mac_panel_content"))
145                .max_rect(content_rect)
146                .layout(egui::Layout::top_down(egui::Align::Min)),
147        );
148        let ret = add_contents(&mut child_ui);
149        (resp, ret)
150    }
151}
152
153/// Direction that the popover arrow points towards the anchor element.
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
155pub enum MacPopoverArrowEdge {
156    /// Arrow emerges from the top edge pointing upwards.
157    #[default]
158    Top,
159    /// Arrow emerges from the bottom edge pointing downwards.
160    Bottom,
161    /// Arrow emerges from the left edge pointing leftwards.
162    Left,
163    /// Arrow emerges from the right edge pointing rightwards.
164    Right,
165    /// No arrow indicator (bubble only).
166    None,
167}
168
169/// Authentic macOS AppKit NSPopover floating surface.
170///
171/// Features authentic balloon bubble geometry, arrow pointer directed at anchor points,
172/// dynamic scaling and opacity transitions, and theme-driven background/border colors.
173///
174/// # Example
175///
176/// ```rust
177/// use egui_macview::{MacPopover, MacPopoverArrowEdge, MacTheme};
178///
179/// # let ctx = egui::Context::default();
180/// # let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
181/// let theme = MacTheme::default();
182/// let popover_rect = egui::Rect::from_min_size(egui::Pos2::new(50.0, 50.0), egui::vec2(160.0, 100.0));
183///
184/// MacPopover::new()
185///     .theme(&theme)
186///     .arrow_edge(MacPopoverArrowEdge::Top)
187///     .show(ui, popover_rect, |popover_ui| {
188///         popover_ui.label("Popover Content");
189///     });
190/// # });
191/// # output.textures_delta.clear();
192/// ```
193pub struct MacPopover<'a> {
194    id: Option<Id>,
195    theme: Option<&'a MacTheme>,
196    arrow_edge: MacPopoverArrowEdge,
197    arrow_offset: Option<f32>,
198    scale: f32,
199    alpha: f32,
200}
201
202impl<'a> Default for MacPopover<'a> {
203    fn default() -> Self {
204        Self {
205            id: None,
206            theme: None,
207            arrow_edge: MacPopoverArrowEdge::Top,
208            arrow_offset: None,
209            scale: 1.0,
210            alpha: 1.0,
211        }
212    }
213}
214
215impl<'a> MacPopover<'a> {
216    /// Creates a new popover surface.
217    pub fn new() -> Self {
218        Self::default()
219    }
220
221    /// Sets an explicit widget `Id` for this popover.
222    pub fn id(mut self, id_source: impl Hash + std::fmt::Debug) -> Self {
223        self.id = Some(Id::NULL.with(id_source));
224        self
225    }
226
227    /// Injects dynamic theme to eliminate hardcoded metrics and colors.
228    pub fn theme(mut self, theme: &'a MacTheme) -> Self {
229        self.theme = Some(theme);
230        self
231    }
232
233    /// Specifies the edge where the directional arrow emerges.
234    pub fn arrow_edge(mut self, edge: MacPopoverArrowEdge) -> Self {
235        self.arrow_edge = edge;
236        self
237    }
238
239    /// Sets custom offset along the arrow edge in points (defaults to center).
240    pub fn arrow_offset(mut self, offset: f32) -> Self {
241        self.arrow_offset = Some(offset);
242        self
243    }
244
245    /// Scales the popover geometry around its center (used during zoom transitions).
246    pub fn scale(mut self, scale: f32) -> Self {
247        self.scale = scale;
248        self
249    }
250
251    /// Sets opacity alpha multiplier in `[0.0, 1.0]` for fade transitions.
252    pub fn alpha(mut self, alpha: f32) -> Self {
253        self.alpha = alpha;
254        self
255    }
256
257    /// Renders the popover balloon and invokes `add_contents` for inner child widgets.
258    pub fn show<R>(
259        self,
260        ui: &mut Ui,
261        rect: Rect,
262        add_contents: impl FnOnce(&mut Ui) -> R,
263    ) -> (Response, R) {
264        let theme_cow = MacTheme::resolve(self.theme, ui.ctx());
265        let theme: &MacTheme = &theme_cow;
266
267        let metrics = theme.popover_metrics();
268        let corner_radius = metrics.corner_radius.points();
269        let border_width = metrics.border_width.points();
270        let arrow_w = metrics.arrow_base_width.points();
271        let arrow_h = metrics.arrow_height.points();
272
273        // Apply scale transformation around rect center
274        let center = rect.center();
275        let scaled_size = rect.size() * self.scale.max(f32::EPSILON);
276        let active_rect = Rect::from_center_size(center, scaled_size);
277
278        let popover_id = self.id.unwrap_or_else(|| {
279            ui.scope_id().with((
280                "mac_popover",
281                rect.min.x.to_bits(),
282                rect.min.y.to_bits(),
283                self.arrow_edge as u8,
284            ))
285        });
286        let resp = ui.interact(active_rect, popover_id, egui::Sense::click_and_drag());
287        if ui.rect_contains_pointer(active_rect) {
288            ui.input_mut(|i| {
289                i.smooth_scroll_delta = Vec2::ZERO;
290            });
291        }
292
293        let bg_color = theme.window_background().gamma_multiply(self.alpha);
294        let border_color = theme.separator().gamma_multiply(self.alpha);
295        let stroke = Stroke::new(border_width, border_color);
296
297        let painter = ui.painter_at(active_rect);
298
299        // Body bubble
300        painter.rect(
301            active_rect,
302            CornerRadius::same(corner_radius as u8),
303            bg_color,
304            stroke,
305            StrokeKind::Inside,
306        );
307
308        // Draw arrow if applicable
309        if self.arrow_edge != MacPopoverArrowEdge::None {
310            let mut arrow_points = [Pos2::ZERO; 3];
311            match self.arrow_edge {
312                MacPopoverArrowEdge::Top => {
313                    let ax = self.arrow_offset.unwrap_or(active_rect.center().x);
314                    arrow_points = [
315                        Pos2::new(ax - arrow_w * 0.5, active_rect.min.y),
316                        Pos2::new(ax, active_rect.min.y - arrow_h),
317                        Pos2::new(ax + arrow_w * 0.5, active_rect.min.y),
318                    ];
319                }
320                MacPopoverArrowEdge::Bottom => {
321                    let ax = self.arrow_offset.unwrap_or(active_rect.center().x);
322                    arrow_points = [
323                        Pos2::new(ax - arrow_w * 0.5, active_rect.max.y),
324                        Pos2::new(ax, active_rect.max.y + arrow_h),
325                        Pos2::new(ax + arrow_w * 0.5, active_rect.max.y),
326                    ];
327                }
328                MacPopoverArrowEdge::Left => {
329                    let ay = self.arrow_offset.unwrap_or(active_rect.center().y);
330                    arrow_points = [
331                        Pos2::new(active_rect.min.x, ay - arrow_w * 0.5),
332                        Pos2::new(active_rect.min.x - arrow_h, ay),
333                        Pos2::new(active_rect.min.x, ay + arrow_w * 0.5),
334                    ];
335                }
336                MacPopoverArrowEdge::Right => {
337                    let ay = self.arrow_offset.unwrap_or(active_rect.center().y);
338                    arrow_points = [
339                        Pos2::new(active_rect.max.x, ay - arrow_w * 0.5),
340                        Pos2::new(active_rect.max.x + arrow_h, ay),
341                        Pos2::new(active_rect.max.x, ay + arrow_w * 0.5),
342                    ];
343                }
344                MacPopoverArrowEdge::None => {}
345            }
346
347            if self.arrow_edge != MacPopoverArrowEdge::None {
348                painter.add(egui::Shape::convex_polygon(
349                    arrow_points.to_vec(),
350                    bg_color,
351                    Stroke::NONE,
352                ));
353                // Draw the two exposed outer legs of the arrow
354                painter.line_segment([arrow_points[0], arrow_points[1]], stroke);
355                painter.line_segment([arrow_points[1], arrow_points[2]], stroke);
356                // Clear the body rectangle border along the arrow base for seamless interior continuity
357                painter.line_segment(
358                    [arrow_points[0], arrow_points[2]],
359                    Stroke::new(border_width * 2.0, bg_color),
360                );
361            }
362        }
363
364        let mut child_ui = ui.new_child(
365            egui::UiBuilder::new()
366                .id_salt((popover_id, "mac_popover_content"))
367                .max_rect(active_rect.shrink(corner_radius.min(metrics.inner_padding.points())))
368                .layout(egui::Layout::top_down(egui::Align::Min)),
369        );
370        let ret = add_contents(&mut child_ui);
371        (resp, ret)
372    }
373}