Skip to main content

teksilo_widgets/
segmented_control.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! SegmentedControl — mutually exclusive segments in a horizontal row.
5//!
6//! Each segment is a real composed widget — a centered icon + label with
7//! a reactive tint — built from a [`Segment`] descriptor. Selection is
8//! bound to a `Signal<Option<SegmentId>>`: **keyed, not positional**, so
9//! inserting or removing a segment never silently re-points the
10//! selection at a different one. The chrome (rounded frame, hover tint,
11//! selected-segment surface) is delegated to the active
12//! [`SegmentedControlStyle`](teksilo_core::styles::SegmentedControlStyle).
13//!
14//! ```ignore
15//! const LIST: SegmentId = SegmentId::from_u64(1);
16//! const GRID: SegmentId = SegmentId::from_u64(2);
17//!
18//! let view = ctx.signal(Some(LIST));
19//! SegmentedControl::new(view.clone())
20//!     .segment(Segment::new(tr!(list_view())).id(LIST).icon(|| IconWidget::list(14.0)))
21//!     .segment(Segment::new(tr!(grid_view())).id(GRID).icon(|| IconWidget::grid(14.0)))
22//!
23//! // Pairing with a Switcher:
24//! Switcher::new(segmented_control::index_signal(&view, &[LIST, GRID]))
25//! ```
26//!
27//! ## When to use
28//!
29//! - Use a `SegmentedControl` for mutually exclusive modes that read
30//!   well as a compact horizontal strip (view mode, time period).
31//! - Prefer a `ComboBox` when the options are many *and* the strip form
32//!   buys nothing — though a segmented control no longer breaks down at
33//!   seven segments, because it overflows (below).
34//! - Prefer `RadioButton` / `RadioTileGroup` when the options need
35//!   vertical space or descriptions.
36//!
37//! ## Width: overflow, not squeeze
38//!
39//! When the segments do not fit, the ones that do not fit move into a
40//! trailing chevron menu rather than all of them compressing into
41//! ellipsised stubs ([`SegmentOverflow::Menu`], the default; opt out with
42//! [`SegmentOverflow::Compress`]).
43//!
44//! Declaration order is stable, with exactly one exception: **the
45//! selected segment is always visible**. If it would have been pushed
46//! into the menu it takes the *last* slot, and it stays there until
47//! another segment is chosen from the menu — so the strip does not
48//! reshuffle under the pointer, and the promotion is forgotten once the
49//! control is wide enough to show everything again.
50//!
51//! ```text
52//! Declared: [A][B][C][D][E][F][G]   fits 4 + chevron
53//!
54//! start, A selected     [A][B][C][D][v]   menu: E F G
55//! pick F from menu      [A][B][C][F][v]   menu: D E G
56//! click A (F stays)     [A][B][C][F][v]   menu: D E G
57//! widen to full fit     [A][B][C][D][E][F][G]
58//! ```
59//!
60//! ## Accessibility
61//!
62//! `Role::RadioGroup` on the control with `active_descendant` pointing at
63//! the selected segment; `Role::RadioButton` per segment, carrying
64//! "N of M" over the whole segment list — including segments currently in
65//! the overflow menu, which are still reachable. Arrow keys cycle
66//! selection (RTL-aware, resolved at event time) and Home/End jump to the
67//! ends, both skipping disabled segments; stepping onto an overflowed
68//! segment promotes it into view. `Increment`/`Decrement` AT actions
69//! mirror the arrows.
70//!
71//! The strip is **one** tab stop. While the control is overflowing the
72//! chevron adds a second, because an overflow menu that no keyboard can
73//! reach is not an overflow menu; it cannot join the arrow sequence,
74//! since here arrows move *selection* rather than a roving focus.
75
76mod cell;
77mod id;
78mod overflow;
79
80#[cfg(test)]
81mod tests;
82
83use std::cell::{Cell, RefCell};
84use std::collections::HashMap;
85use std::rc::Rc;
86
87use teksilo_canvas::{Point, Rect, Size, SizeProposal};
88use teksilo_core::accessibility::AccessNodeBuilder;
89use teksilo_core::build_context::BuildContext;
90use teksilo_core::event::{EventResponse, Key, WidgetEvent};
91use teksilo_core::focus::FocusOrigin;
92use teksilo_core::signal::{Prop, Signal};
93use teksilo_core::styles::{
94    SegmentSlotGeometry, SegmentSlots, SegmentedControlStyleConfig, SharedSegmentedControlStyle,
95};
96use teksilo_core::widget::{
97    CursorIcon, EventContext, LayoutContext, LayoutResponse, Widget, WidgetPlacement,
98};
99use teksilo_core::widget_builder::HandlerSet;
100use teksilo_core::widget_id::WidgetId;
101use teksilo_i18n::LocalizedString;
102
103use crate::primitives::IconWidget;
104use crate::styles::recipe_segmented_control_style::{
105    SEGMENTED_CONTROL_BORDER_WIDTH, SEGMENTED_CONTROL_HEIGHT, SEGMENTED_CONTROL_PADDING_HORIZONTAL,
106    SEGMENTED_CONTROL_PADDING_VERTICAL,
107};
108use cell::SegmentCell;
109use overflow::Plan;
110
111pub use id::SegmentId;
112
113/// Fallback line height when no text backend is available.
114const FALLBACK_LINE_HEIGHT: f32 = 16.0;
115/// Gap between a segment's icon and its label.
116pub(crate) const SEGMENT_ICON_LABEL_SPACING: f32 = 6.0;
117/// Size of the overflow chevron glyph.
118const OVERFLOW_ICON_SIZE: f32 = 12.0;
119
120/// Factory that builds a segment's leading icon. `Rc` (not `Box`) so a
121/// `Segment` descriptor can be cloned into a fresh cell on every rebuild
122/// without consuming it.
123pub(crate) type IconFactory = Rc<dyn Fn() -> IconWidget>;
124
125/// What a segment paints: its icon, its label, or both.
126///
127/// Set on the control with
128/// [`SegmentedControl::display`](super::SegmentedControl::display); it
129/// applies to every segment. Mirrors `TabWidget`'s `TabDisplayMode`.
130///
131/// Icon-only is the classic compact fallback *before* overflow kicks in:
132/// a bar of icon-only segments fits far more of them, so switching to
133/// [`Icon`](SegmentDisplay::Icon) can be the difference between a
134/// complete strip and a chevron menu.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
136pub enum SegmentDisplay {
137    /// Paint whatever the segment declares — icon *and* label when both
138    /// are present, label alone otherwise. The default, and the
139    /// behaviour of every `SegmentedControl` before this mode existed.
140    #[default]
141    Auto,
142    /// Label only. A declared icon is suppressed.
143    Text,
144    /// Icon only; the label is promoted to the hover tooltip (unless the
145    /// segment already declares one). A segment with **no** icon falls
146    /// back to its label, so the mode is never a silent no-op.
147    Icon,
148    /// Icon and label. Identical to [`Auto`](SegmentDisplay::Auto) for a
149    /// segment that declares both; kept for parity with
150    /// `TabDisplayMode` so a caller can be explicit.
151    IconText,
152}
153
154/// How the visible segments divide the control's width.
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
156pub enum SegmentSizing {
157    /// Every visible segment gets the same width — the Apple / IntUI
158    /// look, and the behaviour of every `SegmentedControl` before this
159    /// knob existed. The fit calculation uses the *widest* segment's
160    /// natural width as the unit, so segments never look ragged.
161    #[default]
162    Uniform,
163    /// Every visible segment gets its own natural width, and leftover
164    /// space (when the control fills a wider slot) is shared equally.
165    /// Fits more short segments before overflowing, at the cost of an
166    /// uneven strip.
167    Fit,
168}
169
170/// What the control does when its segments do not fit.
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
172pub enum SegmentOverflow {
173    /// Move the segments that do not fit into a trailing chevron menu,
174    /// keeping the rest at a legible width. The selected segment is
175    /// always among the visible ones. This is the default.
176    #[default]
177    Menu,
178    /// Keep every segment on the strip and let them compress, truncating
179    /// labels with an ellipsis. The behaviour of every
180    /// `SegmentedControl` before overflow existed — appropriate for two
181    /// or three short segments that will never realistically overflow.
182    Compress,
183}
184
185/// One segment descriptor: a localized label with a stable
186/// [`SegmentId`], an optional leading icon, a hover tooltip, and
187/// reactive disabled / visible flags.
188#[derive(Clone)]
189pub struct Segment {
190    pub(crate) id: SegmentId,
191    pub(crate) label: LocalizedString,
192    pub(crate) icon: Option<IconFactory>,
193    /// Plain-text hover tooltip — mutually exclusive with
194    /// `rich_tooltip_source` and `composite_tooltip_factory`.
195    pub(crate) tooltip: Option<LocalizedString>,
196    /// Rich-tooltip source — mutually exclusive with `tooltip` and
197    /// `composite_tooltip_factory`. `RichTooltipSource` is `Clone`.
198    pub(crate) rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
199    /// Composite-tooltip factory — mutually exclusive with `tooltip` and
200    /// `rich_tooltip_source`. Stored as an `Rc<dyn Fn>` (not `Box<dyn
201    /// Widget>`) so the `Segment: Clone` derive stays intact.
202    pub(crate) composite_tooltip_factory: Option<Rc<dyn Fn() -> Box<dyn Widget>>>,
203    pub(crate) disabled: Prop<bool>,
204    pub(crate) visible: Prop<bool>,
205}
206
207impl Segment {
208    /// A text segment with a freshly allocated [`SegmentId`]. The label
209    /// may come from `tr!(...)` (translated — follows a live locale
210    /// switch) or `lit!(...)` (untranslated).
211    ///
212    /// Call [`id`](Self::id) when the segment needs a *stable* identity —
213    /// one that survives a restart, or that another crate can name.
214    pub fn new(label: impl Into<LocalizedString>) -> Self {
215        Self {
216            id: SegmentId::fresh(),
217            label: label.into(),
218            icon: None,
219            tooltip: None,
220            rich_tooltip_source: None,
221            composite_tooltip_factory: None,
222            disabled: Prop::Static(false),
223            visible: Prop::Static(true),
224        }
225    }
226
227    /// Give this segment an app-chosen stable identity, replacing the
228    /// fresh id [`new`](Self::new) allocated. Use this whenever the
229    /// selection is persisted or the segment is contributed by another
230    /// crate.
231    pub fn id(mut self, id: SegmentId) -> Self {
232        self.id = id;
233        self
234    }
235
236    /// This segment's identity.
237    pub fn segment_id(&self) -> SegmentId {
238        self.id
239    }
240
241    /// Add a leading icon. The factory is invoked at build time (and on
242    /// rebuild); the icon's tint is bound reactively to the segment's
243    /// selected / focus / enabled state so it matches the label.
244    pub fn icon(mut self, factory: impl Fn() -> IconWidget + 'static) -> Self {
245        self.icon = Some(Rc::new(factory));
246        self
247    }
248
249    /// Hover tooltip — most useful for icon-only segments.
250    ///
251    /// Mutually exclusive with [`rich_tooltip`](Self::rich_tooltip) /
252    /// [`rich_tooltip_content`](Self::rich_tooltip_content) /
253    /// [`composite_tooltip`](Self::composite_tooltip) — last call wins.
254    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
255        self.tooltip = Some(text.into());
256        self.rich_tooltip_source = None;
257        self.composite_tooltip_factory = None;
258        self
259    }
260
261    /// Rich hover tooltip resolved from the app-wide registry by key.
262    ///
263    /// Mutually exclusive with [`tooltip`](Self::tooltip) /
264    /// [`rich_tooltip_content`](Self::rich_tooltip_content) /
265    /// [`composite_tooltip`](Self::composite_tooltip) — last call wins.
266    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
267        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
268        self.tooltip = None;
269        self.composite_tooltip_factory = None;
270        self
271    }
272
273    /// Rich hover tooltip driven by an inline
274    /// [`TooltipContent`](crate::tooltip::TooltipContent) entry
275    /// (no registry key needed).
276    ///
277    /// Mutually exclusive with [`tooltip`](Self::tooltip) /
278    /// [`rich_tooltip`](Self::rich_tooltip) /
279    /// [`composite_tooltip`](Self::composite_tooltip) — last call wins.
280    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
281        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
282        self.tooltip = None;
283        self.composite_tooltip_factory = None;
284        self
285    }
286
287    /// Composite hover tooltip built by a factory closure at attach time.
288    ///
289    /// The factory is called once per `build()` to produce the tooltip
290    /// body widget. It is stored as an `Rc<dyn Fn>` so that `Segment`
291    /// remains `Clone`.
292    ///
293    /// Mutually exclusive with [`tooltip`](Self::tooltip) /
294    /// [`rich_tooltip`](Self::rich_tooltip) /
295    /// [`rich_tooltip_content`](Self::rich_tooltip_content) — last call wins.
296    pub fn composite_tooltip(mut self, factory: impl Fn() -> Box<dyn Widget> + 'static) -> Self {
297        self.composite_tooltip_factory = Some(Rc::new(factory));
298        self.tooltip = None;
299        self.rich_tooltip_source = None;
300        self
301    }
302
303    /// Disable this segment: not selectable via click or keyboard,
304    /// dimmed, and announced disabled to assistive tech.
305    ///
306    /// Accepts a `bool` or a `Signal<bool>` — a bound signal flips the
307    /// segment live, with **no rebuild**, and keyboard stepping honours
308    /// the new value immediately (the flags are read at event time, not
309    /// snapshotted at build time).
310    pub fn disabled(mut self, disabled: impl Into<Prop<bool>>) -> Self {
311        self.disabled = disabled.into();
312        self
313    }
314
315    /// Hide this segment entirely: it leaves the strip, the overflow
316    /// menu, the keyboard order, and the accessibility tree, and it is
317    /// excluded from the overflow calculation.
318    ///
319    /// Distinct from *overflowed* — an overflowed segment is still
320    /// reachable from the chevron menu, a hidden one is not there at all.
321    /// Accepts a `bool` or a `Signal<bool>`; a bound signal re-runs the
322    /// overflow plan with no rebuild.
323    pub fn visible(mut self, visible: impl Into<Prop<bool>>) -> Self {
324        self.visible = visible.into();
325        self
326    }
327}
328
329/// Label-only convenience: `tr!(day())` / `lit!("Off")` flow straight
330/// into `.segment(...)` / `.segments([...])` without `Segment::new`.
331impl From<LocalizedString> for Segment {
332    fn from(label: LocalizedString) -> Self {
333        Segment::new(label)
334    }
335}
336
337impl std::fmt::Debug for Segment {
338    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
339        f.debug_struct("Segment")
340            .field("id", &self.id)
341            .field("label", &self.label)
342            .field("has_icon", &self.icon.is_some())
343            .field("disabled", &self.disabled.get())
344            .field("visible", &self.visible.get())
345            .finish()
346    }
347}
348
349/// Derive a `Switcher`-compatible index from a keyed selection.
350///
351/// `SegmentedControl` is keyed precisely so that a contributed segment
352/// cannot silently re-point the selection, but `Switcher` is index-driven
353/// — this is the adapter between the two. Unknown or absent ids resolve
354/// to `0`, matching `Switcher`'s own out-of-range behaviour.
355///
356/// ```ignore
357/// Switcher::new(segmented_control::index_signal(&view, &[LIST, GRID, COLUMNS]))
358///     .child(list_pane)
359///     .child(grid_pane)
360///     .child(columns_pane)
361/// ```
362pub fn index_signal(selected: &Signal<Option<SegmentId>>, ids: &[SegmentId]) -> Signal<usize> {
363    let ids: Rc<Vec<SegmentId>> = Rc::new(ids.to_vec());
364    selected.map(move |current| {
365        current
366            .and_then(|id| ids.iter().position(|&candidate| candidate == id))
367            .unwrap_or(0)
368    })
369}
370
371/// A segmented control binding a `Signal<Option<SegmentId>>` to a row of
372/// mutually exclusive segments. Build the segment list with
373/// [`segment`](Self::segment) or [`segments`](Self::segments).
374pub struct SegmentedControl {
375    /// Segment descriptors. Retained (cloned, not consumed, into cells on
376    /// each build) so the control is rebuild-safe and so `layout_response`
377    /// / `accessibility` can read labels even when measured while dormant.
378    segments: Vec<Segment>,
379    /// The public, keyed selection.
380    selected: Signal<Option<SegmentId>>,
381    /// Optional positional mirror installed by [`indexed`](Self::indexed).
382    /// Addresses the **declared** list, so hiding a segment does not
383    /// renumber it under the app's feet.
384    index_mirror: Option<Signal<usize>>,
385    /// Private index mirror over the **live** segment list, kept in
386    /// bidirectional sync with `selected` at build time.
387    ///
388    /// Every internal interactive path — cell taps, AT clicks, arrow
389    /// keys, overflow-menu rows — writes *only* this. `selected` is
390    /// written only by the app and by the index→id effect. A second
391    /// direct writer of `selected` reintroduces the two-writer race the
392    /// `TabBar` bridge exists to avoid.
393    index: Signal<usize>,
394    /// Enabled state, static or reactive; forwarded to the arena at
395    /// build time.
396    enabled: Prop<bool>,
397    /// Accessible name for the group.
398    label: Option<LocalizedString>,
399    /// Live segment index under the pointer, if any.
400    hovered_segment: Signal<Option<usize>>,
401    /// Raw keyboard/pointer focus (any modality). The keyboard-only focus
402    /// ring and the focus-driven selected-segment accent fill are derived
403    /// live from this × the input-modality signal in `build()`
404    /// (`:focus-visible`).
405    focused: Signal<bool>,
406    /// Per-call override for the chrome.
407    style_override: Option<SharedSegmentedControlStyle>,
408    /// Per-call override for every segment's label text style (font, size,
409    /// weight). `None` ⇒ the default `TextStyleRole::Small`. Text *color*
410    /// stays state-driven (selected → `OnAccent`, disabled → `Disabled`)
411    /// and is intentionally not overridable.
412    label_style: Option<teksilo_core::color_prop::TextStyleProp>,
413    display: SegmentDisplay,
414    sizing: SegmentSizing,
415    overflow_mode: SegmentOverflow,
416    fill_width: bool,
417    on_change: Option<Rc<dyn Fn(SegmentId, &mut EventContext)>>,
418
419    // ── Build-time state ────────────────────────────────────────────
420    /// Declaration indices of the segments whose `visible` prop is true,
421    /// resolved once per build.
422    live: Vec<usize>,
423    /// Ids of the live segments, parallel to `live`.
424    live_ids: Vec<SegmentId>,
425    /// One cell per live segment, parallel to `live`.
426    cell_ids: Vec<WidgetId>,
427    /// Currently-active cell ids, for `push_to_radio_group`. Shared with
428    /// the cells and refreshed from `place_children`.
429    group_ids: Rc<RefCell<Vec<WidgetId>>>,
430    /// Per-live-segment overflow flags, published from `place_children`.
431    /// Seeded all-false at build time: the framework polls every
432    /// `visible_when` prop on the *first* pass, before any plan exists.
433    overflowed: Signal<Vec<bool>>,
434    is_overflowing: Signal<bool>,
435    /// Resolved slot geometry handed to the chrome.
436    slots: SegmentSlots,
437    /// Sticky promotion: the segment forced into the last slot. Plain
438    /// `Cell` (not a `Signal`) so mutating it from `place_children`
439    /// dirties nothing.
440    promoted: Cell<Option<SegmentId>>,
441    /// Equality guard for the published plan — without it every layout
442    /// pass would re-dirty the visibility props and the tree would never
443    /// go quiet.
444    last_plan: RefCell<Plan>,
445    chrome_id: Option<WidgetId>,
446    chevron_id: Option<WidgetId>,
447    /// Build-time children — chrome first (back), then one `SegmentCell`
448    /// per live segment, then the overflow trigger.
449    children: Vec<WidgetId>,
450}
451
452impl SegmentedControl {
453    /// Create an empty segmented control bound to `selected`. Add segments
454    /// with [`segment`](Self::segment) or [`segments`](Self::segments).
455    pub fn new(selected: Signal<Option<SegmentId>>) -> Self {
456        Self {
457            segments: Vec::new(),
458            selected,
459            index_mirror: None,
460            index: Signal::new(0),
461            enabled: Prop::Static(true),
462            label: None,
463            hovered_segment: Signal::new(None),
464            focused: Signal::new(false),
465            style_override: None,
466            label_style: None,
467            display: SegmentDisplay::default(),
468            sizing: SegmentSizing::default(),
469            overflow_mode: SegmentOverflow::default(),
470            fill_width: true,
471            on_change: None,
472            live: Vec::new(),
473            live_ids: Vec::new(),
474            cell_ids: Vec::new(),
475            group_ids: Rc::new(RefCell::new(Vec::new())),
476            overflowed: Signal::new(Vec::new()),
477            is_overflowing: Signal::new(false),
478            slots: SegmentSlots::new(),
479            promoted: Cell::new(None),
480            last_plan: RefCell::new(Plan::default()),
481            chrome_id: None,
482            chevron_id: None,
483            children: Vec::new(),
484        }
485    }
486
487    /// Bind a **positional** `Signal<usize>` instead of a keyed
488    /// selection, mirrored in both directions.
489    ///
490    /// Use this only when position *is* the meaning and the segment list
491    /// is closed and local — an enum discriminant over a fixed `ALL`
492    /// array, a `Switcher` index, a settings choice. For anything else
493    /// prefer [`new`](Self::new): an index silently stops meaning the
494    /// same thing the moment a segment is inserted ahead of it, which is
495    /// the entire reason selection is keyed. A persisted selection, or
496    /// segments contributed by another crate, are both firmly in
497    /// "anything else".
498    ///
499    /// Positions address the **declared** list, so a segment hidden with
500    /// [`Segment::visible`] does not renumber the others.
501    ///
502    /// ```ignore
503    /// // `bucket_idx` already drives the rollup maths and a Switcher.
504    /// SegmentedControl::indexed(bucket_idx.clone())
505    ///     .segments([lit!("×2"), lit!("×4"), lit!("×8")])
506    /// ```
507    pub fn indexed(index: Signal<usize>) -> Self {
508        let mut control = Self::new(Signal::new(None));
509        control.index_mirror = Some(index);
510        control
511    }
512
513    /// Append one segment. Accepts a [`Segment`] or, via
514    /// `From<LocalizedString>`, a bare `tr!(...)` / `lit!(...)` label
515    /// (which gets a freshly allocated [`SegmentId`]).
516    pub fn segment(mut self, segment: impl Into<Segment>) -> Self {
517        self.segments.push(segment.into());
518        self
519    }
520
521    /// Append several segments. Label-only:
522    /// `.segments([tr!(day()), tr!(week())])`; rich:
523    /// `.segments([Segment::new(...).id(DAY).icon(...), ...])`.
524    pub fn segments(mut self, segments: impl IntoIterator<Item = impl Into<Segment>>) -> Self {
525        self.segments.extend(segments.into_iter().map(Into::into));
526        self
527    }
528
529    /// The ids of the segments added so far, in declaration order.
530    /// Convenient for feeding [`index_signal`] without repeating the list.
531    pub fn segment_ids(&self) -> Vec<SegmentId> {
532        self.segments.iter().map(|s| s.id).collect()
533    }
534
535    /// Set the enabled state, statically or reactively. Forwarded to
536    /// the arena at build time via
537    /// `ctx.enabled_when(segmented_control_id, self.enabled.clone())`.
538    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
539        self.enabled = enabled.into();
540        self
541    }
542
543    /// Accessible name for the group — e.g. "View mode". Screen readers
544    /// announce it before the selected segment. Matches
545    /// [`RadioGroup::label`](crate::radio_group::RadioGroup::label) and
546    /// [`RadioTileGroup::label`](crate::radio_tile_group::RadioTileGroup::label).
547    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
548        self.label = Some(label.into());
549        self
550    }
551
552    /// Called whenever the user changes the selection — by click, arrow
553    /// key, assistive technology, or the overflow menu. Receives the
554    /// newly selected [`SegmentId`] and an `EventContext`, so it can do
555    /// things a bare `Signal` write cannot (`ctx.set_locale(...)`,
556    /// `ctx.send_intent(...)`, opening a window).
557    ///
558    /// Does **not** fire for programmatic writes to the bound signal —
559    /// there is no event in flight to carry. Observe the signal for that.
560    pub fn on_change(mut self, f: impl Fn(SegmentId, &mut EventContext) + 'static) -> Self {
561        self.on_change = Some(Rc::new(f));
562        self
563    }
564
565    /// Per-call override for the segmented-control chrome.
566    pub fn style(mut self, style: impl teksilo_core::styles::SegmentedControlStyle) -> Self {
567        self.style_override = Some(Rc::new(style));
568        self
569    }
570
571    /// Override every segment's label text style (font, size, weight).
572    /// Accepts a `TextStyleRole`, a `TextStyle`, or a `Signal` of either.
573    /// Default (unset) is `TextStyleRole::Small`. Text color stays
574    /// state-driven and is intentionally not overridable here.
575    pub fn text_style(mut self, style: impl Into<teksilo_core::color_prop::TextStyleProp>) -> Self {
576        self.label_style = Some(style.into());
577        self
578    }
579
580    /// What each segment paints: its icon, its label, or both. See
581    /// [`SegmentDisplay`]. Icon-only fits far more segments, so it is
582    /// worth reaching for *before* the control starts overflowing.
583    pub fn display(mut self, display: SegmentDisplay) -> Self {
584        self.display = display;
585        self
586    }
587
588    /// How the visible segments divide the width. See [`SegmentSizing`].
589    pub fn sizing(mut self, sizing: SegmentSizing) -> Self {
590        self.sizing = sizing;
591        self
592    }
593
594    /// What to do when the segments do not fit. See [`SegmentOverflow`].
595    pub fn overflow(mut self, mode: SegmentOverflow) -> Self {
596        self.overflow_mode = mode;
597        self
598    }
599
600    /// Reactive "some segments are in the overflow menu right now".
601    ///
602    /// Republished from `place_children` behind an equality guard, so it
603    /// is safe for `RepaintOnly` / `AccessibilityOnly` consumers and for
604    /// `Relayout` consumers that do not feed back into this control's own
605    /// width. Mirrors [`Toolbar::is_overflowing`](crate::toolbar::Toolbar::is_overflowing).
606    pub fn is_overflowing(&self) -> Signal<bool> {
607        self.is_overflowing.clone()
608    }
609
610    /// Whether the control claims all the width offered to it (the
611    /// default, and the behaviour before this knob existed) or hugs its
612    /// segments.
613    ///
614    /// `false` also makes the control *shrinkable*: in an over-constrained
615    /// stack it compresses — and overflows — instead of spilling past its
616    /// bounds.
617    pub fn fill_width(mut self, fill: bool) -> Self {
618        self.fill_width = fill;
619        self
620    }
621
622    /// Inset-by-focus-ring-envelope bounds — the actual frame /
623    /// segment-grid area. Mirrors the recipe's compute_visual so
624    /// children land where the chrome paints.
625    fn compute_visual(bounds: Rect, theme: &teksilo_core::Theme) -> Rect {
626        let envelope = theme.shape.focus_ring_offset + theme.shape.focus_ring_width;
627        Rect::new(
628            bounds.x + envelope,
629            bounds.y + envelope,
630            (bounds.width - envelope * 2.0).max(0.0),
631            (bounds.height - envelope * 2.0).max(0.0),
632        )
633    }
634
635    /// The grid area inside the frame's stroke.
636    fn compute_inner(visual: Rect) -> Rect {
637        let bw = SEGMENTED_CONTROL_BORDER_WIDTH;
638        Rect::new(
639            visual.x + bw,
640            visual.y + bw,
641            (visual.width - bw * 2.0).max(0.0),
642            (visual.height - bw * 2.0).max(0.0),
643        )
644    }
645
646    /// Measure every live cell's intrinsic width, plus the chevron's.
647    ///
648    /// Uses [`LayoutContext::measure_intrinsic`], which measures even
649    /// **dormant** widgets — the segments that overflowed into the menu
650    /// still have to report a width, or the control could never work out
651    /// when they fit again.
652    fn measure(&self, ctx: &LayoutContext) -> (Vec<f32>, f32, f32) {
653        let probe = SizeProposal::unspecified();
654        let mut widths = Vec::with_capacity(self.cell_ids.len());
655        let mut tallest = 0.0_f32;
656        for &id in &self.cell_ids {
657            let size = ctx
658                .measure_intrinsic(id, probe)
659                .unwrap_or(Size::new(0.0, 0.0));
660            widths.push(size.width);
661            tallest = tallest.max(size.height);
662        }
663        let chevron = self
664            .chevron_id
665            .and_then(|id| ctx.measure_intrinsic(id, probe))
666            .map(|s| s.width)
667            .unwrap_or(0.0);
668        (widths, chevron, tallest)
669    }
670
671    /// Run the overflow plan for `inner_width`, applying and maintaining
672    /// the sticky promotion.
673    ///
674    /// Pure apart from `promoted`: the two `plan` calls share one
675    /// measurement pass, and the second only happens when the selection
676    /// would otherwise have been hidden.
677    fn resolve_plan(&self, inner_width: f32, natural: &[f32], chevron: f32) -> Plan {
678        let compress = self.overflow_mode == SegmentOverflow::Compress;
679        let live_count = natural.len();
680        if live_count == 0 {
681            return Plan::default();
682        }
683        let promoted_index = self
684            .promoted
685            .get()
686            .and_then(|id| self.live_ids.iter().position(|&candidate| candidate == id));
687
688        let mut plan = overflow::plan(
689            inner_width,
690            natural,
691            promoted_index,
692            chevron,
693            self.sizing,
694            compress,
695        );
696
697        // The invariant: the selected segment is always on the strip. If
698        // the plan hid it, promote it and re-plan — once; the re-planned
699        // `must` is by construction satisfiable, because `plan` keeps at
700        // least the forced segment.
701        let selected = self.index.get().min(live_count - 1);
702        if !plan.is_visible(selected) {
703            self.promoted.set(Some(self.live_ids[selected]));
704            plan = overflow::plan(
705                inner_width,
706                natural,
707                Some(selected),
708                chevron,
709                self.sizing,
710                compress,
711            );
712        }
713
714        // Forget the promotion once everything fits, so a later, unrelated
715        // narrowing starts from clean declaration order rather than
716        // resurrecting a pick the user made minutes ago.
717        if !plan.show_chevron {
718            self.promoted.set(None);
719        }
720        plan
721    }
722
723    /// Next selectable live index in `dir` (true = forward), wrapping and
724    /// skipping disabled segments. Returns `current` if no other segment
725    /// is enabled.
726    ///
727    /// Reads the disabled flags **live** — they are `Prop<bool>`s that an
728    /// app may flip through a bound signal with no rebuild, so a snapshot
729    /// taken at build time would go stale.
730    fn step_selection(current: usize, forward: bool, disabled: &[Prop<bool>]) -> usize {
731        let n = disabled.len();
732        if n == 0 {
733            return current;
734        }
735        let mut i = current;
736        for _ in 0..n {
737            i = if forward {
738                (i + 1) % n
739            } else {
740                (i + n - 1) % n
741            };
742            if !disabled[i].get() {
743                return i;
744            }
745        }
746        current
747    }
748
749    /// First / last enabled live index, for Home / End.
750    fn edge_selection(current: usize, last: bool, disabled: &[Prop<bool>]) -> usize {
751        let n = disabled.len();
752        if n == 0 {
753            return current;
754        }
755        let found = if last {
756            (0..n).rev().find(|i| !disabled[*i].get())
757        } else {
758            (0..n).find(|i| !disabled[*i].get())
759        };
760        found.unwrap_or(current)
761    }
762}
763
764impl std::fmt::Debug for SegmentedControl {
765    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
766        f.debug_struct("SegmentedControl")
767            .field("segments", &self.segments.len())
768            .field("live", &self.live.len())
769            .field("selected", &self.selected.get())
770            .field("enabled", &self.enabled.get())
771            .finish()
772    }
773}
774
775impl Widget for SegmentedControl {
776    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
777        let self_id = ctx.self_id();
778        // Forward the enabled state to the arena; see IconButton.
779        ctx.enabled_when(self_id, self.enabled.clone());
780        let effective_enabled = ctx.effective_enabled_signal(self_id);
781
782        // ── Live segment list ───────────────────────────────────────
783        //
784        // Hiding a segment is a *structural* change, not a resize: it
785        // renumbers the live list the index mirror addresses. Bind
786        // `visible` at `Rebuild` so the whole bridge is rebuilt
787        // consistently; the keyed selection survives it, which is
788        // precisely why the public signal is keyed.
789        {
790            let registry = ctx.binding_registry();
791            for segment in &self.segments {
792                segment.visible.register_if_bound(
793                    self_id,
794                    registry,
795                    teksilo_core::binding::BindingLevel::Rebuild,
796                );
797            }
798        }
799        self.live = (0..self.segments.len())
800            .filter(|&i| self.segments[i].visible.get())
801            .collect();
802        self.live_ids = self.live.iter().map(|&i| self.segments[i].id).collect();
803        let live_count = self.live.len();
804
805        // ── Optional positional mirror (`indexed`) ──────────────────
806        //
807        // Seeded before the keyed sync below, so that sync sees an id it
808        // can resolve. Declared positions, not live ones. This is the one
809        // sanctioned second writer of `selected`; every hop is
810        // equality-guarded, so the cycle
811        // mirror → selected → index → selected settles in one round.
812        if let Some(mirror) = self.index_mirror.clone() {
813            let declared: Vec<SegmentId> = self.segments.iter().map(|s| s.id).collect();
814            let from_position = |position: usize| declared.get(position).copied();
815
816            if self.selected.get().is_none_or(|id| !declared.contains(&id)) {
817                self.selected.set(from_position(mirror.get()));
818            }
819
820            {
821                let declared = declared.clone();
822                let selected = self.selected.clone();
823                ctx.effect(&mirror, move |position| {
824                    let target = declared.get(*position).copied();
825                    if target.is_some() && selected.get() != target {
826                        selected.set(target);
827                    }
828                });
829            }
830            {
831                let declared = declared.clone();
832                let mirror = mirror.clone();
833                ctx.effect(&self.selected, move |maybe_id| {
834                    if let Some(id) = maybe_id
835                        && let Some(position) =
836                            declared.iter().position(|&candidate| candidate == *id)
837                        && mirror.get() != position
838                    {
839                        mirror.set(position);
840                    }
841                });
842            }
843        }
844
845        // ── id ↔ index bridge (the TabBar recipe) ───────────────────
846        //
847        // Both directions resolve against the *live* list rebuilt above.
848        // A build-time snapshot in one direction and a live lookup in the
849        // other is what makes the two effects disagree after a reorder and
850        // feed back unboundedly.
851        let id_to_index: HashMap<SegmentId, usize> = self
852            .live_ids
853            .iter()
854            .enumerate()
855            .map(|(i, &id)| (id, i))
856            .collect();
857
858        if live_count > 0 {
859            match self
860                .selected
861                .get()
862                .and_then(|id| id_to_index.get(&id).copied())
863            {
864                Some(target) => {
865                    if self.index.get() != target {
866                        self.index.set(target);
867                    }
868                }
869                None => {
870                    // Stale or absent id: keep the previous *position*
871                    // clamped into range and re-stamp the id that now
872                    // lives there — the "select the neighbour" convention.
873                    let clamped = self.index.get().min(live_count - 1);
874                    if self.index.get() != clamped {
875                        self.index.set(clamped);
876                    }
877                    let resolved = self.live_ids[clamped];
878                    if self.selected.get() != Some(resolved) {
879                        self.selected.set(Some(resolved));
880                    }
881                }
882            }
883        } else if self.selected.get().is_some() {
884            self.selected.set(None);
885        }
886
887        {
888            let map = id_to_index.clone();
889            let index = self.index.clone();
890            ctx.effect(&self.selected, move |maybe_id| {
891                if let Some(id) = maybe_id
892                    && let Some(&target) = map.get(id)
893                    && index.get() != target
894                {
895                    index.set(target);
896                }
897            });
898        }
899        {
900            let ids = self.live_ids.clone();
901            let selected = self.selected.clone();
902            ctx.effect(&self.index, move |i| {
903                let resolved = ids.get(*i).copied();
904                if selected.get() != resolved {
905                    selected.set(resolved);
906                }
907            });
908        }
909
910        // Selection drives three different kinds of work, on three nodes:
911        // the plan (this node, Relayout — promotion can change which
912        // segments are on the strip), the announced `active_descendant`
913        // (this node, AccessibilityOnly — a relayout no longer re-walks
914        // the AT tree), and the chrome's fill (the chrome node, its own
915        // RepaintOnly binding).
916        {
917            let registry = ctx.binding_registry();
918            self.index.bind_to(
919                self_id,
920                registry,
921                teksilo_core::binding::BindingLevel::Relayout,
922            );
923            self.index.bind_to(
924                self_id,
925                registry,
926                teksilo_core::binding::BindingLevel::AccessibilityOnly,
927            );
928        }
929
930        // Seed the overflow flags before anything can read them: the
931        // framework polls every `visible_when` prop on the first layout
932        // pass, which happens before this widget's `place_children` has
933        // ever run.
934        self.overflowed.set(vec![false; live_count]);
935        self.is_overflowing.set(false);
936        *self.last_plan.borrow_mut() = Plan::default();
937        self.slots.publish(SegmentSlotGeometry::default());
938        self.group_ids.borrow_mut().clear();
939
940        let index = self.index.clone();
941        let hovered_segment = self.hovered_segment.clone();
942        // `:focus-visible`: derive the keyboard/pointer origin live from the
943        // input-modality signal (true after a key event, false after
944        // pointer-down) rather than snapshotting hover at focus time. The
945        // chrome reads `Some(_)` for the selected-segment accent fill (any
946        // focus) and `Some(Keyboard)` for the focus ring, so this keeps the
947        // fill on a click while making the ring keyboard-only.
948        let focused = self.focused.clone();
949        let focus_origin = self.focused.zip(&ctx.focus_visible()).map(|(f, v)| {
950            if !*f {
951                None
952            } else if *v {
953                Some(FocusOrigin::Keyboard)
954            } else {
955                Some(FocusOrigin::Pointer)
956            }
957        });
958
959        // One funnel for every internal selection write, so `on_change`
960        // fires exactly once per user-driven change and the index mirror
961        // stays the single write target.
962        let select: Rc<dyn Fn(usize, &mut EventContext)> = {
963            let index = index.clone();
964            let ids = self.live_ids.clone();
965            let on_change = self.on_change.clone();
966            Rc::new(move |target, ctx| {
967                if index.get() == target {
968                    return;
969                }
970                index.set(target);
971                if let Some(callback) = &on_change
972                    && let Some(id) = ids.get(target).copied()
973                {
974                    callback(id, ctx);
975                }
976            })
977        };
978
979        // Build chrome leaf first (so it sits at index 0 in `children`
980        // and paints behind the segment cells).
981        let style: SharedSegmentedControlStyle = self
982            .style_override
983            .clone()
984            .or_else(|| ctx.theme().style_slots.segmented_control.clone())
985            .unwrap_or_else(|| Rc::new(crate::styles::RecipeSegmentedControlStyle::default()));
986        let chrome_id = style.make_body(
987            &SegmentedControlStyleConfig {
988                slots: self.slots.clone(),
989                selected: index.clone(),
990                hovered_segment: hovered_segment.clone(),
991                focus_origin: focus_origin.clone(),
992                is_enabled: effective_enabled.clone(),
993            },
994            ctx,
995        );
996        self.chrome_id = Some(chrome_id);
997
998        self.children.clear();
999        self.children.push(chrome_id);
1000        self.cell_ids.clear();
1001
1002        for (live_index, &segment_index) in self.live.iter().enumerate() {
1003            let segment = &self.segments[segment_index];
1004            let id = ctx.add(SegmentCell {
1005                label: segment.label.clone(),
1006                icon: segment.icon.clone(),
1007                tooltip: segment.tooltip.clone(),
1008                rich_tooltip_source: segment.rich_tooltip_source.clone(),
1009                composite_tooltip_factory: segment.composite_tooltip_factory.clone(),
1010                label_style: self.label_style.clone(),
1011                display: self.display,
1012                disabled: segment.disabled.clone(),
1013                index: live_index,
1014                live_count,
1015                selected: index.clone(),
1016                hovered_segment: hovered_segment.clone(),
1017                focus_origin: focus_origin.clone(),
1018                group_ids: self.group_ids.clone(),
1019                select: select.clone(),
1020                content_id: None,
1021            });
1022            self.cell_ids.push(id);
1023            self.children.push(id);
1024        }
1025
1026        // Gate each cell on "not overflowed". Fail open on a short flag
1027        // vector so the very first poll — which happens before any plan
1028        // exists — reads as visible rather than panicking.
1029        for (live_index, &cell_id) in self.cell_ids.iter().enumerate() {
1030            let flags = self.overflowed.clone();
1031            let on_strip = flags.map(move |f| f.get(live_index).copied() != Some(true));
1032            ctx.visible_when(cell_id, on_strip);
1033        }
1034
1035        // Overflow trigger. Built unconditionally (so it can be measured
1036        // while dormant) but only *shown* while something has overflowed,
1037        // so it never reserves width it does not need.
1038        if live_count > 0 && self.overflow_mode == SegmentOverflow::Menu {
1039            let chevron_id = overflow::build_overflow_trigger(
1040                ctx,
1041                &self.segments,
1042                &self.live,
1043                &index,
1044                &self.overflowed,
1045                OVERFLOW_ICON_SIZE,
1046                select.clone(),
1047            );
1048            ctx.visible_when(chevron_id, self.is_overflowing.clone());
1049            self.chevron_id = Some(chevron_id);
1050            self.children.push(chevron_id);
1051        } else {
1052            self.chevron_id = None;
1053        }
1054
1055        // Framework gates events on `arena.is_enabled`; focus walker
1056        // skips disabled subtrees.
1057        let mut handlers = HandlerSet::new()
1058            .focusable(true)
1059            .cursor(CursorIcon::Pointer);
1060
1061        // Hover-out on the parent clears the segment highlight when the
1062        // pointer leaves the control entirely.
1063        {
1064            let hovered_segment = hovered_segment.clone();
1065            handlers = handlers.on_hover(move |entered, _ctx| {
1066                if !entered {
1067                    hovered_segment.set(None);
1068                }
1069            });
1070        }
1071
1072        // Live disabled flags, in live order. Held as `Prop`s and read at
1073        // event time: an app may flip a bound signal with no rebuild, and
1074        // a `Vec<bool>` snapshotted here would silently go stale.
1075        let disabled: Rc<Vec<Prop<bool>>> = Rc::new(
1076            self.live
1077                .iter()
1078                .map(|&i| self.segments[i].disabled.clone())
1079                .collect(),
1080        );
1081
1082        // Arrow keys cycle selection, Home/End jump to the ends, both
1083        // skipping disabled segments. Focus stays on the control.
1084        {
1085            let index = index.clone();
1086            let disabled = disabled.clone();
1087            let select = select.clone();
1088            let cell_ids = self.cell_ids.clone();
1089            handlers = handlers.on_key(move |event, ctx: &mut EventContext| {
1090                if live_count == 0 {
1091                    return EventResponse::Ignored;
1092                }
1093                let WidgetEvent::KeyDown { key, .. } = event else {
1094                    return EventResponse::Ignored;
1095                };
1096                // Resolve direction at *event* time, so a locale flip
1097                // re-maps the arrows with no rebuild.
1098                let (previous, next) = if ctx.is_rtl() {
1099                    (Key::ArrowRight, Key::ArrowLeft)
1100                } else {
1101                    (Key::ArrowLeft, Key::ArrowRight)
1102                };
1103                let current = index.get().min(live_count - 1);
1104                let target = if *key == next {
1105                    Self::step_selection(current, true, &disabled)
1106                } else if *key == previous {
1107                    Self::step_selection(current, false, &disabled)
1108                } else if *key == Key::Home {
1109                    Self::edge_selection(current, false, &disabled)
1110                } else if *key == Key::End {
1111                    Self::edge_selection(current, true, &disabled)
1112                } else {
1113                    return EventResponse::Ignored;
1114                };
1115                if target != current {
1116                    select(target, ctx);
1117                    // Reveal the newly selected segment in any enclosing
1118                    // scroll area — an AT/keyboard move does not shift
1119                    // focus, so the framework's focus-follow cannot.
1120                    if let Some(&id) = cell_ids.get(target) {
1121                        ctx.ensure_widget_visible(id);
1122                    }
1123                }
1124                EventResponse::Handled
1125            });
1126        }
1127
1128        // Focus handler. Track raw focus only; the keyboard/pointer
1129        // distinction (for the ring and the selected-segment accent fill) is
1130        // derived live from the input-modality signal in `build()`
1131        // (`:focus-visible`), so clicking to focus then pressing a key
1132        // reveals the ring.
1133        {
1134            let focused = focused.clone();
1135            handlers = handlers.on_focus(move |gained, _ctx| {
1136                focused.set(gained);
1137            });
1138        }
1139
1140        // Access actions — increment/decrement cycle selection (skipping
1141        // disabled segments).
1142        {
1143            let index = index.clone();
1144            let disabled = disabled.clone();
1145            let select = select.clone();
1146            let cell_ids = self.cell_ids.clone();
1147            handlers = handlers.on_access_action(move |action, ctx: &mut EventContext| {
1148                if live_count == 0 {
1149                    return EventResponse::Ignored;
1150                }
1151                let current = index.get().min(live_count - 1);
1152                let target = if action == teksilo_core::accesskit::Action::Increment {
1153                    Self::step_selection(current, true, &disabled)
1154                } else if action == teksilo_core::accesskit::Action::Decrement {
1155                    Self::step_selection(current, false, &disabled)
1156                } else {
1157                    return EventResponse::Ignored;
1158                };
1159                if target != current {
1160                    select(target, ctx);
1161                    if let Some(&id) = cell_ids.get(target) {
1162                        ctx.ensure_widget_visible(id);
1163                    }
1164                }
1165                EventResponse::Handled
1166            });
1167        }
1168
1169        ctx.apply_self_handlers(handlers);
1170
1171        self.children.clone()
1172    }
1173
1174    fn layout_response(
1175        &self,
1176        proposal: SizeProposal,
1177        ctx: &LayoutContext,
1178    ) -> teksilo_core::widget::LayoutResponse {
1179        let envelope = ctx.theme.shape.focus_ring_offset + ctx.theme.shape.focus_ring_width;
1180        let chrome = envelope * 2.0 + SEGMENTED_CONTROL_BORDER_WIDTH * 2.0;
1181
1182        // Real measurement, not a per-character guess: this is what makes
1183        // a control in an `HStack` claim the width its labels actually
1184        // need, and what the overflow plan is calibrated against.
1185        let (natural, chevron, tallest) = self.measure(ctx);
1186        let content_width: f32 = match self.sizing {
1187            SegmentSizing::Uniform => {
1188                let widest = natural.iter().copied().fold(0.0_f32, f32::max);
1189                widest * natural.len() as f32
1190            }
1191            SegmentSizing::Fit => natural.iter().sum(),
1192        };
1193        let natural_width = content_width + chrome;
1194        // One ellipsized segment plus the chevron: the narrowest the
1195        // control can be and still mean something.
1196        let min_width = SEGMENTED_CONTROL_PADDING_HORIZONTAL * 2.0 + chevron + chrome;
1197
1198        // The content height is measured, not assumed, so a 200 % global
1199        // text scale grows the control instead of clipping its labels.
1200        let visual_height = (tallest.max(FALLBACK_LINE_HEIGHT)
1201            + SEGMENTED_CONTROL_PADDING_VERTICAL * 2.0)
1202            .max(SEGMENTED_CONTROL_HEIGHT);
1203        let height = visual_height + envelope * 2.0;
1204
1205        if self.fill_width {
1206            Size::new(proposal.width.unwrap_or(natural_width), height).into()
1207        } else {
1208            LayoutResponse::shrinkable(
1209                Size::new(natural_width, height),
1210                Size::new(min_width.min(natural_width), height),
1211                1.0,
1212            )
1213        }
1214    }
1215
1216    fn place_children(
1217        &self,
1218        bounds: Rect,
1219        _proposal: SizeProposal,
1220        children: &mut [WidgetPlacement],
1221        ctx: &LayoutContext,
1222    ) {
1223        if children.is_empty() {
1224            return;
1225        }
1226
1227        let visual = Self::compute_visual(bounds, ctx.theme);
1228        let inner = Self::compute_inner(visual);
1229        let (natural, chevron_width, _) = self.measure(ctx);
1230        let plan = self.resolve_plan(inner.width, &natural, chevron_width);
1231
1232        // Reading-order offsets, mirrored onto the axis afterwards so RTL
1233        // needs no separate code path. "Last slot" therefore means last in
1234        // *reading* order — next to the chevron — in both directions.
1235        let rtl = ctx.is_rtl();
1236        let place = |offset: f32, width: f32| -> Rect {
1237            let x = if rtl {
1238                inner.x + (inner.width - offset - width)
1239            } else {
1240                inner.x + offset
1241            };
1242            Rect::new(x, inner.y, width, inner.height)
1243        };
1244
1245        let mut slot_rects = Vec::with_capacity(plan.visible.len());
1246        let mut offset = 0.0_f32;
1247        for &width in &plan.widths {
1248            slot_rects.push(place(offset, width));
1249            offset += width;
1250        }
1251        let overflow_rect = plan
1252            .show_chevron
1253            .then(|| place(offset, (inner.width - offset).max(0.0)));
1254
1255        // Publish the resolved geometry for the chrome. Read during the
1256        // paint that follows this very layout pass, so no binding needed.
1257        self.slots.publish(SegmentSlotGeometry {
1258            frame: visual,
1259            segments: slot_rects.clone(),
1260            order: plan.visible.clone(),
1261            overflow: overflow_rect,
1262        });
1263
1264        // ── Place the children, dispatching by id ───────────────────
1265        //
1266        // The slice holds only *active* children, so an overflowed (and
1267        // therefore dormant) cell has no entry at all and positions do not
1268        // line up with `self.children`.
1269        let mut active_cells: Vec<WidgetId> = Vec::with_capacity(plan.visible.len());
1270        for placement in children.iter_mut() {
1271            if Some(placement.id) == self.chrome_id {
1272                placement.origin = bounds.origin();
1273                placement.size = bounds.size();
1274                continue;
1275            }
1276            if Some(placement.id) == self.chevron_id {
1277                let rect = overflow_rect.unwrap_or(Rect::new(inner.right(), inner.y, 0.0, 0.0));
1278                placement.origin = rect.origin();
1279                placement.size = rect.size();
1280                continue;
1281            }
1282            let Some(live_index) = self.cell_ids.iter().position(|&id| id == placement.id) else {
1283                continue;
1284            };
1285            match plan.slot_of(live_index) {
1286                Some(slot) => {
1287                    let rect = slot_rects[slot];
1288                    placement.origin = rect.origin();
1289                    placement.size = rect.size();
1290                    active_cells.push(placement.id);
1291                }
1292                None => {
1293                    // Overflowed on *this* pass but not yet dormant (that
1294                    // lands next pass). Collapse it so it does not flash
1295                    // over the strip in the meantime.
1296                    placement.origin = Point::new(inner.x, inner.y);
1297                    placement.size = Size::new(0.0, 0.0);
1298                }
1299            }
1300        }
1301
1302        // Sibling relations for `push_to_radio_group`: only cells that are
1303        // actually on the strip, since a dormant cell emits no AccessKit
1304        // node and referencing its id would dangle.
1305        {
1306            let mut group = self.group_ids.borrow_mut();
1307            if *group != active_cells {
1308                *group = active_cells;
1309            }
1310        }
1311
1312        // ── Publish, behind an equality guard ───────────────────────
1313        //
1314        // These writes dirty the binding registry; `process_state_changes`
1315        // translates them into dormancy transitions at the top of the
1316        // *next* layout pass. Without the guard every pass would re-dirty
1317        // the visibility props and the tree would never settle.
1318        if *self.last_plan.borrow() != plan {
1319            let mut flags = vec![false; natural.len()];
1320            for &index in &plan.overflowed {
1321                if let Some(slot) = flags.get_mut(index) {
1322                    *slot = true;
1323                }
1324            }
1325            self.overflowed.set(flags);
1326            if self.is_overflowing.get() != plan.show_chevron {
1327                self.is_overflowing.set(plan.show_chevron);
1328            }
1329            // A segment that overflows while hovered fires no
1330            // `PointerLeave`; its cell clears the shared slot from its own
1331            // dormancy hook, but do it here too so the chrome never paints
1332            // one stale frame.
1333            if let Some(hovered) = self.hovered_segment.get()
1334                && !plan.is_visible(hovered)
1335            {
1336                self.hovered_segment.set(None);
1337            }
1338            *self.last_plan.borrow_mut() = plan;
1339        }
1340    }
1341
1342    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1343        builder.set_role(teksilo_core::accesskit::Role::RadioGroup);
1344        if let Some(name) = &self.label {
1345            builder.set_name(name.resolve_now());
1346        }
1347        let selected = self.index.get();
1348        if let Some(segment_index) = self.live.get(selected) {
1349            builder.set_value(self.segments[*segment_index].label.resolve_now());
1350        }
1351        // Roving focus: focus stays on the group, which points at the
1352        // selected segment. Only meaningful while that cell is on the
1353        // strip — an overflowed cell is dormant and has no AT node, but
1354        // the plan guarantees the selected one never is.
1355        if let Some(&cell) = self.cell_ids.get(selected)
1356            && self.group_ids.borrow().contains(&cell)
1357        {
1358            builder.set_active_descendant(teksilo_core::accessibility::widget_id_to_node_id(cell));
1359        }
1360        // Framework a11y walker sets `set_disabled` from arena state.
1361        builder.add_action(teksilo_core::accesskit::Action::Focus);
1362        builder.add_action(teksilo_core::accesskit::Action::Increment);
1363        builder.add_action(teksilo_core::accesskit::Action::Decrement);
1364    }
1365
1366    fn children(&self) -> Vec<WidgetId> {
1367        self.children.clone()
1368    }
1369}