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    selection: Selection<'a>,
261    segments: Vec<Segment>,
262    size: SegmentedSize,
263    fill: bool,
264}
265
266/// Selection model for [`SegmentedControl`].
267///
268/// The default constructors ([`SegmentedControl::new`],
269/// [`SegmentedControl::from_segments`]) produce a [`Selection::Single`]
270/// control where exactly one segment is active at a time. Use
271/// [`SegmentedControl::toggles`] to bind a parallel `&mut [bool]` and
272/// allow each segment to be toggled on or off independently — useful when
273/// "no selection" or "multiple selections" are valid states (e.g.,
274/// Server / Client save targets where neither, either, or both can apply).
275enum Selection<'a> {
276    Single(&'a mut usize),
277    Multi(&'a mut [bool]),
278}
279
280impl<'a> Selection<'a> {
281    fn is_active(&self, i: usize) -> bool {
282        match self {
283            Selection::Single(idx) => **idx == i,
284            Selection::Multi(states) => states.get(i).copied().unwrap_or(false),
285        }
286    }
287
288    fn click(&mut self, i: usize) {
289        match self {
290            Selection::Single(idx) => {
291                if **idx != i {
292                    **idx = i;
293                }
294            }
295            Selection::Multi(states) => {
296                if let Some(s) = states.get_mut(i) {
297                    *s = !*s;
298                }
299            }
300        }
301    }
302}
303
304impl<'a> std::fmt::Debug for SegmentedControl<'a> {
305    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
306        let mut d = f.debug_struct("SegmentedControl");
307        match &self.selection {
308            Selection::Single(idx) => {
309                d.field("mode", &"single");
310                d.field("selected", &**idx);
311            }
312            Selection::Multi(states) => {
313                d.field("mode", &"multi");
314                d.field("states", states);
315            }
316        }
317        d.field("segments", &self.segments)
318            .field("size", &self.size)
319            .field("fill", &self.fill)
320            .finish()
321    }
322}
323
324impl<'a> SegmentedControl<'a> {
325    /// Build a text-only single-select segmented control bound to
326    /// `selected` (a `&mut usize` index). Click-selects; the index is
327    /// always within `0..items.len()` after rendering.
328    pub fn new<I, S>(selected: &'a mut usize, items: I) -> Self
329    where
330        I: IntoIterator<Item = S>,
331        S: Into<WidgetText>,
332    {
333        Self {
334            selection: Selection::Single(selected),
335            segments: items.into_iter().map(Segment::text).collect(),
336            size: SegmentedSize::default(),
337            fill: false,
338        }
339    }
340
341    /// Build a single-select segmented control from explicit [`Segment`]s.
342    /// Use this when you need icons, dots, counts, or disabled states.
343    pub fn from_segments(
344        selected: &'a mut usize,
345        segments: impl IntoIterator<Item = Segment>,
346    ) -> Self {
347        Self {
348            selection: Selection::Single(selected),
349            segments: segments.into_iter().collect(),
350            size: SegmentedSize::default(),
351            fill: false,
352        }
353    }
354
355    /// Build a multi-select segmented control: each click toggles the
356    /// segment's bool independently, so any combination of segments
357    /// (including all on or all off) is a valid state. Visuals match the
358    /// single-select track. `states.len()` and `items` should have the
359    /// same length; extra labels render as always-off, extra states are
360    /// ignored.
361    ///
362    /// ```no_run
363    /// # use elegance::SegmentedControl;
364    /// # egui::__run_test_ui(|ui| {
365    /// let mut targets = [true, false]; // [server_on, client_on]
366    /// ui.add(SegmentedControl::toggles(&mut targets, ["Server", "Client"]));
367    /// # });
368    /// ```
369    pub fn toggles<I, S>(states: &'a mut [bool], items: I) -> Self
370    where
371        I: IntoIterator<Item = S>,
372        S: Into<WidgetText>,
373    {
374        Self {
375            selection: Selection::Multi(states),
376            segments: items.into_iter().map(Segment::text).collect(),
377            size: SegmentedSize::default(),
378            fill: false,
379        }
380    }
381
382    /// Pick a size preset. Default: [`SegmentedSize::Medium`].
383    #[inline]
384    pub fn size(mut self, size: SegmentedSize) -> Self {
385        self.size = size;
386        self
387    }
388
389    /// Force every segment to the same width and stretch the control to
390    /// fill the available horizontal space. Useful as a row affordance.
391    #[inline]
392    pub fn fill(mut self) -> Self {
393        self.fill = true;
394        self
395    }
396}
397
398struct Prepared {
399    icon: Option<Arc<Galley>>,
400    label: Option<Arc<Galley>>,
401    count: Option<Arc<Galley>>,
402    dot: Option<SegmentDot>,
403    enabled: bool,
404    a11y: String,
405    natural_w: f32,
406    natural_h: f32,
407}
408
409const INNER_GAP: f32 = 6.0;
410const DOT_SIZE: f32 = 6.0;
411
412fn count_galley(ui: &Ui, text: &str, size: f32) -> Arc<Galley> {
413    let rt = egui::RichText::new(text)
414        .color(Color32::PLACEHOLDER)
415        .size(size)
416        .strong();
417    egui::WidgetText::from(rt).into_galley(
418        ui,
419        Some(TextWrapMode::Extend),
420        f32::INFINITY,
421        FontSelection::FontId(FontId::monospace(size)),
422    )
423}
424
425fn dot_color(dot: SegmentDot, theme: &Theme, active: bool) -> Color32 {
426    let p = &theme.palette;
427    match dot {
428        SegmentDot::Neutral => {
429            if active {
430                p.sky
431            } else {
432                p.text_faint
433            }
434        }
435        SegmentDot::Sky => p.sky,
436        SegmentDot::Amber => p.warning,
437        SegmentDot::Red => p.danger,
438        SegmentDot::Green => p.success,
439    }
440}
441
442impl<'a> Widget for SegmentedControl<'a> {
443    fn ui(mut self, ui: &mut Ui) -> Response {
444        let theme = Theme::current(ui.ctx());
445        let p = &theme.palette;
446
447        let size = self.size;
448        let track_pad = size.track_pad();
449        let pad_x = size.pad_x();
450        let pad_y = size.pad_y();
451        let font_size = size.font_size(&theme);
452        let icon_size = size.icon_size(&theme);
453        let count_size = (font_size - 1.5).max(10.0);
454        let count_h = size.count_height();
455
456        // 1. Lay out each segment's content.
457        let mut prepared: Vec<Prepared> = Vec::with_capacity(self.segments.len());
458        for seg in &self.segments {
459            let icon = seg
460                .icon
461                .as_ref()
462                .map(|t| placeholder_galley(ui, t.text(), icon_size, false, f32::INFINITY));
463            let label = seg
464                .label
465                .as_ref()
466                .map(|t| placeholder_galley(ui, t.text(), font_size, true, f32::INFINITY));
467            let count = seg
468                .count
469                .as_ref()
470                .map(|t| count_galley(ui, t.text(), count_size));
471
472            let mut content_w = 0.0_f32;
473            let mut content_h = font_size;
474            if seg.dot.is_some() {
475                content_w += DOT_SIZE;
476                content_h = content_h.max(DOT_SIZE);
477            }
478            if let Some(g) = &icon {
479                if content_w > 0.0 {
480                    content_w += INNER_GAP;
481                }
482                content_w += g.size().x;
483                content_h = content_h.max(g.size().y);
484            }
485            if let Some(g) = &label {
486                if content_w > 0.0 {
487                    content_w += INNER_GAP;
488                }
489                content_w += g.size().x;
490                content_h = content_h.max(g.size().y);
491            }
492            if let Some(g) = &count {
493                if content_w > 0.0 {
494                    content_w += INNER_GAP;
495                }
496                let pill_w = (g.size().x + 10.0).max(count_h);
497                content_w += pill_w;
498                content_h = content_h.max(count_h);
499            }
500
501            prepared.push(Prepared {
502                icon,
503                label,
504                count,
505                dot: seg.dot,
506                enabled: seg.enabled,
507                a11y: seg.debug_label(),
508                natural_w: pad_x * 2.0 + content_w,
509                natural_h: pad_y * 2.0 + content_h,
510            });
511        }
512
513        // 2. Resolve cell widths.
514        let segment_h = prepared
515            .iter()
516            .map(|s| s.natural_h)
517            .fold(font_size + pad_y * 2.0, f32::max);
518
519        let cell_widths: Vec<f32> = if self.fill && !prepared.is_empty() {
520            let avail = (ui.available_width() - track_pad * 2.0).max(0.0);
521            let max_natural = prepared.iter().map(|s| s.natural_w).fold(0.0_f32, f32::max);
522            let cell_w = (avail / prepared.len() as f32).max(max_natural);
523            prepared.iter().map(|_| cell_w).collect()
524        } else {
525            prepared.iter().map(|s| s.natural_w).collect()
526        };
527
528        let total_w = track_pad * 2.0 + cell_widths.iter().sum::<f32>();
529        let total_h = track_pad * 2.0 + segment_h;
530
531        // 3. Allocate the outer track rect. We use its auto-allocated id as the
532        //    base for per-segment interact ids, so multiple SegmentedControls
533        //    within the same parent never collide.
534        let (track_rect, response) =
535            ui.allocate_exact_size(Vec2::new(total_w, total_h), Sense::hover());
536        let base_id = response.id;
537
538        // 4. Allocate per-segment interact rects (each is its own focus target).
539        let mut x = track_rect.min.x + track_pad;
540        let segment_y = track_rect.min.y + track_pad;
541        let mut cell_rects: Vec<Rect> = Vec::with_capacity(prepared.len());
542        let mut cell_responses: Vec<Response> = Vec::with_capacity(prepared.len());
543        for (i, prep) in prepared.iter().enumerate() {
544            let cell_rect =
545                Rect::from_min_size(pos2(x, segment_y), Vec2::new(cell_widths[i], segment_h));
546            x += cell_widths[i];
547            let sense = if prep.enabled {
548                Sense::click()
549            } else {
550                Sense::hover()
551            };
552            let cell_resp = ui.interact(cell_rect, base_id.with(("seg", i)), sense);
553            if prep.enabled && cell_resp.clicked() {
554                self.selection.click(i);
555            }
556            cell_rects.push(cell_rect);
557            cell_responses.push(cell_resp);
558        }
559
560        // Per-segment "is active" lookup, branched on selection model.
561        // Disabled segments never paint as active, even if the binding
562        // says they should.
563        let is_active = |i: usize| -> bool {
564            i < prepared.len() && prepared[i].enabled && self.selection.is_active(i)
565        };
566        let hovered_idx = cell_responses
567            .iter()
568            .zip(prepared.iter())
569            .position(|(r, prep)| prep.enabled && r.hovered());
570
571        // 5. Paint.
572        if ui.is_rect_visible(track_rect) {
573            let track_radius = CornerRadius::same(size.track_radius());
574            ui.painter().rect(
575                track_rect,
576                track_radius,
577                p.input_bg,
578                Stroke::new(1.0, p.border),
579                StrokeKind::Inside,
580            );
581
582            // Dividers between adjacent inactive, non-hovered segments.
583            for (i, cell) in cell_rects.iter().enumerate().skip(1) {
584                let left_busy = is_active(i - 1) || hovered_idx == Some(i - 1);
585                let right_busy = is_active(i) || hovered_idx == Some(i);
586                if left_busy || right_busy {
587                    continue;
588                }
589                let div_x = cell.min.x.round() - 0.5;
590                let dy = (segment_h * 0.30).min(8.0);
591                ui.painter().line_segment(
592                    [pos2(div_x, cell.min.y + dy), pos2(div_x, cell.max.y - dy)],
593                    Stroke::new(1.0, with_alpha(p.border, 200)),
594                );
595            }
596
597            let segment_radius = CornerRadius::same(size.segment_radius());
598
599            // Hovered fill (drawn before active, so an active+hover doesn't double-stack).
600            if let Some(h) = hovered_idx {
601                if !is_active(h) {
602                    let hover_fill = with_alpha(p.text, if p.is_dark { 14 } else { 18 });
603                    ui.painter().rect(
604                        cell_rects[h].shrink(0.5),
605                        segment_radius,
606                        hover_fill,
607                        Stroke::NONE,
608                        StrokeKind::Inside,
609                    );
610                }
611            }
612
613            // Active fill(s): drop-shadow + card-coloured pill on every
614            // active segment. Multi-select can have several at once.
615            for (i, cell_rect) in cell_rects.iter().enumerate().take(prepared.len()) {
616                if !is_active(i) {
617                    continue;
618                }
619                let cell = cell_rect.shrink(0.5);
620                let shadow = cell.translate(Vec2::new(0.0, 1.0));
621                ui.painter().rect(
622                    shadow,
623                    segment_radius,
624                    with_alpha(Color32::BLACK, if p.is_dark { 70 } else { 28 }),
625                    Stroke::NONE,
626                    StrokeKind::Inside,
627                );
628                ui.painter().rect(
629                    cell,
630                    segment_radius,
631                    p.card,
632                    Stroke::new(1.0, p.border),
633                    StrokeKind::Inside,
634                );
635            }
636
637            // Per-segment content.
638            for (i, prep) in prepared.iter().enumerate() {
639                let cell_rect = cell_rects[i];
640                let active = is_active(i);
641                let hovered = hovered_idx == Some(i) && !active;
642
643                let text_color = if !prep.enabled {
644                    with_alpha(p.text_faint, 160)
645                } else if active || hovered {
646                    p.text
647                } else {
648                    p.text_muted
649                };
650
651                // Recompute content width to centre.
652                let count_pill_w = prep
653                    .count
654                    .as_ref()
655                    .map(|g| (g.size().x + 10.0).max(count_h));
656                let mut content_w = 0.0_f32;
657                if prep.dot.is_some() {
658                    content_w += DOT_SIZE;
659                }
660                if let Some(g) = &prep.icon {
661                    if content_w > 0.0 {
662                        content_w += INNER_GAP;
663                    }
664                    content_w += g.size().x;
665                }
666                if let Some(g) = &prep.label {
667                    if content_w > 0.0 {
668                        content_w += INNER_GAP;
669                    }
670                    content_w += g.size().x;
671                }
672                if let Some(w) = count_pill_w {
673                    if content_w > 0.0 {
674                        content_w += INNER_GAP;
675                    }
676                    content_w += w;
677                }
678
679                let mut cx = cell_rect.center().x - content_w * 0.5;
680                let cy = cell_rect.center().y;
681
682                if let Some(dot) = prep.dot {
683                    let mut col = dot_color(dot, &theme, active);
684                    if !prep.enabled {
685                        col = with_alpha(col, 120);
686                    }
687                    ui.painter()
688                        .circle_filled(pos2(cx + DOT_SIZE * 0.5, cy), DOT_SIZE * 0.5, col);
689                    cx += DOT_SIZE;
690                }
691                if let Some(icon) = &prep.icon {
692                    if cx > cell_rect.center().x - content_w * 0.5 {
693                        cx += INNER_GAP;
694                    }
695                    let pos = pos2(cx, cy - icon.size().y * 0.5);
696                    ui.painter().galley(pos, icon.clone(), text_color);
697                    cx += icon.size().x;
698                }
699                if let Some(label) = &prep.label {
700                    if cx > cell_rect.center().x - content_w * 0.5 {
701                        cx += INNER_GAP;
702                    }
703                    let pos = pos2(cx, cy - label.size().y * 0.5);
704                    ui.painter().galley(pos, label.clone(), text_color);
705                    cx += label.size().x;
706                }
707                if let (Some(g), Some(pill_w)) = (&prep.count, count_pill_w) {
708                    if cx > cell_rect.center().x - content_w * 0.5 {
709                        cx += INNER_GAP;
710                    }
711                    let pill_rect = Rect::from_min_size(
712                        pos2(cx, cy - count_h * 0.5),
713                        Vec2::new(pill_w, count_h),
714                    );
715                    let (pill_bg, pill_fg) = if active {
716                        (with_alpha(p.sky, 50), p.sky)
717                    } else if !prep.enabled {
718                        (with_alpha(p.text_faint, 35), with_alpha(p.text_faint, 200))
719                    } else {
720                        (with_alpha(p.text_muted, 45), p.text_muted)
721                    };
722                    ui.painter().rect(
723                        pill_rect,
724                        CornerRadius::same(99),
725                        pill_bg,
726                        Stroke::NONE,
727                        StrokeKind::Inside,
728                    );
729                    let text_pos = pos2(
730                        pill_rect.center().x - g.size().x * 0.5,
731                        pill_rect.center().y - g.size().y * 0.5,
732                    );
733                    ui.painter().galley(text_pos, g.clone(), pill_fg);
734                }
735            }
736        }
737
738        // 6. Per-segment a11y info. Single-select reads as RadioButton(s)
739        // in a RadioGroup; multi-select reads as Checkbox(es) in a Group
740        // so a screen reader announces the right semantics.
741        let multi = matches!(self.selection, Selection::Multi(_));
742        let segment_role = if multi {
743            WidgetType::Checkbox
744        } else {
745            WidgetType::RadioButton
746        };
747        let group_role = if multi {
748            WidgetType::Other
749        } else {
750            WidgetType::RadioGroup
751        };
752        for (i, (cell_resp, prep)) in cell_responses.iter().zip(prepared.iter()).enumerate() {
753            let label = prep.a11y.clone();
754            let enabled = prep.enabled;
755            let selected = is_active(i);
756            cell_resp.widget_info(|| WidgetInfo::selected(segment_role, enabled, selected, &label));
757        }
758        response.widget_info(|| WidgetInfo::labeled(group_role, true, "segmented control"));
759        response
760    }
761}