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::controls::MacControlSize;
7use crate::theme::MacTheme;
8use egui::{
9    Color32, CornerRadius, Key, Modifiers, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui,
10    Vec2,
11};
12
13/// Authentic macOS segmented pill selector (`NSSegmentedControl` equivalent).
14///
15/// Features animated sliding selection indicator pill via `CASpring`, token-driven padding and typography,
16/// ambient drop shadow, keyboard arrow navigation, and crisp border separators.
17///
18/// # Example
19/// ```rust
20/// use egui_macview::{MacSegmentedControl, MacTheme};
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/// MacSegmentedControl::new(&["Day", "Week", "Month", "Year"], selected).theme(&theme).show(ui, |i| selected = i);
26/// # });
27/// # output.textures_delta.clear();
28/// ```
29pub struct MacSegmentedControl<'a> {
30    options: &'a [&'a str],
31    selected_idx: usize,
32    enabled: bool,
33    theme: Option<&'a MacTheme>,
34    height: Option<f32>,
35    control_size: MacControlSize,
36    small_square: bool,
37    fixed_rect: Option<Rect>,
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            enabled: true,
47            theme: None,
48            height: None,
49            control_size: MacControlSize::Regular,
50            small_square: false,
51            fixed_rect: None,
52        }
53    }
54
55    /// Enables or disables the segmented control.
56    pub fn enabled(mut self, enabled: bool) -> Self { self.enabled = enabled; self }
57    /// Injects dynamic theme to eliminate hardcoded metrics and colors.
58    pub fn theme(mut self, theme: &'a MacTheme) -> Self { self.theme = Some(theme); self }
59    /// Overrides control height in points (defaults to token segmented height).
60    pub fn height(mut self, height: f32) -> Self { self.height = Some(height); self }
61    /// Sets the AppKit control size (`Regular` `22pt`, `Small` `18pt`, or `Mini` `15pt`).
62    pub fn control_size(mut self, size: MacControlSize) -> Self { self.control_size = size; self }
63    /// Configures AppKit `NSSegmentedStyleSmallSquare` (`2.0pt` corner radius and internal vertical dividers).
64    pub fn small_square(mut self) -> Self { self.small_square = true; self }
65    /// Pins the segmented control to an exact NIB rectangle.
66    pub fn fixed_rect(mut self, rect: Rect) -> Self { self.fixed_rect = Some(rect); self }
67
68    /// Renders a multi-select segmented strip (`NSSegmentedSwitchTrackingSelectAny`), toggling
69    /// `active[i]` when segment `i` is clicked and returning `Some(i)` on toggle.
70    pub fn show_multi(self, ui: &mut Ui, active: &mut [bool]) -> Option<usize> {
71        let theme_cow = MacTheme::resolve(self.theme, ui.ctx());
72        let theme: &MacTheme = &theme_cow;
73        let seg = theme.segmented();
74        let (tier, font_sz) = match self.control_size {
75            MacControlSize::Regular | MacControlSize::Large => (&seg.regular, seg.font_size.points()),
76            MacControlSize::Small | MacControlSize::CompactInfobox => (&seg.small, seg.small_font_size.points()),
77            MacControlSize::Mini => (&seg.mini, seg.mini_font_size.points()),
78        };
79        let height = self.fixed_rect.map(|r| r.height()).or(self.height).unwrap_or_else(|| tier.height.points());
80        let corner_radius = if self.small_square {
81            seg.small_square_corner_radius.points()
82        } else {
83            tier.corner_radius.points()
84        };
85        let border_w = seg.border_width.points();
86        let font_id = egui::FontId::proportional(font_sz);
87        let padding_h = seg.padding_horizontal.points();
88        let mut max_content_w = seg.item_width.points();
89        let mut galleys = Vec::with_capacity(self.options.len());
90        for &opt in self.options {
91            let g = theme.layout_tracked_galley(ui, opt, font_id.clone(), Color32::PLACEHOLDER, f32::INFINITY);
92            max_content_w = max_content_w.max(g.size().x + padding_h * 2.0);
93            galleys.push(g);
94        }
95        let count = self.options.len().max(1);
96        let enabled = self.enabled && ui.is_enabled();
97        let sense = if enabled { Sense::click() } else { Sense::hover() };
98        let (rect, mut response) = if let Some(fixed) = self.fixed_rect {
99            let id = ui.next_auto_id().with("mac_segmented_multi_fixed");
100            ui.skip_ahead_auto_ids(1);
101            (fixed, ui.interact(fixed, id, sense))
102        } else {
103            ui.allocate_exact_size(Vec2::new(max_content_w * count as f32, height), sense)
104        };
105        let segment_width = rect.width() / count as f32;
106        let alpha = if enabled { 1.0 } else { theme.disabled_opacity() };
107        let painter = ui.painter_at(rect);
108        let border_stroke = Stroke::new(border_w, theme.box_border().gamma_multiply(alpha));
109        painter.rect(
110            rect,
111            CornerRadius::same(corner_radius as u8),
112            theme.segmented_small_square_inactive_bg().gamma_multiply(alpha),
113            border_stroke,
114            StrokeKind::Inside,
115        );
116
117        let mut toggled = None;
118        for (i, galley) in galleys.into_iter().enumerate() {
119            let seg_rect = Rect::from_min_size(
120                Pos2::new(rect.min.x + i as f32 * segment_width, rect.min.y),
121                Vec2::new(segment_width, rect.height()),
122            );
123            if enabled && response.clicked() {
124                if let Some(pos) = response.interact_pointer_pos() {
125                    if seg_rect.contains(pos) {
126                        if let Some(slot) = active.get_mut(i) {
127                            *slot = !*slot;
128                        }
129                        toggled = Some(i);
130                        response.mark_changed();
131                    }
132                }
133            }
134            let is_on = active.get(i).copied().unwrap_or(false);
135            if is_on {
136                painter.rect_filled(
137                    seg_rect.shrink(border_w),
138                    CornerRadius::ZERO,
139                    theme.segmented_small_square_active_bg().gamma_multiply(alpha),
140                );
141            }
142            let text_color = if is_on {
143                theme.label().gamma_multiply(alpha)
144            } else {
145                theme.secondary_label().gamma_multiply(alpha)
146            };
147            let text_pos = egui::Align2::CENTER_CENTER.anchor_size(seg_rect.center(), galley.size()).min;
148            painter.galley(text_pos, galley, text_color);
149        }
150        for i in 1..count {
151            let x = rect.min.x + i as f32 * segment_width;
152            painter.line_segment([Pos2::new(x, rect.min.y), Pos2::new(x, rect.max.y)], border_stroke);
153        }
154        toggled
155    }
156
157    /// Renders the segmented control and fires `on_select` when a new segment is clicked.
158    pub fn show(self, ui: &mut Ui, mut on_select: impl FnMut(usize)) -> Response {
159        let theme_cow = MacTheme::resolve(self.theme, ui.ctx());
160        let theme: &MacTheme = &theme_cow;
161        let seg = theme.segmented();
162        let (tier, font_sz) = match self.control_size {
163            MacControlSize::Regular | MacControlSize::Large => (&seg.regular, seg.font_size.points()),
164            MacControlSize::Small | MacControlSize::CompactInfobox => (&seg.small, seg.small_font_size.points()),
165            MacControlSize::Mini => (&seg.mini, seg.mini_font_size.points()),
166        };
167        let height = self.fixed_rect.map(|r| r.height()).or(self.height).unwrap_or_else(|| tier.height.points());
168        let corner_radius = if self.small_square {
169            seg.small_square_corner_radius.points()
170        } else {
171            tier.corner_radius.points()
172        };
173        let indicator_radius = tier.indicator_radius.points();
174        let border_width = seg.border_width.points();
175        let indicator_inset = seg.indicator_inset.points();
176        let font_id = egui::FontId::proportional(font_sz);
177        let padding_h = seg.padding_horizontal.points();
178        let mut max_content_w = seg.item_width.points();
179        let mut galleys = Vec::with_capacity(self.options.len());
180        for &opt in self.options {
181            let galley = theme.layout_tracked_galley(ui, opt, font_id.clone(), Color32::PLACEHOLDER, f32::INFINITY);
182            max_content_w = max_content_w.max(galley.size().x + padding_h * 2.0);
183            galleys.push(galley);
184        }
185        let count = self.options.len().max(1);
186        let enabled = self.enabled && ui.is_enabled();
187        let sense = if enabled { Sense::click() } else { Sense::hover() };
188        let (rect, mut response) = if let Some(fixed) = self.fixed_rect {
189            let id = ui.next_auto_id().with("mac_segmented_fixed");
190            ui.skip_ahead_auto_ids(1);
191            (fixed, ui.interact(fixed, id, sense))
192        } else {
193            ui.allocate_exact_size(Vec2::new(max_content_w * count as f32, height), sense)
194        };
195        let segment_width = rect.width() / count as f32;
196        let mut active_idx = self.selected_idx.min(count.saturating_sub(1));
197
198        if enabled && response.has_focus() {
199            if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::ArrowRight)) && active_idx + 1 < count {
200                active_idx += 1;
201                on_select(active_idx);
202                response.mark_changed();
203            } else if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::ArrowLeft)) && active_idx > 0 {
204                active_idx -= 1;
205                on_select(active_idx);
206                response.mark_changed();
207            }
208        }
209
210        let alpha_factor = if enabled { 1.0 } else { theme.disabled_opacity() };
211        let painter = ui.painter_at(rect);
212        let bg_fill = if self.small_square {
213            theme.segmented_small_square_inactive_bg()
214        } else {
215            theme.control_background()
216        };
217        let border_col = if self.small_square { theme.box_border() } else { theme.separator() };
218        painter.rect(
219            rect,
220            CornerRadius::same(corner_radius as u8),
221            bg_fill.gamma_multiply(alpha_factor),
222            Stroke::new(border_width, border_col.gamma_multiply(alpha_factor)),
223            StrokeKind::Inside,
224        );
225
226        let spring = theme.motion().swiftui_snappy.to_spring();
227        let anim_idx = spring.animate_f32(ui.ctx(), response.id.with("seg_caspring"), active_idx as f32);
228        let pill_x = rect.min.x + anim_idx * segment_width;
229        let animated_seg_rect = Rect::from_min_size(Pos2::new(pill_x, rect.min.y), Vec2::new(segment_width, rect.height()));
230        if self.small_square {
231            painter.rect_filled(
232                animated_seg_rect.shrink(border_width),
233                CornerRadius::ZERO,
234                theme.segmented_small_square_active_bg().gamma_multiply(alpha_factor),
235            );
236            for i in 1..count {
237                let x = rect.min.x + i as f32 * segment_width;
238                painter.line_segment(
239                    [Pos2::new(x, rect.min.y), Pos2::new(x, rect.max.y)],
240                    Stroke::new(border_width, border_col.gamma_multiply(alpha_factor)),
241                );
242            }
243        } else {
244            let pill_rect = animated_seg_rect.shrink(indicator_inset);
245            painter.rect_filled(
246                pill_rect.translate(Vec2::new(0.0, border_width)),
247                CornerRadius::same(indicator_radius as u8),
248                theme.drop_shadow().gamma_multiply(alpha_factor),
249            );
250            painter.rect(
251                pill_rect,
252                CornerRadius::same(indicator_radius as u8),
253                theme.control().gamma_multiply(alpha_factor),
254                Stroke::new(border_width, theme.separator().gamma_multiply(alpha_factor)),
255                StrokeKind::Inside,
256            );
257        }
258
259        for (i, galley) in galleys.into_iter().enumerate() {
260            let seg_rect = Rect::from_min_size(Pos2::new(rect.min.x + i as f32 * segment_width, rect.min.y), Vec2::new(segment_width, rect.height()));
261            if enabled && response.clicked() {
262                if let Some(pos) = response.interact_pointer_pos().or_else(|| ui.input(|inp| inp.pointer.interact_pos())) {
263                    if seg_rect.contains(pos) {
264                        response.request_focus();
265                        on_select(i);
266                        response.mark_changed();
267                    }
268                }
269            }
270            let is_hovered = enabled && ui.rect_contains_pointer(seg_rect);
271            let text_color = if i == active_idx || is_hovered {
272                theme.label().gamma_multiply(alpha_factor)
273            } else {
274                theme.secondary_label().gamma_multiply(alpha_factor)
275            };
276            let text_pos = egui::Align2::CENTER_CENTER.anchor_size(seg_rect.center(), galley.size()).min;
277            painter.galley(text_pos, galley, text_color);
278        }
279        response
280    }
281}