Skip to main content

agg_gui/widgets/
segmented.rs

1//! `SegmentedControl` — a macOS-style segmented picker: one joined track,
2//! several labelled segments, exactly one selected.
3//!
4//! The SwiftUI `Picker(...).pickerStyle(.segmented)` / AppKit
5//! `NSSegmentedControl` look: a single rounded track whose OUTER corners
6//! are rounded, segments butted together with no gap and 1-px hairline
7//! dividers between them, and the selected segment filled with the
8//! theme's accent surface. Compared with composing `Button`s in a
9//! `FlexRow` (the `with_subtle` + `with_active_fn` recipe), this widget
10//! owns the whole strip, so the corners, dividers and keyboard model
11//! (Left/Right move the selection) are right by construction.
12//!
13//! Selection is bound to an `Rc<Cell<usize>>` the caller owns — the same
14//! bidirectional-cell pattern as `RadioGroup::with_selected_cell` and
15//! `ToggleSwitch::with_state_cell`: the cell is read every layout / paint
16//! and written whenever the user picks a segment, so external writes show
17//! up on the next frame without rebuilding the widget.
18//!
19//! Each segment label is a real [`Label`] child (like `RadioGroup`) so
20//! glyph rasterization is cached and the inspector tree mirrors the
21//! visible structure; this widget's `paint()` only draws the chrome and
22//! retints the labels.
23//!
24//! Unit tests live in `segmented_tests.rs` (pulled in as a child module)
25//! to keep this file under the 800-line cap.
26
27#[cfg(test)]
28#[path = "segmented_tests.rs"]
29mod tests;
30
31use std::cell::Cell;
32use std::rc::Rc;
33use std::sync::Arc;
34
35use crate::color::Color;
36use crate::draw_ctx::DrawCtx;
37use crate::event::{Event, EventResult, Key, MouseButton};
38use crate::geometry::{Point, Rect, Size};
39use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
40use crate::text::Font;
41use crate::theme::Visuals;
42use crate::widget::Widget;
43use crate::widgets::label::{Label, LabelAlign};
44
45/// Control height in the regular size.
46const REGULAR_H: f64 = 24.0;
47/// Control height in the compact size (`with_compact`).
48const COMPACT_H: f64 = 20.0;
49/// Default label font size, regular / compact.
50const REGULAR_FONT: f64 = 13.0;
51const COMPACT_FONT: f64 = 11.0;
52/// Horizontal padding inside a segment on each side of its label.
53const REGULAR_PAD: f64 = 12.0;
54const COMPACT_PAD: f64 = 8.0;
55/// Outer corner radius of the track.
56const REGULAR_R: f64 = 6.0;
57const COMPACT_R: f64 = 5.0;
58/// Gap between the track edge and the selected / hovered segment fill,
59/// so the filled segment reads as a raised pill inside the track.
60const FILL_INSET: f64 = 1.0;
61/// Vertical inset of the hairline dividers from the track edges.
62const DIVIDER_INSET: f64 = 4.0;
63/// Bezier circle approximation constant for the per-corner rounded path.
64const KAPPA: f64 = 0.552_284_75;
65
66/// A macOS-style segmented control. See the module docs.
67pub struct SegmentedControl {
68    bounds: Rect,
69    /// One `Label` child per segment, in order.
70    children: Vec<Box<dyn Widget>>,
71    base: WidgetBase,
72    labels: Vec<String>,
73    /// Shared selection cell — the source of truth for the selected index.
74    selected: Rc<Cell<usize>>,
75    font: Arc<Font>,
76    font_size: f64,
77    compact: bool,
78    /// `true`: each segment takes its intrinsic label width. `false`
79    /// (default): all segments share the width of the widest label, the
80    /// macOS default.
81    fit_width: bool,
82    on_change: Option<Box<dyn FnMut(usize)>>,
83    /// Live enabled gate for the whole control (`None` = enabled).
84    enabled_fn: Option<Rc<dyn Fn() -> bool>>,
85    /// Live per-segment enabled gate, by index (`None` = all enabled).
86    segment_enabled_fn: Option<Rc<dyn Fn(usize) -> bool>>,
87    /// Segment rects in widget-local coordinates, computed at layout.
88    segments: Vec<Rect>,
89    hovered: Option<usize>,
90    pressed: Option<usize>,
91    focused: bool,
92}
93
94impl SegmentedControl {
95    /// Create a control with one segment per label, bound to `selected`.
96    /// `font` renders the labels (the crate has no default typeface —
97    /// same as `Button::new` / `RadioGroup::new`).
98    pub fn new(labels: Vec<impl Into<String>>, selected: Rc<Cell<usize>>, font: Arc<Font>) -> Self {
99        let labels: Vec<String> = labels.into_iter().map(Into::into).collect();
100        let font_size = REGULAR_FONT;
101        let children = Self::build_labels(&labels, &font, font_size);
102        let ctl = Self {
103            bounds: Rect::default(),
104            children,
105            base: WidgetBase::new(),
106            labels,
107            selected,
108            font,
109            font_size,
110            compact: false,
111            fit_width: false,
112            on_change: None,
113            enabled_fn: None,
114            segment_enabled_fn: None,
115            segments: Vec::new(),
116            hovered: None,
117            pressed: None,
118            focused: false,
119        };
120        ctl.clamp_selection();
121        ctl
122    }
123
124    fn build_labels(labels: &[String], font: &Arc<Font>, font_size: f64) -> Vec<Box<dyn Widget>> {
125        labels
126            .iter()
127            .map(|text| {
128                Box::new(
129                    Label::new(text.as_str(), Arc::clone(font))
130                        .with_font_size(font_size)
131                        .with_align(LabelAlign::Center),
132                ) as Box<dyn Widget>
133            })
134            .collect()
135    }
136
137    /// Label font size (default 13, or 11 in compact mode).
138    pub fn with_font_size(mut self, size: f64) -> Self {
139        self.font_size = size;
140        self.children = Self::build_labels(&self.labels, &self.font, size);
141        self
142    }
143
144    /// Compact size: shorter track, smaller type, tighter padding
145    /// (SwiftUI `.controlSize(.small)`). Keeps an explicit
146    /// `with_font_size` if one was set before this call.
147    pub fn with_compact(mut self) -> Self {
148        self.compact = true;
149        if (self.font_size - REGULAR_FONT).abs() < f64::EPSILON {
150            self.font_size = COMPACT_FONT;
151            self.children = Self::build_labels(&self.labels, &self.font, COMPACT_FONT);
152        }
153        self
154    }
155
156    /// Size each segment to its own label instead of giving every segment
157    /// the width of the widest one.
158    pub fn with_fit_width(mut self, fit: bool) -> Self {
159        self.fit_width = fit;
160        self
161    }
162
163    /// Called with the new index whenever the user changes the selection
164    /// (pointer or keyboard). Not called for external cell writes.
165    pub fn on_change(mut self, cb: impl FnMut(usize) + 'static) -> Self {
166        self.on_change = Some(Box::new(cb));
167        self
168    }
169
170    /// Gate the whole control on a live predicate: while it returns
171    /// `false` the control paints disabled and ignores input.
172    pub fn with_enabled_fn(mut self, f: impl Fn() -> bool + 'static) -> Self {
173        self.enabled_fn = Some(Rc::new(f));
174        self
175    }
176
177    /// Gate individual segments on a live predicate over the segment
178    /// index. Disabled segments paint dimmed, don't hover, can't be
179    /// clicked, and are skipped by Left/Right.
180    pub fn with_segment_enabled_fn(mut self, f: impl Fn(usize) -> bool + 'static) -> Self {
181        self.segment_enabled_fn = Some(Rc::new(f));
182        self
183    }
184
185    pub fn with_margin(mut self, m: Insets) -> Self {
186        self.base.margin = m;
187        self
188    }
189    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
190        self.base.h_anchor = h;
191        self
192    }
193    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
194        self.base.v_anchor = v;
195        self
196    }
197    pub fn with_min_size(mut self, s: Size) -> Self {
198        self.base.min_size = s;
199        self
200    }
201    pub fn with_max_size(mut self, s: Size) -> Self {
202        self.base.max_size = s;
203        self
204    }
205
206    /// Currently selected index (clamped to the label count).
207    pub fn selected(&self) -> usize {
208        let n = self.labels.len();
209        if n == 0 {
210            0
211        } else {
212            self.selected.get().min(n - 1)
213        }
214    }
215
216    /// Programmatic selection: writes the cell, does NOT fire `on_change`.
217    pub fn set_selected(&mut self, idx: usize) {
218        if idx < self.labels.len() {
219            self.selected.set(idx);
220            crate::animation::request_draw();
221        }
222    }
223
224    /// Shared selection cell.
225    pub fn selected_cell(&self) -> Rc<Cell<usize>> {
226        Rc::clone(&self.selected)
227    }
228
229    pub fn labels(&self) -> &[String] {
230        &self.labels
231    }
232
233    /// Clamp an out-of-range cell value back into the label range.
234    fn clamp_selection(&self) {
235        let n = self.labels.len();
236        if n > 0 && self.selected.get() >= n {
237            self.selected.set(n - 1);
238        }
239    }
240
241    fn is_enabled(&self) -> bool {
242        self.enabled_fn.as_ref().map(|f| f()).unwrap_or(true)
243    }
244
245    fn segment_enabled(&self, i: usize) -> bool {
246        self.is_enabled()
247            && self
248                .segment_enabled_fn
249                .as_ref()
250                .map(|f| f(i))
251                .unwrap_or(true)
252    }
253
254    fn height(&self) -> f64 {
255        let base = if self.compact { COMPACT_H } else { REGULAR_H };
256        base.max(self.font_size * 1.6)
257            .max(self.base.min_size.height)
258    }
259
260    fn corner_radius(&self) -> f64 {
261        if self.compact {
262            COMPACT_R
263        } else {
264            REGULAR_R
265        }
266    }
267
268    fn pad_h(&self) -> f64 {
269        if self.compact {
270            COMPACT_PAD
271        } else {
272            REGULAR_PAD
273        }
274    }
275
276    /// Index of the segment containing `pos`, if any.
277    fn segment_at(&self, pos: Point) -> Option<usize> {
278        self.segments.iter().position(|r| {
279            pos.x >= r.x && pos.x < r.x + r.width && pos.y >= r.y && pos.y < r.y + r.height
280        })
281    }
282
283    /// Select `idx` as a user action: writes the cell and fires
284    /// `on_change` when the value actually changed.
285    fn choose(&mut self, idx: usize) {
286        if idx >= self.labels.len() || !self.segment_enabled(idx) {
287            return;
288        }
289        let was = self.selected();
290        self.selected.set(idx);
291        if was != idx {
292            if let Some(cb) = self.on_change.as_mut() {
293                cb(idx);
294            }
295        }
296        crate::animation::request_draw();
297    }
298
299    /// Next enabled segment from `from` in direction `dir` (+1 / -1).
300    fn step_selection(&self, from: usize, dir: i64) -> Option<usize> {
301        let n = self.labels.len() as i64;
302        let mut i = from as i64 + dir;
303        while i >= 0 && i < n {
304            if self.segment_enabled(i as usize) {
305                return Some(i as usize);
306            }
307            i += dir;
308        }
309        None
310    }
311
312    /// Compute segment widths for the given available width. Pure w.r.t.
313    /// widget state except for the label children's measured widths.
314    fn segment_widths(&mut self, available: Size) -> Vec<f64> {
315        let n = self.labels.len();
316        if n == 0 {
317            return Vec::new();
318        }
319        let h = self.height();
320        let pad = self.pad_h();
321        let natural: Vec<f64> = self
322            .children
323            .iter_mut()
324            .map(|c| c.layout(Size::new(available.width.max(0.0), h)).width + 2.0 * pad)
325            .collect();
326        let mut widths = if self.fit_width {
327            natural
328        } else {
329            let w = natural.iter().cloned().fold(0.0, f64::max);
330            vec![w; n]
331        };
332        let total: f64 = widths.iter().sum();
333        let target = if self.base.h_anchor.is_stretch() {
334            available.width.max(self.base.min_size.width)
335        } else {
336            total.max(self.base.min_size.width)
337        };
338        let target = if available.width > 0.0 {
339            target.min(available.width)
340        } else {
341            target
342        };
343        if total > 0.0 && (total - target).abs() > 1e-6 {
344            if self.fit_width {
345                // Share the surplus (or deficit) proportionally so the
346                // relative label room is preserved.
347                let k = target / total;
348                for w in widths.iter_mut() {
349                    *w *= k;
350                }
351            } else {
352                // Whole-pixel segments: floor the share and hand the
353                // leftover pixels to the leading segments one each, so the
354                // hairline dividers land on pixel boundaries instead of
355                // every segment carrying the same fractional width.
356                Self::distribute_whole_pixels(&mut widths, target);
357            }
358        }
359        widths
360    }
361
362    /// Split `target` into `widths.len()` whole-pixel parts: each gets
363    /// `floor(target / n)` and the first `target mod n` parts one pixel
364    /// more, so the parts sum to `floor(target)` and differ by at most one.
365    fn distribute_whole_pixels(widths: &mut [f64], target: f64) {
366        let n = widths.len();
367        if n == 0 {
368            return;
369        }
370        let whole = target.max(0.0).floor();
371        let base = (whole / n as f64).floor();
372        let extra = (whole - base * n as f64) as usize;
373        for (i, w) in widths.iter_mut().enumerate() {
374            *w = if i < extra { base + 1.0 } else { base };
375        }
376    }
377
378    /// Append a rectangle with independent left / right corner radii to
379    /// the current path. Built from lines and cubic arcs so it renders
380    /// identically on every `DrawCtx` backend.
381    fn path_rect_corners(ctx: &mut dyn DrawCtx, r: Rect, rl: f64, rr: f64) {
382        let rl = rl.min(r.width * 0.5).min(r.height * 0.5).max(0.0);
383        let rr = rr.min(r.width * 0.5).min(r.height * 0.5).max(0.0);
384        let (x0, y0, x1, y1) = (r.x, r.y, r.x + r.width, r.y + r.height);
385        let kl = rl * KAPPA;
386        let kr = rr * KAPPA;
387        ctx.move_to(x0 + rl, y0);
388        ctx.line_to(x1 - rr, y0);
389        if rr > 0.0 {
390            ctx.cubic_to(x1 - rr + kr, y0, x1, y0 + rr - kr, x1, y0 + rr);
391        }
392        ctx.line_to(x1, y1 - rr);
393        if rr > 0.0 {
394            ctx.cubic_to(x1, y1 - rr + kr, x1 - rr + kr, y1, x1 - rr, y1);
395        }
396        ctx.line_to(x0 + rl, y1);
397        if rl > 0.0 {
398            ctx.cubic_to(x0 + rl - kl, y1, x0, y1 - rl + kl, x0, y1 - rl);
399        }
400        ctx.line_to(x0, y0 + rl);
401        if rl > 0.0 {
402            ctx.cubic_to(x0, y0 + rl - kl, x0 + rl - kl, y0, x0 + rl, y0);
403        }
404        ctx.close_path();
405    }
406
407    /// Fill segment `i`'s rect (inset by [`FILL_INSET`]) with `color`,
408    /// rounding only the corners that coincide with the track's outer
409    /// corners.
410    fn fill_segment(&self, ctx: &mut dyn DrawCtx, i: usize, color: Color) {
411        let Some(seg) = self.segments.get(i) else {
412            return;
413        };
414        let n = self.segments.len();
415        let outer_r = (self.corner_radius() - FILL_INSET).max(0.0);
416        let rl = if i == 0 { outer_r } else { 0.0 };
417        let rr = if i + 1 == n { outer_r } else { 0.0 };
418        let inset = Rect::new(
419            seg.x + if i == 0 { FILL_INSET } else { 0.0 },
420            seg.y + FILL_INSET,
421            (seg.width
422                - if i == 0 { FILL_INSET } else { 0.0 }
423                - if i + 1 == n { FILL_INSET } else { 0.0 })
424            .max(0.0),
425            (seg.height - 2.0 * FILL_INSET).max(0.0),
426        );
427        ctx.set_fill_color(color);
428        ctx.begin_path();
429        Self::path_rect_corners(ctx, inset, rl, rr);
430        ctx.fill();
431    }
432
433    /// Foreground / chrome colours for the disabled control, mirroring
434    /// `Button`'s disabled palette so mixed rows of controls grey out alike.
435    fn disabled_colors(v: &Visuals) -> (Color, Color, Color) {
436        if v.is_dark() {
437            (
438                v.window_fill,
439                Color::rgba(1.0, 1.0, 1.0, 0.22),
440                v.text_dim.with_alpha(0.42),
441            )
442        } else {
443            (v.track_bg, v.widget_stroke.with_alpha(0.45), v.text_dim)
444        }
445    }
446
447    /// Ink colour a segment label should use for the current state.
448    fn label_color(&self, v: &Visuals, i: usize, selected: usize, enabled: bool) -> Color {
449        if !enabled || !self.segment_enabled(i) {
450            Self::disabled_colors(v).2
451        } else if i == selected {
452            // Light ink on the accent fill, like `ButtonTheme::default`.
453            Color::white()
454        } else {
455            v.text_color
456        }
457    }
458
459    fn handle_key(&mut self, key: &Key) -> EventResult {
460        let n = self.labels.len();
461        if n == 0 {
462            return EventResult::Ignored;
463        }
464        let cur = self.selected();
465        let next = match key {
466            Key::ArrowLeft | Key::ArrowUp => self.step_selection(cur, -1),
467            Key::ArrowRight | Key::ArrowDown => self.step_selection(cur, 1),
468            Key::Home => (0..n).find(|&i| self.segment_enabled(i)),
469            Key::End => (0..n).rev().find(|&i| self.segment_enabled(i)),
470            _ => return EventResult::Ignored,
471        };
472        match next {
473            Some(i) if i != cur => {
474                self.choose(i);
475                EventResult::Consumed
476            }
477            // A consumed no-op keeps Left at the first segment from
478            // scrolling an enclosing ScrollView, matching RadioGroup.
479            Some(_) => EventResult::Consumed,
480            None => EventResult::Ignored,
481        }
482    }
483}
484
485impl Widget for SegmentedControl {
486    fn type_name(&self) -> &'static str {
487        "SegmentedControl"
488    }
489    fn bounds(&self) -> Rect {
490        self.bounds
491    }
492    fn set_bounds(&mut self, b: Rect) {
493        self.bounds = b;
494    }
495    fn children(&self) -> &[Box<dyn Widget>] {
496        &self.children
497    }
498    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
499        &mut self.children
500    }
501    fn is_focusable(&self) -> bool {
502        self.is_enabled() && !self.labels.is_empty()
503    }
504    fn margin(&self) -> Insets {
505        self.base.margin
506    }
507    fn widget_base(&self) -> Option<&WidgetBase> {
508        Some(&self.base)
509    }
510    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
511        Some(&mut self.base)
512    }
513    fn h_anchor(&self) -> HAnchor {
514        self.base.h_anchor
515    }
516    fn v_anchor(&self) -> VAnchor {
517        self.base.v_anchor
518    }
519    fn min_size(&self) -> Size {
520        self.base.min_size
521    }
522    fn max_size(&self) -> Size {
523        self.base.max_size
524    }
525
526    fn measure_min_height(&self, _available_w: f64) -> f64 {
527        self.height()
528    }
529
530    fn layout(&mut self, available: Size) -> Size {
531        self.clamp_selection();
532        let h = self.height();
533        let widths = self.segment_widths(available);
534        let mut x = 0.0;
535        self.segments.clear();
536        for (i, w) in widths.iter().enumerate() {
537            let seg = Rect::new(x, 0.0, *w, h);
538            self.segments.push(seg);
539            if let Some(child) = self.children.get_mut(i) {
540                let s = child.layout(Size::new(*w, h));
541                let lw = s.width.min(*w);
542                child.set_bounds(Rect::new(
543                    x + ((*w - lw) * 0.5).max(0.0),
544                    ((h - s.height) * 0.5).max(0.0),
545                    lw,
546                    s.height,
547                ));
548            }
549            x += w;
550        }
551        let size = Size::new(x, h);
552        self.bounds = Rect::new(self.bounds.x, self.bounds.y, size.width, size.height);
553        size
554    }
555
556    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
557        let v = ctx.visuals();
558        let w = self.bounds.width;
559        let h = self.bounds.height;
560        let r = self.corner_radius();
561        let enabled = self.is_enabled();
562        let selected = self.selected();
563        let n = self.segments.len();
564        let (disabled_bg, disabled_stroke, _) = Self::disabled_colors(&v);
565
566        // Focus ring just inside the bounds (see Button for why inside).
567        if enabled && self.focused {
568            ctx.set_stroke_color(v.accent_focus);
569            ctx.set_line_width(1.5);
570            ctx.begin_path();
571            ctx.rounded_rect(0.75, 0.75, (w - 1.5).max(0.0), (h - 1.5).max(0.0), r);
572            ctx.stroke();
573        }
574
575        // Track.
576        ctx.set_fill_color(if enabled { v.widget_bg } else { disabled_bg });
577        ctx.begin_path();
578        ctx.rounded_rect(0.0, 0.0, w, h, r);
579        ctx.fill();
580
581        if n > 0 {
582            // Hovered (unselected, enabled) segment: subtle raised fill.
583            if enabled {
584                if let Some(hi) = self.hovered {
585                    if hi != selected && self.segment_enabled(hi) {
586                        self.fill_segment(ctx, hi, v.widget_bg_hovered);
587                    }
588                }
589            }
590            // Selected segment: accent fill (pressed / hovered shades).
591            if enabled {
592                let fill = if self.pressed == Some(selected) {
593                    v.accent_pressed
594                } else if self.hovered == Some(selected) {
595                    v.accent_hovered
596                } else {
597                    v.accent
598                };
599                self.fill_segment(ctx, selected, fill);
600            } else {
601                self.fill_segment(ctx, selected, v.widget_bg_hovered);
602            }
603            // Hairline dividers between segments, skipped next to the
604            // selected segment so its fill reads as one raised pill.
605            ctx.set_stroke_color(if enabled {
606                v.widget_stroke
607            } else {
608                disabled_stroke
609            });
610            ctx.set_line_width(1.0);
611            for i in 1..n {
612                if i == selected || i - 1 == selected {
613                    continue;
614                }
615                let x = self.segments[i].x.round() + 0.5;
616                ctx.begin_path();
617                ctx.move_to(x, DIVIDER_INSET);
618                ctx.line_to(x, h - DIVIDER_INSET);
619                ctx.stroke();
620            }
621        }
622
623        // Track outline.
624        ctx.set_stroke_color(if enabled {
625            v.widget_stroke
626        } else {
627            disabled_stroke
628        });
629        ctx.set_line_width(1.0);
630        ctx.begin_path();
631        ctx.rounded_rect(0.5, 0.5, (w - 1.0).max(0.0), (h - 1.0).max(0.0), r);
632        ctx.stroke();
633
634        // Retint the label children for the current state; the framework
635        // paints them after this returns.
636        for i in 0..n {
637            let color = self.label_color(&v, i, selected, enabled);
638            if let Some(child) = self.children.get_mut(i) {
639                child.set_label_color(color);
640            }
641        }
642    }
643
644    fn on_event(&mut self, event: &Event) -> EventResult {
645        if !self.is_enabled() {
646            // Drop transient state so the control looks idle the moment
647            // it is disabled mid-interaction.
648            if self.hovered.is_some() || self.pressed.is_some() {
649                self.hovered = None;
650                self.pressed = None;
651                crate::animation::request_draw();
652            }
653            return EventResult::Ignored;
654        }
655        match event {
656            Event::MouseMove { pos } => {
657                let was = self.hovered;
658                self.hovered = self.segment_at(*pos).filter(|&i| self.segment_enabled(i));
659                if self.hovered.is_none() {
660                    self.pressed = None;
661                }
662                if was != self.hovered {
663                    crate::animation::request_draw();
664                    return EventResult::Consumed;
665                }
666                EventResult::Ignored
667            }
668            Event::MouseDown {
669                button: MouseButton::Left,
670                pos,
671                ..
672            } => match self.segment_at(*pos) {
673                Some(i) if self.segment_enabled(i) => {
674                    self.pressed = Some(i);
675                    crate::animation::request_draw();
676                    EventResult::Consumed
677                }
678                _ => EventResult::Ignored,
679            },
680            Event::MouseUp {
681                button: MouseButton::Left,
682                pos,
683                ..
684            } => {
685                let was = self.pressed.take();
686                match was {
687                    Some(i) => {
688                        // Like Button: fire only when the release lands on
689                        // the pressed segment (touch never sets hover).
690                        if self.segment_at(*pos) == Some(i) {
691                            self.choose(i);
692                        }
693                        crate::animation::request_draw();
694                        EventResult::Consumed
695                    }
696                    None => EventResult::Ignored,
697                }
698            }
699            Event::KeyDown { key, .. } => self.handle_key(key),
700            Event::FocusGained => {
701                let was = self.focused;
702                self.focused = true;
703                if !was {
704                    crate::animation::request_draw();
705                    EventResult::Consumed
706                } else {
707                    EventResult::Ignored
708                }
709            }
710            Event::FocusLost => {
711                let was = self.focused || self.pressed.is_some();
712                self.focused = false;
713                self.pressed = None;
714                if was {
715                    crate::animation::request_draw();
716                    EventResult::Consumed
717                } else {
718                    EventResult::Ignored
719                }
720            }
721            _ => EventResult::Ignored,
722        }
723    }
724
725    fn properties(&self) -> Vec<(&'static str, String)> {
726        vec![
727            ("segments", self.labels.join(" | ")),
728            ("selected", self.selected().to_string()),
729            ("compact", self.compact.to_string()),
730            ("fit_width", self.fit_width.to_string()),
731        ]
732    }
733}