Skip to main content

egui_macview/layout/
stack_view.rs

1//! Authentic macOS AppKit Stack View (NSStackView equivalent).
2//!
3//! Grounded in macOS HIG and W3C DTCG tokens via `MacTheme`.
4//! Fully data-driven: zero hardcoded dimensions or colors.
5//! Features automatic horizontal/vertical component stacking,
6//! configurable token-driven spacing and padding, section grouping,
7//! and optional hairline divider lines.
8
9use crate::layout::subpixel_snap;
10use crate::theme::MacTheme;
11use egui::{FontId, Pos2, Rect, Stroke, Ui, UiBuilder, Vec2};
12
13/// Orientation of a [`MacStackView`].
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum MacStackOrientation {
16    /// Vertical auto-stacking (column layout, default).
17    #[default]
18    Vertical,
19    /// Horizontal auto-stacking (row layout).
20    Horizontal,
21}
22
23/// Alignment along the cross-axis.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub enum MacStackAlignment {
26    /// Align items to the leading edge (left for vertical, top for horizontal).
27    #[default]
28    Leading,
29    /// Center items along the cross-axis.
30    Center,
31    /// Align items to the trailing edge.
32    Trailing,
33}
34
35/// Authentic macOS AppKit linear stacking container (`NSStackView` equivalent).
36///
37/// Supports automatic horizontal or vertical component stacking,
38/// configurable token-driven spacing and padding, grouped sections with header labels,
39/// and optional hairline divider lines between items.
40///
41/// # Example
42///
43/// ```rust
44/// use egui_macview::{MacStackView, MacTheme};
45///
46/// # let ctx = egui::Context::default();
47/// # let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
48/// let theme = MacTheme::default();
49///
50/// MacStackView::vertical()
51///     .theme(&theme)
52///     .spacing(12.0)
53///     .show_separators(true)
54///     .show(ui, |stack| {
55///         stack.add(|ui| {
56///             ui.label("First item");
57///         });
58///         stack.add_section(Some("Section 2"), |ui| {
59///             ui.label("Section content");
60///         });
61///     });
62/// # });
63/// # output.textures_delta.clear();
64/// ```
65pub struct MacStackView<'a> {
66    orientation: MacStackOrientation,
67    alignment: MacStackAlignment,
68    theme: Option<&'a MacTheme>,
69    spacing: Option<f32>,
70    padding: Option<f32>,
71    show_separators: bool,
72    width: Option<f32>,
73    height: Option<f32>,
74}
75
76/// Interactive stacking context passed to closures in [`MacStackView::show`].
77pub struct MacStackUi<'a> {
78    /// Underlying egui `Ui` instance for embedding widgets inside the stack.
79    pub ui: &'a mut Ui,
80
81    theme: &'a MacTheme,
82    orientation: MacStackOrientation,
83    spacing: f32,
84    separator_thickness: f32,
85    show_separators: bool,
86    container_height: Option<f32>,
87    padding: f32,
88    item_count: usize,
89    section_count: usize,
90}
91
92/// Output returned by [`MacStackView::show`].
93#[derive(Debug, Clone)]
94pub struct MacStackViewOutput<R> {
95    /// Return value of the inner closure.
96    pub inner: R,
97    /// Bounding rectangle enclosing the stacked items and padding.
98    pub rect: Rect,
99    /// Total count of items added to the stack.
100    pub item_count: usize,
101    /// Total count of sections added to the stack.
102    pub section_count: usize,
103}
104
105impl<'a> MacStackUi<'a> {
106    /// Adds an item closure to the stack with automatic spacing and separators.
107    pub fn add<R>(&mut self, item: impl FnOnce(&mut Ui) -> R) -> R {
108        if self.item_count > 0 {
109            if self.show_separators {
110                self.draw_separator();
111            } else {
112                self.advance_spacing();
113            }
114        }
115        self.item_count += 1;
116        item(self.ui)
117    }
118
119    /// Adds a grouped section with an optional section header label.
120    pub fn add_section<R>(
121        &mut self,
122        header: Option<&str>,
123        content: impl FnOnce(&mut Ui) -> R,
124    ) -> R {
125        if self.item_count > 0 || self.section_count > 0 {
126            self.draw_separator();
127        }
128        self.section_count += 1;
129
130        if let Some(title) = header {
131            let label_color = self
132                .theme
133                .secondary_label_on(self.theme.active_surface(self.ui));
134            let font_size = self.theme.stack_view().section_header_font_size.points();
135            let font_id = FontId::proportional(font_size);
136            self.ui.add(
137                egui::Label::new(egui::RichText::new(title).font(font_id).color(label_color))
138                    .wrap_mode(egui::TextWrapMode::Extend),
139            );
140            self.advance_spacing();
141        }
142
143        self.item_count += 1;
144        content(self.ui)
145    }
146
147    /// Explicitly inserts a hairline divider line spanning the cross-axis.
148    pub fn separator(&mut self) {
149        self.draw_separator();
150    }
151
152    /// Explicitly inserts custom spacing in points.
153    pub fn spacing(&mut self, pts: f32) {
154        self.ui.add_space(pts);
155    }
156
157    fn advance_spacing(&mut self) {
158        self.ui.add_space(self.spacing);
159    }
160
161    fn draw_separator(&mut self) {
162        self.ui.add_space(self.spacing * 0.5);
163        let scale = self.ui.pixels_per_point();
164        let th = self.separator_thickness;
165        let color = self.theme.separator();
166
167        match self.orientation {
168            MacStackOrientation::Vertical => {
169                let avail_w = self.ui.available_width();
170                let (rect, _) = self
171                    .ui
172                    .allocate_exact_size(Vec2::new(avail_w, th), egui::Sense::hover());
173                let y = subpixel_snap(rect.center().y, scale);
174                self.ui.painter().line_segment(
175                    [Pos2::new(rect.left(), y), Pos2::new(rect.right(), y)],
176                    Stroke::new(th, color),
177                );
178            }
179            MacStackOrientation::Horizontal => {
180                let avail_h = if let Some(ch) = self.container_height {
181                    (ch - 2.0 * self.padding).max(1.0)
182                } else {
183                    let current_h = self.ui.min_rect().height();
184                    if current_h > 0.0 {
185                        current_h
186                    } else {
187                        self.theme.stack_view().default_item_height.points()
188                    }
189                };
190                let (rect, _) = self
191                    .ui
192                    .allocate_exact_size(Vec2::new(th, avail_h), egui::Sense::hover());
193                let x = subpixel_snap(rect.center().x, scale);
194                self.ui.painter().line_segment(
195                    [Pos2::new(x, rect.top()), Pos2::new(x, rect.bottom())],
196                    Stroke::new(th, color),
197                );
198            }
199        }
200        self.ui.add_space(self.spacing * 0.5);
201    }
202}
203
204impl<'a> MacStackView<'a> {
205    /// Creates a new stack view with the specified orientation.
206    pub fn new(orientation: MacStackOrientation) -> Self {
207        Self {
208            orientation,
209            alignment: MacStackAlignment::Leading,
210            theme: None,
211            spacing: None,
212            padding: None,
213            show_separators: false,
214            width: None,
215            height: None,
216        }
217    }
218
219    /// Creates a vertical column stack view (default).
220    pub fn vertical() -> Self {
221        Self::new(MacStackOrientation::Vertical)
222    }
223
224    /// Creates a horizontal row stack view.
225    pub fn horizontal() -> Self {
226        Self::new(MacStackOrientation::Horizontal)
227    }
228
229    /// Injects dynamic theme to eliminate hardcoded metrics and colors.
230    pub fn theme(mut self, theme: &'a MacTheme) -> Self {
231        self.theme = Some(theme);
232        self
233    }
234
235    /// Sets cross-axis alignment.
236    pub fn alignment(mut self, alignment: MacStackAlignment) -> Self {
237        self.alignment = alignment;
238        self
239    }
240
241    /// Customizes spacing between adjacent elements in points.
242    pub fn spacing(mut self, spacing: f32) -> Self {
243        self.spacing = Some(spacing);
244        self
245    }
246
247    /// Customizes outer edge padding around all stacked elements in points.
248    pub fn padding(mut self, padding: f32) -> Self {
249        self.padding = Some(padding);
250        self
251    }
252
253    /// Enables automatic hairline separators between adjacent items and sections.
254    pub fn show_separators(mut self, show: bool) -> Self {
255        self.show_separators = show;
256        self
257    }
258
259    /// Sets container width constraint in points.
260    pub fn width(mut self, width: f32) -> Self {
261        self.width = Some(width);
262        self
263    }
264
265    /// Sets container height constraint in points.
266    pub fn height(mut self, height: f32) -> Self {
267        self.height = Some(height);
268        self
269    }
270
271    /// Renders the linear stack and executes the items closure.
272    pub fn show<R>(
273        self,
274        ui: &mut Ui,
275        add_contents: impl FnOnce(&mut MacStackUi<'_>) -> R,
276    ) -> MacStackViewOutput<R> {
277        let theme_cow = MacTheme::resolve(self.theme, ui.ctx());
278        let theme: &MacTheme = &theme_cow;
279
280        let metrics = theme.stack_view();
281        let spacing = self
282            .spacing
283            .unwrap_or_else(|| metrics.default_spacing.points());
284        let padding = self
285            .padding
286            .unwrap_or_else(|| metrics.edge_padding.points());
287        let separator_thickness = metrics.separator_thickness.points();
288
289        let available = ui.available_rect_before_wrap();
290        let target_w = self.width.unwrap_or(available.width());
291        let target_h = self.height.unwrap_or(available.height());
292        let container_rect = Rect::from_min_size(available.min, Vec2::new(target_w, target_h));
293
294        let inner_rect = container_rect.shrink(padding);
295
296        let stack_id = ui.next_auto_id();
297        let mut child_ui = match self.orientation {
298            MacStackOrientation::Vertical => {
299                let layout = egui::Layout::top_down(match self.alignment {
300                    MacStackAlignment::Leading => egui::Align::Min,
301                    MacStackAlignment::Center => egui::Align::Center,
302                    MacStackAlignment::Trailing => egui::Align::Max,
303                });
304                ui.new_child(
305                    UiBuilder::new()
306                        .id_salt((stack_id, "stack_v"))
307                        .max_rect(inner_rect)
308                        .layout(layout),
309                )
310            }
311            MacStackOrientation::Horizontal => {
312                let layout = egui::Layout::left_to_right(match self.alignment {
313                    MacStackAlignment::Leading => egui::Align::Min,
314                    MacStackAlignment::Center => egui::Align::Center,
315                    MacStackAlignment::Trailing => egui::Align::Max,
316                });
317                ui.new_child(
318                    UiBuilder::new()
319                        .id_salt((stack_id, "stack_h"))
320                        .max_rect(inner_rect)
321                        .layout(layout),
322                )
323            }
324        };
325
326        let mut stack_ui = MacStackUi {
327            ui: &mut child_ui,
328            theme,
329            orientation: self.orientation,
330            spacing,
331            separator_thickness,
332            show_separators: self.show_separators,
333            container_height: self.height,
334            padding,
335            item_count: 0,
336            section_count: 0,
337        };
338
339        let inner = add_contents(&mut stack_ui);
340        let items_rect = stack_ui.ui.min_rect();
341        let content_w = if items_rect.is_positive() {
342            items_rect.width() + padding * 2.0
343        } else {
344            padding * 2.0
345        };
346        let content_h = if items_rect.is_positive() {
347            items_rect.height() + padding * 2.0
348        } else {
349            padding * 2.0
350        };
351        let final_w = self.width.unwrap_or(content_w).max(content_w);
352        let final_h = self.height.unwrap_or(content_h).max(content_h);
353        let final_rect = Rect::from_min_size(container_rect.min, Vec2::new(final_w, final_h));
354
355        ui.allocate_rect(final_rect, egui::Sense::hover());
356
357        MacStackViewOutput {
358            inner,
359            rect: final_rect,
360            item_count: stack_ui.item_count,
361            section_count: stack_ui.section_count,
362        }
363    }
364}