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