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