Skip to main content

teksilo_widgets/docking/
activity_bar.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `DockActivityBar` — the tailored VS Code-style **vertical** icon rail. One
5//! item per tab of a side; clicking an inactive item selects + shows the side,
6//! clicking the active item hides the side. Always visible (it lives in the
7//! layout chrome, outboard of the collapsible content), so it is the reopen
8//! affordance while the side is hidden.
9//!
10//! Features (configured via [`DockRail`]):
11//! - **Vertical only** — a column of items, pushed to the **top**.
12//! - **Selectable item size** ([`IconButtonSize`]) — one size for all items.
13//! - **`top_slot` / `bottom_slot`** — fixed widgets pinned above the items and
14//!   at the very bottom of the rail (e.g. a logo on top, settings/account at
15//!   the bottom, the VS Code convention).
16//! - **[`DockAction`]s** — dockless command buttons that look and behave like
17//!   activity items but open no panel. Never draggable, never hidable, never
18//!   persisted; grouped into an ARIA `Role::Toolbar` beside — never inside —
19//!   the tab list.
20//! - **Overflow** — when the items don't all fit, the surplus are parked
21//!   dormant and reached through a caller-chosen **overflow item** (an icon)
22//!   that opens a popover list of the overflowed entries.
23//!
24//! **Accessibility structure.** ARIA's Tabs pattern restricts a `role=tablist`
25//! to `role=tab` children, so the rail is NOT one flat tab list: the items live
26//! in a [`DockRailTabList`] (`Role::TabList`) and the actions in one
27//! [`DockRailActionGroup`] (`Role::Toolbar`) per placement, as siblings under a
28//! presentational root. The slots and the overflow trigger are likewise
29//! siblings, never tab-list children. Each composite is its own single Tab stop
30//! with its own roving Arrow/Home/End cycle; Tab/Shift+Tab crosses between them.
31
32use std::cell::{Cell, RefCell};
33use std::collections::HashMap;
34use std::rc::Rc;
35
36use teksilo_canvas::{Canvas, Rect, SizeProposal};
37use teksilo_core::accessibility::{AccessNodeBuilder, widget_id_to_node_id};
38use teksilo_core::binding::BindingLevel;
39use teksilo_core::build_context::BuildContext;
40use teksilo_core::color_prop::ColorProp;
41use teksilo_core::event::{EventResponse, Key, WidgetEvent};
42use teksilo_core::gesture::DragPhase;
43use teksilo_core::signal::{Prop, Signal};
44use teksilo_core::widget::{
45    CursorIcon, EventContext, LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement,
46};
47use teksilo_core::widget_builder::HandlerSet;
48use teksilo_core::widget_id::WidgetId;
49use teksilo_core::{DragPayload, DropFeedback};
50use teksilo_i18n::{LocalizedString, lit};
51use teksilo_tokens::{BorderRole, CornerRadius, HAlignment, SurfaceRole, TextRole, TextStyleRole};
52
53use crate::icon_button::{IconButton, IconButtonSize};
54use crate::popover_widget::PopoverIconButton;
55use crate::primitives::{
56    Center, FixedSize, HStack, IconWidget, Padding, RectWidget, Spacer, TextWidget, VStack, ZStack,
57};
58use crate::styles::recipe_icon_button_style::ICON_BUTTON_CORNER_RADIUS;
59use crate::tool_box::RotatedLabel;
60
61use super::context_menu::{DockMenuKind, activity_context_menu, background_menu};
62use super::drag::{DockTabDragData, dropped_dock_tab, dropped_dock_widget};
63use super::geometry::DockSide;
64use super::model::{DockIconFactory, DockRailItemSize, DockTabId, DockingModel};
65
66/// Shared sink each rail item upserts its `(visible position, world bounds)`
67/// into during layout, so the bar's drop handler can compute an insertion
68/// index from the pointer position. Keyed by visible position so a stale entry
69/// for a now-overflowed item is filtered out (the handler only considers
70/// positions below the current shown count).
71type RailItemBounds = Rc<RefCell<Vec<(usize, Rect)>>>;
72
73/// Shared list of `(visible position → WidgetId)` for the rail's items, used
74/// to move keyboard focus between sibling tabs (roving focus). Keyed by the
75/// same visible position as [`RailItemBounds`] so the two stay aligned.
76type RailItemIds = Rc<RefCell<Vec<(usize, WidgetId)>>>;
77
78/// Factory for a rail slot widget (rebuilt on each rail rebuild).
79///
80/// A slot that wants to match the rail's current item size binds
81/// [`DockingModel::rail_size_mode_signal`](super::DockingModel::rail_size_mode_signal)
82/// — the rail rebuilds its slots whenever the size mode changes, so reading the
83/// signal in the factory is enough to keep the slot in step.
84pub type DockRailSlot = Rc<dyn Fn() -> Box<dyn Widget>>;
85
86/// Map an [`IconButtonSize`] to the rail item's square extent (dp).
87fn item_extent(size: IconButtonSize) -> f32 {
88    use crate::styles::recipe_icon_button_style::*;
89    match size {
90        IconButtonSize::Compact => ICON_BUTTON_SIZE_COMPACT,
91        IconButtonSize::Default => ICON_BUTTON_SIZE_DEFAULT,
92        IconButtonSize::Toolbar => ICON_BUTTON_SIZE_TOOLBAR,
93        IconButtonSize::Large => ICON_BUTTON_SIZE_LARGE,
94        IconButtonSize::Hero => ICON_BUTTON_SIZE_HERO,
95    }
96}
97
98/// Map an [`IconButtonSize`] to the glyph (icon) dimension (dp) drawn inside a
99/// rail item's square box. Mirrors [`IconButton`]'s own
100/// size → glyph scaling so a caller's rail icon tracks the rail size instead of
101/// staying a fixed dp — a 40 dp `Large` box gets a 24 dp glyph, not a tiny one.
102fn item_glyph_size(size: IconButtonSize) -> f32 {
103    use crate::styles::recipe_icon_button_style::*;
104    match size {
105        IconButtonSize::Compact | IconButtonSize::Default => ICON_BUTTON_ICON_SIZE,
106        IconButtonSize::Toolbar => ICON_BUTTON_ICON_SIZE_TOOLBAR,
107        IconButtonSize::Large => ICON_BUTTON_ICON_SIZE_LARGE,
108        IconButtonSize::Hero => ICON_BUTTON_ICON_SIZE_HERO,
109    }
110}
111
112/// Spacing between rail items.
113const RAIL_ITEM_SPACING: f32 = 2.0;
114/// Padding around the rail's item column.
115const RAIL_PADDING: f32 = 4.0;
116/// Rough vertical room a Labeled item's rotated title needs beyond its icon
117/// square, used only by the overflow capacity estimate.
118const LABELED_TITLE_ALLOWANCE: f32 = 72.0;
119/// Top breathing room above a Labeled item's rotated title (so its top
120/// character isn't flush against the rail item's top edge).
121const LABELED_TOP_MARGIN: f32 = 6.0;
122
123// ───────────────────────────────────────────────────────────────────────
124// DockAction — a dockless command button in the rail.
125// ───────────────────────────────────────────────────────────────────────
126
127/// Stable identity for a [`DockAction`].
128///
129/// **Not** used for persistence — a rail action carries no user-mutable state,
130/// so nothing about it is serialized (see [`DockLayoutState`](super::DockLayoutState)'s
131/// "app-config is reconstructed each run" rule). It exists so the accessibility
132/// tree and the automation bridge can address a given action stably across
133/// runs; a fresh-per-run id would make every script that clicks a rail action
134/// flaky.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
136pub struct DockActionId(u64);
137
138impl DockActionId {
139    /// Derive a stable id from a caller-chosen name — identical across runs,
140    /// processes and machines. Prefer this over [`from_raw`](Self::from_raw):
141    /// it removes the hand-picked-`u64`-literal collision hazard entirely.
142    ///
143    /// `const` so ids can be declared as module-scope `const` items, the same
144    /// way apps already declare their [`DockWidgetId`](super::DockWidgetId)s.
145    ///
146    /// ```
147    /// # use teksilo_widgets::docking::DockActionId;
148    /// const SETTINGS: DockActionId = DockActionId::named("app.settings");
149    /// assert_eq!(SETTINGS, DockActionId::named("app.settings"));
150    /// assert_ne!(SETTINGS, DockActionId::named("app.about"));
151    /// ```
152    pub const fn named(name: &str) -> Self {
153        // FNV-1a. Chosen over a stronger hash because it must run in a `const`
154        // context; there is no adversarial input here, only a handful of
155        // app-chosen literals.
156        let bytes = name.as_bytes();
157        let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
158        let mut i = 0;
159        while i < bytes.len() {
160            hash ^= bytes[i] as u64;
161            hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
162            i += 1;
163        }
164        Self(hash)
165    }
166
167    /// Wrap a raw value. Prefer [`named`](Self::named).
168    pub const fn from_raw(v: u64) -> Self {
169        Self(v)
170    }
171
172    pub const fn raw(self) -> u64 {
173        self.0
174    }
175}
176
177/// Where a [`DockAction`] sits along the rail's column.
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179pub enum DockActionPlacement {
180    /// Before the first activity item, in the flowing cluster.
181    Start,
182    /// After the last activity item **and after the overflow trigger**, still
183    /// in the flowing cluster — the group grows downward with the tabs.
184    End,
185    /// Past the flexible spacer, anchored to the rail's far edge regardless of
186    /// how many activities exist — VS Code's Accounts / Manage-gear cluster.
187    /// Where a Settings gear belongs.
188    Pinned,
189}
190
191/// A **dockless command button** in the activity rail: it looks and behaves
192/// like an activity item, but opens no panel — activating it just runs a
193/// closure.
194///
195/// Declared on [`DockRail::action`], so (like the rail's slots) it is per-view
196/// app config, reconstructed each run. A rail action is deliberately **more
197/// restricted** than a real activity: it is never draggable, never hidable, has
198/// no "Move to" menu, and is never overflow-parked — it is reserved space. That
199/// matches every surveyed precedent (VS Code's fixed Accounts / Manage cluster;
200/// IntelliJ's stripe, whose only non-tool-window button is IDE-owned chrome).
201///
202/// ```ignore
203/// DockRail::new(DockSide::Leading).action(
204///     DockAction::new(
205///         DockActionId::named("app.settings"),
206///         lit!("Settings"),
207///         || IconWidget::gear(),
208///         |ctx| ctx.send_intent(Intent::new("app.settings")),
209///     )
210///     .placement(DockActionPlacement::Pinned),
211/// )
212/// ```
213#[derive(Clone)]
214pub struct DockAction {
215    pub(crate) id: DockActionId,
216    pub(crate) placement: DockActionPlacement,
217    pub(crate) label: LocalizedString,
218    pub(crate) icon: DockIconFactory,
219    pub(crate) tooltip: Option<LocalizedString>,
220    pub(crate) enabled: Prop<bool>,
221    /// `Some` => paints the selected surface while the signal is `true`.
222    ///
223    /// **Reflect-only**: the rail never writes this signal — `on_activate`
224    /// owns every write. That is deliberate, and differs from
225    /// [`IconButton::toggle`](crate::icon_button::IconButton::toggle), which
226    /// flips its signal on click: a rail action's toggled state is frequently
227    /// a *derived* signal (a `map` over app state), which cannot be written at
228    /// all, and a writable one would fight the model it mirrors.
229    pub(crate) toggled: Option<Signal<bool>>,
230    pub(crate) on_activate: Rc<dyn Fn(&mut EventContext)>,
231}
232
233impl std::fmt::Debug for DockAction {
234    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235        f.debug_struct("DockAction")
236            .field("id", &self.id)
237            .field("placement", &self.placement)
238            .finish()
239    }
240}
241
242impl DockAction {
243    /// Declare a rail action. Defaults to [`DockActionPlacement::End`],
244    /// enabled, untoggled, with the label as its hover tooltip.
245    pub fn new(
246        id: DockActionId,
247        label: impl Into<LocalizedString>,
248        icon: impl Fn() -> IconWidget + 'static,
249        on_activate: impl Fn(&mut EventContext) + 'static,
250    ) -> Self {
251        Self {
252            id,
253            placement: DockActionPlacement::End,
254            label: label.into(),
255            icon: Rc::new(icon),
256            tooltip: None,
257            enabled: Prop::Static(true),
258            toggled: None,
259            on_activate: Rc::new(on_activate),
260        }
261    }
262
263    /// Where the action sits along the rail. See [`DockActionPlacement`].
264    pub fn placement(mut self, placement: DockActionPlacement) -> Self {
265        self.placement = placement;
266        self
267    }
268
269    /// Override the hover tooltip (defaults to the label). Ignored in
270    /// `Icon + Label` rail mode, which paints the label inline instead.
271    pub fn tooltip(mut self, tooltip: impl Into<LocalizedString>) -> Self {
272        self.tooltip = Some(tooltip.into());
273        self
274    }
275
276    /// Enable / disable the action. Accepts a `bool` or a `Signal<bool>`.
277    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
278        self.enabled = enabled.into();
279        self
280    }
281
282    /// Paint the selected surface while `state` is `true` — the same
283    /// highlight an open activity gets. **Reflect-only**: activating the
284    /// action does not write `state`; `on_activate` must.
285    pub fn toggled(mut self, state: Signal<bool>) -> Self {
286        self.toggled = Some(state);
287        self
288    }
289
290    /// The action's id.
291    pub fn id(&self) -> DockActionId {
292        self.id
293    }
294}
295
296// ───────────────────────────────────────────────────────────────────────
297// DockRail — app-facing configuration of a side's activity rail.
298// ───────────────────────────────────────────────────────────────────────
299
300/// App-facing configuration for a side's activity rail (Rail presentation).
301///
302/// Pass to [`DockingLayout::rail`](super::DockingLayout::rail). All knobs are
303/// optional; an unconfigured rail uses [`IconButtonSize::Large`] items, no
304/// slots, and no overflow affordance (items just clip if the side is too
305/// short).
306#[derive(Clone)]
307pub struct DockRail {
308    pub(crate) side: DockSide,
309    pub(crate) size: IconButtonSize,
310    pub(crate) background: Option<ColorProp>,
311    pub(crate) divider: Option<ColorProp>,
312    pub(crate) top_slot: Option<DockRailSlot>,
313    pub(crate) bottom_slot: Option<DockRailSlot>,
314    pub(crate) leading_slot: Option<DockRailSlot>,
315    pub(crate) trailing_slot: Option<DockRailSlot>,
316    pub(crate) actions: Vec<DockAction>,
317    pub(crate) overflow_icon: Option<DockIconFactory>,
318}
319
320impl std::fmt::Debug for DockRail {
321    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
322        f.debug_struct("DockRail")
323            .field("side", &self.side)
324            .field("size", &self.size)
325            .finish()
326    }
327}
328
329impl DockRail {
330    /// Configure the rail for `side`.
331    pub fn new(side: DockSide) -> Self {
332        Self {
333            side,
334            size: IconButtonSize::Large,
335            background: None,
336            divider: None,
337            top_slot: None,
338            bottom_slot: None,
339            leading_slot: None,
340            trailing_slot: None,
341            actions: Vec::new(),
342            overflow_icon: None,
343        }
344    }
345
346    /// Pick one size for every rail item ([`IconButtonSize::Compact`] …
347    /// [`Hero`](IconButtonSize::Hero)). Default [`IconButtonSize::Large`].
348    pub fn size(mut self, size: IconButtonSize) -> Self {
349        self.size = size;
350        self
351    }
352
353    /// Override the rail strip's background. Accepts `Color`, a
354    /// [`SurfaceRole`], or a `Signal<Color>`.
355    /// Default (unset) is `SurfaceRole::Sunken`.
356    pub fn background(mut self, color: impl Into<ColorProp>) -> Self {
357        self.background = Some(color.into());
358        self
359    }
360
361    /// Draw a 1 dp divider line between the rail and the side's content, on
362    /// the rail's content-facing edge (RTL-aware). Uses `BorderRole::Divider`.
363    /// Off by default. See [`divider_color`](Self::divider_color) for a custom
364    /// colour.
365    pub fn divider(mut self) -> Self {
366        self.divider = Some(BorderRole::Divider.into());
367        self
368    }
369
370    /// Like [`divider`](Self::divider), but with an explicit colour. Accepts
371    /// `Color`, a [`BorderRole`], or a
372    /// `Signal<Color>`.
373    pub fn divider_color(mut self, color: impl Into<ColorProp>) -> Self {
374        self.divider = Some(color.into());
375        self
376    }
377
378    /// Widget pinned **above** the items (e.g. a logo / hamburger). To track the
379    /// rail's item size, bind
380    /// [`DockingModel::rail_size_mode_signal`](super::DockingModel::rail_size_mode_signal)
381    /// inside the factory.
382    pub fn top_slot<W: Widget + 'static>(mut self, f: impl Fn() -> W + 'static) -> Self {
383        self.top_slot = Some(Rc::new(move || Box::new(f()) as Box<dyn Widget>));
384        self
385    }
386
387    /// Widget pinned at the **bottom** of the rail (e.g. settings / account). To
388    /// track the rail's item size, bind
389    /// [`DockingModel::rail_size_mode_signal`](super::DockingModel::rail_size_mode_signal)
390    /// inside the factory.
391    pub fn bottom_slot<W: Widget + 'static>(mut self, f: impl Fn() -> W + 'static) -> Self {
392        self.bottom_slot = Some(Rc::new(move || Box::new(f()) as Box<dyn Widget>));
393        self
394    }
395
396    /// Widget pinned at the **start** of this side's **Strip**-presentation tab
397    /// bar (via [`TabWidget::bar_leading_slot`](crate::tab_widget::TabWidget::bar_leading_slot)).
398    /// The Rail-presentation counterpart is [`top_slot`](Self::top_slot).
399    ///
400    /// **Weaker contract than `top_slot`.** `top_slot`/`bottom_slot` sit on the
401    /// `DockActivityBar`, which is built whenever the side has a rail — they
402    /// survive the side being collapsed. `leading_slot`/`trailing_slot` sit
403    /// inside the side's `TabWidget`, which lives within the collapsing
404    /// `SideClipPane`, so they disappear with the content when the side is
405    /// hidden. If your content must survive a hidden side, use Rail
406    /// presentation, or host it outside the docking system.
407    pub fn leading_slot<W: Widget + 'static>(mut self, f: impl Fn() -> W + 'static) -> Self {
408        self.leading_slot = Some(Rc::new(move || Box::new(f()) as Box<dyn Widget>));
409        self
410    }
411
412    /// Widget pinned at the **end** of this side's **Strip**-presentation tab
413    /// bar. Composed *before* the side's own "hidden activities" hamburger when
414    /// both are present, so neither is dropped. See
415    /// [`leading_slot`](Self::leading_slot) for the visibility contract.
416    pub fn trailing_slot<W: Widget + 'static>(mut self, f: impl Fn() -> W + 'static) -> Self {
417        self.trailing_slot = Some(Rc::new(move || Box::new(f()) as Box<dyn Widget>));
418        self
419    }
420
421    /// Append a **dockless command button** to this side's rail. Declaration
422    /// order is render order within a placement. See [`DockAction`].
423    ///
424    /// **Rail presentation only.** A side in
425    /// [`TabPresentation::Strip`](super::TabPresentation::Strip) renders no
426    /// actions at all — and [`set_side_rail`](super::DockingModel::set_side_rail)
427    /// can flip presentation at runtime, so a side that flips Rail → Strip drops
428    /// its whole action cluster. If that is reachable in your app, mirror the
429    /// cluster with [`trailing_slot`](Self::trailing_slot), which the same
430    /// `DockRail` can carry alongside its actions.
431    pub fn action(mut self, action: DockAction) -> Self {
432        // A duplicate id is always a bug: the id is the action's stable address
433        // for assistive tech and the automation bridge, so two buttons sharing
434        // one makes "click the settings action" ambiguous — and it fails
435        // silently, because both still render. Catch it in debug the same way
436        // `open_dock` catches an unregistered dock. Two distinct names can also
437        // collide under `named`'s FNV-1a; astronomically unlikely, but this
438        // reports it as a collision instead of letting it ship.
439        debug_assert!(
440            !self.actions.iter().any(|a| a.id == action.id),
441            "duplicate DockActionId {:?} on the {:?} rail — ids must be unique \
442             per side (two `DockAction`s declared with the same id, or an \
443             FNV-1a collision between two `DockActionId::named` values)",
444            action.id,
445            self.side,
446        );
447        self.actions.push(action);
448        self
449    }
450
451    /// Choose the glyph for the overflow trigger — the item shown (in place of
452    /// the surplus items) when they don't all fit. Tapping it opens a popover
453    /// list of the overflowed entries.
454    pub fn overflow_icon(mut self, f: impl Fn() -> IconWidget + 'static) -> Self {
455        self.overflow_icon = Some(Rc::new(f));
456        self
457    }
458
459    /// This rail's actions for `placement`, in declaration order.
460    pub(crate) fn actions_at(&self, placement: DockActionPlacement) -> Vec<DockAction> {
461        self.actions
462            .iter()
463            .filter(|a| a.placement == placement)
464            .cloned()
465            .collect()
466    }
467
468    pub(crate) fn side(&self) -> DockSide {
469        self.side
470    }
471
472    /// The rail strip's effective thickness for a size `mode` — the item extent
473    /// (Compact shrinks it to the standard [`IconButtonSize::Default`]; Default /
474    /// Labeled keep the configured size) plus the rail's padding. Drives the
475    /// side's rail width so the activity bar itself follows the Default /
476    /// Compact / Icon + Label switch, not just its items.
477    pub(crate) fn effective_thickness(&self, mode: DockRailItemSize) -> f32 {
478        let size = if matches!(mode, DockRailItemSize::Compact) {
479            IconButtonSize::Default
480        } else {
481            self.size
482        };
483        item_extent(size) + RAIL_PADDING * 2.0
484    }
485}
486
487// ───────────────────────────────────────────────────────────────────────
488// DockActivityBar
489// ───────────────────────────────────────────────────────────────────────
490
491pub(crate) struct DockActivityBar {
492    side: DockSide,
493    model: DockingModel,
494    config: DockRail,
495    /// Number of leading items currently shown; the rest overflow. Set in
496    /// `place_children` from the available height. `usize::MAX` until first
497    /// layout (everything visible).
498    visible_count: Signal<usize>,
499    item_count: usize,
500    /// Per-item world bounds (visible position → rect), populated by the rail
501    /// items during layout; read by the drop handler to place the insertion
502    /// line and compute the drop index. Like a TabBar that accepts external
503    /// tabs + internal reorders, the rail is a drop target for whole dock tabs
504    /// (`move_tab`) and single docks (`promote_to_tab`).
505    item_bounds: RailItemBounds,
506    /// Shared list of `(visible position → WidgetId)` for the currently-built
507    /// rail items, populated in `build()`. Drives Arrow/Home/End roving focus
508    /// between sibling tabs (the `request_focus` target list), the same way
509    /// `TabBar` shares its `header_ids`. Filtered by `visible_count` at nav
510    /// time so overflowed (dormant) items are skipped — they live in the
511    /// overflow popover instead.
512    item_ids: RailItemIds,
513    /// The rail's own world bounds, recorded in `place_children` so the drop
514    /// handler can translate the item world rects into bar-local space.
515    self_bounds: Rc<Cell<Rect>>,
516    /// Per-side content-region ids (owned by the enclosing `DockingLayout`),
517    /// so a rail tab can advertise an AT `controls` relationship pointing at
518    /// the `DockSidePanel` it governs (the ARIA tab → tabpanel link).
519    side_panel_ids: Rc<RefCell<HashMap<DockSide, WidgetId>>>,
520    /// Bar-local y of the active drop insertion line (`None` = no drag over the
521    /// rail). Painted by the `RailDropIndicator` overlay.
522    drop_indicator: Signal<Option<f32>>,
523    root: Option<WidgetId>,
524}
525
526impl std::fmt::Debug for DockActivityBar {
527    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
528        f.debug_struct("DockActivityBar")
529            .field("side", &self.side)
530            .finish()
531    }
532}
533
534impl DockActivityBar {
535    pub(crate) fn new(
536        side: DockSide,
537        model: DockingModel,
538        config: DockRail,
539        side_panel_ids: Rc<RefCell<HashMap<DockSide, WidgetId>>>,
540    ) -> Self {
541        Self {
542            side,
543            model,
544            config,
545            visible_count: Signal::new(usize::MAX),
546            item_count: 0,
547            item_bounds: Rc::new(RefCell::new(Vec::new())),
548            item_ids: Rc::new(RefCell::new(Vec::new())),
549            self_bounds: Rc::new(Cell::new(Rect::ZERO)),
550            side_panel_ids,
551            drop_indicator: Signal::new(None),
552            root: None,
553        }
554    }
555
556    /// The effective rail-item size: compact items shrink to the standard
557    /// [`IconButtonSize::Default`] (not the extra-small `Compact` — a rail item
558    /// is an identify target, so its glyph must stay legible); Default and
559    /// Labeled both keep the rail's configured icon size (Labeled just adds a
560    /// rotated title beneath).
561    fn effective_item_size(&self) -> IconButtonSize {
562        match self.model.side_rail_size(self.side) {
563            DockRailItemSize::Compact => IconButtonSize::Default,
564            DockRailItemSize::Default | DockRailItemSize::Labeled => self.config.size,
565        }
566    }
567
568    fn effective_item_extent(&self) -> f32 {
569        item_extent(self.effective_item_size())
570    }
571
572    /// Per-item vertical stride used by the overflow capacity estimate. Labeled
573    /// items are taller (icon + rotated title), so reserve extra room — a rough
574    /// allowance, since each title's length differs.
575    fn item_stride(&self) -> f32 {
576        let mut s = self.effective_item_extent() + RAIL_ITEM_SPACING;
577        if self.model.side_rail_size(self.side).shows_label() {
578            s += LABELED_TITLE_ALLOWANCE;
579        }
580        s
581    }
582
583    /// Build this rail's [`DockRailActionGroup`] for `placement`, or `None`
584    /// when the app declared no action there. Returning `None` (rather than an
585    /// always-built, sometimes-hidden group) is what keeps an empty
586    /// `Role::Toolbar` out of the AT tree — the same "`if is_some()`" shape the
587    /// slots already use.
588    fn build_action_group(
589        &self,
590        ctx: &mut BuildContext,
591        placement: DockActionPlacement,
592    ) -> Option<WidgetId> {
593        let actions = self.config.actions_at(placement);
594        if actions.is_empty() {
595            return None;
596        }
597        Some(ctx.add(DockRailActionGroup::new(
598            self.side,
599            placement,
600            actions,
601            self.effective_item_extent(),
602            item_glyph_size(self.effective_item_size()),
603            self.model.side_rail_size(self.side).shows_label(),
604        )))
605    }
606}
607
608impl Widget for DockActivityBar {
609    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
610        // Rebuild when this side's rail-item size flips (context-menu "Activity
611        // bar size").
612        let self_id = ctx.self_id();
613        self.model.rail_size_signal(self.side).bind_to(
614            self_id,
615            ctx.binding_registry(),
616            BindingLevel::Rebuild,
617        );
618
619        let bg_color = self
620            .config
621            .background
622            .clone()
623            .unwrap_or_else(|| SurfaceRole::Sunken.into());
624        let bg = ctx.add(RectWidget::new().background(bg_color));
625        let selected = self.model.side_selected_tab_signal(self.side);
626        let visible = self.model.side_visible_signal(self.side);
627        let tabs = self.model.side_tabs(self.side);
628        let extent = self.effective_item_extent();
629        let glyph = item_glyph_size(self.effective_item_size());
630        let labeled = self.model.side_rail_size(self.side).shows_label();
631        let visible_count = self.visible_count.clone();
632
633        // One item per *non-hidden* tab. The item keeps its model tab index
634        // (for selection); overflow parking uses its position among the shown
635        // items, so a hidden tab in the middle doesn't leave a phantom slot.
636        // `model_indices` maps each shown position → model tab index, so the
637        // drop handler can translate a visible insertion position into a
638        // `move_tab` index (a hidden tab in the middle shifts nothing).
639        self.item_bounds.borrow_mut().clear();
640        self.item_ids.borrow_mut().clear();
641        let mut model_indices: Vec<usize> = Vec::with_capacity(tabs.len());
642        let mut items: Vec<WidgetId> = Vec::with_capacity(tabs.len());
643        let mut pos = 0usize;
644        for (model_i, tab) in tabs.iter().enumerate() {
645            if tab.hidden {
646                continue;
647            }
648            let p = pos;
649            pos += 1;
650            model_indices.push(model_i);
651            // Label / icon: explicit activity title → primary (first
652            // non-collapsed) pane's dock → "Panel" / no-icon.
653            let icon = self.model.activity_icon(tab);
654            let label = self.model.activity_label(tab);
655            let id = ctx.add(DockRailItem::new(
656                self.side,
657                model_i,
658                p,
659                tab.id,
660                icon,
661                label,
662                extent,
663                glyph,
664                labeled,
665                selected.clone(),
666                visible.clone(),
667                self.model.clone(),
668                self.item_bounds.clone(),
669                self.item_ids.clone(),
670                self.visible_count.clone(),
671                self.side_panel_ids.clone(),
672            ));
673            // Register the id for roving focus (keyed by visible position).
674            self.item_ids.borrow_mut().push((p, id));
675            ctx.visible_when(id, visible_count.map(move |c| p < *c));
676            items.push(id);
677        }
678        self.item_count = pos;
679
680        // The items go into their own `Role::TabList` wrapper rather than
681        // sitting directly in the rail's column: ARIA's Tabs pattern restricts
682        // a tablist's children to tabs, and the column also holds slots, the
683        // overflow trigger and action groups — none of which are tabs. The
684        // wrapper's inner VStack is a bare `GenericContainer`, so the AT pass
685        // prunes it and the items read as direct tablist children.
686        let mut items_stack = VStack::new().spacing(RAIL_ITEM_SPACING);
687        for id in &items {
688            items_stack = items_stack.add_child(*id);
689        }
690        let items_stack = ctx.add(items_stack);
691        let tab_list = ctx.add(DockRailTabList::new(self.side, items_stack));
692
693        // Item column (pushed to the top by a trailing Spacer).
694        let mut column = VStack::new().spacing(RAIL_ITEM_SPACING);
695        if let Some(top) = &self.config.top_slot {
696            column = column.add_child(ctx.add_boxed((top)()));
697        }
698        if let Some(group) = self.build_action_group(ctx, DockActionPlacement::Start) {
699            column = column.add_child(group);
700        }
701        column = column.add_child(tab_list);
702        // Overflow trigger: a caller-chosen glyph that opens a popover list of
703        // the overflowed entries. Shown only while something overflows.
704        if let Some(of_icon) = &self.config.overflow_icon {
705            let total = pos;
706            let trigger = IconButton::new((of_icon)())
707                .size(self.effective_item_size())
708                .tooltip(lit!("More panels"));
709            let overflow = ctx.add(
710                PopoverIconButton::new(trigger)
711                    .content(DockOverflowMenu::new(
712                        self.side,
713                        self.model.clone(),
714                        visible_count.clone(),
715                    ))
716                    .placement(teksilo_core::overlay::OverlayPlacement::TrailingEdge),
717            );
718            ctx.visible_when(overflow, visible_count.map(move |c| *c < total));
719            column = column.add_child(overflow);
720        }
721        if let Some(group) = self.build_action_group(ctx, DockActionPlacement::End) {
722            column = column.add_child(group);
723        }
724        let spacer = ctx.add(Spacer::new());
725        column = column.add_child(spacer);
726        if let Some(group) = self.build_action_group(ctx, DockActionPlacement::Pinned) {
727            column = column.add_child(group);
728        }
729        if let Some(bottom) = &self.config.bottom_slot {
730            let b = ctx.add_boxed((bottom)());
731            column = column.add_child(b);
732        }
733
734        let column_id = ctx.add(column);
735        let padded = ctx.add(Padding::uniform(RAIL_PADDING).child_id(column_id));
736        // Insertion-line overlay (topmost) — painted while a dock tab / dock
737        // widget is dragged over the rail.
738        let indicator = ctx.add(RailDropIndicator::new(self.drop_indicator.clone()));
739        let mut stack = ZStack::new()
740            .add_child(bg)
741            .add_child(padded)
742            .add_child(indicator);
743        // Optional divider between the rail and the side's content, on the
744        // content-facing edge (drawn above the background so it isn't covered).
745        if let Some(color) = self.config.divider.clone() {
746            stack = stack.add_child(ctx.add(RailEdgeDivider {
747                side: self.side,
748                color,
749            }));
750        }
751        let root = ctx.add(stack);
752        self.root = Some(root);
753
754        // Right-click on empty rail space → the activities checklist + Activity
755        // bar size (the affordance to restore a hidden activity once every item
756        // is hidden, and to resize the rail).
757        let menu_model = self.model.clone();
758        let menu_side = self.side;
759        // Drag-and-drop: the rail accepts external activities (a whole tab
760        // dragged from another side's rail / strip → `move_tab`, a single dock
761        // → `promote_to_tab`) AND internal moves (dragging one of its own rail
762        // items reorders the side's tabs — same `move_tab`, the source-side ==
763        // target-side path). The insertion index comes from the pointer vs the
764        // recorded item bounds; the overlay paints the line.
765        let side = self.side;
766        let model = self.model.clone();
767        let item_bounds_hover = self.item_bounds.clone();
768        let item_bounds_drop = self.item_bounds.clone();
769        let self_bounds_hover = self.self_bounds.clone();
770        let self_bounds_drop = self.self_bounds.clone();
771        let indicator_hover = self.drop_indicator.clone();
772        let indicator_leave = self.drop_indicator.clone();
773        let indicator_drop = self.drop_indicator.clone();
774        let visible_count_hover = self.visible_count.clone();
775        let visible_count_drop = self.visible_count.clone();
776        let model_indices_hover = model_indices.clone();
777        let model_indices_drop = model_indices;
778        ctx.apply_self_handlers(
779            HandlerSet::new()
780                .context_menu(move |_pos, _ctx| {
781                    Some(Box::new(background_menu(
782                        &menu_model,
783                        menu_side,
784                        DockMenuKind::Rail,
785                    )))
786                })
787                .on_drag_hover(move |payload, pos, _ctx| {
788                    if dropped_dock_tab(payload).is_none() && dropped_dock_widget(payload).is_none()
789                    {
790                        indicator_hover.set(None);
791                        return DropFeedback::NoFeedback;
792                    }
793                    let bar = self_bounds_hover.get();
794                    let shown = shown_items(
795                        &item_bounds_hover,
796                        &model_indices_hover,
797                        &visible_count_hover,
798                    );
799                    let (_, line_y) = rail_insertion(pos.y, &shown, bar.y, bar.height);
800                    indicator_hover.set(Some(line_y));
801                    DropFeedback::InsertionLine {
802                        y: line_y,
803                        width: bar.width,
804                    }
805                })
806                .on_drag_leave(move |_ctx| indicator_leave.set(None))
807                .on_drop(move |payload, pos, ctx| {
808                    indicator_drop.set(None);
809                    // A disabled side never mutates from a UI drop (Path 1 of the
810                    // mid-drag-disable race: without this the model silently
811                    // rejects but the side would still be revealed + the drop
812                    // consumed). The widget-destroyed path is handled in core.
813                    if !model.is_side_enabled(side) {
814                        return false;
815                    }
816                    let bar = self_bounds_drop.get();
817                    let shown =
818                        shown_items(&item_bounds_drop, &model_indices_drop, &visible_count_drop);
819                    let (vpos, _) = rail_insertion(pos.y, &shown, bar.y, bar.height);
820                    // Visible insertion position → model tab index; past the
821                    // last shown item ⇒ just after the last *visible* tab (not
822                    // past trailing hidden tabs).
823                    let at = model_indices_drop
824                        .get(vpos)
825                        .copied()
826                        .unwrap_or_else(|| model.side_append_index(side));
827                    if let Some(tab_id) = dropped_dock_tab(&payload) {
828                        model.move_tab(tab_id, side, at);
829                        model.set_side_visible(side, true);
830                        ctx.request_accessibility_update();
831                        true
832                    } else if let Some(dock_id) = dropped_dock_widget(&payload) {
833                        model.promote_to_tab(dock_id, side, at);
834                        model.set_side_visible(side, true);
835                        ctx.request_accessibility_update();
836                        true
837                    } else {
838                        false
839                    }
840                }),
841        );
842
843        vec![root]
844    }
845
846    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
847        self.root
848            .and_then(|id| ctx.child_size(id, proposal))
849            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
850            .into()
851    }
852
853    fn place_children(
854        &self,
855        bounds: Rect,
856        _proposal: SizeProposal,
857        children: &mut [WidgetPlacement],
858        _ctx: &LayoutContext,
859    ) {
860        // Record the rail's world bounds so the drop handler can translate the
861        // item world rects (recorded by the rail items) into bar-local space.
862        self.self_bounds.set(bounds);
863        for child in children.iter_mut() {
864            child.origin = bounds.origin();
865            child.size = bounds.size();
866        }
867
868        // Capacity: how many items fit in the column once the padding, the
869        // optional slots, and (if overflowing) the overflow trigger are
870        // reserved. Slots are treated as roughly one item tall — a good
871        // estimate for a square rail glyph.
872        let stride = self.item_stride();
873        if stride <= 0.0 {
874            return;
875        }
876        let new_visible = shown_capacity(RailCapacity {
877            height: bounds.height,
878            stride,
879            slots: usize::from(self.config.top_slot.is_some())
880                + usize::from(self.config.bottom_slot.is_some()),
881            actions: self.config.actions.len(),
882            total: self.item_count,
883            has_overflow_trigger: self.config.overflow_icon.is_some(),
884        });
885        if self.visible_count.get() != new_visible {
886            self.visible_count.set(new_visible);
887        }
888    }
889
890    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
891        // Deliberately property-free. `Role::TabList` now lives on the
892        // `DockRailTabList` wrapper around the items alone (ARIA forbids
893        // non-tab children of a tablist, and this root also holds slots, the
894        // overflow trigger and the action groups). This node must stay a bare
895        // `GenericContainer` — setting a name or an orientation here would
896        // stop the AT pass pruning it, and a screen reader would announce
897        // "Leading activity bar, group" immediately followed by "Leading
898        // activity bar, tab list".
899        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
900    }
901
902    fn children(&self) -> Vec<WidgetId> {
903        self.root.into_iter().collect()
904    }
905}
906
907/// Inputs to [`shown_capacity`] — everything competing for the rail's height.
908#[derive(Debug, Clone, Copy, PartialEq)]
909struct RailCapacity {
910    /// The rail strip's height.
911    height: f32,
912    /// Per-item vertical stride (item extent + spacing, plus the Labeled
913    /// title allowance when captions are shown).
914    stride: f32,
915    /// How many of `top_slot` / `bottom_slot` are configured. Charged one
916    /// stride each — an approximation, since a caller's slot widget may be any
917    /// height. (Pre-existing; fixing it needs `DockRailSlot` to report a
918    /// measured extent, which is a separate change.)
919    slots: usize,
920    /// Declared [`DockAction`]s. Charged one stride each, which is **exact**:
921    /// an action renders at the rail's own item extent, and the count is fixed
922    /// at build time because an action can never be hidden.
923    actions: usize,
924    /// Non-hidden activity items competing for what's left.
925    total: usize,
926    /// Whether a caller supplied an overflow glyph. Without one the surplus is
927    /// simply clipped rather than parked behind a trigger.
928    has_overflow_trigger: bool,
929}
930
931/// How many activity items the rail can show, given everything else that
932/// reserves space in the same column.
933///
934/// Pure so the arithmetic is directly testable: the widget-level effect
935/// (parking the surplus dormant) depends on a signal write inside
936/// `place_children` propagating to `visible_when`, which a single headless
937/// layout pass does not settle.
938fn shown_capacity(c: RailCapacity) -> usize {
939    if c.stride <= 0.0 {
940        return c.total;
941    }
942    let reserve = RAIL_PADDING * 2.0 + (c.slots + c.actions) as f32 * c.stride;
943    let avail = (c.height - reserve).max(0.0);
944    let fit = (avail / c.stride).floor() as usize;
945    if c.total <= fit {
946        c.total
947    } else if c.has_overflow_trigger {
948        // One slot goes to the overflow trigger itself.
949        fit.saturating_sub(1)
950    } else {
951        fit
952    }
953}
954
955/// Snapshot the currently-shown rail items (visible position, world bounds),
956/// sorted by position, dropping any stale entry beyond the live shown count
957/// (an item parked by overflow keeps a lingering bound until it next lays out).
958fn shown_items(
959    bounds: &RailItemBounds,
960    model_indices: &[usize],
961    visible_count: &Signal<usize>,
962) -> Vec<(usize, Rect)> {
963    let shown = model_indices.len().min(visible_count.get());
964    let mut out: Vec<(usize, Rect)> = bounds
965        .borrow()
966        .iter()
967        .filter(|(p, _)| *p < shown)
968        .copied()
969        .collect();
970    out.sort_by_key(|(p, _)| *p);
971    out
972}
973
974/// A roving-focus navigation step among the rail's shown items.
975enum RailNav {
976    Prev,
977    Next,
978    First,
979    Last,
980}
981
982/// Count of rail tabs currently in the AT tree = items whose visible position
983/// is below the live `visible_count` (overflowed items are dormant). Drives
984/// `size_of_set` on each `Role::Tab`.
985fn shown_rail_count(item_ids: &RailItemIds, visible_count: &Signal<usize>) -> usize {
986    let count = visible_count.get();
987    item_ids.borrow().iter().filter(|(p, _)| *p < count).count()
988}
989
990/// Roving-focus navigation among the rail's currently-shown items. Given the
991/// shared id list, the live visible count, the current item's visible
992/// position, and a navigation step, return the `WidgetId` to focus next
993/// (arrows wrap; Home/End clamp to ends). Overflowed (dormant) items are
994/// excluded — they live in the overflow popover, not the Tab cycle. Mirrors
995/// `TabBar`'s `request_focus(headers[next])` roving (`tab_widget/header.rs`).
996fn rail_focus_target(
997    item_ids: &RailItemIds,
998    visible_count: &Signal<usize>,
999    current_pos: usize,
1000    nav: RailNav,
1001) -> Option<WidgetId> {
1002    let count = visible_count.get();
1003    let mut shown: Vec<(usize, WidgetId)> = item_ids
1004        .borrow()
1005        .iter()
1006        .filter(|(p, _)| *p < count)
1007        .copied()
1008        .collect();
1009    shown.sort_by_key(|(p, _)| *p);
1010    if shown.is_empty() {
1011        return None;
1012    }
1013    let cur = shown.iter().position(|(p, _)| *p == current_pos)?;
1014    let target = match nav {
1015        RailNav::Prev => (cur + shown.len() - 1) % shown.len(),
1016        RailNav::Next => (cur + 1) % shown.len(),
1017        RailNav::First => 0,
1018        RailNav::Last => shown.len() - 1,
1019    };
1020    Some(shown[target].1)
1021}
1022
1023/// Count of overflow rows currently shown in the popover = items whose visible
1024/// position is at or above the live `visible_count` (the parked ones). Drives
1025/// `size_of_set` on each overflow `Role::MenuItem`.
1026fn overflow_shown_count(row_ids: &RailItemIds, visible_count: &Signal<usize>) -> usize {
1027    let count = visible_count.get();
1028    row_ids.borrow().iter().filter(|(p, _)| *p >= count).count()
1029}
1030
1031/// Roving-focus navigation among the overflow popover's shown rows — the
1032/// counterpart of [`rail_focus_target`] for the parked (`pos >= visible_count`)
1033/// items. Returns the row `WidgetId` to focus next.
1034fn overflow_focus_target(
1035    row_ids: &RailItemIds,
1036    visible_count: &Signal<usize>,
1037    current_pos: usize,
1038    nav: RailNav,
1039) -> Option<WidgetId> {
1040    let count = visible_count.get();
1041    let mut shown: Vec<(usize, WidgetId)> = row_ids
1042        .borrow()
1043        .iter()
1044        .filter(|(p, _)| *p >= count)
1045        .copied()
1046        .collect();
1047    shown.sort_by_key(|(p, _)| *p);
1048    if shown.is_empty() {
1049        return None;
1050    }
1051    let cur = shown.iter().position(|(p, _)| *p == current_pos)?;
1052    let target = match nav {
1053        RailNav::Prev => (cur + shown.len() - 1) % shown.len(),
1054        RailNav::Next => (cur + 1) % shown.len(),
1055        RailNav::First => 0,
1056        RailNav::Last => shown.len() - 1,
1057    };
1058    Some(shown[target].1)
1059}
1060
1061/// Given the pointer's bar-local y, the shown items (sorted by position, world
1062/// bounds), and the bar's world origin/height, return the visible insertion
1063/// position (`0..=count`) and the indicator's bar-local y.
1064fn rail_insertion(
1065    local_y: f32,
1066    shown: &[(usize, Rect)],
1067    bar_origin_y: f32,
1068    bar_height: f32,
1069) -> (usize, f32) {
1070    if shown.is_empty() {
1071        return (0, (RAIL_PADDING).min(bar_height));
1072    }
1073    // Insertion position = number of items whose vertical centre is above the
1074    // pointer.
1075    let mut vpos = shown.len();
1076    for (i, (_, b)) in shown.iter().enumerate() {
1077        let center = (b.y + b.height * 0.5) - bar_origin_y;
1078        if local_y < center {
1079            vpos = i;
1080            break;
1081        }
1082    }
1083    let line_y = if vpos == 0 {
1084        let top = shown[0].1.y - bar_origin_y;
1085        (top - RAIL_ITEM_SPACING * 0.5).max(0.0)
1086    } else if vpos >= shown.len() {
1087        let last = &shown[shown.len() - 1].1;
1088        (last.y + last.height - bar_origin_y + RAIL_ITEM_SPACING * 0.5).min(bar_height)
1089    } else {
1090        let prev = &shown[vpos - 1].1;
1091        let next = &shown[vpos].1;
1092        let prev_bottom = prev.y + prev.height - bar_origin_y;
1093        let next_top = next.y - bar_origin_y;
1094        (prev_bottom + next_top) * 0.5
1095    };
1096    (vpos, line_y.clamp(0.0, bar_height))
1097}
1098
1099// ───────────────────────────────────────────────────────────────────────
1100// DockRailTabList — the `Role::TabList` wrapper around the rail's items.
1101// ───────────────────────────────────────────────────────────────────────
1102
1103/// Carries the rail's `Role::TabList` around **only** the [`DockRailItem`]s.
1104///
1105/// ARIA's Tabs pattern restricts a tablist's children to tabs, but the rail's
1106/// column also holds slots, the overflow trigger and the action groups. Wrapping
1107/// just the items keeps every one of those a sibling rather than an illegal
1108/// tablist child.
1109///
1110/// Layout is delegated to the caller-supplied `VStack` (which already carries
1111/// `RAIL_ITEM_SPACING`), so introducing this wrapper cannot change the column's
1112/// spacing. That stack reports a bare `Role::GenericContainer`, so the AT pass
1113/// prunes it and promotes the items to direct children of this node.
1114#[derive(Debug)]
1115pub(crate) struct DockRailTabList {
1116    side: DockSide,
1117    stack: WidgetId,
1118}
1119
1120impl DockRailTabList {
1121    fn new(side: DockSide, stack: WidgetId) -> Self {
1122        Self { side, stack }
1123    }
1124}
1125
1126impl Widget for DockRailTabList {
1127    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
1128        ctx.child_size(self.stack, proposal)
1129            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
1130            .into()
1131    }
1132
1133    fn place_children(
1134        &self,
1135        bounds: Rect,
1136        _proposal: SizeProposal,
1137        children: &mut [WidgetPlacement],
1138        _ctx: &LayoutContext,
1139    ) {
1140        for child in children.iter_mut() {
1141            child.origin = bounds.origin();
1142            child.size = bounds.size();
1143        }
1144    }
1145
1146    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1147        use teksilo_core::accesskit::{Orientation as A11yOrientation, Role};
1148        builder.set_role(Role::TabList);
1149        builder.set_name(super::a11y::rail_label(self.side).resolve_now());
1150        builder.set_orientation(A11yOrientation::Vertical);
1151    }
1152
1153    fn children(&self) -> Vec<WidgetId> {
1154        vec![self.stack]
1155    }
1156}
1157
1158// ───────────────────────────────────────────────────────────────────────
1159// DockRailActionGroup — the `Role::Toolbar` cluster of dockless actions.
1160// ───────────────────────────────────────────────────────────────────────
1161
1162/// One placement's worth of [`DockAction`]s, as an ARIA toolbar sibling of the
1163/// rail's tab list.
1164///
1165/// Deliberately **not** a member of [`DockRailTabList`]'s children and never
1166/// registered into the rail's `RailItemBounds` / `RailItemIds`: the drop
1167/// machinery resolves a drop position through `model_indices[vpos]`, so a
1168/// non-tab entry sharing that indexed sequence would silently move the wrong
1169/// tab. Keeping the two populations structurally separate makes that class of
1170/// bug unreachable rather than merely guarded.
1171///
1172/// Keyboard: the group is its own single Tab stop with an internal roving
1173/// Arrow/Home/End cycle (the ARIA toolbar pattern), independent of the tab
1174/// list's. Tab / Shift+Tab crosses between the two composites; arrows never do.
1175pub(crate) struct DockRailActionGroup {
1176    side: DockSide,
1177    placement: DockActionPlacement,
1178    actions: Vec<DockAction>,
1179    extent: f32,
1180    glyph: f32,
1181    labeled: bool,
1182    /// Which action index is currently the group's single Tab stop. Local
1183    /// focus history — mirrors `Toolbar::roving`, NOT `DockRailItem`'s
1184    /// model-level `selected`: an action group has no "selected" concept.
1185    roving: Signal<usize>,
1186    item_ids: Rc<RefCell<Vec<WidgetId>>>,
1187    root: Option<WidgetId>,
1188}
1189
1190impl std::fmt::Debug for DockRailActionGroup {
1191    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1192        f.debug_struct("DockRailActionGroup")
1193            .field("side", &self.side)
1194            .field("placement", &self.placement)
1195            .field("actions", &self.actions.len())
1196            .finish()
1197    }
1198}
1199
1200impl DockRailActionGroup {
1201    fn new(
1202        side: DockSide,
1203        placement: DockActionPlacement,
1204        actions: Vec<DockAction>,
1205        extent: f32,
1206        glyph: f32,
1207        labeled: bool,
1208    ) -> Self {
1209        Self {
1210            side,
1211            placement,
1212            actions,
1213            extent,
1214            glyph,
1215            labeled,
1216            roving: Signal::new(0),
1217            item_ids: Rc::new(RefCell::new(Vec::new())),
1218            root: None,
1219        }
1220    }
1221}
1222
1223impl Widget for DockRailActionGroup {
1224    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1225        self.item_ids.borrow_mut().clear();
1226        let mut stack = VStack::new().spacing(RAIL_ITEM_SPACING);
1227        let mut ids = Vec::with_capacity(self.actions.len());
1228        for (i, action) in self.actions.iter().enumerate() {
1229            let id = ctx.add(DockRailActionItem::new(
1230                action.clone(),
1231                i,
1232                self.extent,
1233                self.glyph,
1234                self.labeled,
1235                self.roving.clone(),
1236                self.item_ids.clone(),
1237            ));
1238            ids.push(id);
1239            stack = stack.add_child(id);
1240        }
1241        *self.item_ids.borrow_mut() = ids;
1242        // The roving stop can outlive a rebuild that shortened the list (an
1243        // app may declare a different action set per view); re-clamp so the
1244        // group never points its only Tab stop at a missing item.
1245        if self.roving.get() >= self.actions.len() {
1246            self.roving.set(0);
1247        }
1248        let root = ctx.add(stack);
1249        self.root = Some(root);
1250        vec![root]
1251    }
1252
1253    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
1254        self.root
1255            .and_then(|id| ctx.child_size(id, proposal))
1256            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
1257            .into()
1258    }
1259
1260    fn place_children(
1261        &self,
1262        bounds: Rect,
1263        _proposal: SizeProposal,
1264        children: &mut [WidgetPlacement],
1265        _ctx: &LayoutContext,
1266    ) {
1267        for child in children.iter_mut() {
1268            child.origin = bounds.origin();
1269            child.size = bounds.size();
1270        }
1271    }
1272
1273    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1274        use teksilo_core::accesskit::{Orientation as A11yOrientation, Role};
1275        builder.set_role(Role::Toolbar);
1276        builder.set_name(super::a11y::rail_actions_label(self.side, self.placement).resolve_now());
1277        builder.set_orientation(A11yOrientation::Vertical);
1278    }
1279
1280    fn children(&self) -> Vec<WidgetId> {
1281        self.root.into_iter().collect()
1282    }
1283}
1284
1285// ───────────────────────────────────────────────────────────────────────
1286// DockRailActionItem — one dockless action button.
1287// ───────────────────────────────────────────────────────────────────────
1288
1289/// One [`DockAction`], rendered to match a [`DockRailItem`] pixel for pixel.
1290///
1291/// Built from the same primitives as a rail item rather than from an
1292/// [`IconButton`] on purpose:
1293/// * `IconButton::toggle` **writes** its signal on click; a `DockAction`'s
1294///   toggled state is reflect-only (§ [`DockAction::toggled`]).
1295/// * `IconButton`'s tooltip opens `Below`, which on a vertical rail drops it
1296///   onto the next stacked item — rail items use `TooltipPlacement::Side`.
1297/// * The rail owns glyph sizing and the `Icon + Label` rotated caption, so an
1298///   action tracks the Compact / Default / Labeled switch like a real item.
1299struct DockRailActionItem {
1300    action: DockAction,
1301    index: usize,
1302    extent: f32,
1303    glyph: f32,
1304    labeled: bool,
1305    roving: Signal<usize>,
1306    siblings: Rc<RefCell<Vec<WidgetId>>>,
1307    focused: Signal<bool>,
1308    root: Option<WidgetId>,
1309}
1310
1311impl std::fmt::Debug for DockRailActionItem {
1312    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1313        f.debug_struct("DockRailActionItem")
1314            .field("index", &self.index)
1315            .finish()
1316    }
1317}
1318
1319impl DockRailActionItem {
1320    fn new(
1321        action: DockAction,
1322        index: usize,
1323        extent: f32,
1324        glyph: f32,
1325        labeled: bool,
1326        roving: Signal<usize>,
1327        siblings: Rc<RefCell<Vec<WidgetId>>>,
1328    ) -> Self {
1329        Self {
1330            action,
1331            index,
1332            extent,
1333            glyph,
1334            labeled,
1335            roving,
1336            siblings,
1337            focused: Signal::new(false),
1338            root: None,
1339        }
1340    }
1341}
1342
1343impl Widget for DockRailActionItem {
1344    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1345        let self_id = ctx.self_id();
1346        let enabled = self.action.enabled.as_signal();
1347        enabled.bind_to(self_id, ctx.binding_registry(), BindingLevel::RepaintOnly);
1348
1349        // Reflect-only toggled highlight, window-active-aware — the same
1350        // treatment `DockRailItem` gives an open activity, so a toggled action
1351        // reads as "on" exactly like an open panel does.
1352        let toggled = self
1353            .action
1354            .toggled
1355            .clone()
1356            .unwrap_or_else(|| Signal::new(false));
1357        toggled.bind_to(self_id, ctx.binding_registry(), BindingLevel::RepaintOnly);
1358        let bg = toggled.zip(&ctx.window_active_signal()).map(|(t, win)| {
1359            if *t {
1360                if *win {
1361                    SurfaceRole::Selected
1362                } else {
1363                    SurfaceRole::SelectedInactive
1364                }
1365            } else {
1366                SurfaceRole::Transparent
1367            }
1368        });
1369        let ring = self.focused.and(&ctx.focus_visible());
1370        let focus_ring_width = ctx.theme().shape.focus_ring_width;
1371        let border_color: ColorProp = ring
1372            .map(|f| {
1373                if *f {
1374                    BorderRole::Focused
1375                } else {
1376                    BorderRole::Transparent
1377                }
1378            })
1379            .into();
1380        let border_width = ring.map(move |f| if *f { focus_ring_width } else { 0.0 });
1381        let bg_rect = ctx.add(
1382            RectWidget::new()
1383                .background(bg)
1384                .border_color(border_color)
1385                .border_width(border_width)
1386                .corner_radius(CornerRadius::uniform(ICON_BUTTON_CORNER_RADIUS)),
1387        );
1388
1389        let glyph_color: ColorProp = enabled
1390            .map(|e| {
1391                if *e {
1392                    TextRole::Primary
1393                } else {
1394                    TextRole::Disabled
1395                }
1396            })
1397            .into();
1398        let icon = ctx.add(
1399            (self.action.icon)()
1400                .icon_size(self.glyph)
1401                .color(glyph_color.clone()),
1402        );
1403        let centered = ctx.add(Center::new().child_id(icon));
1404        let icon_box = ctx.add(
1405            FixedSize::new()
1406                .width(self.extent)
1407                .height(self.extent)
1408                .child_id(centered),
1409        );
1410
1411        let content = if self.labeled {
1412            let label = ctx.add(RotatedLabel::new(
1413                self.action.label.clone(),
1414                Signal::new(TextRole::Secondary),
1415            ));
1416            let stack = ctx.add(
1417                VStack::new()
1418                    .alignment(HAlignment::Center)
1419                    .spacing(2.0)
1420                    .add_child(label)
1421                    .add_child(icon_box),
1422            );
1423            ctx.add(Padding::new(LABELED_TOP_MARGIN, 0.0, 0.0, 0.0).child_id(stack))
1424        } else {
1425            icon_box
1426        };
1427        let root = ctx.add(ZStack::new().add_child(bg_rect).add_child(content));
1428        self.root = Some(root);
1429
1430        if !self.labeled {
1431            // `Side`, never `Below` — a `Below` tooltip would land on the next
1432            // item down the column (the same reason `DockRailItem` does this).
1433            let text = self
1434                .action
1435                .tooltip
1436                .clone()
1437                .unwrap_or_else(|| self.action.label.clone());
1438            let delay = ctx.theme().motion.tooltip_delay;
1439            crate::tooltip::attach_plain_tooltip_with_placement(
1440                ctx,
1441                root,
1442                text,
1443                delay,
1444                crate::tooltip::TooltipPlacement::Side,
1445            );
1446        }
1447
1448        // One activation path for pointer, keyboard and the AT `Click` action.
1449        // A disabled action is inert on every one of them.
1450        let activate: Rc<dyn Fn(&mut EventContext)> = {
1451            let on_activate = self.action.on_activate.clone();
1452            let enabled = enabled.clone();
1453            let roving = self.roving.clone();
1454            let index = self.index;
1455            Rc::new(move |ctx: &mut EventContext| {
1456                if !enabled.get() {
1457                    return;
1458                }
1459                roving.set(index);
1460                (on_activate)(ctx);
1461            })
1462        };
1463
1464        // Roving tab stop: exactly one member of the group is a Tab stop.
1465        let index = self.index;
1466        ctx.set_tab_stop(self_id, self.roving.map(move |r| *r == index));
1467        // A disabled action stays **focusable** on purpose — it is not
1468        // `enabled_when`'d out of the focus order. Two reasons, one of them a
1469        // real bug this avoids:
1470        //   * ARIA's toolbar pattern explicitly keeps disabled toolbar controls
1471        //     focusable so a keyboard user can discover that the command exists
1472        //     at all (an unreachable greyed button is invisible to them).
1473        //   * The group has exactly ONE Tab stop, chosen by `roving`. If that
1474        //     item were removed from the focus order while disabled, the whole
1475        //     toolbar would become unreachable by keyboard — and since
1476        //     `enabled` is a live `Prop`, that can happen at any time, not just
1477        //     at build. Staying focusable makes the trap unreachable instead of
1478        //     needing a re-clamp on every enablement change.
1479        // Activation is guarded in `activate` and the glyph dims, so a disabled
1480        // action is inert and reads as inert without being lost.
1481
1482        let focused_sig = self.focused.clone();
1483        ctx.apply_self_handlers(
1484            HandlerSet::new()
1485                .on_tap({
1486                    let activate = activate.clone();
1487                    move |_e, ctx| activate(ctx)
1488                })
1489                .on_focus(move |gained, _ctx| focused_sig.set(gained))
1490                .on_key({
1491                    let activate = activate.clone();
1492                    let siblings = self.siblings.clone();
1493                    let roving = self.roving.clone();
1494                    let index = self.index;
1495                    move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
1496                        let WidgetEvent::KeyDown { key, .. } = event else {
1497                            return EventResponse::Ignored;
1498                        };
1499                        let ids = siblings.borrow();
1500                        if ids.is_empty() {
1501                            return EventResponse::Ignored;
1502                        }
1503                        let next = match key {
1504                            Key::ArrowUp | Key::ArrowLeft => (index + ids.len() - 1) % ids.len(),
1505                            Key::ArrowDown | Key::ArrowRight => (index + 1) % ids.len(),
1506                            Key::Home => 0,
1507                            Key::End => ids.len() - 1,
1508                            Key::Enter | Key::Space => {
1509                                drop(ids);
1510                                activate(ctx);
1511                                return EventResponse::Handled;
1512                            }
1513                            _ => return EventResponse::Ignored,
1514                        };
1515                        let target = ids[next];
1516                        drop(ids);
1517                        roving.set(next);
1518                        ctx.request_focus(target);
1519                        EventResponse::Handled
1520                    }
1521                })
1522                .on_access_action({
1523                    let activate = activate.clone();
1524                    move |action: teksilo_core::accesskit::Action, ctx: &mut EventContext| {
1525                        if action == teksilo_core::accesskit::Action::Click {
1526                            activate(ctx);
1527                            EventResponse::Handled
1528                        } else {
1529                            EventResponse::Ignored
1530                        }
1531                    }
1532                })
1533                .focusable(true)
1534                .cursor(CursorIcon::Pointer),
1535        );
1536        vec![root]
1537    }
1538
1539    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
1540        self.root
1541            .and_then(|id| ctx.child_size(id, proposal))
1542            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
1543            .into()
1544    }
1545
1546    fn place_children(
1547        &self,
1548        bounds: Rect,
1549        _proposal: SizeProposal,
1550        children: &mut [WidgetPlacement],
1551        _ctx: &LayoutContext,
1552    ) {
1553        for child in children.iter_mut() {
1554            child.origin = bounds.origin();
1555            child.size = bounds.size();
1556        }
1557    }
1558
1559    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1560        use teksilo_core::accesskit::{Action, Role};
1561        // `Role::Button` — NOT `Role::Tab`. An action controls no tabpanel, so
1562        // announcing it as a tab would promise a panel that never appears.
1563        builder.set_role(Role::Button);
1564        builder.set_name(self.action.label.resolve_now());
1565        builder.add_action(Action::Focus);
1566        // Announce the inert state rather than dropping out of the focus order
1567        // (see the `set_tab_stop` comment in `build`): a disabled toolbar
1568        // control stays reachable so it is discoverable, and says why.
1569        if self.action.enabled.get() {
1570            builder.add_action(Action::Click);
1571        } else {
1572            builder.set_disabled();
1573        }
1574        builder.set_position_in_set(self.index + 1);
1575        builder.set_size_of_set(self.siblings.borrow().len());
1576        // A reflect-only bistate reads as a toggle button to AT.
1577        if let Some(t) = &self.action.toggled {
1578            builder.set_toggled(t.get());
1579        }
1580    }
1581
1582    fn children(&self) -> Vec<WidgetId> {
1583        self.root.into_iter().collect()
1584    }
1585}
1586
1587// ───────────────────────────────────────────────────────────────────────
1588// RailDropIndicator — the horizontal insertion line painted over the rail.
1589// ───────────────────────────────────────────────────────────────────────
1590
1591/// A pure-decoration overlay (topmost child of the rail's ZStack) that paints a
1592/// horizontal accent line at the bar-local y in its `y` signal — the rail's
1593/// equivalent of a `TabBar` insertion indicator. Paints nothing when `y` is
1594/// `None`. Pointer events pass straight through so the rail items below stay
1595/// interactive.
1596struct RailDropIndicator {
1597    y: Signal<Option<f32>>,
1598    color: ColorProp,
1599}
1600
1601impl std::fmt::Debug for RailDropIndicator {
1602    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1603        f.debug_struct("RailDropIndicator").finish()
1604    }
1605}
1606
1607impl RailDropIndicator {
1608    fn new(y: Signal<Option<f32>>) -> Self {
1609        Self {
1610            y,
1611            color: ColorProp::from(BorderRole::Accent),
1612        }
1613    }
1614}
1615
1616impl Widget for RailDropIndicator {
1617    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1618        self.y.bind_to(
1619            ctx.self_id(),
1620            ctx.binding_registry(),
1621            BindingLevel::RepaintOnly,
1622        );
1623        ctx.apply_self_handlers(HandlerSet::new().event_pass_through(true));
1624        vec![]
1625    }
1626
1627    fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
1628        proposal.resolve(0.0, 0.0).into()
1629    }
1630
1631    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
1632        let Some(y) = self.y.get() else {
1633            return;
1634        };
1635        let color = self.color.resolve(ctx.theme, true);
1636        let t = 2.0;
1637        let yy = bounds.y + y - t * 0.5;
1638        // Inset a touch from the rail's padding so the line reads as "between
1639        // items", not flush to the edge.
1640        let x = bounds.x + RAIL_PADDING;
1641        let w = (bounds.width - RAIL_PADDING * 2.0).max(0.0);
1642        canvas.fill_rect(Rect::new(x, yy, w, t), color);
1643    }
1644
1645    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1646        builder.set_hidden();
1647    }
1648}
1649
1650// ───────────────────────────────────────────────────────────────────────
1651// RailEdgeDivider — a 1 dp line between the rail and the side's content.
1652// ───────────────────────────────────────────────────────────────────────
1653
1654/// A pure-decoration overlay (topmost child of the rail's ZStack) that paints a
1655/// 1 dp vertical line on the rail's content-facing edge — the boundary between
1656/// the activity rail and the side's resizable content. The edge is derived from
1657/// the side (the rail always hugs the outer / leading-cross edge, so content
1658/// sits on the opposite vertical edge) and the active layout direction, so it
1659/// stays correct under RTL. Pointer events pass straight through.
1660struct RailEdgeDivider {
1661    side: DockSide,
1662    color: ColorProp,
1663}
1664
1665impl std::fmt::Debug for RailEdgeDivider {
1666    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1667        f.debug_struct("RailEdgeDivider").finish()
1668    }
1669}
1670
1671impl Widget for RailEdgeDivider {
1672    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1673        ctx.apply_self_handlers(HandlerSet::new().event_pass_through(true));
1674        vec![]
1675    }
1676
1677    fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
1678        proposal.resolve(0.0, 0.0).into()
1679    }
1680
1681    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
1682        let rtl = matches!(
1683            ctx.layout_direction,
1684            teksilo_core::environment::LayoutDirection::RightToLeft
1685        );
1686        // The rail hugs the outer thickness edge (leading / trailing) or the
1687        // leading cross-edge (top / bottom), so the content is on the trailing
1688        // geometric edge for every side except Trailing, where it's the leading
1689        // edge. Resolve that to a concrete left / right under RTL.
1690        let content_on_right = match self.side {
1691            DockSide::Trailing => rtl,
1692            _ => !rtl,
1693        };
1694        let t = 1.0;
1695        let x = if content_on_right {
1696            bounds.x + bounds.width - t
1697        } else {
1698            bounds.x
1699        };
1700        let color = self.color.resolve(ctx.theme, true);
1701        canvas.fill_rect(Rect::new(x, bounds.y, t, bounds.height), color);
1702    }
1703
1704    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1705        builder.set_hidden();
1706    }
1707}
1708
1709// ───────────────────────────────────────────────────────────────────────
1710// DockOverflowMenu — the popover content listing the overflowed entries.
1711// ───────────────────────────────────────────────────────────────────────
1712
1713/// A column of rows (one per tab), each shown only while that tab is
1714/// overflowed (`index >= visible_count`). Selecting a row activates its tab
1715/// and shows the side.
1716#[derive(Debug)]
1717struct DockOverflowMenu {
1718    side: DockSide,
1719    model: DockingModel,
1720    visible_count: Signal<usize>,
1721    /// Shared `(visible position → row WidgetId)` list for roving Arrow/Home/End
1722    /// focus among the overflowed rows.
1723    row_ids: RailItemIds,
1724    root: Option<WidgetId>,
1725}
1726
1727impl DockOverflowMenu {
1728    fn new(side: DockSide, model: DockingModel, visible_count: Signal<usize>) -> Self {
1729        Self {
1730            side,
1731            model,
1732            visible_count,
1733            row_ids: Rc::new(RefCell::new(Vec::new())),
1734            root: None,
1735        }
1736    }
1737}
1738
1739impl Widget for DockOverflowMenu {
1740    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1741        let tabs = self.model.side_tabs(self.side);
1742        let mut column = VStack::new().spacing(2.0);
1743        self.row_ids.borrow_mut().clear();
1744        // Mirror the rail: only non-hidden tabs are rail items, and overflow is
1745        // keyed on the position among shown items (so an overflowed row appears
1746        // here exactly when its rail item is parked).
1747        let mut pos = 0usize;
1748        for (model_i, tab) in tabs.iter().enumerate() {
1749            if tab.hidden {
1750                continue;
1751            }
1752            let p = pos;
1753            pos += 1;
1754            let label = self.model.activity_label(tab);
1755            let row = ctx.add(DockOverflowRow::new(
1756                self.side,
1757                model_i,
1758                p,
1759                tab.id,
1760                label,
1761                self.model.clone(),
1762                self.row_ids.clone(),
1763                self.visible_count.clone(),
1764            ));
1765            self.row_ids.borrow_mut().push((p, row));
1766            ctx.visible_when(row, self.visible_count.map(move |c| p >= *c));
1767            column = column.add_child(row);
1768        }
1769        let column_id = ctx.add(column);
1770        let root = ctx.add(Padding::uniform(4.0).child_id(column_id));
1771        self.root = Some(root);
1772        vec![root]
1773    }
1774
1775    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
1776        self.root
1777            .and_then(|id| ctx.child_size(id, proposal))
1778            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
1779            .into()
1780    }
1781
1782    fn place_children(
1783        &self,
1784        bounds: Rect,
1785        _proposal: SizeProposal,
1786        children: &mut [WidgetPlacement],
1787        _ctx: &LayoutContext,
1788    ) {
1789        for child in children.iter_mut() {
1790            child.origin = bounds.origin();
1791            child.size = bounds.size();
1792        }
1793    }
1794
1795    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1796        use teksilo_core::accesskit::{Orientation, Role};
1797        builder.set_role(Role::Menu);
1798        builder.set_orientation(Orientation::Vertical);
1799    }
1800
1801    fn children(&self) -> Vec<WidgetId> {
1802        self.root.into_iter().collect()
1803    }
1804}
1805
1806// ───────────────────────────────────────────────────────────────────────
1807// DockRailItem — one activity-rail item.
1808// ───────────────────────────────────────────────────────────────────────
1809
1810struct DockRailItem {
1811    side: DockSide,
1812    index: usize,
1813    /// Position among the *shown* (non-hidden) items — the key the drop
1814    /// handler indexes by when computing an insertion position.
1815    pos: usize,
1816    tab_id: DockTabId,
1817    icon: Option<DockIconFactory>,
1818    label: LocalizedString,
1819    extent: f32,
1820    /// Glyph (icon) dimension drawn inside the `extent`-sized box — derived from
1821    /// the rail size so the icon scales with it (see [`item_glyph_size`]).
1822    glyph: f32,
1823    /// Labeled mode: paint a 90°-rotated title under the icon (no tooltip).
1824    /// Icon-only modes attach the title as a hover tooltip instead.
1825    labeled: bool,
1826    selected: Signal<usize>,
1827    visible: Signal<bool>,
1828    model: DockingModel,
1829    /// The bar's shared item-bounds sink; this item upserts its world bounds
1830    /// (keyed by `pos`) here each layout pass.
1831    bounds_sink: RailItemBounds,
1832    /// Shared sibling-id list (for Arrow/Home/End roving focus) and the live
1833    /// overflow count (so nav and `size_of_set` skip parked items).
1834    item_ids: RailItemIds,
1835    visible_count: Signal<usize>,
1836    /// Per-side content-region ids, for the `controls` (tab → tabpanel) link.
1837    side_panel_ids: Rc<RefCell<HashMap<DockSide, WidgetId>>>,
1838    /// Keyboard `:focus-visible` state — `true` only while this item holds
1839    /// focus AND the last input was the keyboard; drives the focus ring.
1840    focused: Signal<bool>,
1841    root: Option<WidgetId>,
1842}
1843
1844impl std::fmt::Debug for DockRailItem {
1845    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1846        f.debug_struct("DockRailItem")
1847            .field("index", &self.index)
1848            .finish()
1849    }
1850}
1851
1852impl DockRailItem {
1853    #[allow(clippy::too_many_arguments)]
1854    fn new(
1855        side: DockSide,
1856        index: usize,
1857        pos: usize,
1858        tab_id: DockTabId,
1859        icon: Option<DockIconFactory>,
1860        label: LocalizedString,
1861        extent: f32,
1862        glyph: f32,
1863        labeled: bool,
1864        selected: Signal<usize>,
1865        visible: Signal<bool>,
1866        model: DockingModel,
1867        bounds_sink: RailItemBounds,
1868        item_ids: RailItemIds,
1869        visible_count: Signal<usize>,
1870        side_panel_ids: Rc<RefCell<HashMap<DockSide, WidgetId>>>,
1871    ) -> Self {
1872        Self {
1873            side,
1874            index,
1875            pos,
1876            tab_id,
1877            icon,
1878            label,
1879            extent,
1880            glyph,
1881            labeled,
1882            selected,
1883            visible,
1884            model,
1885            bounds_sink,
1886            item_ids,
1887            visible_count,
1888            side_panel_ids,
1889            focused: Signal::new(false),
1890            root: None,
1891        }
1892    }
1893}
1894
1895impl Widget for DockRailItem {
1896    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1897        let idx = self.index;
1898        let active = self
1899            .selected
1900            .zip(&self.visible)
1901            .map(move |(s, v)| *s == idx && *v);
1902        // Window-active-aware selection highlight. `surface_selected` is
1903        // deliberately excluded from the theme-side inactive-window accent
1904        // desaturation (`ColorTokens::for_inactive_window`), so — like
1905        // `StandardListItem` / `TableView` — the rail item must opt in
1906        // explicitly, swapping to the muted `SelectedInactive` token when the
1907        // host window loses focus (macOS / Qt `QPalette::Inactive`). The rail
1908        // is persistent chrome whose "active" item tracks the open side (app
1909        // state, not a keyboard-focus-scoped selection), so it gates on
1910        // window-active alone — not view focus — keeping the open-side
1911        // indicator vivid while the window is active regardless of where
1912        // keyboard focus sits.
1913        let bg = active.zip(&ctx.window_active_signal()).map(|(a, win)| {
1914            if *a {
1915                if *win {
1916                    SurfaceRole::Selected
1917                } else {
1918                    SurfaceRole::SelectedInactive
1919                }
1920            } else {
1921                SurfaceRole::Transparent
1922            }
1923        });
1924        // Keyboard focus ring, gated on `:focus-visible` (the item is focused
1925        // AND the last input was the keyboard) — the same pattern as
1926        // `IconButton` (`recipe_icon_button_style.rs`). The border IS the focus
1927        // indicator; it coexists with the selection background on this rect.
1928        let ring = self.focused.and(&ctx.focus_visible());
1929        let focus_ring_width = ctx.theme().shape.focus_ring_width;
1930        let border_color: ColorProp = ring
1931            .map(|f| {
1932                if *f {
1933                    BorderRole::Focused
1934                } else {
1935                    BorderRole::Transparent
1936                }
1937            })
1938            .into();
1939        let border_width = ring.map(move |f| if *f { focus_ring_width } else { 0.0 });
1940        // Rounded selection highlight matching the IconButton corner style, so
1941        // the rail items read as buttons rather than full-square fills.
1942        let bg_rect = ctx.add(
1943            RectWidget::new()
1944                .background(bg)
1945                .border_color(border_color)
1946                .border_width(border_width)
1947                .corner_radius(CornerRadius::uniform(ICON_BUTTON_CORNER_RADIUS)),
1948        );
1949
1950        let glyph = if let Some(icon) = self.icon.take() {
1951            // Size the caller's icon to the rail's glyph dimension so it tracks
1952            // the rail size (Compact…Hero) instead of whatever fixed dp the
1953            // factory picked — the rail owns glyph sizing, like `IconButton`.
1954            ctx.add((icon)().icon_size(self.glyph))
1955        } else {
1956            let s = self.label.resolve_now();
1957            let ch: String = s.chars().take(1).collect();
1958            ctx.add(
1959                TextWidget::new(lit!(ch))
1960                    .style(TextStyleRole::BodyBold)
1961                    .color(TextRole::Primary),
1962            )
1963        };
1964        let centered = ctx.add(Center::new().child_id(glyph));
1965        let icon_box = ctx.add(
1966            FixedSize::new()
1967                .width(self.extent)
1968                .height(self.extent)
1969                .child_id(centered),
1970        );
1971
1972        // Labeled mode: a 90°-rotated title above the icon square (the
1973        // vertical-accordion look). The title is painted, so no tooltip. Icon
1974        // modes show the icon alone and surface the title as a hover tooltip.
1975        let content = if self.labeled {
1976            let label = ctx.add(RotatedLabel::new(
1977                self.label.clone(),
1978                Signal::new(TextRole::Secondary),
1979            ));
1980            let stack = ctx.add(
1981                VStack::new()
1982                    .alignment(HAlignment::Center)
1983                    .spacing(2.0)
1984                    .add_child(label)
1985                    .add_child(icon_box),
1986            );
1987            // A bit of top breathing room so the rotated title's top character
1988            // isn't flush against the rail item's top edge.
1989            ctx.add(Padding::new(LABELED_TOP_MARGIN, 0.0, 0.0, 0.0).child_id(stack))
1990        } else {
1991            icon_box
1992        };
1993        let root = ctx.add(ZStack::new().add_child(bg_rect).add_child(content));
1994        self.root = Some(root);
1995
1996        if !self.labeled {
1997            // The activity rail is vertical-only; its icon-only items stack
1998            // top-to-bottom, so the title tooltip opens to the trailing `Side`
1999            // (a `Below` tooltip would drop onto the next rail item).
2000            let delay = ctx.theme().motion.tooltip_delay;
2001            crate::tooltip::attach_plain_tooltip_with_placement(
2002                ctx,
2003                root,
2004                self.label.clone(),
2005                delay,
2006                crate::tooltip::TooltipPlacement::Side,
2007            );
2008        }
2009
2010        let self_id = ctx.self_id();
2011        let policy = self.model.policy();
2012        let side = self.side;
2013        let tab_id = self.tab_id;
2014        let menu_model = self.model.clone();
2015        let allow_collapse = policy.allow_side_collapse;
2016
2017        // The single activation path, shared by pointer tap, keyboard
2018        // Enter/Space, and the AT `Click` action — so the rail item is
2019        // operable by mouse, keyboard, and screen reader alike. Clicking the
2020        // active item hides the side (a collapse toggle) unless collapse is
2021        // locked; any other item selects it and shows the side.
2022        let activate: Rc<dyn Fn(&mut EventContext)> = {
2023            let model = self.model.clone();
2024            let selected = self.selected.clone();
2025            let visible = self.visible.clone();
2026            Rc::new(move |_ctx: &mut EventContext| {
2027                if selected.get() == idx && visible.get() {
2028                    if allow_collapse {
2029                        model.set_side_visible(side, false);
2030                    }
2031                } else {
2032                    model.select_tab(side, idx);
2033                    model.set_side_visible(side, true);
2034                }
2035            })
2036        };
2037
2038        // Reflect the keyboard `:focus-visible` ring.
2039        let focused_sig = self.focused.clone();
2040        // Roving tab stop (ARIA tabs pattern): only the selected item is a
2041        // Tab/Shift+Tab stop; siblings stay reachable via Arrow keys +
2042        // `request_focus`. Matches `TabBar` (`tab_widget/header.rs`).
2043        ctx.set_tab_stop(self_id, self.selected.map(move |s| *s == idx));
2044
2045        let mut handlers = HandlerSet::new()
2046            .on_tap({
2047                let activate = activate.clone();
2048                move |_e, ctx| activate(ctx)
2049            })
2050            .on_focus(move |gained, _ctx| focused_sig.set(gained))
2051            .on_key({
2052                let activate = activate.clone();
2053                let item_ids = self.item_ids.clone();
2054                let visible_count = self.visible_count.clone();
2055                let pos = self.pos;
2056                move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
2057                    let WidgetEvent::KeyDown { key, .. } = event else {
2058                        return EventResponse::Ignored;
2059                    };
2060                    let nav = match key {
2061                        Key::ArrowUp | Key::ArrowLeft => RailNav::Prev,
2062                        Key::ArrowDown | Key::ArrowRight => RailNav::Next,
2063                        Key::Home => RailNav::First,
2064                        Key::End => RailNav::Last,
2065                        Key::Enter | Key::Space => {
2066                            // Manual activation: arrows only move focus; the
2067                            // panel is shown/hidden on explicit Enter/Space.
2068                            activate(ctx);
2069                            return EventResponse::Handled;
2070                        }
2071                        _ => return EventResponse::Ignored,
2072                    };
2073                    if let Some(target) = rail_focus_target(&item_ids, &visible_count, pos, nav) {
2074                        ctx.request_focus(target);
2075                        EventResponse::Handled
2076                    } else {
2077                        EventResponse::Ignored
2078                    }
2079                }
2080            })
2081            .on_access_action({
2082                let activate = activate.clone();
2083                move |action: teksilo_core::accesskit::Action, ctx: &mut EventContext| {
2084                    if action == teksilo_core::accesskit::Action::Click {
2085                        activate(ctx);
2086                        EventResponse::Handled
2087                    } else {
2088                        EventResponse::Ignored
2089                    }
2090                }
2091            });
2092        // Drag a rail item to reorder / move the activity — only when allowed.
2093        if policy.allow_activity_drag {
2094            handlers = handlers.on_drag(move |phase, ctx| {
2095                if let DragPhase::Started { .. } = phase {
2096                    ctx.start_drag(
2097                        self_id,
2098                        DragPayload::typed(DockTabDragData {
2099                            tab_id,
2100                            source_side: side,
2101                        }),
2102                    );
2103                }
2104            });
2105        }
2106        handlers = handlers
2107            .context_menu(move |_pos, _ctx| {
2108                Some(Box::new(activity_context_menu(
2109                    &menu_model,
2110                    side,
2111                    tab_id,
2112                    DockMenuKind::Rail,
2113                )))
2114            })
2115            .focusable(true)
2116            .cursor(CursorIcon::Pointer);
2117        ctx.apply_self_handlers(handlers);
2118        vec![root]
2119    }
2120
2121    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
2122        self.root
2123            .and_then(|id| ctx.child_size(id, proposal))
2124            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
2125            .into()
2126    }
2127
2128    fn place_children(
2129        &self,
2130        bounds: Rect,
2131        _proposal: SizeProposal,
2132        children: &mut [WidgetPlacement],
2133        _ctx: &LayoutContext,
2134    ) {
2135        // Upsert this item's world bounds (keyed by its shown position) so the
2136        // bar's drop handler can compute an insertion line.
2137        {
2138            let mut sink = self.bounds_sink.borrow_mut();
2139            if let Some(slot) = sink.iter_mut().find(|(p, _)| *p == self.pos) {
2140                slot.1 = bounds;
2141            } else {
2142                sink.push((self.pos, bounds));
2143            }
2144        }
2145        for child in children.iter_mut() {
2146            child.origin = bounds.origin();
2147            child.size = bounds.size();
2148        }
2149    }
2150
2151    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2152        use teksilo_core::accesskit::{Action, Role};
2153        builder.set_role(Role::Tab);
2154        builder.set_name(self.label.resolve_now());
2155        let is_selected = self.selected.get() == self.index;
2156        builder.set_selected(is_selected && self.visible.get());
2157        builder.add_action(Action::Focus);
2158        builder.add_action(Action::Click);
2159        // "panel N of M" — M counts only the rail tabs currently in the AT
2160        // tree (overflowed items are dormant, represented by the popover rows).
2161        // `pos` is this item's 0-based visible position; only shown items run
2162        // `accessibility()`, so `pos < visible_count` holds here.
2163        builder.set_position_in_set(self.pos + 1);
2164        builder.set_size_of_set(shown_rail_count(&self.item_ids, &self.visible_count));
2165        // Communicate the collapse toggle on the active tab: expanded when its
2166        // panel is shown, collapsed when hidden. Omitted on the other tabs
2167        // (the "expanded" concept doesn't apply to an inactive tab).
2168        if is_selected {
2169            builder.set_expanded(self.visible.get());
2170        }
2171        // `controls` → the side's content region (ARIA tab → tabpanel link).
2172        // Only while the side is shown: a hidden side parks its `DockSidePanel`
2173        // dormant (pruned from the AT tree), so linking it then would dangle.
2174        if self.visible.get()
2175            && let Some(&panel_id) = self.side_panel_ids.borrow().get(&self.side)
2176        {
2177            builder.push_controlled(widget_id_to_node_id(panel_id));
2178        }
2179    }
2180
2181    fn children(&self) -> Vec<WidgetId> {
2182        self.root.into_iter().collect()
2183    }
2184}
2185
2186// ───────────────────────────────────────────────────────────────────────
2187// DockOverflowRow — one row in the overflow popover.
2188// ───────────────────────────────────────────────────────────────────────
2189
2190#[derive(Debug)]
2191struct DockOverflowRow {
2192    side: DockSide,
2193    index: usize,
2194    /// Visible position among the side's non-hidden tabs (matches the rail
2195    /// item's `pos`); the key for roving focus + `position_in_set`.
2196    pos: usize,
2197    tab_id: DockTabId,
2198    label: LocalizedString,
2199    model: DockingModel,
2200    /// Shared sibling-row id list + live overflow count, for Arrow/Home/End
2201    /// roving focus and `size_of_set` among the shown overflow rows.
2202    row_ids: RailItemIds,
2203    visible_count: Signal<usize>,
2204    /// Keyboard `:focus-visible` state — drives the row's focus ring.
2205    focused: Signal<bool>,
2206    root: Option<WidgetId>,
2207}
2208
2209impl DockOverflowRow {
2210    #[allow(clippy::too_many_arguments)]
2211    fn new(
2212        side: DockSide,
2213        index: usize,
2214        pos: usize,
2215        tab_id: DockTabId,
2216        label: LocalizedString,
2217        model: DockingModel,
2218        row_ids: RailItemIds,
2219        visible_count: Signal<usize>,
2220    ) -> Self {
2221        Self {
2222            side,
2223            index,
2224            pos,
2225            tab_id,
2226            label,
2227            model,
2228            row_ids,
2229            visible_count,
2230            focused: Signal::new(false),
2231            root: None,
2232        }
2233    }
2234}
2235
2236impl Widget for DockOverflowRow {
2237    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
2238        let label = ctx.add(
2239            TextWidget::new(self.label.clone())
2240                .style(TextStyleRole::Body)
2241                .color(TextRole::Primary)
2242                .single_line(),
2243        );
2244        let spacer = ctx.add(Spacer::new());
2245        let row = ctx.add(
2246            HStack::new()
2247                .spacing(8.0)
2248                .add_child(label)
2249                .add_child(spacer),
2250        );
2251        let content = ctx.add(Padding::symmetric(6.0, 10.0).child_id(row));
2252
2253        // Backing surface: a subtle highlight on focus + the keyboard
2254        // `:focus-visible` ring, so a row navigated to by keyboard is visible
2255        // (it reads like a menu item).
2256        let ring = self.focused.and(&ctx.focus_visible());
2257        let focus_ring_width = ctx.theme().shape.focus_ring_width;
2258        let bg_role: ColorProp = self
2259            .focused
2260            .map(|f| {
2261                if *f {
2262                    SurfaceRole::Hover
2263                } else {
2264                    SurfaceRole::Transparent
2265                }
2266            })
2267            .into();
2268        let border_color: ColorProp = ring
2269            .map(|f| {
2270                if *f {
2271                    BorderRole::Focused
2272                } else {
2273                    BorderRole::Transparent
2274                }
2275            })
2276            .into();
2277        let border_width = ring.map(move |f| if *f { focus_ring_width } else { 0.0 });
2278        let bg_rect = ctx.add(
2279            RectWidget::new()
2280                .background(bg_role)
2281                .border_color(border_color)
2282                .border_width(border_width)
2283                .corner_radius(CornerRadius::uniform(ICON_BUTTON_CORNER_RADIUS)),
2284        );
2285        let root = ctx.add(ZStack::new().add_child(bg_rect).add_child(content));
2286        self.root = Some(root);
2287
2288        // Single activation path (tap / Enter-Space / AT Click): select the
2289        // tab and show the side.
2290        let activate: Rc<dyn Fn(&mut EventContext)> = {
2291            let model = self.model.clone();
2292            let side = self.side;
2293            let idx = self.index;
2294            Rc::new(move |_ctx: &mut EventContext| {
2295                model.select_tab(side, idx);
2296                model.set_side_visible(side, true);
2297            })
2298        };
2299        let focused_sig = self.focused.clone();
2300        ctx.apply_self_handlers(
2301            HandlerSet::new()
2302                .on_tap({
2303                    let activate = activate.clone();
2304                    move |_e, ctx| activate(ctx)
2305                })
2306                .on_focus(move |gained, _ctx| focused_sig.set(gained))
2307                .on_key({
2308                    let activate = activate.clone();
2309                    let row_ids = self.row_ids.clone();
2310                    let visible_count = self.visible_count.clone();
2311                    let pos = self.pos;
2312                    move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
2313                        let WidgetEvent::KeyDown { key, .. } = event else {
2314                            return EventResponse::Ignored;
2315                        };
2316                        let nav = match key {
2317                            Key::ArrowUp | Key::ArrowLeft => RailNav::Prev,
2318                            Key::ArrowDown | Key::ArrowRight => RailNav::Next,
2319                            Key::Home => RailNav::First,
2320                            Key::End => RailNav::Last,
2321                            Key::Enter | Key::Space => {
2322                                activate(ctx);
2323                                return EventResponse::Handled;
2324                            }
2325                            _ => return EventResponse::Ignored,
2326                        };
2327                        if let Some(target) =
2328                            overflow_focus_target(&row_ids, &visible_count, pos, nav)
2329                        {
2330                            ctx.request_focus(target);
2331                            EventResponse::Handled
2332                        } else {
2333                            EventResponse::Ignored
2334                        }
2335                    }
2336                })
2337                .on_access_action({
2338                    let activate = activate.clone();
2339                    move |action: teksilo_core::accesskit::Action, ctx: &mut EventContext| {
2340                        if action == teksilo_core::accesskit::Action::Click {
2341                            activate(ctx);
2342                            EventResponse::Handled
2343                        } else {
2344                            EventResponse::Ignored
2345                        }
2346                    }
2347                })
2348                .focusable(true)
2349                .cursor(CursorIcon::Pointer),
2350        );
2351        vec![root]
2352    }
2353
2354    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
2355        self.root
2356            .and_then(|id| ctx.child_size(id, proposal))
2357            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
2358            .into()
2359    }
2360
2361    fn place_children(
2362        &self,
2363        bounds: Rect,
2364        _proposal: SizeProposal,
2365        children: &mut [WidgetPlacement],
2366        _ctx: &LayoutContext,
2367    ) {
2368        for child in children.iter_mut() {
2369            child.origin = bounds.origin();
2370            child.size = bounds.size();
2371        }
2372    }
2373
2374    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2375        use teksilo_core::accesskit::{Action, Role};
2376        builder.set_role(Role::MenuItem);
2377        builder.set_name(self.label.resolve_now());
2378        builder.add_action(Action::Focus);
2379        builder.add_action(Action::Click);
2380        // "N of M" within the overflow set. Only shown (parked) rows run
2381        // `accessibility()`, so `pos >= visible_count` holds; the 1-based
2382        // position within the overflowed run is `pos - visible_count + 1`.
2383        let count = self.visible_count.get();
2384        builder.set_position_in_set(self.pos.saturating_sub(count) + 1);
2385        builder.set_size_of_set(overflow_shown_count(&self.row_ids, &self.visible_count));
2386    }
2387
2388    fn children(&self) -> Vec<WidgetId> {
2389        self.root.into_iter().collect()
2390    }
2391}
2392
2393#[cfg(test)]
2394mod tests {
2395    use super::*;
2396
2397    #[test]
2398    fn rail_insertion_picks_the_gap_under_the_pointer() {
2399        // Three items stacked at world y = 100 / 142 / 184 (40 tall each); the
2400        // bar's world origin y is 100, height 300, so item local centres are
2401        // 20 / 62 / 104.
2402        let items = vec![
2403            (0usize, Rect::new(100.0, 100.0, 40.0, 40.0)),
2404            (1, Rect::new(100.0, 142.0, 40.0, 40.0)),
2405            (2, Rect::new(100.0, 184.0, 40.0, 40.0)),
2406        ];
2407        assert_eq!(
2408            rail_insertion(5.0, &items, 100.0, 300.0).0,
2409            0,
2410            "above all → front"
2411        );
2412        assert_eq!(
2413            rail_insertion(40.0, &items, 100.0, 300.0).0,
2414            1,
2415            "past item 0 → 1"
2416        );
2417        assert_eq!(
2418            rail_insertion(70.0, &items, 100.0, 300.0).0,
2419            2,
2420            "past item 1 → 2"
2421        );
2422        assert_eq!(
2423            rail_insertion(290.0, &items, 100.0, 300.0).0,
2424            3,
2425            "below all → end"
2426        );
2427    }
2428
2429    #[test]
2430    fn rail_insertion_on_empty_rail_is_front() {
2431        assert_eq!(rail_insertion(50.0, &[], 0.0, 100.0).0, 0);
2432    }
2433
2434    /// A 260 dp rail with 42 dp items: 252 dp usable ⇒ 6 items fit.
2435    fn cap() -> RailCapacity {
2436        RailCapacity {
2437            height: 260.0,
2438            stride: 42.0,
2439            slots: 0,
2440            actions: 0,
2441            total: 8,
2442            has_overflow_trigger: false,
2443        }
2444    }
2445
2446    #[test]
2447    fn capacity_shows_everything_when_it_all_fits() {
2448        let c = RailCapacity { total: 4, ..cap() };
2449        assert_eq!(shown_capacity(c), 4, "no overflow ⇒ every item shows");
2450    }
2451
2452    #[test]
2453    fn capacity_clips_without_a_trigger_and_reserves_one_slot_with_one() {
2454        assert_eq!(
2455            shown_capacity(cap()),
2456            6,
2457            "no trigger ⇒ the surplus is clipped"
2458        );
2459        assert_eq!(
2460            shown_capacity(RailCapacity {
2461                has_overflow_trigger: true,
2462                ..cap()
2463            }),
2464            5,
2465            "the trigger itself costs one slot"
2466        );
2467    }
2468
2469    #[test]
2470    fn capacity_charges_actions_and_slots() {
2471        // Each action is reserved space, never overflow-parked, so it costs an
2472        // activity slot — this is the whole point of charging them here.
2473        assert_eq!(
2474            shown_capacity(RailCapacity {
2475                actions: 3,
2476                ..cap()
2477            }),
2478            3,
2479            "three actions cost three activity slots (6 → 3)"
2480        );
2481        assert_eq!(
2482            shown_capacity(RailCapacity { slots: 2, ..cap() }),
2483            4,
2484            "top_slot + bottom_slot cost one stride each"
2485        );
2486        assert_eq!(
2487            shown_capacity(RailCapacity {
2488                slots: 2,
2489                actions: 3,
2490                has_overflow_trigger: true,
2491                ..cap()
2492            }),
2493            0,
2494            "a rail crowded past its height shows no activities, and never \
2495             underflows"
2496        );
2497    }
2498
2499    #[test]
2500    fn capacity_never_underflows_or_divides_by_zero() {
2501        assert_eq!(
2502            shown_capacity(RailCapacity {
2503                height: 0.0,
2504                has_overflow_trigger: true,
2505                ..cap()
2506            }),
2507            0,
2508            "a zero-height rail shows nothing rather than wrapping around"
2509        );
2510        assert_eq!(
2511            shown_capacity(RailCapacity {
2512                stride: 0.0,
2513                ..cap()
2514            }),
2515            8,
2516            "a degenerate stride falls back to showing everything, not a divide by zero"
2517        );
2518    }
2519
2520    /// Fabricate a `WidgetId` without an arena — same convention as the
2521    /// `menu_bar` dispatcher unit tests.
2522    fn wid(n: u64) -> WidgetId {
2523        slotmap::KeyData::from_ffi(n).into()
2524    }
2525
2526    fn ids(items: &[(usize, u64)]) -> RailItemIds {
2527        Rc::new(RefCell::new(
2528            items.iter().map(|(p, w)| (*p, wid(*w))).collect(),
2529        ))
2530    }
2531
2532    #[test]
2533    fn rail_focus_target_wraps_among_shown_items() {
2534        let item_ids = ids(&[(0, 10), (1, 11), (2, 12)]);
2535        let vc = Signal::new(3usize);
2536        assert_eq!(
2537            rail_focus_target(&item_ids, &vc, 0, RailNav::Next),
2538            Some(wid(11))
2539        );
2540        assert_eq!(
2541            rail_focus_target(&item_ids, &vc, 2, RailNav::Next),
2542            Some(wid(10)),
2543            "ArrowDown past the last item wraps to the first"
2544        );
2545        assert_eq!(
2546            rail_focus_target(&item_ids, &vc, 0, RailNav::Prev),
2547            Some(wid(12)),
2548            "ArrowUp before the first item wraps to the last"
2549        );
2550        assert_eq!(
2551            rail_focus_target(&item_ids, &vc, 1, RailNav::First),
2552            Some(wid(10))
2553        );
2554        assert_eq!(
2555            rail_focus_target(&item_ids, &vc, 1, RailNav::Last),
2556            Some(wid(12))
2557        );
2558    }
2559
2560    #[test]
2561    fn rail_focus_target_skips_overflowed_items() {
2562        // visible_count = 2 → only positions 0,1 are navigable; pos 2 overflowed.
2563        let item_ids = ids(&[(0, 10), (1, 11), (2, 12)]);
2564        let vc = Signal::new(2usize);
2565        assert_eq!(shown_rail_count(&item_ids, &vc), 2);
2566        assert_eq!(
2567            rail_focus_target(&item_ids, &vc, 1, RailNav::Next),
2568            Some(wid(10)),
2569            "nav wraps within the two shown items, skipping the overflowed one"
2570        );
2571    }
2572
2573    #[test]
2574    fn overflow_helpers_target_the_parked_rows() {
2575        // 4 items, 2 shown on the rail → positions 2,3 overflow into the popover.
2576        let row_ids = ids(&[(0, 10), (1, 11), (2, 12), (3, 13)]);
2577        let vc = Signal::new(2usize);
2578        assert_eq!(overflow_shown_count(&row_ids, &vc), 2);
2579        assert_eq!(
2580            overflow_focus_target(&row_ids, &vc, 2, RailNav::Next),
2581            Some(wid(13))
2582        );
2583        assert_eq!(
2584            overflow_focus_target(&row_ids, &vc, 3, RailNav::Next),
2585            Some(wid(12)),
2586            "nav wraps within the overflowed set"
2587        );
2588        assert_eq!(
2589            overflow_focus_target(&row_ids, &vc, 2, RailNav::Prev),
2590            Some(wid(13))
2591        );
2592    }
2593}