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