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