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}