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