Skip to main content

egui_macview/controls/
box_view.rs

1//! Authentic macOS AppKit Grouping Box and Separator (`NSBox` equivalent, REQ-012-07).
2//!
3//! Grounded in Apple AppKit `NSBox` with primary grouped border insets
4//! (`{top: 20.0, bottom: 10.0, left: 9.0, right: 9.0} pt`), $5.0\,\text{pt}$ corner radius,
5//! subtle grouped box background (`theme.box_background()`), and $1.0\,\text{pt}$ hairline separator.
6
7use crate::controls::MacInsets;
8use crate::layout::subpixel_snap_rect;
9use crate::theme::MacTheme;
10use egui::{
11    CornerRadius, FontId, InnerResponse, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui,
12    UiBuilder, Vec2, WidgetText,
13};
14
15/// Authentic macOS AppKit Grouping Box container (`NSBox` equivalent).
16///
17/// # Example
18/// ```rust
19/// use egui_macview::{MacAppearance, MacBox, MacTheme};
20///
21/// # let ctx = egui::Context::default();
22/// # let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
23/// let theme = MacTheme::new(MacAppearance::Aqua);
24///
25/// MacBox::new("Audio Output")
26///     .theme(&theme)
27///     .show(ui, |ui| {
28///         ui.label("Sample Rate: 48.0 kHz");
29///         MacBox::separator(ui);
30///         ui.label("Bit Depth: 24-bit");
31///     });
32/// # });
33/// # out.textures_delta.clear();
34/// ```
35pub struct MacBox<'a> {
36    title: Option<WidgetText>,
37    theme: Option<&'a MacTheme>,
38    fixed_rect: Option<Rect>,
39}
40
41impl<'a> MacBox<'a> {
42    /// Creates a new grouping box with an optional title.
43    pub fn new(title: impl Into<WidgetText>) -> Self {
44        Self {
45            title: Some(title.into()),
46            theme: None,
47            fixed_rect: None,
48        }
49    }
50
51    /// Creates an untitled primary grouping box.
52    pub fn untitled() -> Self {
53        Self {
54            title: None,
55            theme: None,
56            fixed_rect: None,
57        }
58    }
59
60    /// Injects dynamic theme.
61    pub fn theme(mut self, theme: &'a MacTheme) -> Self {
62        self.theme = Some(theme);
63        self
64    }
65
66    /// Pins the grouping box to an exact NIB coordinate rectangle.
67    pub fn fixed_rect(mut self, rect: Rect) -> Self {
68        self.fixed_rect = Some(rect);
69        self
70    }
71
72    /// Content padding insets from outer box border.
73    pub fn content_insets(&self) -> MacInsets {
74        let theme_cow = MacTheme::resolve_or_default(self.theme);
75        let m = theme_cow.box_view();
76        MacInsets {
77            top: m.inset_top.points(),
78            bottom: m.inset_bottom.points(),
79            left: m.inset_left.points(),
80            right: m.inset_right.points(),
81        }
82    }
83
84    /// Grouping box corner radius in points ($5.0\,\text{pt}$).
85    pub fn corner_radius(&self) -> f32 {
86        let theme_cow = MacTheme::resolve_or_default(self.theme);
87        theme_cow.box_view().corner_radius.points()
88    }
89
90    /// Grouping box outer border stroke width ($1.0\,\text{pt}$).
91    pub fn border_width(&self) -> f32 {
92        let theme_cow = MacTheme::resolve_or_default(self.theme);
93        theme_cow.box_view().border_width.points()
94    }
95
96    /// Paints the grouping box background, border, and optional title at `rect` without allocating flow space.
97    pub fn paint_at(&self, ui: &mut Ui, rect: Rect) {
98        let theme_cow = MacTheme::resolve(self.theme, ui.ctx());
99        let theme: &MacTheme = &theme_cow;
100        let insets = self.content_insets();
101        let corner_r = self.corner_radius();
102        let border_w = self.border_width();
103        let m = theme.box_view();
104        let snapped = subpixel_snap_rect(rect, ui.ctx().pixels_per_point());
105        let box_bg = theme.box_background();
106        theme.register_surface_rect(ui.ctx(), rect.union(snapped), box_bg);
107
108        ui.painter().add(egui::epaint::RectShape::new(
109            snapped,
110            CornerRadius::same(corner_r as u8),
111            box_bg,
112            Stroke::new(border_w, theme.box_border()),
113            StrokeKind::Inside,
114        ));
115
116        if let Some(ref title) = self.title {
117            let font_id = FontId::proportional(m.title_font_size.points());
118            let title_fg = theme.label_on(theme.box_background());
119            let galley = theme.layout_tracked_galley(
120                ui,
121                title.text(),
122                font_id,
123                title_fg,
124                snapped.width(),
125            );
126            let title_pos = Pos2::new(
127                snapped.min.x + insets.left,
128                snapped.min.y + insets.top * 0.5 - galley.size().y * 0.5,
129            );
130            ui.painter().galley(title_pos, galley, title_fg);
131        }
132    }
133
134    /// Shows the grouping box container enclosing inner content.
135    pub fn show<R>(
136        self,
137        ui: &mut Ui,
138        add_contents: impl FnOnce(&mut Ui) -> R,
139    ) -> InnerResponse<R> {
140        let theme_cow = MacTheme::resolve(self.theme, ui.ctx());
141        let theme: &MacTheme = &theme_cow;
142        let insets = self.content_insets();
143        let corner_r = self.corner_radius();
144        let border_w = self.border_width();
145        let title_font_sz = theme.box_view().title_font_size.points();
146
147        let (start_pos, available_w, fixed_box_rect) = if let Some(fixed) = self.fixed_rect {
148            (fixed.min, fixed.width(), Some(fixed))
149        } else {
150            (ui.cursor().min, ui.available_width(), None)
151        };
152
153        let content_max_y = fixed_box_rect.map_or(f32::INFINITY, |r| r.max.y - insets.bottom);
154        let content_rect = Rect::from_min_max(
155            Pos2::new(start_pos.x + insets.left, start_pos.y + insets.top),
156            Pos2::new(start_pos.x + available_w - insets.right, content_max_y),
157        );
158
159        let bg_shape_idx = ui.painter().add(egui::Shape::Noop);
160        let box_scope_id = ui.next_auto_id().with("mac_box_content");
161
162        let mut child_ui = ui.new_child(
163            UiBuilder::new()
164                .id_salt(box_scope_id)
165                .max_rect(content_rect),
166        );
167        let box_bg = theme.box_background();
168        child_ui.visuals_mut().panel_fill = box_bg;
169        let surface_slot = theme.register_surface_rect_slot(ui.ctx(), Rect::NOTHING, box_bg);
170        let inner = add_contents(&mut child_ui);
171        let content_min_rect = child_ui.min_rect();
172
173        let (rect, response) = if let Some(fixed) = fixed_box_rect {
174            let r = subpixel_snap_rect(fixed, ui.ctx().pixels_per_point());
175            let resp = ui.interact(r, box_scope_id.with("fixed_box"), Sense::hover());
176            (r, resp)
177        } else {
178            let box_h = insets.top + content_min_rect.height() + insets.bottom;
179            let box_rect = Rect::from_min_size(start_pos, Vec2::new(available_w, box_h));
180            let box_rect = subpixel_snap_rect(box_rect, ui.ctx().pixels_per_point());
181            ui.allocate_exact_size(box_rect.size(), Sense::hover())
182        };
183        theme.update_surface_rect_slot(
184            ui.ctx(),
185            surface_slot,
186            fixed_box_rect.map_or(rect, |f| f.union(rect)),
187        );
188
189        ui.painter().set(
190            bg_shape_idx,
191            egui::epaint::RectShape::new(
192                rect,
193                CornerRadius::same(corner_r as u8),
194                box_bg,
195                Stroke::new(border_w, theme.box_border()),
196                StrokeKind::Inside,
197            ),
198        );
199
200        if let Some(title) = self.title {
201            let title_fg = theme.label_on(box_bg);
202            let galley = theme.layout_tracked_galley(
203                ui,
204                title.text(),
205                FontId::proportional(title_font_sz),
206                title_fg,
207                available_w,
208            );
209            let title_pos = Pos2::new(
210                rect.min.x + insets.left,
211                rect.min.y + insets.top * 0.5 - galley.size().y * 0.5,
212            );
213            ui.painter().galley(title_pos, galley, title_fg);
214        }
215
216        InnerResponse::new(inner, response)
217    }
218
219    /// Draws an authentic macOS horizontal hairline separator primitive inside a $5.0\,\text{pt}$ frame.
220    pub fn separator(ui: &mut Ui) -> Response {
221        let theme = MacTheme::resolve(None, ui.ctx());
222        let frame_h = theme.box_view().separator_frame_height.points();
223        let border_w = theme.box_view().border_width.points();
224
225        let (rect, response) =
226            ui.allocate_exact_size(Vec2::new(ui.available_width(), frame_h), Sense::hover());
227        let painter = ui.painter_at(rect);
228        let cy = rect.center().y;
229
230        painter.line_segment(
231            [Pos2::new(rect.min.x, cy), Pos2::new(rect.max.x, cy)],
232            Stroke::new(border_w, theme.separator()),
233        );
234
235        response
236    }
237
238    /// Paints a horizontal or vertical hairline separator at an exact NIB `rect`.
239    pub fn separator_at(ui: &mut Ui, rect: Rect, theme: &MacTheme) {
240        let border_w = theme.box_view().border_width.points();
241        let stroke = Stroke::new(border_w, theme.separator());
242        let painter = ui.painter();
243        if rect.width() >= rect.height() {
244            let cy = rect.center().y;
245            painter.line_segment([Pos2::new(rect.min.x, cy), Pos2::new(rect.max.x, cy)], stroke);
246        } else {
247            let cx = rect.center().x;
248            painter.line_segment([Pos2::new(cx, rect.min.y), Pos2::new(cx, rect.max.y)], stroke);
249        }
250    }
251}