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}