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//! and $1.0\,\text{pt}$ hairline separator primitive.
6
7use crate::controls::MacInsets;
8use crate::layout::subpixel_snap_rect;
9use crate::theme::MacTheme;
10use egui::{
11    CornerRadius, 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}
39
40impl<'a> MacBox<'a> {
41    /// Creates a new grouping box with an optional title.
42    pub fn new(title: impl Into<WidgetText>) -> Self {
43        Self {
44            title: Some(title.into()),
45            theme: None,
46        }
47    }
48
49    /// Creates an untitled primary grouping box.
50    pub fn untitled() -> Self {
51        Self {
52            title: None,
53            theme: None,
54        }
55    }
56
57    /// Injects dynamic theme.
58    pub fn theme(mut self, theme: &'a MacTheme) -> Self {
59        self.theme = Some(theme);
60        self
61    }
62
63    /// Content padding insets from outer box border.
64    pub fn content_insets(&self) -> MacInsets {
65        let theme_cow = MacTheme::resolve_or_default(self.theme);
66        let m = theme_cow.box_view();
67        MacInsets {
68            top: m.inset_top.points(),
69            bottom: m.inset_bottom.points(),
70            left: m.inset_left.points(),
71            right: m.inset_right.points(),
72        }
73    }
74
75    /// Grouping box corner radius in points ($5.0\,\text{pt}$).
76    pub fn corner_radius(&self) -> f32 {
77        let theme_cow = MacTheme::resolve_or_default(self.theme);
78        theme_cow.box_view().corner_radius.points()
79    }
80
81    /// Grouping box outer border stroke width ($1.0\,\text{pt}$).
82    pub fn border_width(&self) -> f32 {
83        let theme_cow = MacTheme::resolve_or_default(self.theme);
84        theme_cow.box_view().border_width.points()
85    }
86
87    /// Shows the grouping box container enclosing inner content.
88    pub fn show<R>(
89        self,
90        ui: &mut Ui,
91        add_contents: impl FnOnce(&mut Ui) -> R,
92    ) -> InnerResponse<R> {
93        let theme_cow = MacTheme::resolve(self.theme, ui.ctx());
94        let theme: &MacTheme = &theme_cow;
95        let insets = self.content_insets();
96        let corner_r = self.corner_radius();
97        let border_w = self.border_width();
98
99        let available_w = ui.available_width();
100        let start_pos = ui.cursor().min;
101
102        let content_rect = Rect::from_min_max(
103            Pos2::new(start_pos.x + insets.left, start_pos.y + insets.top),
104            Pos2::new(start_pos.x + available_w - insets.right, f32::INFINITY),
105        );
106
107        // Pre-allocate background shape on painter so it renders BEHIND child widgets (Z-order invariant)
108        let bg_shape_idx = ui.painter().add(egui::Shape::Noop);
109        let box_scope_id = ui.next_auto_id().with("mac_box_content");
110
111        let mut child_ui = ui.new_child(
112            UiBuilder::new()
113                .id_salt(box_scope_id)
114                .max_rect(content_rect),
115        );
116        let inner = add_contents(&mut child_ui);
117        let content_min_rect = child_ui.min_rect();
118
119        let box_h = insets.top + content_min_rect.height() + insets.bottom;
120        let box_rect = Rect::from_min_size(start_pos, Vec2::new(available_w, box_h));
121        let box_rect = subpixel_snap_rect(box_rect, ui.ctx().pixels_per_point());
122
123        let (rect, response) = ui.allocate_exact_size(box_rect.size(), Sense::hover());
124
125        // Update pre-allocated shape with outer bezel frame behind content
126        ui.painter().set(
127            bg_shape_idx,
128            egui::epaint::RectShape::new(
129                rect,
130                CornerRadius::same(corner_r as u8),
131                theme.control_background(),
132                Stroke::new(border_w, theme.separator()),
133                StrokeKind::Inside,
134            ),
135        );
136
137        // Draw title label in top-left inset if provided
138        if let Some(title) = self.title {
139            let galley = title.into_galley(
140                ui,
141                Some(egui::TextWrapMode::Extend),
142                available_w,
143                egui::TextStyle::Body,
144            );
145            let title_pos = Pos2::new(rect.min.x + insets.left, rect.min.y + insets.top * 0.5 - galley.size().y * 0.5);
146            ui.painter().galley(title_pos, galley, theme.label());
147        }
148
149        InnerResponse::new(inner, response)
150    }
151
152    /// Draws an authentic macOS horizontal hairline separator primitive inside a $5.0\,\text{pt}$ frame.
153    pub fn separator(ui: &mut Ui) -> Response {
154        let theme = MacTheme::resolve(None, ui.ctx());
155        let frame_h = theme.box_view().separator_frame_height.points();
156        let border_w = theme.box_view().border_width.points();
157
158        let (rect, response) = ui.allocate_exact_size(Vec2::new(ui.available_width(), frame_h), Sense::hover());
159        let painter = ui.painter_at(rect);
160        let cy = rect.center().y;
161
162        painter.line_segment(
163            [Pos2::new(rect.min.x, cy), Pos2::new(rect.max.x, cy)],
164            Stroke::new(border_w, theme.separator()),
165        );
166
167        response
168    }
169}