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_offset + theme.shape.focus_ring_width;
640        Rect::new(
641            bounds.x + envelope,
642            bounds.y + envelope,
643            (bounds.width - envelope * 2.0).max(0.0),
644            (bounds.height - envelope * 2.0).max(0.0),
645        )
646    }
647
648    /// The grid area inside the frame's stroke.
649    fn compute_inner(visual: Rect) -> Rect {
650        let bw = SEGMENTED_CONTROL_BORDER_WIDTH;
651        Rect::new(
652            visual.x + bw,
653            visual.y + bw,
654            (visual.width - bw * 2.0).max(0.0),
655            (visual.height - bw * 2.0).max(0.0),
656        )
657    }
658
659    /// Measure every live cell's intrinsic width, plus the chevron's.
660    ///
661    /// Uses [`LayoutContext::measure_intrinsic`], which measures even
662    /// **dormant** widgets — the segments that overflowed into the menu
663    /// still have to report a width, or the control could never work out
664    /// when they fit again.
665    fn measure(&self, ctx: &LayoutContext) -> (Vec<f32>, f32, f32) {
666        let probe = SizeProposal::unspecified();
667        let mut widths = Vec::with_capacity(self.cell_ids.len());
668        let mut tallest = 0.0_f32;
669        for &id in &self.cell_ids {
670            let size = ctx
671                .measure_intrinsic(id, probe)
672                .unwrap_or(Size::new(0.0, 0.0));
673            widths.push(size.width);
674            tallest = tallest.max(size.height);
675        }
676        let chevron = self
677            .chevron_id
678            .and_then(|id| ctx.measure_intrinsic(id, probe))
679            .map(|s| s.width)
680            .unwrap_or(0.0);
681        (widths, chevron, tallest)
682    }
683
684    /// Run the overflow plan for `inner_width`, applying and maintaining
685    /// the sticky promotion.
686    ///
687    /// Pure apart from `promoted`: the two `plan` calls share one
688    /// measurement pass, and the second only happens when the selection
689    /// would otherwise have been hidden.
690    fn resolve_plan(&self, inner_width: f32, natural: &[f32], chevron: f32) -> Plan {
691        let compress = self.overflow_mode == SegmentOverflow::Compress;
692        let live_count = natural.len();
693        if live_count == 0 {
694            return Plan::default();
695        }
696        let promoted_index = self
697            .promoted
698            .get()
699            .and_then(|id| self.live_ids.iter().position(|&candidate| candidate == id));
700
701        let mut plan = overflow::plan(
702            inner_width,
703            natural,
704            promoted_index,
705            chevron,
706            self.sizing,
707            compress,
708        );
709
710        // The invariant: the selected segment is always on the strip. If
711        // the plan hid it, promote it and re-plan — once; the re-planned
712        // `must` is by construction satisfiable, because `plan` keeps at
713        // least the forced segment.
714        let selected = self.index.get().min(live_count - 1);
715        if !plan.is_visible(selected) {
716            self.promoted.set(Some(self.live_ids[selected]));
717            plan = overflow::plan(
718                inner_width,
719                natural,
720                Some(selected),
721                chevron,
722                self.sizing,
723                compress,
724            );
725        }
726
727        // Forget the promotion once everything fits, so a later, unrelated
728        // narrowing starts from clean declaration order rather than
729        // resurrecting a pick the user made minutes ago.
730        if !plan.show_chevron {
731            self.promoted.set(None);
732        }
733        plan
734    }
735
736    /// Next selectable live index in `dir` (true = forward), wrapping and
737    /// skipping disabled segments. Returns `current` if no other segment
738    /// is enabled.
739    ///
740    /// Reads the disabled flags **live** — they are `Prop<bool>`s that an
741    /// app may flip through a bound signal with no rebuild, so a snapshot
742    /// taken at build time would go stale.
743    fn step_selection(current: usize, forward: bool, disabled: &[Prop<bool>]) -> usize {
744        let n = disabled.len();
745        if n == 0 {
746            return current;
747        }
748        let mut i = current;
749        for _ in 0..n {
750            i = if forward {
751                (i + 1) % n
752            } else {
753                (i + n - 1) % n
754            };
755            if !disabled[i].get() {
756                return i;
757            }
758        }
759        current
760    }
761
762    /// First / last enabled live index, for Home / End.
763    fn edge_selection(current: usize, last: bool, disabled: &[Prop<bool>]) -> usize {
764        let n = disabled.len();
765        if n == 0 {
766            return current;
767        }
768        let found = if last {
769            (0..n).rev().find(|i| !disabled[*i].get())
770        } else {
771            (0..n).find(|i| !disabled[*i].get())
772        };
773        found.unwrap_or(current)
774    }
775}
776
777impl std::fmt::Debug for SegmentedControl {
778    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
779        f.debug_struct("SegmentedControl")
780            .field("segments", &self.segments.len())
781            .field("live", &self.live.len())
782            .field("selected", &self.selected.get())
783            .field("enabled", &self.enabled.get())
784            .finish()
785    }
786}
787
788impl Widget for SegmentedControl {
789    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
790        let self_id = ctx.self_id();
791        // Forward the enabled state to the arena; see IconButton.
792        ctx.enabled_when(self_id, self.enabled.clone());
793        let effective_enabled = ctx.effective_enabled_signal(self_id);
794
795        // ── Live segment list ───────────────────────────────────────
796        //
797        // Hiding a segment is a *structural* change, not a resize: it
798        // renumbers the live list the index mirror addresses. Bind
799        // `visible` at `Rebuild` so the whole bridge is rebuilt
800        // consistently; the keyed selection survives it, which is
801        // precisely why the public signal is keyed.
802        {
803            let registry = ctx.binding_registry();
804            for segment in &self.segments {
805                segment.visible.register_if_bound(
806                    self_id,
807                    registry,
808                    teksilo_core::binding::BindingLevel::Rebuild,
809                );
810            }
811        }
812        self.live = (0..self.segments.len())
813            .filter(|&i| self.segments[i].visible.get())
814            .collect();
815        self.live_ids = self.live.iter().map(|&i| self.segments[i].id).collect();
816        let live_count = self.live.len();
817
818        // ── Optional positional mirror (`indexed`) ──────────────────
819        //
820        // Seeded before the keyed sync below, so that sync sees an id it
821        // can resolve. Declared positions, not live ones. This is the one
822        // sanctioned second writer of `selected`; every hop is
823        // equality-guarded, so the cycle
824        // mirror → selected → index → selected settles in one round.
825        if let Some(mirror) = self.index_mirror.clone() {
826            let declared: Vec<SegmentId> = self.segments.iter().map(|s| s.id).collect();
827            let from_position = |position: usize| declared.get(position).copied();
828
829            if self.selected.get().is_none_or(|id| !declared.contains(&id)) {
830                self.selected.set(from_position(mirror.get()));
831            }
832
833            {
834                let declared = declared.clone();
835                let selected = self.selected.clone();
836                ctx.effect(&mirror, move |position| {
837                    let target = declared.get(*position).copied();
838                    if target.is_some() && selected.get() != target {
839                        selected.set(target);
840                    }
841                });
842            }
843            {
844                let declared = declared.clone();
845                let mirror = mirror.clone();
846                ctx.effect(&self.selected, move |maybe_id| {
847                    if let Some(id) = maybe_id
848                        && let Some(position) =
849                            declared.iter().position(|&candidate| candidate == *id)
850                        && mirror.get() != position
851                    {
852                        mirror.set(position);
853                    }
854                });
855            }
856        }
857
858        // ── id ↔ index bridge (the TabBar recipe) ───────────────────
859        //
860        // Both directions resolve against the *live* list rebuilt above.
861        // A build-time snapshot in one direction and a live lookup in the
862        // other is what makes the two effects disagree after a reorder and
863        // feed back unboundedly.
864        let id_to_index: HashMap<SegmentId, usize> = self
865            .live_ids
866            .iter()
867            .enumerate()
868            .map(|(i, &id)| (id, i))
869            .collect();
870
871        if live_count > 0 {
872            match self
873                .selected
874                .get()
875                .and_then(|id| id_to_index.get(&id).copied())
876            {
877                Some(target) => {
878                    if self.index.get() != target {
879                        self.index.set(target);
880                    }
881                }
882                None => {
883                    // Stale or absent id: keep the previous *position*
884                    // clamped into range and re-stamp the id that now
885                    // lives there — the "select the neighbour" convention.
886                    let clamped = self.index.get().min(live_count - 1);
887                    if self.index.get() != clamped {
888                        self.index.set(clamped);
889                    }
890                    let resolved = self.live_ids[clamped];
891                    if self.selected.get() != Some(resolved) {
892                        self.selected.set(Some(resolved));
893                    }
894                }
895            }
896        } else if self.selected.get().is_some() {
897            self.selected.set(None);
898        }
899
900        {
901            let map = id_to_index.clone();
902            let index = self.index.clone();
903            ctx.effect(&self.selected, move |maybe_id| {
904                if let Some(id) = maybe_id
905                    && let Some(&target) = map.get(id)
906                    && index.get() != target
907                {
908                    index.set(target);
909                }
910            });
911        }
912        {
913            let ids = self.live_ids.clone();
914            let selected = self.selected.clone();
915            ctx.effect(&self.index, move |i| {
916                let resolved = ids.get(*i).copied();
917                if selected.get() != resolved {
918                    selected.set(resolved);
919                }
920            });
921        }
922
923        // Selection drives three different kinds of work, on three nodes:
924        // the plan (this node, Relayout — promotion can change which
925        // segments are on the strip), the announced `active_descendant`
926        // (this node, AccessibilityOnly — a relayout no longer re-walks
927        // the AT tree), and the chrome's fill (the chrome node, its own
928        // RepaintOnly binding).
929        {
930            let registry = ctx.binding_registry();
931            self.index.bind_to(
932                self_id,
933                registry,
934                teksilo_core::binding::BindingLevel::Relayout,
935            );
936            self.index.bind_to(
937                self_id,
938                registry,
939                teksilo_core::binding::BindingLevel::AccessibilityOnly,
940            );
941        }
942
943        // Seed the overflow flags before anything can read them: the
944        // framework polls every `visible_when` prop on the first layout
945        // pass, which happens before this widget's `place_children` has
946        // ever run.
947        self.overflowed.set(vec![false; live_count]);
948        self.is_overflowing.set(false);
949        *self.last_plan.borrow_mut() = Plan::default();
950        self.slots.publish(SegmentSlotGeometry::default());
951        self.group_ids.borrow_mut().clear();
952
953        let index = self.index.clone();
954        let hovered_segment = self.hovered_segment.clone();
955        // `:focus-visible`: derive the keyboard/pointer origin live from the
956        // input-modality signal (true after a key event, false after
957        // pointer-down) rather than snapshotting hover at focus time. The
958        // chrome reads `Some(_)` for the selected-segment accent fill (any
959        // focus) and `Some(Keyboard)` for the focus ring, so this keeps the
960        // fill on a click while making the ring keyboard-only.
961        let focused = self.focused.clone();
962        let focus_origin = self.focused.zip(&ctx.focus_visible()).map(|(f, v)| {
963            if !*f {
964                None
965            } else if *v {
966                Some(FocusOrigin::Keyboard)
967            } else {
968                Some(FocusOrigin::POINTER)
969            }
970        });
971
972        // One funnel for every internal selection write, so `on_change`
973        // fires exactly once per user-driven change and the index mirror
974        // stays the single write target.
975        let select: Rc<dyn Fn(usize, &mut EventContext)> = {
976            let index = index.clone();
977            let ids = self.live_ids.clone();
978            let on_change = self.on_change.clone();
979            Rc::new(move |target, ctx| {
980                if index.get() == target {
981                    return;
982                }
983                index.set(target);
984                if let Some(callback) = &on_change
985                    && let Some(id) = ids.get(target).copied()
986                {
987                    callback(id, ctx);
988                }
989            })
990        };
991
992        // Build chrome leaf first (so it sits at index 0 in `children`
993        // and paints behind the segment cells).
994        let style: SharedSegmentedControlStyle = self
995            .style_override
996            .clone()
997            .or_else(|| ctx.theme().style_slots.segmented_control.clone())
998            .unwrap_or_else(|| {
999                Rc::new(crate::styles::RecipeSegmentedControlStyle::for_tokens(
1000                    &ctx.theme().input,
1001                ))
1002            });
1003        let chrome_id = style.make_body(
1004            &SegmentedControlStyleConfig {
1005                slots: self.slots.clone(),
1006                selected: index.clone(),
1007                hovered_segment: hovered_segment.clone(),
1008                focus_origin: focus_origin.clone(),
1009                is_enabled: effective_enabled.clone(),
1010            },
1011            ctx,
1012        );
1013        self.chrome_id = Some(chrome_id);
1014
1015        self.children.clear();
1016        self.children.push(chrome_id);
1017        self.cell_ids.clear();
1018
1019        for (live_index, &segment_index) in self.live.iter().enumerate() {
1020            let segment = &self.segments[segment_index];
1021            let id = ctx.add(SegmentCell {
1022                label: segment.label.clone(),
1023                icon: segment.icon.clone(),
1024                tooltip: segment.tooltip.clone(),
1025                rich_tooltip_source: segment.rich_tooltip_source.clone(),
1026                composite_tooltip_factory: segment.composite_tooltip_factory.clone(),
1027                label_style: self.label_style.clone(),
1028                display: self.display,
1029                disabled: segment.disabled.clone(),
1030                index: live_index,
1031                live_count,
1032                selected: index.clone(),
1033                hovered_segment: hovered_segment.clone(),
1034                focus_origin: focus_origin.clone(),
1035                group_ids: self.group_ids.clone(),
1036                select: select.clone(),
1037                content_id: None,
1038            });
1039            self.cell_ids.push(id);
1040            self.children.push(id);
1041        }
1042
1043        // Gate each cell on "not overflowed". Fail open on a short flag
1044        // vector so the very first poll — which happens before any plan
1045        // exists — reads as visible rather than panicking.
1046        for (live_index, &cell_id) in self.cell_ids.iter().enumerate() {
1047            let flags = self.overflowed.clone();
1048            let on_strip = flags.map(move |f| f.get(live_index).copied() != Some(true));
1049            ctx.visible_when(cell_id, on_strip);
1050        }
1051
1052        // Overflow trigger. Built unconditionally (so it can be measured
1053        // while dormant) but only *shown* while something has overflowed,
1054        // so it never reserves width it does not need.
1055        if live_count > 0 && self.overflow_mode == SegmentOverflow::Menu {
1056            let chevron_id = overflow::build_overflow_trigger(
1057                ctx,
1058                &self.segments,
1059                &self.live,
1060                &index,
1061                &self.overflowed,
1062                OVERFLOW_ICON_SIZE,
1063                select.clone(),
1064            );
1065            ctx.visible_when(chevron_id, self.is_overflowing.clone());
1066            self.chevron_id = Some(chevron_id);
1067            self.children.push(chevron_id);
1068        } else {
1069            self.chevron_id = None;
1070        }
1071
1072        // Framework gates events on `arena.is_enabled`; focus walker
1073        // skips disabled subtrees.
1074        let mut handlers = HandlerSet::new()
1075            .focusable(true)
1076            .cursor(CursorIcon::Pointer);
1077
1078        // Hover-out on the parent clears the segment highlight when the
1079        // pointer leaves the control entirely.
1080        {
1081            let hovered_segment = hovered_segment.clone();
1082            handlers = handlers.on_hover(move |entered, _ctx| {
1083                if !entered {
1084                    hovered_segment.set(None);
1085                }
1086            });
1087        }
1088
1089        // Live disabled flags, in live order. Held as `Prop`s and read at
1090        // event time: an app may flip a bound signal with no rebuild, and
1091        // a `Vec<bool>` snapshotted here would silently go stale.
1092        let disabled: Rc<Vec<Prop<bool>>> = Rc::new(
1093            self.live
1094                .iter()
1095                .map(|&i| self.segments[i].disabled.clone())
1096                .collect(),
1097        );
1098
1099        // Arrow keys cycle selection, Home/End jump to the ends, both
1100        // skipping disabled segments. Focus stays on the control.
1101        {
1102            let index = index.clone();
1103            let disabled = disabled.clone();
1104            let select = select.clone();
1105            let cell_ids = self.cell_ids.clone();
1106            handlers = handlers.on_key(move |event, ctx: &mut EventContext| {
1107                if live_count == 0 {
1108                    return EventResponse::Ignored;
1109                }
1110                let WidgetEvent::KeyDown { key, .. } = event else {
1111                    return EventResponse::Ignored;
1112                };
1113                // Resolve direction at *event* time, so a locale flip
1114                // re-maps the arrows with no rebuild.
1115                let (previous, next) = if ctx.is_rtl() {
1116                    (Key::ArrowRight, Key::ArrowLeft)
1117                } else {
1118                    (Key::ArrowLeft, Key::ArrowRight)
1119                };
1120                let current = index.get().min(live_count - 1);
1121                let target = if *key == next {
1122                    Self::step_selection(current, true, &disabled)
1123                } else if *key == previous {
1124                    Self::step_selection(current, false, &disabled)
1125                } else if *key == Key::Home {
1126                    Self::edge_selection(current, false, &disabled)
1127                } else if *key == Key::End {
1128                    Self::edge_selection(current, true, &disabled)
1129                } else {
1130                    return EventResponse::Ignored;
1131                };
1132                if target != current {
1133                    select(target, ctx);
1134                    // Reveal the newly selected segment in any enclosing
1135                    // scroll area — an AT/keyboard move does not shift
1136                    // focus, so the framework's focus-follow cannot.
1137                    if let Some(&id) = cell_ids.get(target) {
1138                        ctx.ensure_widget_visible(id);
1139                    }
1140                }
1141                EventResponse::Handled
1142            });
1143        }
1144
1145        // Focus handler. Track raw focus only; the keyboard/pointer
1146        // distinction (for the ring and the selected-segment accent fill) is
1147        // derived live from the input-modality signal in `build()`
1148        // (`:focus-visible`), so clicking to focus then pressing a key
1149        // reveals the ring.
1150        {
1151            let focused = focused.clone();
1152            handlers = handlers.on_focus(move |gained, _ctx| {
1153                focused.set(gained);
1154            });
1155        }
1156
1157        // Access actions — increment/decrement cycle selection (skipping
1158        // disabled segments).
1159        {
1160            let index = index.clone();
1161            let disabled = disabled.clone();
1162            let select = select.clone();
1163            let cell_ids = self.cell_ids.clone();
1164            handlers = handlers.on_access_action(move |action, ctx: &mut EventContext| {
1165                if live_count == 0 {
1166                    return EventResponse::Ignored;
1167                }
1168                let current = index.get().min(live_count - 1);
1169                let target = if action == teksilo_core::accesskit::Action::Increment {
1170                    Self::step_selection(current, true, &disabled)
1171                } else if action == teksilo_core::accesskit::Action::Decrement {
1172                    Self::step_selection(current, false, &disabled)
1173                } else {
1174                    return EventResponse::Ignored;
1175                };
1176                if target != current {
1177                    select(target, ctx);
1178                    if let Some(&id) = cell_ids.get(target) {
1179                        ctx.ensure_widget_visible(id);
1180                    }
1181                }
1182                EventResponse::Handled
1183            });
1184        }
1185
1186        ctx.apply_self_handlers(handlers);
1187
1188        self.children.clone()
1189    }
1190
1191    fn layout_response(
1192        &self,
1193        proposal: SizeProposal,
1194        ctx: &LayoutContext,
1195    ) -> teksilo_core::widget::LayoutResponse {
1196        let envelope = ctx.theme.shape.focus_ring_offset + ctx.theme.shape.focus_ring_width;
1197        let chrome = envelope * 2.0 + SEGMENTED_CONTROL_BORDER_WIDTH * 2.0;
1198
1199        // Real measurement, not a per-character guess: this is what makes
1200        // a control in an `HStack` claim the width its labels actually
1201        // need, and what the overflow plan is calibrated against.
1202        let (natural, chevron, tallest) = self.measure(ctx);
1203        let content_width: f32 = match self.sizing {
1204            SegmentSizing::Uniform => {
1205                let widest = natural.iter().copied().fold(0.0_f32, f32::max);
1206                widest * natural.len() as f32
1207            }
1208            SegmentSizing::Fit => natural.iter().sum(),
1209        };
1210        let natural_width = content_width + chrome;
1211        // One ellipsized segment plus the chevron: the narrowest the
1212        // control can be and still mean something.
1213        let min_width = SEGMENTED_CONTROL_PADDING_HORIZONTAL * 2.0 + chevron + chrome;
1214
1215        // The content height is measured, not assumed, so a 200 % global
1216        // text scale grows the control instead of clipping its labels.
1217        let visual_height = (tallest.max(FALLBACK_LINE_HEIGHT)
1218            + SEGMENTED_CONTROL_PADDING_VERTICAL * 2.0)
1219            .max(SEGMENTED_CONTROL_HEIGHT);
1220        let height = visual_height + envelope * 2.0;
1221
1222        if self.fill_width {
1223            Size::new(proposal.width.unwrap_or(natural_width), height).into()
1224        } else {
1225            LayoutResponse::shrinkable(
1226                Size::new(natural_width, height),
1227                Size::new(min_width.min(natural_width), height),
1228                1.0,
1229            )
1230        }
1231    }
1232
1233    fn place_children(
1234        &self,
1235        bounds: Rect,
1236        _proposal: SizeProposal,
1237        children: &mut [WidgetPlacement],
1238        ctx: &LayoutContext,
1239    ) {
1240        if children.is_empty() {
1241            return;
1242        }
1243
1244        let visual = Self::compute_visual(bounds, ctx.theme);
1245        let inner = Self::compute_inner(visual);
1246        let (natural, chevron_width, _) = self.measure(ctx);
1247        let plan = self.resolve_plan(inner.width, &natural, chevron_width);
1248
1249        // Reading-order offsets, mirrored onto the axis afterwards so RTL
1250        // needs no separate code path. "Last slot" therefore means last in
1251        // *reading* order — next to the chevron — in both directions.
1252        let rtl = ctx.is_rtl();
1253        let place = |offset: f32, width: f32| -> Rect {
1254            let x = if rtl {
1255                inner.x + (inner.width - offset - width)
1256            } else {
1257                inner.x + offset
1258            };
1259            Rect::new(x, inner.y, width, inner.height)
1260        };
1261
1262        let mut slot_rects = Vec::with_capacity(plan.visible.len());
1263        let mut offset = 0.0_f32;
1264        for &width in &plan.widths {
1265            slot_rects.push(place(offset, width));
1266            offset += width;
1267        }
1268        let overflow_rect = plan
1269            .show_chevron
1270            .then(|| place(offset, (inner.width - offset).max(0.0)));
1271
1272        // Publish the resolved geometry for the chrome. Read during the
1273        // paint that follows this very layout pass, so no binding needed.
1274        self.slots.publish(SegmentSlotGeometry {
1275            frame: visual,
1276            segments: slot_rects.clone(),
1277            order: plan.visible.clone(),
1278            overflow: overflow_rect,
1279        });
1280
1281        // ── Place the children, dispatching by id ───────────────────
1282        //
1283        // The slice holds only *active* children, so an overflowed (and
1284        // therefore dormant) cell has no entry at all and positions do not
1285        // line up with `self.children`.
1286        let mut active_cells: Vec<WidgetId> = Vec::with_capacity(plan.visible.len());
1287        for placement in children.iter_mut() {
1288            if Some(placement.id) == self.chrome_id {
1289                placement.origin = bounds.origin();
1290                placement.size = bounds.size();
1291                continue;
1292            }
1293            if Some(placement.id) == self.chevron_id {
1294                let rect = overflow_rect.unwrap_or(Rect::new(inner.right(), inner.y, 0.0, 0.0));
1295                placement.origin = rect.origin();
1296                placement.size = rect.size();
1297                continue;
1298            }
1299            let Some(live_index) = self.cell_ids.iter().position(|&id| id == placement.id) else {
1300                continue;
1301            };
1302            match plan.slot_of(live_index) {
1303                Some(slot) => {
1304                    let rect = slot_rects[slot];
1305                    placement.origin = rect.origin();
1306                    placement.size = rect.size();
1307                    active_cells.push(placement.id);
1308                }
1309                None => {
1310                    // Overflowed on *this* pass but not yet dormant (that
1311                    // lands next pass). Collapse it so it does not flash
1312                    // over the strip in the meantime.
1313                    placement.origin = Point::new(inner.x, inner.y);
1314                    placement.size = Size::new(0.0, 0.0);
1315                }
1316            }
1317        }
1318
1319        // Sibling relations for `push_to_radio_group`: only cells that are
1320        // actually on the strip, since a dormant cell emits no AccessKit
1321        // node and referencing its id would dangle.
1322        {
1323            let mut group = self.group_ids.borrow_mut();
1324            if *group != active_cells {
1325                *group = active_cells;
1326            }
1327        }
1328
1329        // ── Publish, behind an equality guard ───────────────────────
1330        //
1331        // These writes dirty the binding registry; `process_state_changes`
1332        // translates them into dormancy transitions at the top of the
1333        // *next* layout pass. Without the guard every pass would re-dirty
1334        // the visibility props and the tree would never settle.
1335        if *self.last_plan.borrow() != plan {
1336            let mut flags = vec![false; natural.len()];
1337            for &index in &plan.overflowed {
1338                if let Some(slot) = flags.get_mut(index) {
1339                    *slot = true;
1340                }
1341            }
1342            self.overflowed.set(flags);
1343            if self.is_overflowing.get() != plan.show_chevron {
1344                self.is_overflowing.set(plan.show_chevron);
1345            }
1346            // A segment that overflows while hovered fires no
1347            // `PointerLeave`; its cell clears the shared slot from its own
1348            // dormancy hook, but do it here too so the chrome never paints
1349            // one stale frame.
1350            if let Some(hovered) = self.hovered_segment.get()
1351                && !plan.is_visible(hovered)
1352            {
1353                self.hovered_segment.set(None);
1354            }
1355            *self.last_plan.borrow_mut() = plan;
1356        }
1357    }
1358
1359    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1360        builder.set_role(teksilo_core::accesskit::Role::RadioGroup);
1361        if let Some(name) = &self.label {
1362            builder.set_name(name.resolve_now());
1363        }
1364        // The set size belongs on the container, not on each item:
1365        // AccessKit's `size_of_set` differs from ARIA's per-item
1366        // `aria-setsize`, and `size_of_set_from_container` resolves an
1367        // item's set size by walking *up* from it.
1368        // `live`, not the rendered cells: a segment pushed into the
1369        // overflow menu is still one of the choices, so it still counts.
1370        if !self.live.is_empty() {
1371            builder.set_size_of_set(self.live.len());
1372        }
1373        let selected = self.index.get();
1374        if let Some(segment_index) = self.live.get(selected) {
1375            builder.set_value(self.segments[*segment_index].label.resolve_now());
1376        }
1377        // Roving focus: focus stays on the group, which points at the
1378        // selected segment. Only meaningful while that cell is on the
1379        // strip — an overflowed cell is dormant and has no AT node, but
1380        // the plan guarantees the selected one never is.
1381        if let Some(&cell) = self.cell_ids.get(selected)
1382            && self.group_ids.borrow().contains(&cell)
1383        {
1384            builder.set_active_descendant(teksilo_core::accessibility::widget_id_to_node_id(cell));
1385        }
1386        // Framework a11y walker sets `set_disabled` from arena state.
1387        builder.add_action(teksilo_core::accesskit::Action::Focus);
1388        builder.add_action(teksilo_core::accesskit::Action::Increment);
1389        builder.add_action(teksilo_core::accesskit::Action::Decrement);
1390    }
1391
1392    fn children(&self) -> Vec<WidgetId> {
1393        self.children.clone()
1394    }
1395}