Skip to main content

egui_macview/controls/
tab_view.rs

1//! Authentic macOS AppKit Tab View (`NSTabView` equivalent, REQ-012-04).
2//!
3//! Grounded in Apple AppKit `NSTabView` with flipped coordinate model (`isFlipped == 1`),
4//! exact MacBook content insets across `TopTabsBezelBorder` and `NoTabsNoBorder` styles,
5//! and W3C DTCG tokens via [`MacTheme`].
6
7use crate::controls::MacControlSize;
8use crate::layout::subpixel_snap_rect;
9use crate::theme::MacTheme;
10use egui::{
11    CornerRadius, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, UiBuilder,
12    Vec2, WidgetText,
13};
14
15/// Content insets in logical points (`f32`) matching AppKit `NSEdgeInsets`.
16#[derive(Debug, Clone, Copy, PartialEq, Default)]
17pub struct MacInsets {
18    /// Inset from the top edge in points.
19    pub top: f32,
20    /// Inset from the bottom edge in points.
21    pub bottom: f32,
22    /// Inset from the left edge in points.
23    pub left: f32,
24    /// Inset from the right edge in points.
25    pub right: f32,
26}
27
28/// Tab presentation and bezel border styles matching AppKit `NSTabViewType`.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30pub enum MacTabStyle {
31    /// Classic macOS segmented top tab bar with container bezel border (`NSTopTabsBezelBorder`).
32    #[default]
33    TopTabsBezelBorder,
34    /// Borderless card container without tab bar (`NSNoTabsNoBorder`).
35    NoTabsNoBorder,
36}
37
38type TabContentFn<'a> = Box<dyn FnOnce(&mut Ui) + 'a>;
39
40/// Single tab item descriptor.
41pub struct MacTabItem<'a> {
42    title: WidgetText,
43    content: Option<TabContentFn<'a>>,
44}
45
46/// Authentic macOS AppKit Tab View container (`NSTabView` equivalent).
47///
48/// Features hardware-calibrated content insets, flipped coordinate layout,
49/// animated segment transitions, and bezel border strokes.
50///
51/// # Example
52/// ```rust
53/// use egui_macview::{MacAppearance, MacTabView, MacTheme};
54///
55/// # let ctx = egui::Context::default();
56/// # let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
57/// let theme = MacTheme::new(MacAppearance::Aqua);
58/// let mut active_tab = 0usize;
59///
60/// MacTabView::new(&mut active_tab)
61///     .theme(&theme)
62///     .tab("General", |ui| {
63///         ui.label("General preferences");
64///     })
65///     .tab("Advanced", |ui| {
66///         ui.label("Advanced settings");
67///     })
68///     .show(ui);
69/// # });
70/// # out.textures_delta.clear();
71/// ```
72pub struct MacTabView<'a> {
73    selected_index: &'a mut usize,
74    style: MacTabStyle,
75    control_size: MacControlSize,
76    theme: Option<&'a MacTheme>,
77    tabs: Vec<MacTabItem<'a>>,
78}
79
80impl<'a> MacTabView<'a> {
81    /// Creates a new tab view bound to an active tab selection index.
82    pub fn new(selected_index: &'a mut usize) -> Self {
83        Self {
84            selected_index,
85            style: MacTabStyle::TopTabsBezelBorder,
86            control_size: MacControlSize::Regular,
87            theme: None,
88            tabs: Vec::new(),
89        }
90    }
91
92    /// Sets the tab display style (`TopTabsBezelBorder` or `NoTabsNoBorder`).
93    pub fn style(mut self, style: MacTabStyle) -> Self {
94        self.style = style;
95        self
96    }
97
98    /// Sets the control size (`Regular`, `Small`, or `Mini`).
99    pub fn control_size(mut self, size: MacControlSize) -> Self {
100        self.control_size = size;
101        self
102    }
103
104    /// Injects dynamic theme.
105    pub fn theme(mut self, theme: &'a MacTheme) -> Self {
106        self.theme = Some(theme);
107        self
108    }
109
110    /// Appends a tab with a title and content closure.
111    pub fn tab(mut self, title: impl Into<WidgetText>, content: impl FnOnce(&mut Ui) + 'a) -> Self {
112        self.tabs.push(MacTabItem {
113            title: title.into(),
114            content: Some(Box::new(content)),
115        });
116        self
117    }
118
119    /// Resolves tab bar header height in points.
120    pub fn tab_bar_height(&self) -> f32 {
121        if self.style == MacTabStyle::NoTabsNoBorder {
122            return 0.0;
123        }
124        let theme_cow = MacTheme::resolve_or_default(self.theme);
125        let m = theme_cow.tab_view();
126        match self.control_size {
127            MacControlSize::Regular | MacControlSize::Large => m.regular_bar_height.points(),
128            MacControlSize::Small | MacControlSize::CompactInfobox => m.small_bar_height.points(),
129            MacControlSize::Mini => m.mini_bar_height.points(),
130        }
131    }
132
133    /// Resolves exact MacBook content insets from outer container bounds.
134    pub fn content_insets(&self) -> MacInsets {
135        if self.style == MacTabStyle::NoTabsNoBorder {
136            return MacInsets {
137                top: 0.0,
138                bottom: 0.0,
139                left: 0.0,
140                right: 0.0,
141            };
142        }
143        let theme_cow = MacTheme::resolve_or_default(self.theme);
144        let m = theme_cow.tab_view();
145        match self.control_size {
146            MacControlSize::Regular | MacControlSize::Large => MacInsets {
147                top: m.regular_inset_top.points(),
148                bottom: m.regular_inset_bottom.points(),
149                left: m.regular_inset_horizontal.points(),
150                right: m.regular_inset_horizontal.points(),
151            },
152            MacControlSize::Small | MacControlSize::CompactInfobox => MacInsets {
153                top: m.small_inset_top.points(),
154                bottom: m.small_inset_bottom.points(),
155                left: m.small_inset_horizontal.points(),
156                right: m.small_inset_horizontal.points(),
157            },
158            MacControlSize::Mini => MacInsets {
159                top: m.mini_inset_top.points(),
160                bottom: m.mini_inset_bottom.points(),
161                left: m.mini_inset_horizontal.points(),
162                right: m.mini_inset_horizontal.points(),
163            },
164        }
165    }
166
167    /// Resolves MacBook minimum container dimensions across control sizes.
168    pub fn min_container_size(&self) -> Vec2 {
169        let theme_cow = MacTheme::resolve_or_default(self.theme);
170        let m = theme_cow.tab_view();
171        match self.control_size {
172            MacControlSize::Regular | MacControlSize::Large => {
173                Vec2::new(m.regular_min_width.points(), m.regular_min_height.points())
174            }
175            MacControlSize::Small | MacControlSize::CompactInfobox => {
176                Vec2::new(m.small_min_width.points(), m.small_min_height.points())
177            }
178            MacControlSize::Mini => {
179                Vec2::new(m.mini_min_width.points(), m.mini_min_height.points())
180            }
181        }
182    }
183
184    /// Computes the layout rectangle of the top segmented tab bar within a container.
185    pub fn tab_bar_rect(&self, container_rect: Rect, ui: &Ui) -> Rect {
186        let theme_cow = MacTheme::resolve(self.theme, ui.ctx());
187        let metrics = theme_cow.tab_view();
188        let bar_h = self.tab_bar_height();
189        let min_size = self.min_container_size();
190        let pad = metrics.tab_padding_horizontal.points();
191        let mut total_intrinsic_w = 0.0f32;
192        for tab in &self.tabs {
193            let g = tab.title.clone().into_galley(
194                ui,
195                Some(egui::TextWrapMode::Extend),
196                f32::INFINITY,
197                egui::TextStyle::Button,
198            );
199            total_intrinsic_w += g.size().x + pad;
200        }
201        let bar_w = total_intrinsic_w.clamp(min_size.x.min(container_rect.width()), container_rect.width());
202        Rect::from_center_size(
203            Pos2::new(container_rect.center().x, container_rect.min.y + bar_h * 0.5),
204            Vec2::new(bar_w, bar_h),
205        )
206    }
207
208    /// Shows the tab view layout with tab bar and selected content panel.
209    pub fn show(mut self, ui: &mut Ui) -> Response {
210        let theme_cow = MacTheme::resolve(self.theme, ui.ctx());
211        let theme: &MacTheme = &theme_cow;
212        let metrics = theme.tab_view();
213
214        let available_w = ui.available_width();
215        let start_pos = ui.cursor().min;
216        let bar_h = self.tab_bar_height();
217        let insets = self.content_insets();
218        let min_size = self.min_container_size();
219
220        let tab_count = self.tabs.len();
221        let active_idx = *self.selected_index;
222
223        let container_w = available_w.max(min_size.x);
224        let content_start_y = start_pos.y + insets.top;
225        let content_rect = Rect::from_min_max(
226            Pos2::new(start_pos.x + insets.left, content_start_y),
227            Pos2::new(start_pos.x + container_w - insets.right, f32::INFINITY),
228        );
229
230        // Pre-allocate background shape on ui painter so it renders BEHIND tab child content (Z-order invariant)
231        let bg_shape_idx = ui.painter().add(egui::Shape::Noop);
232        let tab_scope_id = ui.next_auto_id().with("tab_content").with(active_idx);
233
234        let mut child_ui = ui.new_child(
235            UiBuilder::new()
236                .id_salt(tab_scope_id)
237                .max_rect(content_rect),
238        );
239
240        if active_idx < tab_count {
241            if let Some(content_fn) = self.tabs[active_idx].content.take() {
242                content_fn(&mut child_ui);
243            }
244        }
245
246        let child_min_rect = child_ui.min_rect();
247        let content_h = if active_idx < tab_count {
248            child_min_rect.height()
249        } else {
250            0.0
251        };
252
253        let container_h = (if self.style == MacTabStyle::TopTabsBezelBorder {
254            insets.top + content_h + insets.bottom
255        } else {
256            content_h
257        }).max(min_size.y);
258
259        let container_rect = Rect::from_min_size(start_pos, Vec2::new(container_w, container_h));
260        let container_rect = subpixel_snap_rect(container_rect, ui.ctx().pixels_per_point());
261
262        let (rect, mut response) = ui.allocate_exact_size(container_rect.size(), Sense::click());
263
264        if self.style == MacTabStyle::TopTabsBezelBorder {
265            let corner_r = metrics.corner_radius.points();
266
267            // Bezel border frame below tab bar pre-allocated BEHIND child content
268            let body_rect = Rect::from_min_max(
269                Pos2::new(rect.min.x, rect.min.y + bar_h * 0.5),
270                rect.max,
271            );
272            ui.painter().set(
273                bg_shape_idx,
274                egui::epaint::RectShape::new(
275                    body_rect,
276                    CornerRadius::same(corner_r as u8),
277                    theme.control_background(),
278                    Stroke::new(1.0, theme.separator()),
279                    StrokeKind::Inside,
280                ),
281            );
282
283            let painter = ui.painter_at(rect);
284
285            // Centered segmented tab bar segments
286            if tab_count > 0 {
287                let bar_rect = self.tab_bar_rect(rect, ui);
288                painter.rect(
289                    bar_rect,
290                    CornerRadius::same(corner_r as u8),
291                    theme.window_background(),
292                    Stroke::new(1.0, theme.separator()),
293                    StrokeKind::Inside,
294                );
295
296                let tab_w = bar_rect.width() / (tab_count as f32);
297                for i in 0..tab_count {
298                    let tab_rect = Rect::from_min_size(
299                        Pos2::new(bar_rect.min.x + (i as f32) * tab_w, bar_rect.min.y),
300                        Vec2::new(tab_w, bar_h),
301                    );
302                    let tab_hovered = ui
303                        .input(|inp| inp.pointer.hover_pos())
304                        .is_some_and(|p| tab_rect.contains(p));
305
306                    let is_active = *self.selected_index == i;
307                    if is_active || tab_hovered {
308                        let tab_fill = if is_active {
309                            theme.control_background()
310                        } else {
311                            theme.control()
312                        };
313
314                        painter.rect(
315                            tab_rect,
316                            CornerRadius::same(corner_r as u8),
317                            tab_fill,
318                            Stroke::new(1.0, theme.separator()),
319                            StrokeKind::Inside,
320                        );
321                    }
322
323                    let galley = self.tabs[i].title.clone().into_galley(
324                        ui,
325                        Some(egui::TextWrapMode::Extend),
326                        tab_w,
327                        egui::TextStyle::Button,
328                    );
329                    let text_pos = Pos2::new(
330                        tab_rect.center().x - galley.size().x * 0.5,
331                        tab_rect.center().y - galley.size().y * 0.5,
332                    );
333                    let text_color = if is_active {
334                        theme.label()
335                    } else {
336                        theme.secondary_label()
337                    };
338                    painter.galley(text_pos, galley, text_color);
339
340                    if tab_hovered && ui.input(|inp| inp.pointer.primary_clicked()) {
341                        *self.selected_index = i;
342                        response.mark_changed();
343                    }
344                }
345            }
346        }
347
348        response
349    }
350}