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::{
8    Color32, CornerRadius, Key, Modifiers, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui,
9    Vec2,
10};
11
12/// Authentic macOS segmented pill selector (`NSSegmentedControl` equivalent).
13///
14/// Features animated sliding selection indicator pill via `CASpring`, token-driven padding and typography,
15/// ambient drop shadow, keyboard arrow navigation, and crisp border separators.
16///
17/// # Example
18///
19/// ```rust
20/// use egui_macview::{MacSegmentedControl, MacTheme};
21///
22/// # let ctx = egui::Context::default();
23/// # let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
24/// let theme = MacTheme::default();
25/// let mut selected = 1;
26/// let tabs = ["Day", "Week", "Month", "Year"];
27///
28/// MacSegmentedControl::new(&tabs, selected)
29///     .theme(&theme)
30///     .show(ui, |new_idx| {
31///         selected = new_idx;
32///     });
33/// # });
34/// # output.textures_delta.clear();
35/// ```
36pub struct MacSegmentedControl<'a> {
37    options: &'a [&'a str],
38    selected_idx: usize,
39    enabled: bool,
40    theme: Option<&'a MacTheme>,
41    height: Option<f32>,
42}
43
44impl<'a> MacSegmentedControl<'a> {
45    /// Creates a segmented control with option labels and currently selected index.
46    pub fn new(options: &'a [&'a str], selected_idx: usize) -> Self {
47        Self {
48            options,
49            selected_idx,
50            enabled: true,
51            theme: None,
52            height: None,
53        }
54    }
55
56    /// Enables or disables the segmented control.
57    pub fn enabled(mut self, enabled: bool) -> Self {
58        self.enabled = enabled;
59        self
60    }
61
62    /// Injects dynamic theme to eliminate hardcoded metrics and colors.
63    pub fn theme(mut self, theme: &'a MacTheme) -> Self {
64        self.theme = Some(theme);
65        self
66    }
67
68    /// Overrides control height in points (defaults to token segmented height).
69    pub fn height(mut self, height: f32) -> Self {
70        self.height = Some(height);
71        self
72    }
73
74    /// Renders the segmented control and fires `on_select` when a new segment is clicked.
75    pub fn show(self, ui: &mut Ui, mut on_select: impl FnMut(usize)) -> Response {
76        let theme_cow = MacTheme::resolve(self.theme, ui.ctx());
77        let theme: &MacTheme = &theme_cow;
78
79        let segmented_tokens = theme.segmented();
80        let metrics = &segmented_tokens.regular;
81        let height = self.height.unwrap_or_else(|| metrics.height.points());
82        let corner_radius = metrics.corner_radius.points();
83        let indicator_radius = metrics.indicator_radius.points();
84        let border_width = segmented_tokens.border_width.points();
85        let indicator_inset = segmented_tokens.indicator_inset.points();
86
87        let font_id = egui::FontId::proportional(segmented_tokens.font_size.points());
88        let padding_h = segmented_tokens.padding_horizontal.points();
89        let min_item_w = segmented_tokens.item_width.points();
90
91        let mut max_content_w = min_item_w;
92        let mut galleys = Vec::with_capacity(self.options.len());
93        for &opt in self.options {
94            let galley = theme.layout_tracked_galley(
95                ui,
96                opt,
97                font_id.clone(),
98                Color32::PLACEHOLDER,
99                f32::INFINITY,
100            );
101            let padded_w = galley.size().x + padding_h * 2.0;
102            if padded_w > max_content_w {
103                max_content_w = padded_w;
104            }
105            galleys.push(galley);
106        }
107        let segment_width = max_content_w;
108        let count = self.options.len().max(1);
109        let total_width = segment_width * count as f32;
110
111        let sense = if self.enabled {
112            Sense::click()
113        } else {
114            Sense::hover()
115        };
116        let (rect, mut response) = ui.allocate_exact_size(Vec2::new(total_width, height), sense);
117        let mut active_idx = self.selected_idx.min(count.saturating_sub(1));
118
119        if self.enabled && response.has_focus() {
120            if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::ArrowRight))
121                && active_idx + 1 < count
122            {
123                active_idx += 1;
124                on_select(active_idx);
125                response.mark_changed();
126            } else if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::ArrowLeft))
127                && active_idx > 0
128            {
129                active_idx -= 1;
130                on_select(active_idx);
131                response.mark_changed();
132            }
133        }
134
135        let alpha_factor = if self.enabled {
136            1.0
137        } else {
138            theme.disabled_opacity()
139        };
140        let painter = ui.painter_at(rect);
141
142        painter.rect(
143            rect,
144            CornerRadius::same(corner_radius as u8),
145            theme.control_background().gamma_multiply(alpha_factor),
146            Stroke::new(border_width, theme.separator().gamma_multiply(alpha_factor)),
147            StrokeKind::Inside,
148        );
149
150        // CASpring sliding pill animation stored in egui temp memory
151        let spring = theme.motion().swiftui_snappy.to_spring();
152        let spring_key = response.id.with("seg_caspring");
153        let target_idx = active_idx as f32;
154        let anim_idx = spring.animate_f32(ui.ctx(), spring_key, target_idx);
155
156        let pill_x = rect.min.x + anim_idx * segment_width;
157        let animated_seg_rect = Rect::from_min_size(
158            Pos2::new(pill_x, rect.min.y),
159            Vec2::new(segment_width, height),
160        );
161        let pill_rect = animated_seg_rect.shrink(indicator_inset);
162
163        painter.rect_filled(
164            pill_rect.translate(Vec2::new(0.0, border_width)),
165            CornerRadius::same(indicator_radius as u8),
166            theme.drop_shadow().gamma_multiply(alpha_factor),
167        );
168
169        painter.rect(
170            pill_rect,
171            CornerRadius::same(indicator_radius as u8),
172            theme.control().gamma_multiply(alpha_factor),
173            Stroke::new(border_width, theme.separator().gamma_multiply(alpha_factor)),
174            StrokeKind::Inside,
175        );
176
177        for (i, galley) in galleys.into_iter().enumerate() {
178            let seg_rect = Rect::from_min_size(
179                Pos2::new(rect.min.x + i as f32 * segment_width, rect.min.y),
180                Vec2::new(segment_width, height),
181            );
182
183            if self.enabled && response.clicked() {
184                if let Some(pos) = ui.input(|inp| inp.pointer.interact_pos()) {
185                    if seg_rect.contains(pos) {
186                        response.request_focus();
187                        on_select(i);
188                        response.mark_changed();
189                    }
190                }
191            }
192
193            let is_hovered = self.enabled && ui.rect_contains_pointer(seg_rect);
194            let text_color = if i == active_idx || is_hovered {
195                theme.label().gamma_multiply(alpha_factor)
196            } else {
197                theme.secondary_label().gamma_multiply(alpha_factor)
198            };
199
200            let text_pos = egui::Align2::CENTER_CENTER
201                .anchor_size(seg_rect.center(), galley.size())
202                .min;
203            painter.galley(text_pos, galley, text_color);
204        }
205
206        response
207    }
208}