Skip to main content

elegance/
segmented_control.rs

1//! A multi-option segmented control: a row of mutually-exclusive segments
2//! sharing a single track.
3//!
4//! Use it for compact pickers where every option fits on one line and the
5//! caller wants the choices visible at a glance: timeframes (1h / 6h / 24h),
6//! density (Compact / Comfortable / Spacious), view modes (Dashboard /
7//! Inbox / Calendar). Each segment can carry a label, an icon, a status
8//! dot, and a count badge.
9
10use std::hash::Hash;
11use std::sync::Arc;
12
13use egui::{
14    pos2, Color32, CornerRadius, FontId, FontSelection, Galley, Rect, Response, Sense, Stroke,
15    StrokeKind, TextWrapMode, Ui, Vec2, Widget, WidgetInfo, WidgetText, WidgetType,
16};
17
18use crate::theme::{placeholder_galley, with_alpha, Theme};
19
20/// Size variants for [`SegmentedControl`].
21///
22/// Sizes scale font, padding, track inset, and corner radii together so a
23/// segmented control sits naturally next to other elegance controls of the
24/// same size class.
25#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
26pub enum SegmentedSize {
27    /// Compact, for toolbars and tight headers.
28    Small,
29    /// Default size.
30    #[default]
31    Medium,
32    /// Chunkier — pairs with [`ButtonSize::Large`](crate::ButtonSize::Large)
33    /// in mixed action rows.
34    Large,
35}
36
37impl SegmentedSize {
38    fn font_size(self, theme: &Theme) -> f32 {
39        let t = &theme.typography;
40        match self {
41            Self::Small => t.small,
42            Self::Medium => t.label,
43            Self::Large => t.button,
44        }
45    }
46    fn icon_size(self, theme: &Theme) -> f32 {
47        self.font_size(theme)
48    }
49    fn pad_x(self) -> f32 {
50        match self {
51            Self::Small => 10.0,
52            Self::Medium => 12.0,
53            Self::Large => 16.0,
54        }
55    }
56    fn pad_y(self) -> f32 {
57        match self {
58            Self::Small => 3.0,
59            Self::Medium => 5.0,
60            Self::Large => 7.0,
61        }
62    }
63    fn track_pad(self) -> f32 {
64        match self {
65            Self::Small => 2.0,
66            Self::Medium => 3.0,
67            Self::Large => 4.0,
68        }
69    }
70    fn track_radius(self) -> u8 {
71        match self {
72            Self::Small => 6,
73            Self::Medium => 7,
74            Self::Large => 8,
75        }
76    }
77    fn segment_radius(self) -> u8 {
78        match self {
79            Self::Small => 4,
80            Self::Medium => 5,
81            Self::Large => 6,
82        }
83    }
84    fn count_height(self) -> f32 {
85        match self {
86            Self::Small => 16.0,
87            Self::Medium => 18.0,
88            Self::Large => 20.0,
89        }
90    }
91}
92
93/// Status colour for the optional dot indicator inside a [`Segment`].
94///
95/// Maps to the palette's status accents (`success`, `warning`, `danger`,
96/// `sky`) plus a neutral grey. Pick the variant that matches what the
97/// segment represents (open / triaged / resolved / rejected, etc.).
98#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
99pub enum SegmentDot {
100    /// Neutral grey — non-status segments or "all" buckets.
101    Neutral,
102    /// Sky — informational / in-progress.
103    Sky,
104    /// Amber — warning / open.
105    Amber,
106    /// Red — error / rejected.
107    Red,
108    /// Green — success / resolved.
109    Green,
110}
111
112/// A single segment inside a [`SegmentedControl`].
113///
114/// Build with [`Segment::text`], [`Segment::icon`], or
115/// [`Segment::icon_text`], then layer optional decorations with
116/// [`Segment::dot`] and [`Segment::count`]. Mark unavailable segments
117/// with [`Segment::enabled`].
118///
119/// ```no_run
120/// # use elegance::{Segment, SegmentDot};
121/// let seg = Segment::text("Open").dot(SegmentDot::Amber).count("12");
122/// # let _ = seg;
123/// ```
124#[must_use = "Use with SegmentedControl::from_segments(...)"]
125pub struct Segment {
126    label: Option<WidgetText>,
127    icon: Option<WidgetText>,
128    count: Option<WidgetText>,
129    dot: Option<SegmentDot>,
130    enabled: bool,
131}
132
133impl std::fmt::Debug for Segment {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        f.debug_struct("Segment")
136            .field("label", &self.label.as_ref().map(|l| l.text().to_string()))
137            .field("icon", &self.icon.as_ref().map(|i| i.text().to_string()))
138            .field("count", &self.count.as_ref().map(|c| c.text().to_string()))
139            .field("dot", &self.dot)
140            .field("enabled", &self.enabled)
141            .finish()
142    }
143}
144
145impl Default for Segment {
146    fn default() -> Self {
147        Self {
148            label: None,
149            icon: None,
150            count: None,
151            dot: None,
152            enabled: true,
153        }
154    }
155}
156
157impl Segment {
158    /// Text-only segment.
159    pub fn text(label: impl Into<WidgetText>) -> Self {
160        Self {
161            label: Some(label.into()),
162            ..Self::default()
163        }
164    }
165
166    /// Icon-only segment. The icon is any [`WidgetText`] — typically a
167    /// glyph from [`elegance::glyphs`](crate::glyphs) wrapped in
168    /// [`egui::RichText`].
169    pub fn icon(icon: impl Into<WidgetText>) -> Self {
170        Self {
171            icon: Some(icon.into()),
172            ..Self::default()
173        }
174    }
175
176    /// Icon + label segment. The icon precedes the label.
177    pub fn icon_text(icon: impl Into<WidgetText>, label: impl Into<WidgetText>) -> Self {
178        Self {
179            icon: Some(icon.into()),
180            label: Some(label.into()),
181            ..Self::default()
182        }
183    }
184
185    /// Add a count badge that follows the label.
186    #[inline]
187    pub fn count(mut self, count: impl Into<WidgetText>) -> Self {
188        self.count = Some(count.into());
189        self
190    }
191
192    /// Add a leading status dot.
193    #[inline]
194    pub fn dot(mut self, dot: SegmentDot) -> Self {
195        self.dot = Some(dot);
196        self
197    }
198
199    /// Disable the segment. Disabled segments render dimmed and don't
200    /// respond to clicks. Default: enabled.
201    #[inline]
202    pub fn enabled(mut self, enabled: bool) -> Self {
203        self.enabled = enabled;
204        self
205    }
206
207    fn debug_label(&self) -> String {
208        if let Some(l) = &self.label {
209            l.text().to_string()
210        } else if let Some(i) = &self.icon {
211            i.text().to_string()
212        } else {
213            String::new()
214        }
215    }
216}
217
218/// A row of mutually-exclusive segments sharing a single rounded track.
219///
220/// Bind to a `&mut usize` index. Click selects; the selected index is
221/// updated in place and the response is marked changed. Use
222/// [`SegmentedControl::new`] for plain text segments and
223/// [`SegmentedControl::from_segments`] when you need icons, dots, counts,
224/// or per-segment disabled state.
225///
226/// ```no_run
227/// # use elegance::{SegmentedControl, SegmentedSize};
228/// # egui::__run_test_ui(|ui| {
229/// let mut selected = 1usize;
230/// ui.add(SegmentedControl::new(&mut selected, ["Day", "Week", "Month"]));
231/// ui.add(
232///     SegmentedControl::new(&mut selected, ["Compact", "Comfortable", "Spacious"])
233///         .size(SegmentedSize::Small),
234/// );
235/// # });
236/// ```
237///
238/// Rich segments with status dots and counts:
239///
240/// ```no_run
241/// # use elegance::{Segment, SegmentDot, SegmentedControl};
242/// # egui::__run_test_ui(|ui| {
243/// let mut selected = 0usize;
244/// ui.add(
245///     SegmentedControl::from_segments(
246///         &mut selected,
247///         [
248///             Segment::text("Open").dot(SegmentDot::Amber).count("12"),
249///             Segment::text("Triaged").dot(SegmentDot::Neutral).count("84"),
250///             Segment::text("Resolved").dot(SegmentDot::Green).count("1,204"),
251///             Segment::text("Rejected").dot(SegmentDot::Red).count("31"),
252///         ],
253///     )
254///     .fill(),
255/// );
256/// # });
257/// ```
258#[must_use = "Add with `ui.add(...)`."]
259pub struct SegmentedControl<'a> {
260    selected: &'a mut usize,
261    segments: Vec<Segment>,
262    size: SegmentedSize,
263    fill: bool,
264}
265
266impl<'a> std::fmt::Debug for SegmentedControl<'a> {
267    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
268        f.debug_struct("SegmentedControl")
269            .field("selected", &*self.selected)
270            .field("segments", &self.segments)
271            .field("size", &self.size)
272            .field("fill", &self.fill)
273            .finish()
274    }
275}
276
277impl<'a> SegmentedControl<'a> {
278    /// Build a text-only segmented control. Each item is converted into a
279    /// plain-label [`Segment`].
280    pub fn new<I, S>(selected: &'a mut usize, items: I) -> Self
281    where
282        I: IntoIterator<Item = S>,
283        S: Into<WidgetText>,
284    {
285        Self {
286            selected,
287            segments: items.into_iter().map(Segment::text).collect(),
288            size: SegmentedSize::default(),
289            fill: false,
290        }
291    }
292
293    /// Build a segmented control from explicit [`Segment`]s. Use this when
294    /// you need icons, dots, counts, or disabled states.
295    pub fn from_segments(
296        selected: &'a mut usize,
297        segments: impl IntoIterator<Item = Segment>,
298    ) -> Self {
299        Self {
300            selected,
301            segments: segments.into_iter().collect(),
302            size: SegmentedSize::default(),
303            fill: false,
304        }
305    }
306
307    /// Pick a size preset. Default: [`SegmentedSize::Medium`].
308    #[inline]
309    pub fn size(mut self, size: SegmentedSize) -> Self {
310        self.size = size;
311        self
312    }
313
314    /// Force every segment to the same width and stretch the control to
315    /// fill the available horizontal space. Useful as a row affordance.
316    #[inline]
317    pub fn fill(mut self) -> Self {
318        self.fill = true;
319        self
320    }
321}
322
323struct Prepared {
324    icon: Option<Arc<Galley>>,
325    label: Option<Arc<Galley>>,
326    count: Option<Arc<Galley>>,
327    dot: Option<SegmentDot>,
328    enabled: bool,
329    a11y: String,
330    natural_w: f32,
331    natural_h: f32,
332}
333
334const INNER_GAP: f32 = 6.0;
335const DOT_SIZE: f32 = 6.0;
336
337fn count_galley(ui: &Ui, text: &str, size: f32) -> Arc<Galley> {
338    let rt = egui::RichText::new(text)
339        .color(Color32::PLACEHOLDER)
340        .size(size)
341        .strong();
342    egui::WidgetText::from(rt).into_galley(
343        ui,
344        Some(TextWrapMode::Extend),
345        f32::INFINITY,
346        FontSelection::FontId(FontId::monospace(size)),
347    )
348}
349
350fn dot_color(dot: SegmentDot, theme: &Theme, active: bool) -> Color32 {
351    let p = &theme.palette;
352    match dot {
353        SegmentDot::Neutral => {
354            if active {
355                p.sky
356            } else {
357                p.text_faint
358            }
359        }
360        SegmentDot::Sky => p.sky,
361        SegmentDot::Amber => p.warning,
362        SegmentDot::Red => p.danger,
363        SegmentDot::Green => p.success,
364    }
365}
366
367impl<'a> Widget for SegmentedControl<'a> {
368    fn ui(self, ui: &mut Ui) -> Response {
369        let theme = Theme::current(ui.ctx());
370        let p = &theme.palette;
371
372        let size = self.size;
373        let track_pad = size.track_pad();
374        let pad_x = size.pad_x();
375        let pad_y = size.pad_y();
376        let font_size = size.font_size(&theme);
377        let icon_size = size.icon_size(&theme);
378        let count_size = (font_size - 1.5).max(10.0);
379        let count_h = size.count_height();
380
381        // 1. Lay out each segment's content.
382        let mut prepared: Vec<Prepared> = Vec::with_capacity(self.segments.len());
383        for seg in &self.segments {
384            let icon = seg
385                .icon
386                .as_ref()
387                .map(|t| placeholder_galley(ui, t.text(), icon_size, false, f32::INFINITY));
388            let label = seg
389                .label
390                .as_ref()
391                .map(|t| placeholder_galley(ui, t.text(), font_size, true, f32::INFINITY));
392            let count = seg
393                .count
394                .as_ref()
395                .map(|t| count_galley(ui, t.text(), count_size));
396
397            let mut content_w = 0.0_f32;
398            let mut content_h = font_size;
399            if seg.dot.is_some() {
400                content_w += DOT_SIZE;
401                content_h = content_h.max(DOT_SIZE);
402            }
403            if let Some(g) = &icon {
404                if content_w > 0.0 {
405                    content_w += INNER_GAP;
406                }
407                content_w += g.size().x;
408                content_h = content_h.max(g.size().y);
409            }
410            if let Some(g) = &label {
411                if content_w > 0.0 {
412                    content_w += INNER_GAP;
413                }
414                content_w += g.size().x;
415                content_h = content_h.max(g.size().y);
416            }
417            if let Some(g) = &count {
418                if content_w > 0.0 {
419                    content_w += INNER_GAP;
420                }
421                let pill_w = (g.size().x + 10.0).max(count_h);
422                content_w += pill_w;
423                content_h = content_h.max(count_h);
424            }
425
426            prepared.push(Prepared {
427                icon,
428                label,
429                count,
430                dot: seg.dot,
431                enabled: seg.enabled,
432                a11y: seg.debug_label(),
433                natural_w: pad_x * 2.0 + content_w,
434                natural_h: pad_y * 2.0 + content_h,
435            });
436        }
437
438        // 2. Resolve cell widths.
439        let segment_h = prepared
440            .iter()
441            .map(|s| s.natural_h)
442            .fold(font_size + pad_y * 2.0, f32::max);
443
444        let cell_widths: Vec<f32> = if self.fill && !prepared.is_empty() {
445            let avail = (ui.available_width() - track_pad * 2.0).max(0.0);
446            let max_natural = prepared.iter().map(|s| s.natural_w).fold(0.0_f32, f32::max);
447            let cell_w = (avail / prepared.len() as f32).max(max_natural);
448            prepared.iter().map(|_| cell_w).collect()
449        } else {
450            prepared.iter().map(|s| s.natural_w).collect()
451        };
452
453        let total_w = track_pad * 2.0 + cell_widths.iter().sum::<f32>();
454        let total_h = track_pad * 2.0 + segment_h;
455
456        // 3. Allocate the outer track rect. We use its auto-allocated id as the
457        //    base for per-segment interact ids, so multiple SegmentedControls
458        //    within the same parent never collide.
459        let (track_rect, response) =
460            ui.allocate_exact_size(Vec2::new(total_w, total_h), Sense::hover());
461        let base_id = response.id;
462
463        // 4. Allocate per-segment interact rects (each is its own focus target).
464        let mut x = track_rect.min.x + track_pad;
465        let segment_y = track_rect.min.y + track_pad;
466        let mut cell_rects: Vec<Rect> = Vec::with_capacity(prepared.len());
467        let mut cell_responses: Vec<Response> = Vec::with_capacity(prepared.len());
468        for (i, prep) in prepared.iter().enumerate() {
469            let cell_rect =
470                Rect::from_min_size(pos2(x, segment_y), Vec2::new(cell_widths[i], segment_h));
471            x += cell_widths[i];
472            let sense = if prep.enabled {
473                Sense::click()
474            } else {
475                Sense::hover()
476            };
477            let cell_resp = ui.interact(cell_rect, base_id.with(("seg", i)), sense);
478            if prep.enabled && cell_resp.clicked() && *self.selected != i {
479                *self.selected = i;
480            }
481            cell_rects.push(cell_rect);
482            cell_responses.push(cell_resp);
483        }
484
485        let active_idx = if *self.selected < prepared.len() && prepared[*self.selected].enabled {
486            Some(*self.selected)
487        } else {
488            None
489        };
490        let hovered_idx = cell_responses
491            .iter()
492            .zip(prepared.iter())
493            .position(|(r, prep)| prep.enabled && r.hovered());
494
495        // 5. Paint.
496        if ui.is_rect_visible(track_rect) {
497            let track_radius = CornerRadius::same(size.track_radius());
498            ui.painter().rect(
499                track_rect,
500                track_radius,
501                p.input_bg,
502                Stroke::new(1.0, p.border),
503                StrokeKind::Inside,
504            );
505
506            // Dividers between adjacent inactive, non-hovered segments.
507            for (i, cell) in cell_rects.iter().enumerate().skip(1) {
508                let left_busy = active_idx == Some(i - 1) || hovered_idx == Some(i - 1);
509                let right_busy = active_idx == Some(i) || hovered_idx == Some(i);
510                if left_busy || right_busy {
511                    continue;
512                }
513                let div_x = cell.min.x.round() - 0.5;
514                let dy = (segment_h * 0.30).min(8.0);
515                ui.painter().line_segment(
516                    [pos2(div_x, cell.min.y + dy), pos2(div_x, cell.max.y - dy)],
517                    Stroke::new(1.0, with_alpha(p.border, 200)),
518                );
519            }
520
521            let segment_radius = CornerRadius::same(size.segment_radius());
522
523            // Hovered fill (drawn before active, so an active+hover doesn't double-stack).
524            if let Some(h) = hovered_idx {
525                if active_idx != Some(h) {
526                    let hover_fill = with_alpha(p.text, if p.is_dark { 14 } else { 18 });
527                    ui.painter().rect(
528                        cell_rects[h].shrink(0.5),
529                        segment_radius,
530                        hover_fill,
531                        Stroke::NONE,
532                        StrokeKind::Inside,
533                    );
534                }
535            }
536
537            // Active fill: a soft drop-shadow then a card-coloured raised pill.
538            if let Some(a) = active_idx {
539                let cell = cell_rects[a].shrink(0.5);
540                let shadow = cell.translate(Vec2::new(0.0, 1.0));
541                ui.painter().rect(
542                    shadow,
543                    segment_radius,
544                    with_alpha(Color32::BLACK, if p.is_dark { 70 } else { 28 }),
545                    Stroke::NONE,
546                    StrokeKind::Inside,
547                );
548                ui.painter().rect(
549                    cell,
550                    segment_radius,
551                    p.card,
552                    Stroke::new(1.0, p.border),
553                    StrokeKind::Inside,
554                );
555            }
556
557            // Per-segment content.
558            for (i, prep) in prepared.iter().enumerate() {
559                let cell_rect = cell_rects[i];
560                let is_active = active_idx == Some(i);
561                let is_hovered = hovered_idx == Some(i) && !is_active;
562
563                let text_color = if !prep.enabled {
564                    with_alpha(p.text_faint, 160)
565                } else if is_active || is_hovered {
566                    p.text
567                } else {
568                    p.text_muted
569                };
570
571                // Recompute content width to centre.
572                let count_pill_w = prep
573                    .count
574                    .as_ref()
575                    .map(|g| (g.size().x + 10.0).max(count_h));
576                let mut content_w = 0.0_f32;
577                if prep.dot.is_some() {
578                    content_w += DOT_SIZE;
579                }
580                if let Some(g) = &prep.icon {
581                    if content_w > 0.0 {
582                        content_w += INNER_GAP;
583                    }
584                    content_w += g.size().x;
585                }
586                if let Some(g) = &prep.label {
587                    if content_w > 0.0 {
588                        content_w += INNER_GAP;
589                    }
590                    content_w += g.size().x;
591                }
592                if let Some(w) = count_pill_w {
593                    if content_w > 0.0 {
594                        content_w += INNER_GAP;
595                    }
596                    content_w += w;
597                }
598
599                let mut cx = cell_rect.center().x - content_w * 0.5;
600                let cy = cell_rect.center().y;
601
602                if let Some(dot) = prep.dot {
603                    let mut col = dot_color(dot, &theme, is_active);
604                    if !prep.enabled {
605                        col = with_alpha(col, 120);
606                    }
607                    ui.painter()
608                        .circle_filled(pos2(cx + DOT_SIZE * 0.5, cy), DOT_SIZE * 0.5, col);
609                    cx += DOT_SIZE;
610                }
611                if let Some(icon) = &prep.icon {
612                    if cx > cell_rect.center().x - content_w * 0.5 {
613                        cx += INNER_GAP;
614                    }
615                    let pos = pos2(cx, cy - icon.size().y * 0.5);
616                    ui.painter().galley(pos, icon.clone(), text_color);
617                    cx += icon.size().x;
618                }
619                if let Some(label) = &prep.label {
620                    if cx > cell_rect.center().x - content_w * 0.5 {
621                        cx += INNER_GAP;
622                    }
623                    let pos = pos2(cx, cy - label.size().y * 0.5);
624                    ui.painter().galley(pos, label.clone(), text_color);
625                    cx += label.size().x;
626                }
627                if let (Some(g), Some(pill_w)) = (&prep.count, count_pill_w) {
628                    if cx > cell_rect.center().x - content_w * 0.5 {
629                        cx += INNER_GAP;
630                    }
631                    let pill_rect = Rect::from_min_size(
632                        pos2(cx, cy - count_h * 0.5),
633                        Vec2::new(pill_w, count_h),
634                    );
635                    let (pill_bg, pill_fg) = if is_active {
636                        (with_alpha(p.sky, 50), p.sky)
637                    } else if !prep.enabled {
638                        (with_alpha(p.text_faint, 35), with_alpha(p.text_faint, 200))
639                    } else {
640                        (with_alpha(p.text_muted, 45), p.text_muted)
641                    };
642                    ui.painter().rect(
643                        pill_rect,
644                        CornerRadius::same(99),
645                        pill_bg,
646                        Stroke::NONE,
647                        StrokeKind::Inside,
648                    );
649                    let text_pos = pos2(
650                        pill_rect.center().x - g.size().x * 0.5,
651                        pill_rect.center().y - g.size().y * 0.5,
652                    );
653                    ui.painter().galley(text_pos, g.clone(), pill_fg);
654                }
655            }
656        }
657
658        // 6. Per-segment a11y info.
659        for (i, (cell_resp, prep)) in cell_responses.iter().zip(prepared.iter()).enumerate() {
660            let label = prep.a11y.clone();
661            let enabled = prep.enabled;
662            let selected = active_idx == Some(i);
663            cell_resp.widget_info(|| {
664                WidgetInfo::selected(WidgetType::RadioButton, enabled, selected, &label)
665            });
666        }
667        response
668            .widget_info(|| WidgetInfo::labeled(WidgetType::RadioGroup, true, "segmented control"));
669        response
670    }
671}