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 shown_tabs = shown_rail_count(&self.item_ids, &self.visible_count);
692        let tab_list = ctx.add(DockRailTabList::new(self.side, items_stack, shown_tabs));
693
694        // Item column (pushed to the top by a trailing Spacer).
695        let mut column = VStack::new().spacing(RAIL_ITEM_SPACING);
696        if let Some(top) = &self.config.top_slot {
697            column = column.add_child(ctx.add_boxed((top)()));
698        }
699        if let Some(group) = self.build_action_group(ctx, DockActionPlacement::Start) {
700            column = column.add_child(group);
701        }
702        column = column.add_child(tab_list);
703        // Overflow trigger: a caller-chosen glyph that opens a popover list of
704        // the overflowed entries. Shown only while something overflows.
705        if let Some(of_icon) = &self.config.overflow_icon {
706            let total = pos;
707            let trigger = IconButton::new((of_icon)())
708                .size(self.effective_item_size())
709                .tooltip(lit!("More panels"));
710            let overflow = ctx.add(
711                PopoverIconButton::new(trigger)
712                    .content(DockOverflowMenu::new(
713                        self.side,
714                        self.model.clone(),
715                        visible_count.clone(),
716                    ))
717                    .placement(teksilo_core::overlay::OverlayPlacement::TrailingEdge),
718            );
719            ctx.visible_when(overflow, visible_count.map(move |c| *c < total));
720            column = column.add_child(overflow);
721        }
722        if let Some(group) = self.build_action_group(ctx, DockActionPlacement::End) {
723            column = column.add_child(group);
724        }
725        let spacer = ctx.add(Spacer::new());
726        column = column.add_child(spacer);
727        if let Some(group) = self.build_action_group(ctx, DockActionPlacement::Pinned) {
728            column = column.add_child(group);
729        }
730        if let Some(bottom) = &self.config.bottom_slot {
731            let b = ctx.add_boxed((bottom)());
732            column = column.add_child(b);
733        }
734
735        let column_id = ctx.add(column);
736        let padded = ctx.add(Padding::uniform(RAIL_PADDING).child_id(column_id));
737        // Insertion-line overlay (topmost) — painted while a dock tab / dock
738        // widget is dragged over the rail.
739        let indicator = ctx.add(RailDropIndicator::new(self.drop_indicator.clone()));
740        let mut stack = ZStack::new()
741            .add_child(bg)
742            .add_child(padded)
743            .add_child(indicator);
744        // Optional divider between the rail and the side's content, on the
745        // content-facing edge (drawn above the background so it isn't covered).
746        if let Some(color) = self.config.divider.clone() {
747            stack = stack.add_child(ctx.add(RailEdgeDivider {
748                side: self.side,
749                color,
750            }));
751        }
752        let root = ctx.add(stack);
753        self.root = Some(root);
754
755        // Right-click on empty rail space → the activities checklist + Activity
756        // bar size (the affordance to restore a hidden activity once every item
757        // is hidden, and to resize the rail).
758        let menu_model = self.model.clone();
759        let menu_side = self.side;
760        // Drag-and-drop: the rail accepts external activities (a whole tab
761        // dragged from another side's rail / strip → `move_tab`, a single dock
762        // → `promote_to_tab`) AND internal moves (dragging one of its own rail
763        // items reorders the side's tabs — same `move_tab`, the source-side ==
764        // target-side path). The insertion index comes from the pointer vs the
765        // recorded item bounds; the overlay paints the line.
766        let side = self.side;
767        let model = self.model.clone();
768        let item_bounds_hover = self.item_bounds.clone();
769        let item_bounds_drop = self.item_bounds.clone();
770        let self_bounds_hover = self.self_bounds.clone();
771        let self_bounds_drop = self.self_bounds.clone();
772        let indicator_hover = self.drop_indicator.clone();
773        let indicator_leave = self.drop_indicator.clone();
774        let indicator_drop = self.drop_indicator.clone();
775        let visible_count_hover = self.visible_count.clone();
776        let visible_count_drop = self.visible_count.clone();
777        let model_indices_hover = model_indices.clone();
778        let model_indices_drop = model_indices;
779        ctx.apply_self_handlers(
780            HandlerSet::new()
781                .context_menu(move |_pos, _ctx| {
782                    Some(Box::new(background_menu(
783                        &menu_model,
784                        menu_side,
785                        DockMenuKind::Rail,
786                    )))
787                })
788                .on_drag_hover(move |payload, pos, _ctx| {
789                    if dropped_dock_tab(payload).is_none() && dropped_dock_widget(payload).is_none()
790                    {
791                        indicator_hover.set(None);
792                        return DropFeedback::NoFeedback;
793                    }
794                    let bar = self_bounds_hover.get();
795                    let shown = shown_items(
796                        &item_bounds_hover,
797                        &model_indices_hover,
798                        &visible_count_hover,
799                    );
800                    let (_, line_y) = rail_insertion(pos.y, &shown, bar.y, bar.height);
801                    indicator_hover.set(Some(line_y));
802                    DropFeedback::InsertionLine {
803                        y: line_y,
804                        width: bar.width,
805                    }
806                })
807                .on_drag_leave(move |_ctx| indicator_leave.set(None))
808                .on_drop(move |payload, pos, ctx| {
809                    indicator_drop.set(None);
810                    // A disabled side never mutates from a UI drop (Path 1 of the
811                    // mid-drag-disable race: without this the model silently
812                    // rejects but the side would still be revealed + the drop
813                    // consumed). The widget-destroyed path is handled in core.
814                    if !model.is_side_enabled(side) {
815                        return false;
816                    }
817                    let bar = self_bounds_drop.get();
818                    let shown =
819                        shown_items(&item_bounds_drop, &model_indices_drop, &visible_count_drop);
820                    let (vpos, _) = rail_insertion(pos.y, &shown, bar.y, bar.height);
821                    // Visible insertion position → model tab index; past the
822                    // last shown item ⇒ just after the last *visible* tab (not
823                    // past trailing hidden tabs).
824                    let at = model_indices_drop
825                        .get(vpos)
826                        .copied()
827                        .unwrap_or_else(|| model.side_append_index(side));
828                    if let Some(tab_id) = dropped_dock_tab(&payload) {
829                        model.move_tab(tab_id, side, at);
830                        model.set_side_visible(side, true);
831                        ctx.request_accessibility_update();
832                        true
833                    } else if let Some(dock_id) = dropped_dock_widget(&payload) {
834                        model.promote_to_tab(dock_id, side, at);
835                        model.set_side_visible(side, true);
836                        ctx.request_accessibility_update();
837                        true
838                    } else {
839                        false
840                    }
841                }),
842        );
843
844        vec![root]
845    }
846
847    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
848        self.root
849            .and_then(|id| ctx.child_size(id, proposal))
850            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
851            .into()
852    }
853
854    fn place_children(
855        &self,
856        bounds: Rect,
857        _proposal: SizeProposal,
858        children: &mut [WidgetPlacement],
859        _ctx: &LayoutContext,
860    ) {
861        // Record the rail's world bounds so the drop handler can translate the
862        // item world rects (recorded by the rail items) into bar-local space.
863        self.self_bounds.set(bounds);
864        for child in children.iter_mut() {
865            child.origin = bounds.origin();
866            child.size = bounds.size();
867        }
868
869        // Capacity: how many items fit in the column once the padding, the
870        // optional slots, and (if overflowing) the overflow trigger are
871        // reserved. Slots are treated as roughly one item tall — a good
872        // estimate for a square rail glyph.
873        let stride = self.item_stride();
874        if stride <= 0.0 {
875            return;
876        }
877        let new_visible = shown_capacity(RailCapacity {
878            height: bounds.height,
879            stride,
880            slots: usize::from(self.config.top_slot.is_some())
881                + usize::from(self.config.bottom_slot.is_some()),
882            actions: self.config.actions.len(),
883            total: self.item_count,
884            has_overflow_trigger: self.config.overflow_icon.is_some(),
885        });
886        if self.visible_count.get() != new_visible {
887            self.visible_count.set(new_visible);
888        }
889    }
890
891    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
892        // Deliberately property-free. `Role::TabList` now lives on the
893        // `DockRailTabList` wrapper around the items alone (ARIA forbids
894        // non-tab children of a tablist, and this root also holds slots, the
895        // overflow trigger and the action groups). This node must stay a bare
896        // `GenericContainer` — setting a name or an orientation here would
897        // stop the AT pass pruning it, and a screen reader would announce
898        // "Leading activity bar, group" immediately followed by "Leading
899        // activity bar, tab list".
900        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
901    }
902
903    fn children(&self) -> Vec<WidgetId> {
904        self.root.into_iter().collect()
905    }
906}
907
908/// Inputs to [`shown_capacity`] — everything competing for the rail's height.
909#[derive(Debug, Clone, Copy, PartialEq)]
910struct RailCapacity {
911    /// The rail strip's height.
912    height: f32,
913    /// Per-item vertical stride (item extent + spacing, plus the Labeled
914    /// title allowance when captions are shown).
915    stride: f32,
916    /// How many of `top_slot` / `bottom_slot` are configured. Charged one
917    /// stride each — an approximation, since a caller's slot widget may be any
918    /// height. (Pre-existing; fixing it needs `DockRailSlot` to report a
919    /// measured extent, which is a separate change.)
920    slots: usize,
921    /// Declared [`DockAction`]s. Charged one stride each, which is **exact**:
922    /// an action renders at the rail's own item extent, and the count is fixed
923    /// at build time because an action can never be hidden.
924    actions: usize,
925    /// Non-hidden activity items competing for what's left.
926    total: usize,
927    /// Whether a caller supplied an overflow glyph. Without one the surplus is
928    /// simply clipped rather than parked behind a trigger.
929    has_overflow_trigger: bool,
930}
931
932/// How many activity items the rail can show, given everything else that
933/// reserves space in the same column.
934///
935/// Pure so the arithmetic is directly testable: the widget-level effect
936/// (parking the surplus dormant) depends on a signal write inside
937/// `place_children` propagating to `visible_when`, which a single headless
938/// layout pass does not settle.
939fn shown_capacity(c: RailCapacity) -> usize {
940    if c.stride <= 0.0 {
941        return c.total;
942    }
943    let reserve = RAIL_PADDING * 2.0 + (c.slots + c.actions) as f32 * c.stride;
944    let avail = (c.height - reserve).max(0.0);
945    let fit = (avail / c.stride).floor() as usize;
946    if c.total <= fit {
947        c.total
948    } else if c.has_overflow_trigger {
949        // One slot goes to the overflow trigger itself.
950        fit.saturating_sub(1)
951    } else {
952        fit
953    }
954}
955
956/// Snapshot the currently-shown rail items (visible position, world bounds),
957/// sorted by position, dropping any stale entry beyond the live shown count
958/// (an item parked by overflow keeps a lingering bound until it next lays out).
959fn shown_items(
960    bounds: &RailItemBounds,
961    model_indices: &[usize],
962    visible_count: &Signal<usize>,
963) -> Vec<(usize, Rect)> {
964    let shown = model_indices.len().min(visible_count.get());
965    let mut out: Vec<(usize, Rect)> = bounds
966        .borrow()
967        .iter()
968        .filter(|(p, _)| *p < shown)
969        .copied()
970        .collect();
971    out.sort_by_key(|(p, _)| *p);
972    out
973}
974
975/// A roving-focus navigation step among the rail's shown items.
976enum RailNav {
977    Prev,
978    Next,
979    First,
980    Last,
981}
982
983/// Count of rail tabs currently in the AT tree = items whose visible position
984/// is below the live `visible_count` (overflowed items are dormant). Drives
985/// `size_of_set` on the rail's `Role::TabList` container, never on a
986/// `Role::Tab`: AccessKit resolves an item's set size by walking up from its
987/// parent, so a count written on the item is read by no adapter.
988fn shown_rail_count(item_ids: &RailItemIds, visible_count: &Signal<usize>) -> usize {
989    let count = visible_count.get();
990    item_ids.borrow().iter().filter(|(p, _)| *p < count).count()
991}
992
993/// Roving-focus navigation among the rail's currently-shown items. Given the
994/// shared id list, the live visible count, the current item's visible
995/// position, and a navigation step, return the `WidgetId` to focus next
996/// (arrows wrap; Home/End clamp to ends). Overflowed (dormant) items are
997/// excluded — they live in the overflow popover, not the Tab cycle. Mirrors
998/// `TabBar`'s `request_focus(headers[next])` roving (`tab_widget/header.rs`).
999fn rail_focus_target(
1000    item_ids: &RailItemIds,
1001    visible_count: &Signal<usize>,
1002    current_pos: usize,
1003    nav: RailNav,
1004) -> Option<WidgetId> {
1005    let count = visible_count.get();
1006    let mut shown: Vec<(usize, WidgetId)> = item_ids
1007        .borrow()
1008        .iter()
1009        .filter(|(p, _)| *p < count)
1010        .copied()
1011        .collect();
1012    shown.sort_by_key(|(p, _)| *p);
1013    if shown.is_empty() {
1014        return None;
1015    }
1016    let cur = shown.iter().position(|(p, _)| *p == current_pos)?;
1017    let target = match nav {
1018        RailNav::Prev => (cur + shown.len() - 1) % shown.len(),
1019        RailNav::Next => (cur + 1) % shown.len(),
1020        RailNav::First => 0,
1021        RailNav::Last => shown.len() - 1,
1022    };
1023    Some(shown[target].1)
1024}
1025
1026/// Count of overflow rows currently shown in the popover = items whose visible
1027/// position is at or above the live `visible_count` (the parked ones). Drives
1028/// `size_of_set` on the popover's own `Role::Menu` node, not on each
1029/// `Role::MenuItem`: AccessKit reads a set size from the container.
1030fn overflow_shown_count(row_ids: &RailItemIds, visible_count: &Signal<usize>) -> usize {
1031    let count = visible_count.get();
1032    row_ids.borrow().iter().filter(|(p, _)| *p >= count).count()
1033}
1034
1035/// Roving-focus navigation among the overflow popover's shown rows — the
1036/// counterpart of [`rail_focus_target`] for the parked (`pos >= visible_count`)
1037/// items. Returns the row `WidgetId` to focus next.
1038fn overflow_focus_target(
1039    row_ids: &RailItemIds,
1040    visible_count: &Signal<usize>,
1041    current_pos: usize,
1042    nav: RailNav,
1043) -> Option<WidgetId> {
1044    let count = visible_count.get();
1045    let mut shown: Vec<(usize, WidgetId)> = row_ids
1046        .borrow()
1047        .iter()
1048        .filter(|(p, _)| *p >= count)
1049        .copied()
1050        .collect();
1051    shown.sort_by_key(|(p, _)| *p);
1052    if shown.is_empty() {
1053        return None;
1054    }
1055    let cur = shown.iter().position(|(p, _)| *p == current_pos)?;
1056    let target = match nav {
1057        RailNav::Prev => (cur + shown.len() - 1) % shown.len(),
1058        RailNav::Next => (cur + 1) % shown.len(),
1059        RailNav::First => 0,
1060        RailNav::Last => shown.len() - 1,
1061    };
1062    Some(shown[target].1)
1063}
1064
1065/// Given the pointer's bar-local y, the shown items (sorted by position, world
1066/// bounds), and the bar's world origin/height, return the visible insertion
1067/// position (`0..=count`) and the indicator's bar-local y.
1068fn rail_insertion(
1069    local_y: f32,
1070    shown: &[(usize, Rect)],
1071    bar_origin_y: f32,
1072    bar_height: f32,
1073) -> (usize, f32) {
1074    if shown.is_empty() {
1075        return (0, (RAIL_PADDING).min(bar_height));
1076    }
1077    // Insertion position = number of items whose vertical centre is above the
1078    // pointer.
1079    let mut vpos = shown.len();
1080    for (i, (_, b)) in shown.iter().enumerate() {
1081        let center = (b.y + b.height * 0.5) - bar_origin_y;
1082        if local_y < center {
1083            vpos = i;
1084            break;
1085        }
1086    }
1087    let line_y = if vpos == 0 {
1088        let top = shown[0].1.y - bar_origin_y;
1089        (top - RAIL_ITEM_SPACING * 0.5).max(0.0)
1090    } else if vpos >= shown.len() {
1091        let last = &shown[shown.len() - 1].1;
1092        (last.y + last.height - bar_origin_y + RAIL_ITEM_SPACING * 0.5).min(bar_height)
1093    } else {
1094        let prev = &shown[vpos - 1].1;
1095        let next = &shown[vpos].1;
1096        let prev_bottom = prev.y + prev.height - bar_origin_y;
1097        let next_top = next.y - bar_origin_y;
1098        (prev_bottom + next_top) * 0.5
1099    };
1100    (vpos, line_y.clamp(0.0, bar_height))
1101}
1102
1103// ───────────────────────────────────────────────────────────────────────
1104// DockRailTabList — the `Role::TabList` wrapper around the rail's items.
1105// ───────────────────────────────────────────────────────────────────────
1106
1107/// Carries the rail's `Role::TabList` around **only** the [`DockRailItem`]s.
1108///
1109/// ARIA's Tabs pattern restricts a tablist's children to tabs, but the rail's
1110/// column also holds slots, the overflow trigger and the action groups. Wrapping
1111/// just the items keeps every one of those a sibling rather than an illegal
1112/// tablist child.
1113///
1114/// Layout is delegated to the caller-supplied `VStack` (which already carries
1115/// `RAIL_ITEM_SPACING`), so introducing this wrapper cannot change the column's
1116/// spacing. That stack reports a bare `Role::GenericContainer`, so the AT pass
1117/// prunes it and promotes the items to direct children of this node.
1118#[derive(Debug)]
1119pub(crate) struct DockRailTabList {
1120    side: DockSide,
1121    stack: WidgetId,
1122    /// How many rail tabs are shown, for this node's `size_of_set`. Same value
1123    /// each `DockRailItem` used to write on itself, where no adapter read it.
1124    shown: usize,
1125}
1126
1127impl DockRailTabList {
1128    fn new(side: DockSide, stack: WidgetId, shown: usize) -> Self {
1129        Self { side, stack, shown }
1130    }
1131}
1132
1133impl Widget for DockRailTabList {
1134    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
1135        ctx.child_size(self.stack, proposal)
1136            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
1137            .into()
1138    }
1139
1140    fn place_children(
1141        &self,
1142        bounds: Rect,
1143        _proposal: SizeProposal,
1144        children: &mut [WidgetPlacement],
1145        _ctx: &LayoutContext,
1146    ) {
1147        for child in children.iter_mut() {
1148            child.origin = bounds.origin();
1149            child.size = bounds.size();
1150        }
1151    }
1152
1153    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1154        use teksilo_core::accesskit::{Orientation as A11yOrientation, Role};
1155        builder.set_role(Role::TabList);
1156        builder.set_name(super::a11y::rail_label(self.side).resolve_now());
1157        builder.set_orientation(A11yOrientation::Vertical);
1158        // The set size belongs on this container, not on each item:
1159        // AccessKit's `size_of_set` differs from ARIA's per-item
1160        // `aria-setsize`, and `size_of_set_from_container` resolves an
1161        // item's set size by walking *up* from it.
1162        if self.shown > 0 {
1163            builder.set_size_of_set(self.shown);
1164        }
1165    }
1166
1167    fn children(&self) -> Vec<WidgetId> {
1168        vec![self.stack]
1169    }
1170}
1171
1172// ───────────────────────────────────────────────────────────────────────
1173// DockRailActionGroup — the `Role::Toolbar` cluster of dockless actions.
1174// ───────────────────────────────────────────────────────────────────────
1175
1176/// One placement's worth of [`DockAction`]s, as an ARIA toolbar sibling of the
1177/// rail's tab list.
1178///
1179/// Deliberately **not** a member of [`DockRailTabList`]'s children and never
1180/// registered into the rail's `RailItemBounds` / `RailItemIds`: the drop
1181/// machinery resolves a drop position through `model_indices[vpos]`, so a
1182/// non-tab entry sharing that indexed sequence would silently move the wrong
1183/// tab. Keeping the two populations structurally separate makes that class of
1184/// bug unreachable rather than merely guarded.
1185///
1186/// Keyboard: the group is its own single Tab stop with an internal roving
1187/// Arrow/Home/End cycle (the ARIA toolbar pattern), independent of the tab
1188/// list's. Tab / Shift+Tab crosses between the two composites; arrows never do.
1189pub(crate) struct DockRailActionGroup {
1190    side: DockSide,
1191    placement: DockActionPlacement,
1192    actions: Vec<DockAction>,
1193    extent: f32,
1194    glyph: f32,
1195    labeled: bool,
1196    /// Which action index is currently the group's single Tab stop. Local
1197    /// focus history — mirrors `Toolbar::roving`, NOT `DockRailItem`'s
1198    /// model-level `selected`: an action group has no "selected" concept.
1199    roving: Signal<usize>,
1200    item_ids: Rc<RefCell<Vec<WidgetId>>>,
1201    root: Option<WidgetId>,
1202}
1203
1204impl std::fmt::Debug for DockRailActionGroup {
1205    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1206        f.debug_struct("DockRailActionGroup")
1207            .field("side", &self.side)
1208            .field("placement", &self.placement)
1209            .field("actions", &self.actions.len())
1210            .finish()
1211    }
1212}
1213
1214impl DockRailActionGroup {
1215    fn new(
1216        side: DockSide,
1217        placement: DockActionPlacement,
1218        actions: Vec<DockAction>,
1219        extent: f32,
1220        glyph: f32,
1221        labeled: bool,
1222    ) -> Self {
1223        Self {
1224            side,
1225            placement,
1226            actions,
1227            extent,
1228            glyph,
1229            labeled,
1230            roving: Signal::new(0),
1231            item_ids: Rc::new(RefCell::new(Vec::new())),
1232            root: None,
1233        }
1234    }
1235}
1236
1237impl Widget for DockRailActionGroup {
1238    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1239        self.item_ids.borrow_mut().clear();
1240        let mut stack = VStack::new().spacing(RAIL_ITEM_SPACING);
1241        let mut ids = Vec::with_capacity(self.actions.len());
1242        for (i, action) in self.actions.iter().enumerate() {
1243            let id = ctx.add(DockRailActionItem::new(
1244                action.clone(),
1245                i,
1246                self.extent,
1247                self.glyph,
1248                self.labeled,
1249                self.roving.clone(),
1250                self.item_ids.clone(),
1251            ));
1252            ids.push(id);
1253            stack = stack.add_child(id);
1254        }
1255        *self.item_ids.borrow_mut() = ids;
1256        // The roving stop can outlive a rebuild that shortened the list (an
1257        // app may declare a different action set per view); re-clamp so the
1258        // group never points its only Tab stop at a missing item.
1259        if self.roving.get() >= self.actions.len() {
1260            self.roving.set(0);
1261        }
1262        let root = ctx.add(stack);
1263        self.root = Some(root);
1264        vec![root]
1265    }
1266
1267    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
1268        self.root
1269            .and_then(|id| ctx.child_size(id, proposal))
1270            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
1271            .into()
1272    }
1273
1274    fn place_children(
1275        &self,
1276        bounds: Rect,
1277        _proposal: SizeProposal,
1278        children: &mut [WidgetPlacement],
1279        _ctx: &LayoutContext,
1280    ) {
1281        for child in children.iter_mut() {
1282            child.origin = bounds.origin();
1283            child.size = bounds.size();
1284        }
1285    }
1286
1287    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1288        use teksilo_core::accesskit::{Orientation as A11yOrientation, Role};
1289        builder.set_role(Role::Toolbar);
1290        builder.set_name(super::a11y::rail_actions_label(self.side, self.placement).resolve_now());
1291        builder.set_orientation(A11yOrientation::Vertical);
1292        // The set size belongs on this container, not on each action:
1293        // AccessKit's `size_of_set` differs from ARIA's per-item
1294        // `aria-setsize`, and `size_of_set_from_container` resolves an item's
1295        // set size by walking *up* from it. `item_ids` is the same list each
1296        // `DockRailActionItem` receives as its `siblings`.
1297        let count = self.item_ids.borrow().len();
1298        if count > 0 {
1299            builder.set_size_of_set(count);
1300        }
1301    }
1302
1303    fn children(&self) -> Vec<WidgetId> {
1304        self.root.into_iter().collect()
1305    }
1306}
1307
1308// ───────────────────────────────────────────────────────────────────────
1309// DockRailActionItem — one dockless action button.
1310// ───────────────────────────────────────────────────────────────────────
1311
1312/// One [`DockAction`], rendered to match a [`DockRailItem`] pixel for pixel.
1313///
1314/// Built from the same primitives as a rail item rather than from an
1315/// [`IconButton`] on purpose:
1316/// * `IconButton::toggle` **writes** its signal on click; a `DockAction`'s
1317///   toggled state is reflect-only (§ [`DockAction::toggled`]).
1318/// * `IconButton`'s tooltip opens `Below`, which on a vertical rail drops it
1319///   onto the next stacked item — rail items use `TooltipPlacement::Side`.
1320/// * The rail owns glyph sizing and the `Icon + Label` rotated caption, so an
1321///   action tracks the Compact / Default / Labeled switch like a real item.
1322struct DockRailActionItem {
1323    action: DockAction,
1324    index: usize,
1325    extent: f32,
1326    glyph: f32,
1327    labeled: bool,
1328    roving: Signal<usize>,
1329    siblings: Rc<RefCell<Vec<WidgetId>>>,
1330    focused: Signal<bool>,
1331    root: Option<WidgetId>,
1332}
1333
1334impl std::fmt::Debug for DockRailActionItem {
1335    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1336        f.debug_struct("DockRailActionItem")
1337            .field("index", &self.index)
1338            .finish()
1339    }
1340}
1341
1342impl DockRailActionItem {
1343    fn new(
1344        action: DockAction,
1345        index: usize,
1346        extent: f32,
1347        glyph: f32,
1348        labeled: bool,
1349        roving: Signal<usize>,
1350        siblings: Rc<RefCell<Vec<WidgetId>>>,
1351    ) -> Self {
1352        Self {
1353            action,
1354            index,
1355            extent,
1356            glyph,
1357            labeled,
1358            roving,
1359            siblings,
1360            focused: Signal::new(false),
1361            root: None,
1362        }
1363    }
1364}
1365
1366impl Widget for DockRailActionItem {
1367    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1368        let self_id = ctx.self_id();
1369        let enabled = self.action.enabled.as_signal();
1370        enabled.bind_to(self_id, ctx.binding_registry(), BindingLevel::RepaintOnly);
1371
1372        // Reflect-only toggled highlight, window-active-aware — the same
1373        // treatment `DockRailItem` gives an open activity, so a toggled action
1374        // reads as "on" exactly like an open panel does.
1375        let toggled = self
1376            .action
1377            .toggled
1378            .clone()
1379            .unwrap_or_else(|| Signal::new(false));
1380        toggled.bind_to(self_id, ctx.binding_registry(), BindingLevel::RepaintOnly);
1381        let bg = toggled.zip(&ctx.window_active_signal()).map(|(t, win)| {
1382            if *t {
1383                if *win {
1384                    SurfaceRole::Selected
1385                } else {
1386                    SurfaceRole::SelectedInactive
1387                }
1388            } else {
1389                SurfaceRole::Transparent
1390            }
1391        });
1392        let ring = self.focused.and(&ctx.focus_visible());
1393        let focus_ring_width = ctx.theme().shape.focus_ring_width;
1394        let border_color: ColorProp = ring
1395            .map(|f| {
1396                if *f {
1397                    BorderRole::Focused
1398                } else {
1399                    BorderRole::Transparent
1400                }
1401            })
1402            .into();
1403        let border_width = ring.map(move |f| if *f { focus_ring_width } else { 0.0 });
1404        let bg_rect = ctx.add(
1405            RectWidget::new()
1406                .background(bg)
1407                .border_color(border_color)
1408                .border_width(border_width)
1409                .corner_radius(CornerRadius::uniform(ICON_BUTTON_CORNER_RADIUS)),
1410        );
1411
1412        let glyph_color: ColorProp = enabled
1413            .map(|e| {
1414                if *e {
1415                    TextRole::Primary
1416                } else {
1417                    TextRole::Disabled
1418                }
1419            })
1420            .into();
1421        let icon = ctx.add(
1422            (self.action.icon)()
1423                .icon_size(self.glyph)
1424                .color(glyph_color.clone()),
1425        );
1426        let centered = ctx.add(Center::new().child_id(icon));
1427        let icon_box = ctx.add(
1428            FixedSize::new()
1429                .width(self.extent)
1430                .height(self.extent)
1431                .child_id(centered),
1432        );
1433
1434        let content = if self.labeled {
1435            let label = ctx.add(RotatedLabel::new(
1436                self.action.label.clone(),
1437                Signal::new(TextRole::Secondary),
1438            ));
1439            let stack = ctx.add(
1440                VStack::new()
1441                    .alignment(HAlignment::Center)
1442                    .spacing(2.0)
1443                    .add_child(label)
1444                    .add_child(icon_box),
1445            );
1446            ctx.add(Padding::new(LABELED_TOP_MARGIN, 0.0, 0.0, 0.0).child_id(stack))
1447        } else {
1448            icon_box
1449        };
1450        let root = ctx.add(ZStack::new().add_child(bg_rect).add_child(content));
1451        self.root = Some(root);
1452
1453        if !self.labeled {
1454            // `Side`, never `Below` — a `Below` tooltip would land on the next
1455            // item down the column (the same reason `DockRailItem` does this).
1456            let text = self
1457                .action
1458                .tooltip
1459                .clone()
1460                .unwrap_or_else(|| self.action.label.clone());
1461            let delay = ctx.theme().motion.tooltip_delay;
1462            crate::tooltip::attach_plain_tooltip_with_placement(
1463                ctx,
1464                root,
1465                text,
1466                delay,
1467                crate::tooltip::TooltipPlacement::Side,
1468            );
1469        }
1470
1471        // One activation path for pointer, keyboard and the AT `Click` action.
1472        // A disabled action is inert on every one of them.
1473        let activate: Rc<dyn Fn(&mut EventContext)> = {
1474            let on_activate = self.action.on_activate.clone();
1475            let enabled = enabled.clone();
1476            let roving = self.roving.clone();
1477            let index = self.index;
1478            Rc::new(move |ctx: &mut EventContext| {
1479                if !enabled.get() {
1480                    return;
1481                }
1482                roving.set(index);
1483                (on_activate)(ctx);
1484            })
1485        };
1486
1487        // Roving tab stop: exactly one member of the group is a Tab stop.
1488        let index = self.index;
1489        ctx.set_tab_stop(self_id, self.roving.map(move |r| *r == index));
1490        // A disabled action stays **focusable** on purpose — it is not
1491        // `enabled_when`'d out of the focus order. Two reasons, one of them a
1492        // real bug this avoids:
1493        //   * ARIA's toolbar pattern explicitly keeps disabled toolbar controls
1494        //     focusable so a keyboard user can discover that the command exists
1495        //     at all (an unreachable greyed button is invisible to them).
1496        //   * The group has exactly ONE Tab stop, chosen by `roving`. If that
1497        //     item were removed from the focus order while disabled, the whole
1498        //     toolbar would become unreachable by keyboard — and since
1499        //     `enabled` is a live `Prop`, that can happen at any time, not just
1500        //     at build. Staying focusable makes the trap unreachable instead of
1501        //     needing a re-clamp on every enablement change.
1502        // Activation is guarded in `activate` and the glyph dims, so a disabled
1503        // action is inert and reads as inert without being lost.
1504
1505        let focused_sig = self.focused.clone();
1506        ctx.apply_self_handlers(
1507            HandlerSet::new()
1508                .on_tap({
1509                    let activate = activate.clone();
1510                    move |_e, ctx| activate(ctx)
1511                })
1512                .on_focus(move |gained, _ctx| focused_sig.set(gained))
1513                .on_key({
1514                    let activate = activate.clone();
1515                    let siblings = self.siblings.clone();
1516                    let roving = self.roving.clone();
1517                    let index = self.index;
1518                    move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
1519                        let WidgetEvent::KeyDown { key, .. } = event else {
1520                            return EventResponse::Ignored;
1521                        };
1522                        let ids = siblings.borrow();
1523                        if ids.is_empty() {
1524                            return EventResponse::Ignored;
1525                        }
1526                        let next = match key {
1527                            Key::ArrowUp | Key::ArrowLeft => (index + ids.len() - 1) % ids.len(),
1528                            Key::ArrowDown | Key::ArrowRight => (index + 1) % ids.len(),
1529                            Key::Home => 0,
1530                            Key::End => ids.len() - 1,
1531                            Key::Enter | Key::Space => {
1532                                drop(ids);
1533                                activate(ctx);
1534                                return EventResponse::Handled;
1535                            }
1536                            _ => return EventResponse::Ignored,
1537                        };
1538                        let target = ids[next];
1539                        drop(ids);
1540                        roving.set(next);
1541                        ctx.request_focus(target);
1542                        EventResponse::Handled
1543                    }
1544                })
1545                .on_access_action({
1546                    let activate = activate.clone();
1547                    move |action: teksilo_core::accesskit::Action, ctx: &mut EventContext| {
1548                        if action == teksilo_core::accesskit::Action::Click {
1549                            activate(ctx);
1550                            EventResponse::Handled
1551                        } else {
1552                            EventResponse::Ignored
1553                        }
1554                    }
1555                })
1556                .focusable(true)
1557                .cursor(CursorIcon::Pointer),
1558        );
1559        vec![root]
1560    }
1561
1562    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
1563        self.root
1564            .and_then(|id| ctx.child_size(id, proposal))
1565            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
1566            .into()
1567    }
1568
1569    fn place_children(
1570        &self,
1571        bounds: Rect,
1572        _proposal: SizeProposal,
1573        children: &mut [WidgetPlacement],
1574        _ctx: &LayoutContext,
1575    ) {
1576        for child in children.iter_mut() {
1577            child.origin = bounds.origin();
1578            child.size = bounds.size();
1579        }
1580    }
1581
1582    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1583        use teksilo_core::accesskit::{Action, Role};
1584        // `Role::Button` — NOT `Role::Tab`. An action controls no tabpanel, so
1585        // announcing it as a tab would promise a panel that never appears.
1586        builder.set_role(Role::Button);
1587        builder.set_name(self.action.label.resolve_now());
1588        builder.add_action(Action::Focus);
1589        // Announce the inert state rather than dropping out of the focus order
1590        // (see the `set_tab_stop` comment in `build`): a disabled toolbar
1591        // control stays reachable so it is discoverable, and says why.
1592        if self.action.enabled.get() {
1593            builder.add_action(Action::Click);
1594        } else {
1595            builder.set_disabled();
1596        }
1597        builder.set_position_in_set(self.index + 1);
1598        // The "of N" half lives on the `Role::Toolbar` action group.
1599        // A reflect-only bistate reads as a toggle button to AT.
1600        if let Some(t) = &self.action.toggled {
1601            builder.set_toggled(t.get());
1602        }
1603    }
1604
1605    fn children(&self) -> Vec<WidgetId> {
1606        self.root.into_iter().collect()
1607    }
1608}
1609
1610// ───────────────────────────────────────────────────────────────────────
1611// RailDropIndicator — the horizontal insertion line painted over the rail.
1612// ───────────────────────────────────────────────────────────────────────
1613
1614/// A pure-decoration overlay (topmost child of the rail's ZStack) that paints a
1615/// horizontal accent line at the bar-local y in its `y` signal — the rail's
1616/// equivalent of a `TabBar` insertion indicator. Paints nothing when `y` is
1617/// `None`. Pointer events pass straight through so the rail items below stay
1618/// interactive.
1619struct RailDropIndicator {
1620    y: Signal<Option<f32>>,
1621    color: ColorProp,
1622}
1623
1624impl std::fmt::Debug for RailDropIndicator {
1625    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1626        f.debug_struct("RailDropIndicator").finish()
1627    }
1628}
1629
1630impl RailDropIndicator {
1631    fn new(y: Signal<Option<f32>>) -> Self {
1632        Self {
1633            y,
1634            color: ColorProp::from(BorderRole::Accent),
1635        }
1636    }
1637}
1638
1639impl Widget for RailDropIndicator {
1640    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1641        self.y.bind_to(
1642            ctx.self_id(),
1643            ctx.binding_registry(),
1644            BindingLevel::RepaintOnly,
1645        );
1646        ctx.apply_self_handlers(HandlerSet::new().event_pass_through(true));
1647        vec![]
1648    }
1649
1650    fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
1651        proposal.resolve(0.0, 0.0).into()
1652    }
1653
1654    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
1655        let Some(y) = self.y.get() else {
1656            return;
1657        };
1658        let color = self.color.resolve(ctx.theme, true);
1659        let t = 2.0;
1660        let yy = bounds.y + y - t * 0.5;
1661        // Inset a touch from the rail's padding so the line reads as "between
1662        // items", not flush to the edge.
1663        let x = bounds.x + RAIL_PADDING;
1664        let w = (bounds.width - RAIL_PADDING * 2.0).max(0.0);
1665        canvas.fill_rect(Rect::new(x, yy, w, t), color);
1666    }
1667
1668    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1669        builder.set_hidden();
1670    }
1671}
1672
1673// ───────────────────────────────────────────────────────────────────────
1674// RailEdgeDivider — a 1 dp line between the rail and the side's content.
1675// ───────────────────────────────────────────────────────────────────────
1676
1677/// A pure-decoration overlay (topmost child of the rail's ZStack) that paints a
1678/// 1 dp vertical line on the rail's content-facing edge — the boundary between
1679/// the activity rail and the side's resizable content. The edge is derived from
1680/// the side (the rail always hugs the outer / leading-cross edge, so content
1681/// sits on the opposite vertical edge) and the active layout direction, so it
1682/// stays correct under RTL. Pointer events pass straight through.
1683struct RailEdgeDivider {
1684    side: DockSide,
1685    color: ColorProp,
1686}
1687
1688impl std::fmt::Debug for RailEdgeDivider {
1689    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1690        f.debug_struct("RailEdgeDivider").finish()
1691    }
1692}
1693
1694impl Widget for RailEdgeDivider {
1695    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1696        ctx.apply_self_handlers(HandlerSet::new().event_pass_through(true));
1697        vec![]
1698    }
1699
1700    fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
1701        proposal.resolve(0.0, 0.0).into()
1702    }
1703
1704    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
1705        let rtl = matches!(
1706            ctx.layout_direction,
1707            teksilo_core::environment::LayoutDirection::RightToLeft
1708        );
1709        // The rail hugs the outer thickness edge (leading / trailing) or the
1710        // leading cross-edge (top / bottom), so the content is on the trailing
1711        // geometric edge for every side except Trailing, where it's the leading
1712        // edge. Resolve that to a concrete left / right under RTL.
1713        let content_on_right = match self.side {
1714            DockSide::Trailing => rtl,
1715            _ => !rtl,
1716        };
1717        let t = 1.0;
1718        let x = if content_on_right {
1719            bounds.x + bounds.width - t
1720        } else {
1721            bounds.x
1722        };
1723        let color = self.color.resolve(ctx.theme, true);
1724        canvas.fill_rect(Rect::new(x, bounds.y, t, bounds.height), color);
1725    }
1726
1727    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1728        builder.set_hidden();
1729    }
1730}
1731
1732// ───────────────────────────────────────────────────────────────────────
1733// DockOverflowMenu — the popover content listing the overflowed entries.
1734// ───────────────────────────────────────────────────────────────────────
1735
1736/// A column of rows (one per tab), each shown only while that tab is
1737/// overflowed (`index >= visible_count`). Selecting a row activates its tab
1738/// and shows the side.
1739#[derive(Debug)]
1740struct DockOverflowMenu {
1741    side: DockSide,
1742    model: DockingModel,
1743    visible_count: Signal<usize>,
1744    /// Shared `(visible position → row WidgetId)` list for roving Arrow/Home/End
1745    /// focus among the overflowed rows.
1746    row_ids: RailItemIds,
1747    root: Option<WidgetId>,
1748}
1749
1750impl DockOverflowMenu {
1751    fn new(side: DockSide, model: DockingModel, visible_count: Signal<usize>) -> Self {
1752        Self {
1753            side,
1754            model,
1755            visible_count,
1756            row_ids: Rc::new(RefCell::new(Vec::new())),
1757            root: None,
1758        }
1759    }
1760}
1761
1762impl Widget for DockOverflowMenu {
1763    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1764        let tabs = self.model.side_tabs(self.side);
1765        let mut column = VStack::new().spacing(2.0);
1766        self.row_ids.borrow_mut().clear();
1767        // Mirror the rail: only non-hidden tabs are rail items, and overflow is
1768        // keyed on the position among shown items (so an overflowed row appears
1769        // here exactly when its rail item is parked).
1770        let mut pos = 0usize;
1771        for (model_i, tab) in tabs.iter().enumerate() {
1772            if tab.hidden {
1773                continue;
1774            }
1775            let p = pos;
1776            pos += 1;
1777            let label = self.model.activity_label(tab);
1778            let row = ctx.add(DockOverflowRow::new(
1779                self.side,
1780                model_i,
1781                p,
1782                tab.id,
1783                label,
1784                self.model.clone(),
1785                self.row_ids.clone(),
1786                self.visible_count.clone(),
1787            ));
1788            self.row_ids.borrow_mut().push((p, row));
1789            ctx.visible_when(row, self.visible_count.map(move |c| p >= *c));
1790            column = column.add_child(row);
1791        }
1792        let column_id = ctx.add(column);
1793        let root = ctx.add(Padding::uniform(4.0).child_id(column_id));
1794        self.root = Some(root);
1795        vec![root]
1796    }
1797
1798    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
1799        self.root
1800            .and_then(|id| ctx.child_size(id, proposal))
1801            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
1802            .into()
1803    }
1804
1805    fn place_children(
1806        &self,
1807        bounds: Rect,
1808        _proposal: SizeProposal,
1809        children: &mut [WidgetPlacement],
1810        _ctx: &LayoutContext,
1811    ) {
1812        for child in children.iter_mut() {
1813            child.origin = bounds.origin();
1814            child.size = bounds.size();
1815        }
1816    }
1817
1818    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1819        use teksilo_core::accesskit::{Orientation, Role};
1820        builder.set_role(Role::Menu);
1821        builder.set_orientation(Orientation::Vertical);
1822        // The set size belongs on this container, not on each item:
1823        // AccessKit's `size_of_set` differs from ARIA's per-item
1824        // `aria-setsize`, and `size_of_set_from_container` resolves an
1825        // item's set size by walking *up* from it.
1826        let shown = overflow_shown_count(&self.row_ids, &self.visible_count);
1827        if shown > 0 {
1828            builder.set_size_of_set(shown);
1829        }
1830    }
1831
1832    fn children(&self) -> Vec<WidgetId> {
1833        self.root.into_iter().collect()
1834    }
1835}
1836
1837// ───────────────────────────────────────────────────────────────────────
1838// DockRailItem — one activity-rail item.
1839// ───────────────────────────────────────────────────────────────────────
1840
1841struct DockRailItem {
1842    side: DockSide,
1843    index: usize,
1844    /// Position among the *shown* (non-hidden) items — the key the drop
1845    /// handler indexes by when computing an insertion position.
1846    pos: usize,
1847    tab_id: DockTabId,
1848    icon: Option<DockIconFactory>,
1849    label: LocalizedString,
1850    extent: f32,
1851    /// Glyph (icon) dimension drawn inside the `extent`-sized box — derived from
1852    /// the rail size so the icon scales with it (see [`item_glyph_size`]).
1853    glyph: f32,
1854    /// Labeled mode: paint a 90°-rotated title under the icon (no tooltip).
1855    /// Icon-only modes attach the title as a hover tooltip instead.
1856    labeled: bool,
1857    selected: Signal<usize>,
1858    visible: Signal<bool>,
1859    model: DockingModel,
1860    /// The bar's shared item-bounds sink; this item upserts its world bounds
1861    /// (keyed by `pos`) here each layout pass.
1862    bounds_sink: RailItemBounds,
1863    /// Shared sibling-id list (for Arrow/Home/End roving focus) and the live
1864    /// overflow count (so nav and `size_of_set` skip parked items).
1865    item_ids: RailItemIds,
1866    visible_count: Signal<usize>,
1867    /// Per-side content-region ids, for the `controls` (tab → tabpanel) link.
1868    side_panel_ids: Rc<RefCell<HashMap<DockSide, WidgetId>>>,
1869    /// Keyboard `:focus-visible` state — `true` only while this item holds
1870    /// focus AND the last input was the keyboard; drives the focus ring.
1871    focused: Signal<bool>,
1872    root: Option<WidgetId>,
1873}
1874
1875impl std::fmt::Debug for DockRailItem {
1876    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1877        f.debug_struct("DockRailItem")
1878            .field("index", &self.index)
1879            .finish()
1880    }
1881}
1882
1883impl DockRailItem {
1884    #[allow(clippy::too_many_arguments)]
1885    fn new(
1886        side: DockSide,
1887        index: usize,
1888        pos: usize,
1889        tab_id: DockTabId,
1890        icon: Option<DockIconFactory>,
1891        label: LocalizedString,
1892        extent: f32,
1893        glyph: f32,
1894        labeled: bool,
1895        selected: Signal<usize>,
1896        visible: Signal<bool>,
1897        model: DockingModel,
1898        bounds_sink: RailItemBounds,
1899        item_ids: RailItemIds,
1900        visible_count: Signal<usize>,
1901        side_panel_ids: Rc<RefCell<HashMap<DockSide, WidgetId>>>,
1902    ) -> Self {
1903        Self {
1904            side,
1905            index,
1906            pos,
1907            tab_id,
1908            icon,
1909            label,
1910            extent,
1911            glyph,
1912            labeled,
1913            selected,
1914            visible,
1915            model,
1916            bounds_sink,
1917            item_ids,
1918            visible_count,
1919            side_panel_ids,
1920            focused: Signal::new(false),
1921            root: None,
1922        }
1923    }
1924}
1925
1926impl Widget for DockRailItem {
1927    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1928        let idx = self.index;
1929        let active = self
1930            .selected
1931            .zip(&self.visible)
1932            .map(move |(s, v)| *s == idx && *v);
1933        // Window-active-aware selection highlight. `surface_selected` is
1934        // deliberately excluded from the theme-side inactive-window accent
1935        // desaturation (`ColorTokens::for_inactive_window`), so — like
1936        // `StandardListItem` / `TableView` — the rail item must opt in
1937        // explicitly, swapping to the muted `SelectedInactive` token when the
1938        // host window loses focus (macOS / Qt `QPalette::Inactive`). The rail
1939        // is persistent chrome whose "active" item tracks the open side (app
1940        // state, not a keyboard-focus-scoped selection), so it gates on
1941        // window-active alone — not view focus — keeping the open-side
1942        // indicator vivid while the window is active regardless of where
1943        // keyboard focus sits.
1944        let bg = active.zip(&ctx.window_active_signal()).map(|(a, win)| {
1945            if *a {
1946                if *win {
1947                    SurfaceRole::Selected
1948                } else {
1949                    SurfaceRole::SelectedInactive
1950                }
1951            } else {
1952                SurfaceRole::Transparent
1953            }
1954        });
1955        // Keyboard focus ring, gated on `:focus-visible` (the item is focused
1956        // AND the last input was the keyboard) — the same pattern as
1957        // `IconButton` (`recipe_icon_button_style.rs`). The border IS the focus
1958        // indicator; it coexists with the selection background on this rect.
1959        let ring = self.focused.and(&ctx.focus_visible());
1960        let focus_ring_width = ctx.theme().shape.focus_ring_width;
1961        let border_color: ColorProp = ring
1962            .map(|f| {
1963                if *f {
1964                    BorderRole::Focused
1965                } else {
1966                    BorderRole::Transparent
1967                }
1968            })
1969            .into();
1970        let border_width = ring.map(move |f| if *f { focus_ring_width } else { 0.0 });
1971        // Rounded selection highlight matching the IconButton corner style, so
1972        // the rail items read as buttons rather than full-square fills.
1973        let bg_rect = ctx.add(
1974            RectWidget::new()
1975                .background(bg)
1976                .border_color(border_color)
1977                .border_width(border_width)
1978                .corner_radius(CornerRadius::uniform(ICON_BUTTON_CORNER_RADIUS)),
1979        );
1980
1981        let glyph = if let Some(icon) = self.icon.take() {
1982            // Size the caller's icon to the rail's glyph dimension so it tracks
1983            // the rail size (Compact…Hero) instead of whatever fixed dp the
1984            // factory picked — the rail owns glyph sizing, like `IconButton`.
1985            ctx.add((icon)().icon_size(self.glyph))
1986        } else {
1987            let s = self.label.resolve_now();
1988            let ch: String = s.chars().take(1).collect();
1989            ctx.add(
1990                TextWidget::new(lit!(ch))
1991                    .style(TextStyleRole::BodyBold)
1992                    .color(TextRole::Primary),
1993            )
1994        };
1995        let centered = ctx.add(Center::new().child_id(glyph));
1996        let icon_box = ctx.add(
1997            FixedSize::new()
1998                .width(self.extent)
1999                .height(self.extent)
2000                .child_id(centered),
2001        );
2002
2003        // Labeled mode: a 90°-rotated title above the icon square (the
2004        // vertical-accordion look). The title is painted, so no tooltip. Icon
2005        // modes show the icon alone and surface the title as a hover tooltip.
2006        let content = if self.labeled {
2007            let label = ctx.add(RotatedLabel::new(
2008                self.label.clone(),
2009                Signal::new(TextRole::Secondary),
2010            ));
2011            let stack = ctx.add(
2012                VStack::new()
2013                    .alignment(HAlignment::Center)
2014                    .spacing(2.0)
2015                    .add_child(label)
2016                    .add_child(icon_box),
2017            );
2018            // A bit of top breathing room so the rotated title's top character
2019            // isn't flush against the rail item's top edge.
2020            ctx.add(Padding::new(LABELED_TOP_MARGIN, 0.0, 0.0, 0.0).child_id(stack))
2021        } else {
2022            icon_box
2023        };
2024        let root = ctx.add(ZStack::new().add_child(bg_rect).add_child(content));
2025        self.root = Some(root);
2026
2027        if !self.labeled {
2028            // The activity rail is vertical-only; its icon-only items stack
2029            // top-to-bottom, so the title tooltip opens to the trailing `Side`
2030            // (a `Below` tooltip would drop onto the next rail item).
2031            let delay = ctx.theme().motion.tooltip_delay;
2032            crate::tooltip::attach_plain_tooltip_with_placement(
2033                ctx,
2034                root,
2035                self.label.clone(),
2036                delay,
2037                crate::tooltip::TooltipPlacement::Side,
2038            );
2039        }
2040
2041        let self_id = ctx.self_id();
2042        let policy = self.model.policy();
2043        let side = self.side;
2044        let tab_id = self.tab_id;
2045        let menu_model = self.model.clone();
2046        let allow_collapse = policy.allow_side_collapse;
2047
2048        // The single activation path, shared by pointer tap, keyboard
2049        // Enter/Space, and the AT `Click` action — so the rail item is
2050        // operable by mouse, keyboard, and screen reader alike. Clicking the
2051        // active item hides the side (a collapse toggle) unless collapse is
2052        // locked; any other item selects it and shows the side.
2053        let activate: Rc<dyn Fn(&mut EventContext)> = {
2054            let model = self.model.clone();
2055            let selected = self.selected.clone();
2056            let visible = self.visible.clone();
2057            Rc::new(move |_ctx: &mut EventContext| {
2058                if selected.get() == idx && visible.get() {
2059                    if allow_collapse {
2060                        model.set_side_visible(side, false);
2061                    }
2062                } else {
2063                    model.select_tab(side, idx);
2064                    model.set_side_visible(side, true);
2065                }
2066            })
2067        };
2068
2069        // Reflect the keyboard `:focus-visible` ring.
2070        let focused_sig = self.focused.clone();
2071        // Roving tab stop (ARIA tabs pattern): only the selected item is a
2072        // Tab/Shift+Tab stop; siblings stay reachable via Arrow keys +
2073        // `request_focus`. Matches `TabBar` (`tab_widget/header.rs`).
2074        ctx.set_tab_stop(self_id, self.selected.map(move |s| *s == idx));
2075
2076        let mut handlers = HandlerSet::new()
2077            .on_tap({
2078                let activate = activate.clone();
2079                move |_e, ctx| activate(ctx)
2080            })
2081            .on_focus(move |gained, _ctx| focused_sig.set(gained))
2082            .on_key({
2083                let activate = activate.clone();
2084                let item_ids = self.item_ids.clone();
2085                let visible_count = self.visible_count.clone();
2086                let pos = self.pos;
2087                move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
2088                    let WidgetEvent::KeyDown { key, .. } = event else {
2089                        return EventResponse::Ignored;
2090                    };
2091                    let nav = match key {
2092                        Key::ArrowUp | Key::ArrowLeft => RailNav::Prev,
2093                        Key::ArrowDown | Key::ArrowRight => RailNav::Next,
2094                        Key::Home => RailNav::First,
2095                        Key::End => RailNav::Last,
2096                        Key::Enter | Key::Space => {
2097                            // Manual activation: arrows only move focus; the
2098                            // panel is shown/hidden on explicit Enter/Space.
2099                            activate(ctx);
2100                            return EventResponse::Handled;
2101                        }
2102                        _ => return EventResponse::Ignored,
2103                    };
2104                    if let Some(target) = rail_focus_target(&item_ids, &visible_count, pos, nav) {
2105                        ctx.request_focus(target);
2106                        EventResponse::Handled
2107                    } else {
2108                        EventResponse::Ignored
2109                    }
2110                }
2111            })
2112            .on_access_action({
2113                let activate = activate.clone();
2114                move |action: teksilo_core::accesskit::Action, ctx: &mut EventContext| {
2115                    if action == teksilo_core::accesskit::Action::Click {
2116                        activate(ctx);
2117                        EventResponse::Handled
2118                    } else {
2119                        EventResponse::Ignored
2120                    }
2121                }
2122            });
2123        // Drag a rail item to reorder / move the activity — only when allowed.
2124        if policy.allow_activity_drag {
2125            handlers = handlers.on_drag(move |phase, ctx| {
2126                if let DragPhase::Started { .. } = phase {
2127                    ctx.start_drag(
2128                        self_id,
2129                        DragPayload::typed(DockTabDragData {
2130                            tab_id,
2131                            source_side: side,
2132                        }),
2133                    );
2134                }
2135            });
2136        }
2137        handlers = handlers
2138            .context_menu(move |_pos, _ctx| {
2139                Some(Box::new(activity_context_menu(
2140                    &menu_model,
2141                    side,
2142                    tab_id,
2143                    DockMenuKind::Rail,
2144                )))
2145            })
2146            .focusable(true)
2147            .cursor(CursorIcon::Pointer);
2148        ctx.apply_self_handlers(handlers);
2149        vec![root]
2150    }
2151
2152    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
2153        self.root
2154            .and_then(|id| ctx.child_size(id, proposal))
2155            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
2156            .into()
2157    }
2158
2159    fn place_children(
2160        &self,
2161        bounds: Rect,
2162        _proposal: SizeProposal,
2163        children: &mut [WidgetPlacement],
2164        _ctx: &LayoutContext,
2165    ) {
2166        // Upsert this item's world bounds (keyed by its shown position) so the
2167        // bar's drop handler can compute an insertion line.
2168        {
2169            let mut sink = self.bounds_sink.borrow_mut();
2170            if let Some(slot) = sink.iter_mut().find(|(p, _)| *p == self.pos) {
2171                slot.1 = bounds;
2172            } else {
2173                sink.push((self.pos, bounds));
2174            }
2175        }
2176        for child in children.iter_mut() {
2177            child.origin = bounds.origin();
2178            child.size = bounds.size();
2179        }
2180    }
2181
2182    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2183        use teksilo_core::accesskit::{Action, Role};
2184        builder.set_role(Role::Tab);
2185        builder.set_name(self.label.resolve_now());
2186        let is_selected = self.selected.get() == self.index;
2187        builder.set_selected(is_selected && self.visible.get());
2188        builder.add_action(Action::Focus);
2189        builder.add_action(Action::Click);
2190        // "panel N of M" — M counts only the rail tabs currently in the AT
2191        // tree (overflowed items are dormant, represented by the popover rows).
2192        // `pos` is this item's 0-based visible position; only shown items run
2193        // `accessibility()`, so `pos < visible_count` holds here.
2194        builder.set_position_in_set(self.pos + 1);
2195        // The "of N" half lives on the `Role::TabList` wrapper.
2196        // Communicate the collapse toggle on the active tab: expanded when its
2197        // panel is shown, collapsed when hidden. Omitted on the other tabs
2198        // (the "expanded" concept doesn't apply to an inactive tab).
2199        if is_selected {
2200            builder.set_expanded(self.visible.get());
2201        }
2202        // `controls` → the side's content region (ARIA tab → tabpanel link).
2203        // Only while the side is shown: a hidden side parks its `DockSidePanel`
2204        // dormant (pruned from the AT tree), so linking it then would dangle.
2205        if self.visible.get()
2206            && let Some(&panel_id) = self.side_panel_ids.borrow().get(&self.side)
2207        {
2208            builder.push_controlled(widget_id_to_node_id(panel_id));
2209        }
2210    }
2211
2212    fn children(&self) -> Vec<WidgetId> {
2213        self.root.into_iter().collect()
2214    }
2215}
2216
2217// ───────────────────────────────────────────────────────────────────────
2218// DockOverflowRow — one row in the overflow popover.
2219// ───────────────────────────────────────────────────────────────────────
2220
2221#[derive(Debug)]
2222struct DockOverflowRow {
2223    side: DockSide,
2224    index: usize,
2225    /// Visible position among the side's non-hidden tabs (matches the rail
2226    /// item's `pos`); the key for roving focus + `position_in_set`.
2227    pos: usize,
2228    tab_id: DockTabId,
2229    label: LocalizedString,
2230    model: DockingModel,
2231    /// Shared sibling-row id list + live overflow count, for Arrow/Home/End
2232    /// roving focus and `size_of_set` among the shown overflow rows.
2233    row_ids: RailItemIds,
2234    visible_count: Signal<usize>,
2235    /// Keyboard `:focus-visible` state — drives the row's focus ring.
2236    focused: Signal<bool>,
2237    root: Option<WidgetId>,
2238}
2239
2240impl DockOverflowRow {
2241    #[allow(clippy::too_many_arguments)]
2242    fn new(
2243        side: DockSide,
2244        index: usize,
2245        pos: usize,
2246        tab_id: DockTabId,
2247        label: LocalizedString,
2248        model: DockingModel,
2249        row_ids: RailItemIds,
2250        visible_count: Signal<usize>,
2251    ) -> Self {
2252        Self {
2253            side,
2254            index,
2255            pos,
2256            tab_id,
2257            label,
2258            model,
2259            row_ids,
2260            visible_count,
2261            focused: Signal::new(false),
2262            root: None,
2263        }
2264    }
2265}
2266
2267impl Widget for DockOverflowRow {
2268    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
2269        let label = ctx.add(
2270            TextWidget::new(self.label.clone())
2271                .style(TextStyleRole::Body)
2272                .color(TextRole::Primary)
2273                .single_line(),
2274        );
2275        let spacer = ctx.add(Spacer::new());
2276        let row = ctx.add(
2277            HStack::new()
2278                .spacing(8.0)
2279                .add_child(label)
2280                .add_child(spacer),
2281        );
2282        let content = ctx.add(Padding::symmetric(6.0, 10.0).child_id(row));
2283
2284        // Backing surface: a subtle highlight on focus + the keyboard
2285        // `:focus-visible` ring, so a row navigated to by keyboard is visible
2286        // (it reads like a menu item).
2287        let ring = self.focused.and(&ctx.focus_visible());
2288        let focus_ring_width = ctx.theme().shape.focus_ring_width;
2289        let bg_role: ColorProp = self
2290            .focused
2291            .map(|f| {
2292                if *f {
2293                    SurfaceRole::Hover
2294                } else {
2295                    SurfaceRole::Transparent
2296                }
2297            })
2298            .into();
2299        let border_color: ColorProp = ring
2300            .map(|f| {
2301                if *f {
2302                    BorderRole::Focused
2303                } else {
2304                    BorderRole::Transparent
2305                }
2306            })
2307            .into();
2308        let border_width = ring.map(move |f| if *f { focus_ring_width } else { 0.0 });
2309        let bg_rect = ctx.add(
2310            RectWidget::new()
2311                .background(bg_role)
2312                .border_color(border_color)
2313                .border_width(border_width)
2314                .corner_radius(CornerRadius::uniform(ICON_BUTTON_CORNER_RADIUS)),
2315        );
2316        let root = ctx.add(ZStack::new().add_child(bg_rect).add_child(content));
2317        self.root = Some(root);
2318
2319        // Single activation path (tap / Enter-Space / AT Click): select the
2320        // tab and show the side.
2321        let activate: Rc<dyn Fn(&mut EventContext)> = {
2322            let model = self.model.clone();
2323            let side = self.side;
2324            let idx = self.index;
2325            Rc::new(move |_ctx: &mut EventContext| {
2326                model.select_tab(side, idx);
2327                model.set_side_visible(side, true);
2328            })
2329        };
2330        let focused_sig = self.focused.clone();
2331        ctx.apply_self_handlers(
2332            HandlerSet::new()
2333                .on_tap({
2334                    let activate = activate.clone();
2335                    move |_e, ctx| activate(ctx)
2336                })
2337                .on_focus(move |gained, _ctx| focused_sig.set(gained))
2338                .on_key({
2339                    let activate = activate.clone();
2340                    let row_ids = self.row_ids.clone();
2341                    let visible_count = self.visible_count.clone();
2342                    let pos = self.pos;
2343                    move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
2344                        let WidgetEvent::KeyDown { key, .. } = event else {
2345                            return EventResponse::Ignored;
2346                        };
2347                        let nav = match key {
2348                            Key::ArrowUp | Key::ArrowLeft => RailNav::Prev,
2349                            Key::ArrowDown | Key::ArrowRight => RailNav::Next,
2350                            Key::Home => RailNav::First,
2351                            Key::End => RailNav::Last,
2352                            Key::Enter | Key::Space => {
2353                                activate(ctx);
2354                                return EventResponse::Handled;
2355                            }
2356                            _ => return EventResponse::Ignored,
2357                        };
2358                        if let Some(target) =
2359                            overflow_focus_target(&row_ids, &visible_count, pos, nav)
2360                        {
2361                            ctx.request_focus(target);
2362                            EventResponse::Handled
2363                        } else {
2364                            EventResponse::Ignored
2365                        }
2366                    }
2367                })
2368                .on_access_action({
2369                    let activate = activate.clone();
2370                    move |action: teksilo_core::accesskit::Action, ctx: &mut EventContext| {
2371                        if action == teksilo_core::accesskit::Action::Click {
2372                            activate(ctx);
2373                            EventResponse::Handled
2374                        } else {
2375                            EventResponse::Ignored
2376                        }
2377                    }
2378                })
2379                .focusable(true)
2380                .cursor(CursorIcon::Pointer),
2381        );
2382        vec![root]
2383    }
2384
2385    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
2386        self.root
2387            .and_then(|id| ctx.child_size(id, proposal))
2388            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
2389            .into()
2390    }
2391
2392    fn place_children(
2393        &self,
2394        bounds: Rect,
2395        _proposal: SizeProposal,
2396        children: &mut [WidgetPlacement],
2397        _ctx: &LayoutContext,
2398    ) {
2399        for child in children.iter_mut() {
2400            child.origin = bounds.origin();
2401            child.size = bounds.size();
2402        }
2403    }
2404
2405    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2406        use teksilo_core::accesskit::{Action, Role};
2407        builder.set_role(Role::MenuItem);
2408        builder.set_name(self.label.resolve_now());
2409        builder.add_action(Action::Focus);
2410        builder.add_action(Action::Click);
2411        // "N of M" within the overflow set. Only shown (parked) rows run
2412        // `accessibility()`, so `pos >= visible_count` holds; the 1-based
2413        // position within the overflowed run is `pos - visible_count + 1`.
2414        let count = self.visible_count.get();
2415        builder.set_position_in_set(self.pos.saturating_sub(count) + 1);
2416        // The "of N" half lives on the `Role::Menu` container.
2417    }
2418
2419    fn children(&self) -> Vec<WidgetId> {
2420        self.root.into_iter().collect()
2421    }
2422}
2423
2424#[cfg(test)]
2425mod tests {
2426    use super::*;
2427
2428    #[test]
2429    fn rail_insertion_picks_the_gap_under_the_pointer() {
2430        // Three items stacked at world y = 100 / 142 / 184 (40 tall each); the
2431        // bar's world origin y is 100, height 300, so item local centres are
2432        // 20 / 62 / 104.
2433        let items = vec![
2434            (0usize, Rect::new(100.0, 100.0, 40.0, 40.0)),
2435            (1, Rect::new(100.0, 142.0, 40.0, 40.0)),
2436            (2, Rect::new(100.0, 184.0, 40.0, 40.0)),
2437        ];
2438        assert_eq!(
2439            rail_insertion(5.0, &items, 100.0, 300.0).0,
2440            0,
2441            "above all → front"
2442        );
2443        assert_eq!(
2444            rail_insertion(40.0, &items, 100.0, 300.0).0,
2445            1,
2446            "past item 0 → 1"
2447        );
2448        assert_eq!(
2449            rail_insertion(70.0, &items, 100.0, 300.0).0,
2450            2,
2451            "past item 1 → 2"
2452        );
2453        assert_eq!(
2454            rail_insertion(290.0, &items, 100.0, 300.0).0,
2455            3,
2456            "below all → end"
2457        );
2458    }
2459
2460    #[test]
2461    fn rail_insertion_on_empty_rail_is_front() {
2462        assert_eq!(rail_insertion(50.0, &[], 0.0, 100.0).0, 0);
2463    }
2464
2465    /// A 260 dp rail with 42 dp items: 252 dp usable ⇒ 6 items fit.
2466    fn cap() -> RailCapacity {
2467        RailCapacity {
2468            height: 260.0,
2469            stride: 42.0,
2470            slots: 0,
2471            actions: 0,
2472            total: 8,
2473            has_overflow_trigger: false,
2474        }
2475    }
2476
2477    #[test]
2478    fn capacity_shows_everything_when_it_all_fits() {
2479        let c = RailCapacity { total: 4, ..cap() };
2480        assert_eq!(shown_capacity(c), 4, "no overflow ⇒ every item shows");
2481    }
2482
2483    #[test]
2484    fn capacity_clips_without_a_trigger_and_reserves_one_slot_with_one() {
2485        assert_eq!(
2486            shown_capacity(cap()),
2487            6,
2488            "no trigger ⇒ the surplus is clipped"
2489        );
2490        assert_eq!(
2491            shown_capacity(RailCapacity {
2492                has_overflow_trigger: true,
2493                ..cap()
2494            }),
2495            5,
2496            "the trigger itself costs one slot"
2497        );
2498    }
2499
2500    #[test]
2501    fn capacity_charges_actions_and_slots() {
2502        // Each action is reserved space, never overflow-parked, so it costs an
2503        // activity slot — this is the whole point of charging them here.
2504        assert_eq!(
2505            shown_capacity(RailCapacity {
2506                actions: 3,
2507                ..cap()
2508            }),
2509            3,
2510            "three actions cost three activity slots (6 → 3)"
2511        );
2512        assert_eq!(
2513            shown_capacity(RailCapacity { slots: 2, ..cap() }),
2514            4,
2515            "top_slot + bottom_slot cost one stride each"
2516        );
2517        assert_eq!(
2518            shown_capacity(RailCapacity {
2519                slots: 2,
2520                actions: 3,
2521                has_overflow_trigger: true,
2522                ..cap()
2523            }),
2524            0,
2525            "a rail crowded past its height shows no activities, and never \
2526             underflows"
2527        );
2528    }
2529
2530    #[test]
2531    fn capacity_never_underflows_or_divides_by_zero() {
2532        assert_eq!(
2533            shown_capacity(RailCapacity {
2534                height: 0.0,
2535                has_overflow_trigger: true,
2536                ..cap()
2537            }),
2538            0,
2539            "a zero-height rail shows nothing rather than wrapping around"
2540        );
2541        assert_eq!(
2542            shown_capacity(RailCapacity {
2543                stride: 0.0,
2544                ..cap()
2545            }),
2546            8,
2547            "a degenerate stride falls back to showing everything, not a divide by zero"
2548        );
2549    }
2550
2551    /// Fabricate a `WidgetId` without an arena — same convention as the
2552    /// `menu_bar` dispatcher unit tests.
2553    fn wid(n: u64) -> WidgetId {
2554        slotmap::KeyData::from_ffi(n).into()
2555    }
2556
2557    fn ids(items: &[(usize, u64)]) -> RailItemIds {
2558        Rc::new(RefCell::new(
2559            items.iter().map(|(p, w)| (*p, wid(*w))).collect(),
2560        ))
2561    }
2562
2563    #[test]
2564    fn rail_focus_target_wraps_among_shown_items() {
2565        let item_ids = ids(&[(0, 10), (1, 11), (2, 12)]);
2566        let vc = Signal::new(3usize);
2567        assert_eq!(
2568            rail_focus_target(&item_ids, &vc, 0, RailNav::Next),
2569            Some(wid(11))
2570        );
2571        assert_eq!(
2572            rail_focus_target(&item_ids, &vc, 2, RailNav::Next),
2573            Some(wid(10)),
2574            "ArrowDown past the last item wraps to the first"
2575        );
2576        assert_eq!(
2577            rail_focus_target(&item_ids, &vc, 0, RailNav::Prev),
2578            Some(wid(12)),
2579            "ArrowUp before the first item wraps to the last"
2580        );
2581        assert_eq!(
2582            rail_focus_target(&item_ids, &vc, 1, RailNav::First),
2583            Some(wid(10))
2584        );
2585        assert_eq!(
2586            rail_focus_target(&item_ids, &vc, 1, RailNav::Last),
2587            Some(wid(12))
2588        );
2589    }
2590
2591    #[test]
2592    fn rail_focus_target_skips_overflowed_items() {
2593        // visible_count = 2 → only positions 0,1 are navigable; pos 2 overflowed.
2594        let item_ids = ids(&[(0, 10), (1, 11), (2, 12)]);
2595        let vc = Signal::new(2usize);
2596        assert_eq!(shown_rail_count(&item_ids, &vc), 2);
2597        assert_eq!(
2598            rail_focus_target(&item_ids, &vc, 1, RailNav::Next),
2599            Some(wid(10)),
2600            "nav wraps within the two shown items, skipping the overflowed one"
2601        );
2602    }
2603
2604    #[test]
2605    fn overflow_helpers_target_the_parked_rows() {
2606        // 4 items, 2 shown on the rail → positions 2,3 overflow into the popover.
2607        let row_ids = ids(&[(0, 10), (1, 11), (2, 12), (3, 13)]);
2608        let vc = Signal::new(2usize);
2609        assert_eq!(overflow_shown_count(&row_ids, &vc), 2);
2610        assert_eq!(
2611            overflow_focus_target(&row_ids, &vc, 2, RailNav::Next),
2612            Some(wid(13))
2613        );
2614        assert_eq!(
2615            overflow_focus_target(&row_ids, &vc, 3, RailNav::Next),
2616            Some(wid(12)),
2617            "nav wraps within the overflowed set"
2618        );
2619        assert_eq!(
2620            overflow_focus_target(&row_ids, &vc, 2, RailNav::Prev),
2621            Some(wid(13))
2622        );
2623    }
2624}