Skip to main content

egui_macview/controls/
segmented.rs

1//! Authentic macOS NSSegmentedControl primitive.
2//!
3//! Grounded in macOS HIG and W3C DTCG tokens via [`MacTheme`].
4//! Fully data-driven: zero hardcoded metrics.
5
6use crate::theme::MacTheme;
7use egui::{Color32, CornerRadius, Pos2, Rect, Response, Stroke, StrokeKind, Ui, Vec2};
8
9/// Authentic macOS segmented pill selector (`NSSegmentedControl` equivalent).
10///
11/// Features animated sliding selection indicator pill, token-driven padding and typography,
12/// ambient drop shadow, and crisp border separators.
13///
14/// # Example
15///
16/// ```rust
17/// use egui_macview::{MacSegmentedControl, MacTheme};
18///
19/// # let ctx = egui::Context::default();
20/// # let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
21/// let theme = MacTheme::default();
22/// let mut selected = 1;
23/// let tabs = ["Day", "Week", "Month", "Year"];
24///
25/// MacSegmentedControl::new(&tabs, selected)
26///     .theme(&theme)
27///     .show(ui, |new_idx| {
28///         selected = new_idx;
29///     });
30/// # });
31/// # output.textures_delta.clear();
32/// ```
33pub struct MacSegmentedControl<'a> {
34    options: &'a [&'a str],
35    selected_idx: usize,
36    theme: Option<&'a MacTheme>,
37    height: Option<f32>,
38}
39
40impl<'a> MacSegmentedControl<'a> {
41    /// Creates a segmented control with option labels and currently selected index.
42    pub fn new(options: &'a [&'a str], selected_idx: usize) -> Self {
43        Self {
44            options,
45            selected_idx,
46            theme: None,
47            height: None,
48        }
49    }
50
51    /// Injects dynamic theme to eliminate hardcoded metrics and colors.
52    pub fn theme(mut self, theme: &'a MacTheme) -> Self {
53        self.theme = Some(theme);
54        self
55    }
56
57    /// Overrides control height in points (defaults to token segmented height).
58    pub fn height(mut self, height: f32) -> Self {
59        self.height = Some(height);
60        self
61    }
62
63    /// Renders the segmented control and fires `on_select` when a new segment is clicked.
64    pub fn show(self, ui: &mut Ui, mut on_select: impl FnMut(usize)) -> Response {
65        let fallback_theme;
66        let theme = match self.theme {
67            Some(t) => t,
68            None => {
69                fallback_theme = MacTheme::default();
70                &fallback_theme
71            }
72        };
73
74        let segmented_tokens = theme.segmented();
75        let metrics = &segmented_tokens.regular;
76        let height = self.height.unwrap_or_else(|| metrics.height.points());
77        let corner_radius = metrics.corner_radius.points();
78        let indicator_radius = metrics.indicator_radius.points();
79        let border_width = segmented_tokens.border_width.points();
80        let indicator_inset = segmented_tokens.indicator_inset.points();
81
82        let font_id = egui::FontId::proportional(segmented_tokens.font_size.points());
83        let padding_h = segmented_tokens.padding_horizontal.points();
84        let min_item_w = segmented_tokens.item_width.points();
85
86        let mut max_content_w = min_item_w;
87        for opt in self.options.iter() {
88            let galley = ui.painter().layout_no_wrap(
89                (*opt).to_string(),
90                font_id.clone(),
91                egui::Color32::WHITE,
92            );
93            let label_w = galley.size().x;
94            let padded_w = label_w + padding_h + padding_h;
95            if padded_w > max_content_w {
96                max_content_w = padded_w;
97            }
98        }
99        let segment_width = max_content_w;
100        let count = self.options.len().max(1);
101        let total_width = segment_width * count as f32;
102
103        let (rect, response) =
104            ui.allocate_exact_size(Vec2::new(total_width, height), egui::Sense::click());
105
106        let painter = ui.painter_at(rect);
107
108        // Container background
109        painter.rect(
110            rect,
111            CornerRadius::same(corner_radius as u8),
112            theme.control_background(),
113            Stroke::new(border_width, theme.separator()),
114            StrokeKind::Inside,
115        );
116
117        // Smoothly animated segment index offset via egui animation
118        let anim_idx = ui.ctx().animate_value_with_time(
119            response.id.with("seg_anim"),
120            self.selected_idx as f32,
121            segmented_tokens.animation_duration.points(),
122        );
123
124        let pill_x = rect.min.x + anim_idx * segment_width;
125        let animated_seg_rect = Rect::from_min_size(
126            Pos2::new(pill_x, rect.min.y),
127            Vec2::new(segment_width, height),
128        );
129        let pill_rect = animated_seg_rect.shrink(indicator_inset);
130
131        // Ambient shadow and active segment pill
132        painter.rect_filled(
133            pill_rect.translate(Vec2::new(0.0, 1.0)),
134            CornerRadius::same(indicator_radius as u8),
135            Color32::from_black_alpha(20),
136        );
137
138        painter.rect(
139            pill_rect,
140            CornerRadius::same(indicator_radius as u8),
141            theme.control(),
142            Stroke::new(border_width, theme.separator()),
143            StrokeKind::Inside,
144        );
145
146        for (i, opt) in self.options.iter().enumerate() {
147            let seg_rect = Rect::from_min_size(
148                Pos2::new(rect.min.x + i as f32 * segment_width, rect.min.y),
149                Vec2::new(segment_width, height),
150            );
151
152            // Click detection
153            if response.clicked() {
154                if let Some(pos) = ui.input(|inp| inp.pointer.interact_pos()) {
155                    if seg_rect.contains(pos) {
156                        on_select(i);
157                    }
158                }
159            }
160
161            let text_color = if i == self.selected_idx {
162                theme.label()
163            } else {
164                theme.secondary_label()
165            };
166
167            painter.text(
168                seg_rect.center(),
169                egui::Align2::CENTER_CENTER,
170                *opt,
171                font_id.clone(),
172                text_color,
173            );
174        }
175
176        response
177    }
178}