Skip to main content

teksilo_widgets/
tool_box.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! ToolBox — a vertical stack of collapsible sections, exactly one expanded
5//! at a time.
6//!
7//! Semantic cousin of Qt's `QToolBox` and the collapsible groups in
8//! IntelliJ's Settings dialog. Differs from [`Accordion`](crate::Accordion)
9//! (single-item independent disclosure) and [`TabWidget`](crate::TabWidget)
10//! (horizontal tab bar with dormant panes) by combining vertical layout,
11//! always-visible headers, and exclusive expansion in one widget.
12//!
13//! Int UI visual language:
14//! - flat, borderless headers (no corner radius)
15//! - 1 dp accent indicator bar on the leading edge of the active header
16//! - color-only emphasis (selected / hover / pressed surface roles)
17//! - border IS the focus ring: an accent border at the theme's
18//!   `focus_ring_width` (2 dp under Int UI) appears on the focused
19//!   header, no separate ring primitive
20//! - content swaps are **instant** — Int UI's house rule is to avoid
21//!   decorative animation for inline transitions; see
22//!   [`MotionTokens`](teksilo_tokens::MotionTokens). Matches the existing
23//!   [`TabWidget`](crate::TabWidget) precedent where pane swaps have no
24//!   transition.
25//!
26//! ```ignore
27//! let selected = ctx.signal(0_usize);
28//! ToolBox::new(selected.clone())
29//!     .item(lit!("Outline"),    outline_widget)
30//!     .item(lit!("Properties"), properties_widget)
31//!     .add(ToolBoxItem::new(lit!("Build"), build_widget).enabled(false))
32//! ```
33//!
34//! ## Touch and pen
35//!
36//! A section header activates from its tap, so it already actuates on the
37//! release. Two things did change:
38//!
39//! * its **pressed** appearance is the framework's press now, not the keyboard
40//!   path's — before, the recipe painted a state no pointer of either kind ever
41//!   wrote;
42//! * a tap by a contact rests the header **idle** rather than hovered. A finger
43//!   sends no hover-leave to correct a resting `Hovered` with, so a tapped header
44//!   stayed lit with nothing on it — visible once the selection moved elsewhere,
45//!   because a selected header's own chrome hides the tint until then.
46//!
47//! The optional header drag (`on_header_drag`, the dock panel's handle) needs no
48//! declaration for a contact: `DragActivation::Auto` resolves to `Immediate`
49//! where nothing competes for the axis, and to the hold where a scroller does.
50
51use std::cell::{Cell, RefCell};
52use std::rc::Rc;
53
54use teksilo_canvas::{Point, Rect, Size, SizeProposal, Transform2D};
55use teksilo_core::accessibility::{AccessNodeBuilder, widget_id_to_node_id};
56use teksilo_core::binding::BindingLevel;
57use teksilo_core::build_context::BuildContext;
58use teksilo_core::color_prop::{ColorProp, TextStyleProp};
59use teksilo_core::event::{EventResponse, Key, WidgetEvent};
60use teksilo_core::signal::{Prop, Signal};
61use teksilo_core::widget::{
62    CursorIcon, EventContext, LayoutContext, PendingChild, Widget, WidgetPlacement,
63};
64use teksilo_core::widget_builder::HandlerSet;
65use teksilo_core::widget_id::WidgetId;
66use teksilo_i18n::LocalizedString;
67use teksilo_tokens::{BorderRole, InputTokens, SurfaceRole, TargetRole, TextRole, TextStyleRole};
68
69use crate::primitives::{
70    Divider, FixedSize, HStack, IconWidget, MinSize, RectWidget, Spacer, TextWidget, VStack, ZStack,
71};
72use crate::tooltip::{RichTooltipSource, TooltipContent, attach_rich_tooltip_source};
73use teksilo_core::styles::density::{dp, spacing};
74
75/// Orientation of a [`ToolBox`]: how its collapsible sections are arranged.
76///
77/// [`Vertical`](ToolBoxOrientation::Vertical) (the default) stacks sections
78/// top-to-bottom with horizontal headers and an up/down chevron — the
79/// classic `QToolBox`. [`Horizontal`](ToolBoxOrientation::Horizontal) lays
80/// sections left-to-right; each header becomes a narrow **vertical strip**
81/// with its label rotated 90° and a left/right chevron. The horizontal form
82/// is used by side-docks anchored to the top/bottom edges (where the wide,
83/// short region calls for vertical header strips).
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
85pub enum ToolBoxOrientation {
86    /// Sections stacked top-to-bottom; horizontal headers (default).
87    #[default]
88    Vertical,
89    /// Sections arranged left-to-right; vertical header strips with
90    /// rotated labels and left/right chevrons.
91    Horizontal,
92}
93
94// ---------------------------------------------------------------------------
95// Public API
96// ---------------------------------------------------------------------------
97
98/// One section of a [`ToolBox`]. Construct with [`ToolBoxItem::new`] and pass
99/// to [`ToolBox::add`], or use the convenience [`ToolBox::item`] builder
100/// directly (it takes a widget or a `WidgetId`) when leading / trailing slots
101/// and tooltip are not needed.
102///
103/// Layout of the header row:
104///
105/// ```text
106/// [indicator] [leading?] [label] [spacer] [trailing?] [chevron]
107/// ```
108///
109/// Both `leading` and `trailing` accept any `impl Widget` — typical uses
110/// are a small `IconWidget`, a `Checkbox` (checkable section), a
111/// `Badge` (count), or a `Button` (per-row action).
112pub struct ToolBoxItem {
113    label: LocalizedString,
114    leading: Option<Box<dyn Widget>>,
115    trailing: Option<Box<dyn Widget>>,
116    /// Plain-text tooltip body. Mutually exclusive with `rich_tooltip` and
117    /// `composite_tooltip_content` (the last tooltip setter called wins).
118    /// Kept as a `LocalizedString` so a `tr!(...)` source stays locale-reactive.
119    tooltip_text: Option<LocalizedString>,
120    /// Rich (registry-key or inline `TooltipContent`) tooltip.
121    rich_tooltip: Option<RichTooltipSource>,
122    /// Composite (arbitrary widget body) tooltip. Mutually exclusive with the
123    /// plain and rich variants — the last setter called wins.
124    composite_tooltip_content: Option<Box<dyn Widget>>,
125    content: PendingChild,
126    /// Enabled state, static or reactive. Forwarded into the arena via
127    /// `ctx.enabled_when(header_id, self.enabled.clone())` at build time.
128    /// After build the arena is the single source of truth and ANDs
129    /// with ancestors — so a disabled `ToolBox` ancestor disables every
130    /// item header regardless of its own `enabled`.
131    enabled: Prop<bool>,
132}
133
134impl std::fmt::Debug for ToolBoxItem {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        f.debug_struct("ToolBoxItem")
137            .field("label", &self.label)
138            .field("enabled", &self.enabled.get())
139            .finish()
140    }
141}
142
143impl ToolBoxItem {
144    /// Build an item with an inline content widget. The label may come from
145    /// `tr!(...)` (translated) or `lit!(...)`.
146    pub fn new(label: impl Into<LocalizedString>, content: impl Widget + 'static) -> Self {
147        let ls: LocalizedString = label.into();
148        Self {
149            label: ls,
150            leading: None,
151            trailing: None,
152            tooltip_text: None,
153            rich_tooltip: None,
154            composite_tooltip_content: None,
155            content: teksilo_core::IntoTeksiChild::into_pending(content),
156            enabled: Prop::Static(true),
157        }
158    }
159
160    /// Build an item whose content is a pre-registered widget id.
161    pub fn new_id(label: impl Into<LocalizedString>, content_id: WidgetId) -> Self {
162        let ls: LocalizedString = label.into();
163        Self {
164            label: ls,
165            leading: None,
166            trailing: None,
167            tooltip_text: None,
168            rich_tooltip: None,
169            composite_tooltip_content: None,
170            content: PendingChild::Id(content_id),
171            enabled: Prop::Static(true),
172        }
173    }
174
175    /// Attach a leading-slot widget rendered before the label (after
176    /// the selection indicator bar). Use for a small `IconWidget`, a
177    /// `Checkbox` for checkable sections, a `Badge`, or any other
178    /// label-sized widget. The slot widget owns its own events — a
179    /// `Checkbox` inside the leading slot toggles independently of
180    /// the header's own tap.
181    pub fn leading(mut self, widget: impl Widget + 'static) -> Self {
182        self.leading = Some(Box::new(widget));
183        self
184    }
185
186    /// Attach a trailing-slot widget rendered between the row's flexible
187    /// spacer and the chevron. Use for per-row actions — a dismiss
188    /// button, a badge, a secondary `Toggle`. The slot widget owns its
189    /// own events: tapping a `Button` inside the trailing slot fires the
190    /// button's action; gesture recognisers on the trailing widget stop
191    /// the header's own tap from firing, so a close-button click does
192    /// not also select the section.
193    pub fn trailing(mut self, widget: impl Widget + 'static) -> Self {
194        self.trailing = Some(Box::new(widget));
195        self
196    }
197
198    /// Attach a plain-text tooltip shown after a hover delay on the header
199    /// row. The text may come from `tr!(...)` (translated, locale-reactive)
200    /// or `lit!(...)`. Mirrors `.tooltip(...)` on Button / IconButton /
201    /// MenuItem. Clears any previously set rich or composite tooltip (the
202    /// last tooltip setter called wins).
203    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
204        self.tooltip_text = Some(text.into());
205        self.rich_tooltip = None;
206        self.composite_tooltip_content = None;
207        self
208    }
209
210    /// Attach a rich tooltip resolved from the app-wide
211    /// [`TooltipRegistry`](crate::tooltip::TooltipRegistry) by key.
212    /// Clears any previously set plain or composite tooltip (the last
213    /// tooltip setter called wins).
214    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
215        self.rich_tooltip = Some(RichTooltipSource::Key(key.into()));
216        self.tooltip_text = None;
217        self.composite_tooltip_content = None;
218        self
219    }
220
221    /// Attach a rich tooltip driven by inline [`TooltipContent`] — for
222    /// one-offs that don't belong in the registry. Clears any previously
223    /// set plain or composite tooltip (the last tooltip setter called wins).
224    pub fn rich_tooltip_content(mut self, content: TooltipContent) -> Self {
225        self.rich_tooltip = Some(RichTooltipSource::Content(content));
226        self.tooltip_text = None;
227        self.composite_tooltip_content = None;
228        self
229    }
230
231    /// Attach a composite tooltip — an arbitrary `impl Widget` body shown
232    /// in a larger, scrollable overlay after a longer hover delay. Use for
233    /// rich on-demand previews: charts, property tables, image thumbnails.
234    /// Clears any previously set plain or rich tooltip (the last tooltip
235    /// setter called wins).
236    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
237        self.composite_tooltip_content = Some(Box::new(content));
238        self.tooltip_text = None;
239        self.rich_tooltip = None;
240        self
241    }
242
243    /// Disable the item: its header renders in the disabled text role,
244    /// click and keyboard activation are ignored, and arrow navigation
245    /// skips it. Accepts a static bool or a reactive `Signal<bool>`.
246    ///
247    /// Read **once** at build time: a `false` snapshot is forwarded to the
248    /// arena via `ctx.enabled_when(header_id, false)`, and the arena is then
249    /// the single source of truth, ANDing with ancestors — disabling the
250    /// surrounding `ToolBox` (or any ancestor) disables every item regardless
251    /// of this flag. The `Prop` itself is *not* handed to the arena, so
252    /// flipping a bound `Signal<bool>` after build has no effect until the
253    /// `ToolBox` is rebuilt.
254    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
255        self.enabled = enabled.into();
256        self
257    }
258}
259
260/// ToolBox design tokens.
261pub const TOOL_BOX_HEADER_MIN_HEIGHT: f32 = 28.0;
262
263/// [`TOOL_BOX_HEADER_MIN_HEIGHT`] raised to the density's `target_size`
264/// (24 / 32 / 44 dp). The identity at Compact.
265pub fn tool_box_header_min_height(tokens: &InputTokens) -> f32 {
266    dp(TOOL_BOX_HEADER_MIN_HEIGHT, TargetRole::Target, tokens)
267}
268pub const TOOL_BOX_HEADER_PADDING_HORIZONTAL: f32 = 12.0;
269
270/// [`TOOL_BOX_HEADER_PADDING_HORIZONTAL`] scaled by the density's `spacing_factor`
271/// (1.00 / 1.15 / 1.30).
272pub fn tool_box_header_padding_horizontal(tokens: &InputTokens) -> f32 {
273    spacing(TOOL_BOX_HEADER_PADDING_HORIZONTAL, tokens)
274}
275pub const TOOL_BOX_ICON_TEXT_SPACING: f32 = 8.0;
276
277/// [`TOOL_BOX_ICON_TEXT_SPACING`] scaled by the density's `spacing_factor`
278/// (1.00 / 1.15 / 1.30).
279pub fn tool_box_icon_text_spacing(tokens: &InputTokens) -> f32 {
280    spacing(TOOL_BOX_ICON_TEXT_SPACING, tokens)
281}
282pub const TOOL_BOX_CHEVRON_SIZE: f32 = 12.0;
283pub const TOOL_BOX_INDICATOR_THICKNESS: f32 = 1.0;
284
285/// `selected` value meaning "no section open" — used only in
286/// [`ToolBox::collapsible`] mode (every section collapsed). Out of range of any
287/// real index, so [`ToolBoxPanel`] treats every panel as inactive.
288const COLLAPSED_SENTINEL: usize = usize::MAX;
289
290/// A vertical container of collapsible sections with exactly one expanded
291/// at a time — the Int UI / `QToolBox` pattern.
292///
293/// The active section is driven by a caller-owned `Signal<usize>`; mirrors
294/// [`TabWidget::new`](crate::TabWidget::new) so persistence, synchronised
295/// windows, and programmatic activation work identically.
296pub struct ToolBox {
297    selected: Signal<usize>,
298    items: Vec<ToolBoxItem>,
299    show_dividers: bool,
300    orientation: ToolBoxOrientation,
301    /// When set, the active section's panel **fills** the ToolBox's allotted
302    /// space instead of sizing to its content's natural extent. See
303    /// [`ToolBox::fill`].
304    fill: bool,
305    /// When set, clicking the **active** header collapses it (all sections may
306    /// be closed at once). See [`ToolBox::collapsible`].
307    collapsible: bool,
308    /// Optional drag-source hook: when set, each section header becomes a
309    /// drag source. Fired (with the section index) when a drag gesture
310    /// *starts* on a header — the callback typically calls
311    /// `ctx.start_drag(...)`. Tap-to-select still works (the gesture arena
312    /// disambiguates a tap from a drag).
313    header_drag: Option<Rc<dyn Fn(usize, &mut EventContext)>>,
314    root_child_id: Option<WidgetId>,
315}
316
317impl ToolBox {
318    /// Create a ToolBox driven by `selected` (visible section index). Set the
319    /// signal to `0` to open the first section by default; modify it
320    /// programmatically or share it across windows for synchronized state.
321    pub fn new(selected: Signal<usize>) -> Self {
322        Self {
323            selected,
324            items: Vec::new(),
325            show_dividers: false,
326            orientation: ToolBoxOrientation::Vertical,
327            fill: false,
328            collapsible: false,
329            header_drag: None,
330            root_child_id: None,
331        }
332    }
333
334    /// Set the section arrangement orientation (default
335    /// [`ToolBoxOrientation::Vertical`]).
336    pub fn orientation(mut self, orientation: ToolBoxOrientation) -> Self {
337        self.orientation = orientation;
338        self
339    }
340
341    /// Make the active section's panel **fill** the ToolBox's allotted space
342    /// rather than size to its content's natural extent.
343    ///
344    /// With `fill` on, the active panel stretches to the full cross axis and
345    /// flexes / shrinks (and clips) along the main axis, so a ToolBox placed
346    /// in a bounded region lays its content out at *exactly* the available
347    /// size — the `QToolBox` convention. A panel whose content carries a
348    /// trailing `Spacer` therefore pins a bottom toolbar to the visible
349    /// bottom edge instead of overflowing past it.
350    ///
351    /// Default `false` (the panel keeps its content's natural size — the
352    /// historical behaviour, appropriate when the ToolBox itself lives inside
353    /// a scroll area).
354    pub fn fill(mut self, fill: bool) -> Self {
355        self.fill = fill;
356        self
357    }
358
359    /// Allow **collapsing** the active section: clicking (or Enter/Space on, or
360    /// the AT `Collapse` action of) the already-expanded header closes it, so
361    /// *all* sections can be collapsed at once. A subsequent click re-expands.
362    ///
363    /// Default `false` — the classic "exactly one section open" behaviour. This
364    /// is what makes a **single-section** ToolBox a plain collapsible panel
365    /// (header toggles its content), e.g. a dock panel.
366    pub fn collapsible(mut self, collapsible: bool) -> Self {
367        self.collapsible = collapsible;
368        self
369    }
370
371    /// Shorthand for [`ToolBox::orientation`]`(`[`ToolBoxOrientation::Horizontal`]`)`.
372    pub fn horizontal(mut self) -> Self {
373        self.orientation = ToolBoxOrientation::Horizontal;
374        self
375    }
376
377    /// Make each section header a drag source. `f` is invoked (with the
378    /// section index) when a drag gesture *starts* on a header; it should
379    /// begin a drag (e.g. `ctx.start_drag(source, payload)`). Tapping a
380    /// header still selects it — the gesture arena tells a tap from a drag.
381    pub fn on_header_drag(mut self, f: impl Fn(usize, &mut EventContext) + 'static) -> Self {
382        self.header_drag = Some(Rc::new(f));
383        self
384    }
385
386    /// Append an item with an inline content widget. Convenience wrapper
387    /// around [`ToolBox::add`] that skips the [`ToolBoxItem`] builder for
388    /// the common label-plus-content case.
389    pub fn item(
390        self,
391        label: impl Into<LocalizedString>,
392        content: impl teksilo_core::IntoTeksiChild,
393    ) -> Self {
394        match teksilo_core::IntoTeksiChild::into_pending(content) {
395            teksilo_core::PendingChild::Id(id) => self.add(ToolBoxItem::new_id(label, id)),
396            teksilo_core::PendingChild::Deferred(w) => self.add(ToolBoxItem::new(label, w)),
397        }
398    }
399
400    /// Append a fully-built [`ToolBoxItem`] — required when an icon,
401    /// tooltip, or disabled flag is needed.
402    #[allow(clippy::should_implement_trait)]
403    pub fn add(mut self, item: ToolBoxItem) -> Self {
404        self.items.push(item);
405        self
406    }
407
408    /// Append multiple items from an iterator.
409    pub fn items<I>(mut self, items: I) -> Self
410    where
411        I: IntoIterator<Item = ToolBoxItem>,
412    {
413        self.items.extend(items);
414        self
415    }
416
417    /// Show a 1 dp `BorderRole::Divider` line between consecutive header /
418    /// panel rows. Default: `false` — IntelliJ Settings-style collapsibles
419    /// stack without explicit dividers, letting the flat background roles
420    /// delineate the rows.
421    pub fn show_dividers(mut self, show: bool) -> Self {
422        self.show_dividers = show;
423        self
424    }
425}
426
427impl std::fmt::Debug for ToolBox {
428    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
429        f.debug_struct("ToolBox")
430            .field("items", &self.items.len())
431            .finish()
432    }
433}
434
435// ---------------------------------------------------------------------------
436// Keyboard navigation helpers — mirror `next_enabled_index` in tab_widget/header.rs
437// ---------------------------------------------------------------------------
438
439fn next_enabled_index(enabled: &[bool], current: usize, direction: isize) -> usize {
440    if enabled.is_empty() {
441        return current;
442    }
443    let len = enabled.len() as isize;
444    let mut offset = 1_isize;
445    while offset <= len {
446        let candidate = (current as isize + direction * offset).rem_euclid(len) as usize;
447        if enabled[candidate] {
448            return candidate;
449        }
450        offset += 1;
451    }
452    current
453}
454
455fn first_enabled_index(enabled: &[bool]) -> Option<usize> {
456    enabled.iter().position(|&e| e)
457}
458
459fn last_enabled_index(enabled: &[bool]) -> Option<usize> {
460    enabled.iter().rposition(|&e| e)
461}
462
463// ---------------------------------------------------------------------------
464// ToolBoxHeader — one button-like row per item
465// ---------------------------------------------------------------------------
466
467struct ToolBoxHeader {
468    label: LocalizedString,
469    index: usize,
470    /// Structural per-item enabled flag. Forwarded into the arena at
471    /// build time; the arena is then the single source of truth (events,
472    /// focus, a11y `set_disabled`, leaf role-substitution all consult
473    /// `arena.is_enabled(self_id)` / `PaintContext::effective_enabled`).
474    /// Kept on the struct only so `accessibility()` can decide whether
475    /// to advertise the `Click` / `Expand` / `Collapse` actions for the
476    /// structural-disabled case.
477    initial_enabled: bool,
478    selected: Signal<usize>,
479    /// Shared ordered list of header widget ids. Populated by
480    /// [`ToolBox::build`] as each header is registered. Headers read this to
481    /// focus siblings from the arrow-key / Home / End handlers.
482    header_ids: Rc<RefCell<Vec<WidgetId>>>,
483    /// Shared ordered list of panel widget ids, used to publish the
484    /// ARIA `controls` relation in [`ToolBoxHeader::accessibility`].
485    panel_ids: Rc<RefCell<Vec<WidgetId>>>,
486    /// One entry per item — `true` if that header is structurally
487    /// enabled (per its own `initial_enabled`). Used by arrow / Home /
488    /// End navigation to skip structurally-disabled siblings. Ancestor-
489    /// driven disable cascades through the arena and the focus walker,
490    /// so it doesn't need to be re-evaluated here.
491    enabled_flags: Rc<Vec<bool>>,
492    pending_leading: Option<Box<dyn Widget>>,
493    pending_trailing: Option<Box<dyn Widget>>,
494    tooltip_text: Option<LocalizedString>,
495    rich_tooltip: Option<RichTooltipSource>,
496    composite_tooltip_content: Option<Box<dyn Widget>>,
497    orientation: ToolBoxOrientation,
498    /// When set, clicking the active header collapses it (see
499    /// [`ToolBox::collapsible`]).
500    collapsible: bool,
501    on_header_drag: Option<Rc<dyn Fn(usize, &mut EventContext)>>,
502    root_child_id: Option<WidgetId>,
503}
504
505impl std::fmt::Debug for ToolBoxHeader {
506    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
507        f.debug_struct("ToolBoxHeader")
508            .field("index", &self.index)
509            .field("orientation", &self.orientation)
510            .field("draggable", &self.on_header_drag.is_some())
511            .finish()
512    }
513}
514
515impl ToolBoxHeader {
516    #[allow(clippy::too_many_arguments)]
517    fn new(
518        label: LocalizedString,
519        index: usize,
520        initial_enabled: bool,
521        selected: Signal<usize>,
522        header_ids: Rc<RefCell<Vec<WidgetId>>>,
523        panel_ids: Rc<RefCell<Vec<WidgetId>>>,
524        enabled_flags: Rc<Vec<bool>>,
525        pending_leading: Option<Box<dyn Widget>>,
526        pending_trailing: Option<Box<dyn Widget>>,
527        tooltip_text: Option<LocalizedString>,
528        rich_tooltip: Option<RichTooltipSource>,
529        composite_tooltip_content: Option<Box<dyn Widget>>,
530        orientation: ToolBoxOrientation,
531        collapsible: bool,
532        on_header_drag: Option<Rc<dyn Fn(usize, &mut EventContext)>>,
533    ) -> Self {
534        Self {
535            label,
536            index,
537            initial_enabled,
538            selected,
539            header_ids,
540            panel_ids,
541            enabled_flags,
542            pending_leading,
543            pending_trailing,
544            tooltip_text,
545            rich_tooltip,
546            composite_tooltip_content,
547            orientation,
548            collapsible,
549            on_header_drag,
550            root_child_id: None,
551        }
552    }
553}
554
555impl Widget for ToolBoxHeader {
556    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
557        let self_id = ctx.self_id();
558        let theme = ctx.theme();
559        let focus_ring_width = theme.shape.focus_ring_width;
560
561        let idx = self.index;
562        // Forward the structural per-item enabled hint into the arena.
563        // After this point the arena is the single source of truth:
564        // events are gated by `arena.is_enabled(self_id)`, the focus
565        // walker skips disabled subtrees, the a11y walker auto-emits
566        // `set_disabled()`, and the leaf widgets (TextWidget /
567        // IconWidget for label + chevron) substitute `TextRole::Disabled`
568        // at paint time via `PaintContext::effective_enabled`.
569        if !self.initial_enabled {
570            ctx.enabled_when(self_id, false);
571        }
572
573        // Derived read-only signal: am I the active section?
574        let is_selected = self.selected.map(move |s| *s == idx);
575
576        // Interaction state — Hovered / Pressed / Idle. `selected` is
577        // orthogonal: a selected-but-not-hovered header is still `Idle`
578        // here, with `sel = true` driving the selected-surface branch in
579        // the role-resolver below.
580        let interaction = ctx.signal(HeaderInteraction::Idle);
581        // Track focus origin so the focus border only appears when focus
582        // was gained via the keyboard — pointer clicks move focus here but
583        // must not show the ring. Same pattern as SegmentedControl / Slider
584        // / Toggle (`TabHeader` reads `ctx.focus_visible()` instead).
585        let focus_origin: Signal<Option<teksilo_core::focus::FocusOrigin>> = ctx.signal(None);
586
587        let registry = ctx.binding_registry();
588        self.selected
589            .bind_to(self_id, registry, BindingLevel::RepaintOnly);
590        interaction.bind_to(self_id, registry, BindingLevel::RepaintOnly);
591        focus_origin.bind_to(self_id, registry, BindingLevel::RepaintOnly);
592
593        // Derived roles — Signal<SurfaceRole> / Signal<TextRole> (see
594        // CLAUDE.md "Theming"). No `enabled` branch: the leaves
595        // (TextWidget for the label, IconWidget for the chevron)
596        // consult `PaintContext::effective_enabled` and substitute
597        // Derived roles — Signal<SurfaceRole> / Signal<TextRole> (see
598        // CLAUDE.md "Theming"). No `enabled` branch: the leaves
599        // (TextWidget for the label, IconWidget for the chevron)
600        // consult `PaintContext::effective_enabled` and substitute
601        // `TextRole::Disabled` themselves. `SurfaceRole::Disabled` is
602        // deliberately left unused here — a disabled header simply
603        // renders with its idle background (Transparent / Hover /
604        // Selected per interaction).
605        let bg_role = interaction.zip(&is_selected).map(move |(state, sel)| {
606            if *state == HeaderInteraction::Pressed {
607                return SurfaceRole::Pressed;
608            }
609            if *sel {
610                return SurfaceRole::Selected;
611            }
612            if *state == HeaderInteraction::Hovered {
613                return SurfaceRole::Hover;
614            }
615            SurfaceRole::Transparent
616        });
617        let text_role = interaction.zip(&is_selected).map(move |(state, sel)| {
618            if *sel || *state == HeaderInteraction::Hovered {
619                return TextRole::Primary;
620            }
621            TextRole::Secondary
622        });
623
624        // Int UI: the border IS the focus ring. Rest-state border is
625        // width-zero and transparent; on *keyboard* focus it snaps to
626        // `focus_ring_width` with the accent colour. A `Pointer` focus
627        // origin leaves the ring hidden.
628        let focus_border_width = focus_origin.map(move |o| match o {
629            Some(teksilo_core::focus::FocusOrigin::Keyboard) => focus_ring_width,
630            _ => 0.0,
631        });
632        let focus_border_color = focus_origin.map(|o| match o {
633            Some(teksilo_core::focus::FocusOrigin::Keyboard) => BorderRole::Focused,
634            _ => BorderRole::Transparent,
635        });
636
637        // Leading 1 dp indicator: accent fill when selected, transparent
638        // otherwise. Always occupies the same pixel column so labels line
639        // up across selection states. `SurfaceRole::Accent` is the
640        // semantic "accent solid fill" — same colour value as
641        // `BorderRole::Accent` but correctly scoped as a fill.
642        let indicator_bg = is_selected.map(|sel| {
643            if *sel {
644                SurfaceRole::Accent
645            } else {
646                SurfaceRole::Transparent
647            }
648        });
649        let is_horizontal = self.orientation == ToolBoxOrientation::Horizontal;
650
651        // Selection indicator: a 1 dp accent bar on the header's leading
652        // edge — a vertical bar for a vertical toolbox, a top bar for a
653        // horizontal (vertical-strip) header.
654        let indicator_rect_id = ctx.add(RectWidget::new().background(indicator_bg));
655        let indicator_id = if is_horizontal {
656            ctx.add(
657                FixedSize::new()
658                    .height(TOOL_BOX_INDICATOR_THICKNESS)
659                    .child(indicator_rect_id),
660            )
661        } else {
662            ctx.add(
663                FixedSize::new()
664                    .width(TOOL_BOX_INDICATOR_THICKNESS)
665                    .child(indicator_rect_id),
666            )
667        };
668
669        // Optional leading / trailing slot widgets.
670        let leading_id = self.pending_leading.take().map(|w| ctx.add_boxed(w));
671        let trailing_id = self.pending_trailing.take().map(|w| ctx.add_boxed(w));
672        let spacer_id = ctx.add(Spacer::new());
673
674        // Compose the header content along the appropriate axis.
675        let padded_content_id = if is_horizontal {
676            // Vertical strip, top → bottom:
677            //   [indicator] [leading?] [chevron L/R] [rotated label] [trailing?] [spacer]
678            // Chevron points right while collapsed (content expands to the
679            // trailing side) and left once expanded.
680            let chevron_right_id =
681                ctx.add(IconWidget::chevron_right(TOOL_BOX_CHEVRON_SIZE).color(text_role.clone()));
682            let chevron_left_id =
683                ctx.add(IconWidget::chevron_left(TOOL_BOX_CHEVRON_SIZE).color(text_role.clone()));
684            ctx.visible_when(chevron_left_id, is_selected.clone());
685            ctx.visible_when(chevron_right_id, is_selected.map(|v| !*v));
686            let label_id = ctx.add(RotatedLabel::new(self.label.clone(), text_role));
687
688            let mut col = VStack::new().spacing(tool_box_icon_text_spacing(&ctx.theme().input));
689            col = col.child(indicator_id);
690            if let Some(id) = leading_id {
691                col = col.child(id);
692            }
693            col = col
694                .child(chevron_left_id)
695                .child(chevron_right_id)
696                .child(label_id);
697            if let Some(id) = trailing_id {
698                col = col.child(id);
699            }
700            col = col.child(spacer_id);
701            let col_id = ctx.add(col);
702            ctx.add(
703                crate::primitives::Padding::symmetric(
704                    tool_box_header_padding_horizontal(&ctx.theme().input),
705                    0.0,
706                )
707                .child(col_id),
708            )
709        } else {
710            // Horizontal row:
711            //   [indicator] [leading?] [label] [spacer] [trailing?] [chevron]
712            let label_id = ctx.add(
713                TextWidget::new(self.label.clone())
714                    .color(text_role.clone())
715                    .style(TextStyleRole::Body)
716                    .single_line()
717                    .a11y_hidden(),
718            );
719            let chevron_down_id =
720                ctx.add(IconWidget::chevron_down(TOOL_BOX_CHEVRON_SIZE).color(text_role.clone()));
721            let chevron_right_id =
722                ctx.add(IconWidget::chevron_right(TOOL_BOX_CHEVRON_SIZE).color(text_role));
723            ctx.visible_when(chevron_down_id, is_selected.clone());
724            ctx.visible_when(chevron_right_id, is_selected.map(|v| !*v));
725
726            let mut row = HStack::new().spacing(tool_box_icon_text_spacing(&ctx.theme().input));
727            row = row.child(indicator_id);
728            if let Some(id) = leading_id {
729                row = row.child(id);
730            }
731            row = row.child(label_id).child(spacer_id);
732            if let Some(id) = trailing_id {
733                row = row.child(id);
734            }
735            row = row.child(chevron_down_id).child(chevron_right_id);
736            let row_id = ctx.add(row);
737            // The indicator sits inset by the container's padding (IntelliJ
738            // Settings convention).
739            ctx.add(
740                crate::primitives::Padding::symmetric(
741                    0.0,
742                    tool_box_header_padding_horizontal(&ctx.theme().input),
743                )
744                .child(row_id),
745            )
746        };
747
748        // Background fills the whole header.
749        let bg_rect_id = ctx.add(RectWidget::new().background(bg_role));
750
751        // Focus-border rect is inset by half the focus stroke width on
752        // every side so the centred stroke fits *entirely* inside the
753        // ZStack bounds (otherwise the parent clips the outer half and the
754        // ring reads as truncated).
755        let focus_inset = focus_ring_width * 0.5;
756        let focus_rect_id = ctx.add(
757            RectWidget::new()
758                .border_color(focus_border_color)
759                .border_width(focus_border_width),
760        );
761        let focus_padded_id =
762            ctx.add(crate::primitives::Padding::uniform(focus_inset).child(focus_rect_id));
763        let zstack_id = ctx.add(
764            ZStack::new()
765                .child(bg_rect_id)
766                .child(focus_padded_id)
767                .child(padded_content_id),
768        );
769
770        // Enforce the Int UI 28 dp extent on the cross axis: min height
771        // for a horizontal header row, min width for a vertical strip.
772        let header_extent = tool_box_header_min_height(&ctx.theme().input);
773        let root_id = if is_horizontal {
774            ctx.add(MinSize::new(header_extent, 0.0).child(zstack_id))
775        } else {
776            ctx.add(MinSize::new(0.0, header_extent).child(zstack_id))
777        };
778        self.root_child_id = Some(root_id);
779
780        // Attach tooltip if configured. The three variants are mutually
781        // exclusive (last setter on `ToolBoxItem` wins); composite takes
782        // precedence over rich which takes precedence over plain.
783        if let Some(content) = self.composite_tooltip_content.take() {
784            let delay = ctx.theme().motion.tooltip_delay_heavy;
785            crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
786        } else if let Some(source) = self.rich_tooltip.take() {
787            let delay = ctx.theme().motion.tooltip_delay;
788            attach_rich_tooltip_source(ctx, root_id, source, delay);
789        } else if let Some(text) = self.tooltip_text.take() {
790            let delay = ctx.theme().motion.tooltip_delay;
791            crate::tooltip::attach_plain_tooltip(ctx, root_id, text, delay);
792        }
793
794        // --- V2 attached handlers on the header's own node ---
795        let collapsible = self.collapsible;
796        let selected_tap = self.selected.clone();
797        let selected_key = self.selected.clone();
798        let selected_access = self.selected.clone();
799        let header_ids_for_key = self.header_ids.clone();
800        let enabled_flags_for_key = self.enabled_flags.clone();
801        let interaction_for_tap = interaction.clone();
802        let interaction_for_hover = interaction.clone();
803        // The header's pressed chrome, driven by the router's press record. It
804        // was previously written by the keyboard path alone, so the recipe's
805        // pressed appearance was unreachable for a mouse and for a finger
806        // alike; the framework press also survives a press sliding off the
807        // header and back on, and is withdrawn without a release when a
808        // surrounding scroller claims the pan.
809        let hovered_cell = Rc::new(Cell::new(false));
810        {
811            let hovered = hovered_cell.clone();
812            crate::common::interaction::bind_press_state(
813                ctx,
814                interaction.clone(),
815                HeaderInteraction::Pressed,
816                move || {
817                    if hovered.get() {
818                        HeaderInteraction::Hovered
819                    } else {
820                        HeaderInteraction::Idle
821                    }
822                },
823            );
824        }
825        let hovered_for_hover = hovered_cell.clone();
826        let interaction_for_key = interaction.clone();
827        let interaction_for_focus = interaction.clone();
828        let focus_origin_for_focus = focus_origin.clone();
829
830        let mut handler_set = HandlerSet::new()
831            .on_tap(move |_pos, ctx: &mut EventContext| {
832                if collapsible && selected_tap.get() == idx {
833                    selected_tap.set(COLLAPSED_SENTINEL);
834                } else {
835                    selected_tap.set(idx);
836                }
837                // Where the header rests after an activation. A mouse or a pen
838                // is still over it; a finger is gone the instant it lifts and
839                // sends no hover-leave to correct a `Hovered` state with, so it
840                // would otherwise leave the header tinted with nothing on it.
841                interaction_for_tap.set(if ctx.pointer_kind().hovers() {
842                    HeaderInteraction::Hovered
843                } else {
844                    HeaderInteraction::Idle
845                });
846            })
847            .on_hover(move |entered, _ctx| {
848                // Recorded beside the signal as well: while the header is
849                // `Pressed` the hover truth has nowhere to live in the enum,
850                // and the press binding needs it to pick the resting state.
851                hovered_for_hover.set(entered);
852                interaction_for_hover.set(if entered {
853                    HeaderInteraction::Hovered
854                } else {
855                    HeaderInteraction::Idle
856                });
857            })
858            .on_focus(move |gained, _ctx| {
859                if !gained {
860                    focus_origin_for_focus.set(None);
861                    return;
862                }
863                // If the pointer is over the header at the moment focus
864                // arrives, the focus came from a click — record Pointer
865                // so the focus border stays hidden. Otherwise treat it
866                // as keyboard-driven.
867                let origin = if interaction_for_focus.get() == HeaderInteraction::Hovered {
868                    teksilo_core::focus::FocusOrigin::POINTER
869                } else {
870                    teksilo_core::focus::FocusOrigin::Keyboard
871                };
872                focus_origin_for_focus.set(Some(origin));
873            })
874            .on_key(
875                move |event: &WidgetEvent, ctx: &mut EventContext| match event {
876                    WidgetEvent::KeyDown {
877                        key: Key::Space | Key::Enter,
878                        ..
879                    } => {
880                        interaction_for_key.set(HeaderInteraction::Pressed);
881                        EventResponse::Handled
882                    }
883                    WidgetEvent::KeyUp {
884                        key: Key::Space | Key::Enter,
885                        ..
886                    } => {
887                        if collapsible && selected_key.get() == idx {
888                            selected_key.set(COLLAPSED_SENTINEL);
889                        } else {
890                            selected_key.set(idx);
891                        }
892                        interaction_for_key.set(HeaderInteraction::Hovered);
893                        EventResponse::Handled
894                    }
895                    WidgetEvent::KeyDown {
896                        key: Key::ArrowDown,
897                        ..
898                    } => {
899                        let headers = header_ids_for_key.borrow();
900                        if headers.is_empty() {
901                            return EventResponse::Ignored;
902                        }
903                        let next = next_enabled_index(&enabled_flags_for_key, idx, 1);
904                        if next != idx {
905                            ctx.request_focus(headers[next]);
906                        }
907                        EventResponse::Handled
908                    }
909                    WidgetEvent::KeyDown {
910                        key: Key::ArrowUp, ..
911                    } => {
912                        let headers = header_ids_for_key.borrow();
913                        if headers.is_empty() {
914                            return EventResponse::Ignored;
915                        }
916                        let prev = next_enabled_index(&enabled_flags_for_key, idx, -1);
917                        if prev != idx {
918                            ctx.request_focus(headers[prev]);
919                        }
920                        EventResponse::Handled
921                    }
922                    WidgetEvent::KeyDown { key: Key::Home, .. } => {
923                        let headers = header_ids_for_key.borrow();
924                        if let Some(first) = first_enabled_index(&enabled_flags_for_key)
925                            && let Some(&target) = headers.get(first)
926                        {
927                            ctx.request_focus(target);
928                            return EventResponse::Handled;
929                        }
930                        EventResponse::Ignored
931                    }
932                    WidgetEvent::KeyDown { key: Key::End, .. } => {
933                        let headers = header_ids_for_key.borrow();
934                        if let Some(last) = last_enabled_index(&enabled_flags_for_key)
935                            && let Some(&target) = headers.get(last)
936                        {
937                            ctx.request_focus(target);
938                            return EventResponse::Handled;
939                        }
940                        EventResponse::Ignored
941                    }
942                    _ => EventResponse::Ignored,
943                },
944            )
945            .on_access_action(move |action, _ctx| {
946                match action {
947                    teksilo_core::accesskit::Action::Click
948                    | teksilo_core::accesskit::Action::Expand => {
949                        selected_access.set(idx);
950                        EventResponse::Handled
951                    }
952                    teksilo_core::accesskit::Action::Collapse => {
953                        // Collapsible: close the active section. Otherwise
954                        // exclusive disclosure forbids collapsing the only open
955                        // section — swallow.
956                        if collapsible && selected_access.get() == idx {
957                            selected_access.set(COLLAPSED_SENTINEL);
958                        }
959                        EventResponse::Handled
960                    }
961                    _ => EventResponse::Ignored,
962                }
963            })
964            // The focus walker skips disabled subtrees on its own, so
965            // we set `focusable(true)` unconditionally — the static
966            // intent is "this header takes keyboard focus" and the
967            // arena gates whether it actually does.
968            .focusable(true)
969            .cursor(CursorIcon::Pointer);
970
971        // Drag source: when configured, a drag gesture starting on this
972        // header fires the hook with the section index. Tap-to-select is
973        // unaffected — the gesture arena disambiguates tap from drag.
974        if let Some(drag) = self.on_header_drag.clone() {
975            handler_set = handler_set.on_drag(move |phase, ctx| {
976                if let teksilo_core::gesture::DragPhase::Started { .. } = phase {
977                    (drag)(idx, ctx);
978                }
979            });
980        }
981
982        ctx.apply_self_handlers(handler_set);
983
984        vec![root_id]
985    }
986
987    fn layout_response(
988        &self,
989        proposal: SizeProposal,
990        ctx: &LayoutContext,
991    ) -> teksilo_core::widget::LayoutResponse {
992        if let Some(root) = self.root_child_id
993            && let Some(size) = ctx.child_size(root, proposal)
994        {
995            return (size).into();
996        }
997        proposal.resolve(0.0, 0.0).into()
998    }
999
1000    fn place_children(
1001        &self,
1002        bounds: Rect,
1003        _proposal: SizeProposal,
1004        children: &mut [WidgetPlacement],
1005        _ctx: &LayoutContext,
1006    ) {
1007        for child in children.iter_mut() {
1008            child.origin = bounds.origin();
1009            child.size = bounds.size();
1010        }
1011    }
1012
1013    fn children(&self) -> Vec<WidgetId> {
1014        self.root_child_id.into_iter().collect()
1015    }
1016
1017    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1018        use teksilo_core::accesskit::{Action, Role};
1019        builder.set_role(Role::Button);
1020        builder.set_name(self.label.resolve_now());
1021        let is_active = self.selected.get() == self.index;
1022        builder.set_expanded(is_active);
1023        // Framework a11y walker auto-emits `set_disabled()` when
1024        // `arena.is_enabled(self_id) == false`, so we don't call it
1025        // here. The action set still reflects the structural
1026        // per-item enabled flag — a disabled header advertises no
1027        // Click / Expand / Collapse actions to AT.
1028        if self.initial_enabled {
1029            builder.add_action(Action::Click);
1030            builder.add_action(Action::Expand);
1031            builder.add_action(Action::Collapse);
1032        }
1033        builder.add_action(Action::Focus);
1034        // ARIA `controls`: this header controls the matching panel.
1035        if let Some(&panel_id) = self.panel_ids.borrow().get(self.index) {
1036            builder.push_controlled(widget_id_to_node_id(panel_id));
1037        }
1038    }
1039}
1040
1041#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1042enum HeaderInteraction {
1043    Idle,
1044    Hovered,
1045    Pressed,
1046}
1047
1048// ---------------------------------------------------------------------------
1049// ToolBoxPanel — content wrapper that parks its content dormant and
1050// reports a zero size on both axes when inactive.
1051// ---------------------------------------------------------------------------
1052
1053#[derive(Debug)]
1054struct ToolBoxPanel {
1055    label: LocalizedString,
1056    selected: Signal<usize>,
1057    index: usize,
1058    content: Option<PendingChild>,
1059    /// When set, the active panel fills its allotted space (see
1060    /// [`ToolBox::fill`]); the inner content is held directly (no `MaxSize`
1061    /// clamp) and the cross-/main-axis behaviour is computed in
1062    /// [`ToolBoxPanel::layout_response`].
1063    fill: bool,
1064    orientation: ToolBoxOrientation,
1065    root_child_id: Option<WidgetId>,
1066}
1067
1068impl ToolBoxPanel {
1069    fn new(
1070        label: LocalizedString,
1071        selected: Signal<usize>,
1072        index: usize,
1073        content: PendingChild,
1074        fill: bool,
1075        orientation: ToolBoxOrientation,
1076    ) -> Self {
1077        Self {
1078            label,
1079            selected,
1080            index,
1081            content: Some(content),
1082            fill,
1083            orientation,
1084            root_child_id: None,
1085        }
1086    }
1087}
1088
1089impl Widget for ToolBoxPanel {
1090    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1091        let content_id = match self.content.take().expect("ToolBoxPanel built twice") {
1092            PendingChild::Id(id) => id,
1093            PendingChild::Deferred(w) => ctx.add_boxed(w),
1094        };
1095
1096        // An inactive section's content is parked **dormant** (out of
1097        // layout / paint / focus / AT) via `visible_when`, instead of being
1098        // laid out and clamped to zero. A clamped-to-zero panel still lays its
1099        // content out (overflowing the 0-px slot), which the inspector flags
1100        // and which costs real layout work; dormancy avoids both. ToolBox
1101        // section swaps are instant (no animation), so there's nothing to
1102        // tween — dormancy is the right tool.
1103        let idx = self.index;
1104        let is_selected = self.selected.map(move |s| *s == idx);
1105        ctx.visible_when(content_id, is_selected);
1106        self.root_child_id = Some(content_id);
1107        // Re-measure the panel when the active section changes.
1108        self.selected.bind_to(
1109            ctx.self_id(),
1110            ctx.binding_registry(),
1111            BindingLevel::Relayout,
1112        );
1113        vec![content_id]
1114    }
1115
1116    fn layout_response(
1117        &self,
1118        proposal: SizeProposal,
1119        ctx: &LayoutContext,
1120    ) -> teksilo_core::widget::LayoutResponse {
1121        let Some(root) = self.root_child_id else {
1122            return proposal.resolve(0.0, 0.0).into();
1123        };
1124
1125        // Inactive → zero (the content is dormant, contributing nothing).
1126        if self.selected.get() != self.index {
1127            return Size::ZERO.into();
1128        }
1129
1130        let content = ctx.child_size(root, proposal).unwrap_or(Size::ZERO);
1131        if self.fill {
1132            // Active: fill the cross axis and report flex + shrink on the main
1133            // axis so the parent stack grows / shrinks us into the leftover
1134            // space. `min = 0` lets us shrink fully under over-constraint; the
1135            // content is clipped (`clips_children`) if it can't fit.
1136            let size = match self.orientation {
1137                ToolBoxOrientation::Vertical => {
1138                    Size::new(proposal.width.unwrap_or(content.width), content.height)
1139                }
1140                ToolBoxOrientation::Horizontal => {
1141                    Size::new(content.width, proposal.height.unwrap_or(content.height))
1142                }
1143            };
1144            return teksilo_core::widget::LayoutResponse::shrinkable(size, Size::ZERO, 1.0)
1145                .with_flex(1.0);
1146        }
1147        content.into()
1148    }
1149
1150    fn place_children(
1151        &self,
1152        bounds: Rect,
1153        _proposal: SizeProposal,
1154        children: &mut [WidgetPlacement],
1155        _ctx: &LayoutContext,
1156    ) {
1157        for child in children.iter_mut() {
1158            child.origin = bounds.origin();
1159            child.size = bounds.size();
1160        }
1161    }
1162
1163    fn clips_children(&self) -> bool {
1164        // Fill mode clips so an over-tall active panel (or a collapsed
1165        // zero-size one) never bleeds past its slot. Natural mode keeps the
1166        // historical non-clipping behaviour.
1167        self.fill
1168    }
1169
1170    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1171        use teksilo_core::accesskit::Role;
1172        // `Region` is the ARIA role for a labelled landmark section. Used
1173        // by screen readers to announce the panel as "Region: <label>".
1174        builder.set_role(Role::Region);
1175        builder.set_name(self.label.resolve_now());
1176        // Collapsed panels are hidden from AT. Their content is parked dormant
1177        // (`visible_when`), so it's already out of the AT tree; this also hides
1178        // the panel landmark node itself so a collapsed section isn't announced.
1179        if self.selected.get() != self.index {
1180            builder.set_hidden();
1181        }
1182    }
1183
1184    fn children(&self) -> Vec<WidgetId> {
1185        self.root_child_id.into_iter().collect()
1186    }
1187}
1188
1189// ---------------------------------------------------------------------------
1190// ToolBox Widget impl
1191// ---------------------------------------------------------------------------
1192// RotatedLabel — a single-line label painted rotated 90° for horizontal
1193// (vertical-strip) ToolBox headers. The footprint is the label's natural
1194// size with width/height swapped; the label glyphs are rotated via a
1195// transform scope while the header rect itself stays axis-aligned, so
1196// hit-testing / layout / drag all work in normal coordinates.
1197// ---------------------------------------------------------------------------
1198
1199/// `T(pivot) · R(theta) · T(-pivot)` — rotation about a world-space pivot.
1200fn pivoted_rotation(pivot: Point, theta: f32) -> Transform2D {
1201    let (s, c) = theta.sin_cos();
1202    Transform2D {
1203        m: [
1204            c,
1205            s,
1206            -s,
1207            c,
1208            pivot.x * (1.0 - c) + pivot.y * s,
1209            pivot.y * (1.0 - c) - pivot.x * s,
1210        ],
1211    }
1212}
1213
1214#[derive(Debug)]
1215pub(crate) struct RotatedLabel {
1216    label: LocalizedString,
1217    color: ColorProp,
1218    style: TextStyleProp,
1219    child_id: Option<WidgetId>,
1220    natural: Cell<Size>,
1221    transform_signal: Option<Signal<Transform2D>>,
1222}
1223
1224impl RotatedLabel {
1225    pub(crate) fn new(label: LocalizedString, color: impl Into<ColorProp>) -> Self {
1226        Self {
1227            label,
1228            color: color.into(),
1229            style: TextStyleRole::Body.into(),
1230            child_id: None,
1231            natural: Cell::new(Size::ZERO),
1232            transform_signal: None,
1233        }
1234    }
1235
1236    /// Override the rotated label's text style (defaults to
1237    /// [`TextStyleRole::Body`]).
1238    pub(crate) fn style(mut self, style: impl Into<TextStyleProp>) -> Self {
1239        self.style = style.into();
1240        self
1241    }
1242}
1243
1244impl Widget for RotatedLabel {
1245    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1246        let child = ctx.add(
1247            TextWidget::new(self.label.clone())
1248                .color(self.color.clone())
1249                .style(self.style.clone())
1250                .single_line()
1251                .a11y_hidden(),
1252        );
1253        self.child_id = Some(child);
1254        let t = ctx.signal(Transform2D::IDENTITY);
1255        ctx.set_transform(ctx.self_id(), t.clone());
1256        self.transform_signal = Some(t);
1257        vec![child]
1258    }
1259
1260    fn layout_response(
1261        &self,
1262        _proposal: SizeProposal,
1263        ctx: &LayoutContext,
1264    ) -> teksilo_core::widget::LayoutResponse {
1265        // Measure the label at its single-line intrinsic size, then swap
1266        // width/height for the rotated footprint.
1267        let natural = self
1268            .child_id
1269            .and_then(|id| {
1270                ctx.child_size(
1271                    id,
1272                    SizeProposal {
1273                        width: None,
1274                        height: None,
1275                    },
1276                )
1277            })
1278            .unwrap_or(Size::ZERO);
1279        self.natural.set(natural);
1280        Size::new(natural.height, natural.width).into()
1281    }
1282
1283    fn place_children(
1284        &self,
1285        bounds: Rect,
1286        _proposal: SizeProposal,
1287        children: &mut [WidgetPlacement],
1288        _ctx: &LayoutContext,
1289    ) {
1290        let natural = self.natural.get();
1291        // Centre the (un-rotated) child on the slot centre; a 90° rotation
1292        // about that centre maps its W×H onto the slot's H×W exactly.
1293        let cx = bounds.x + bounds.width * 0.5;
1294        let cy = bounds.y + bounds.height * 0.5;
1295        let origin = Point::new(cx - natural.width * 0.5, cy - natural.height * 0.5);
1296        for child in children.iter_mut() {
1297            child.origin = origin;
1298            child.size = natural;
1299        }
1300        if let Some(t) = &self.transform_signal {
1301            // -90°: text reads bottom-to-top (the desktop convention for a
1302            // leading-edge vertical tab/strip).
1303            t.set(pivoted_rotation(
1304                Point::new(cx, cy),
1305                -std::f32::consts::FRAC_PI_2,
1306            ));
1307        }
1308    }
1309
1310    fn clips_children(&self) -> bool {
1311        false
1312    }
1313
1314    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
1315        // The header node carries the accessible name; the rotated label
1316        // is decorative chrome.
1317    }
1318
1319    fn children(&self) -> Vec<WidgetId> {
1320        self.child_id.into_iter().collect()
1321    }
1322}
1323
1324// ---------------------------------------------------------------------------
1325
1326impl Widget for ToolBox {
1327    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1328        let items = std::mem::take(&mut self.items);
1329        let enabled_flags: Rc<Vec<bool>> = Rc::new(items.iter().map(|i| i.enabled.get()).collect());
1330        let header_ids: Rc<RefCell<Vec<WidgetId>>> =
1331            Rc::new(RefCell::new(Vec::with_capacity(items.len())));
1332        let panel_ids: Rc<RefCell<Vec<WidgetId>>> =
1333            Rc::new(RefCell::new(Vec::with_capacity(items.len())));
1334
1335        let orientation = self.orientation;
1336        let show_dividers = self.show_dividers;
1337        let item_count = items.len();
1338
1339        // Collect the ordered child ids, then wrap in a VStack (vertical)
1340        // or HStack (horizontal). Each section contributes its header then
1341        // its panel; the panel collapses to zero on the main axis when
1342        // inactive (it already clamps *both* axes), so a collapsed
1343        // horizontal section shrinks to just its header strip.
1344        let mut child_ids: Vec<WidgetId> = Vec::with_capacity(item_count * 3);
1345
1346        for (index, item) in items.into_iter().enumerate() {
1347            let label = item.label.clone();
1348            let header_id = ctx.add(ToolBoxHeader::new(
1349                item.label.clone(),
1350                index,
1351                item.enabled.get(),
1352                self.selected.clone(),
1353                header_ids.clone(),
1354                panel_ids.clone(),
1355                enabled_flags.clone(),
1356                item.leading,
1357                item.trailing,
1358                item.tooltip_text,
1359                item.rich_tooltip,
1360                item.composite_tooltip_content,
1361                orientation,
1362                self.collapsible,
1363                self.header_drag.clone(),
1364            ));
1365            header_ids.borrow_mut().push(header_id);
1366
1367            let panel_id = ctx.add(ToolBoxPanel::new(
1368                label,
1369                self.selected.clone(),
1370                index,
1371                item.content,
1372                self.fill,
1373                orientation,
1374            ));
1375            panel_ids.borrow_mut().push(panel_id);
1376
1377            child_ids.push(header_id);
1378            child_ids.push(panel_id);
1379
1380            if show_dividers && index + 1 < item_count {
1381                // The divider runs across the section boundary: a
1382                // horizontal toolbox needs a vertical divider and vice
1383                // versa.
1384                let divider = match orientation {
1385                    ToolBoxOrientation::Vertical => Divider::new(),
1386                    ToolBoxOrientation::Horizontal => Divider::vertical(),
1387                };
1388                child_ids.push(ctx.add(divider.color(BorderRole::Divider)));
1389            }
1390        }
1391
1392        let root = match orientation {
1393            ToolBoxOrientation::Vertical => {
1394                let mut stack = VStack::new().spacing(0.0);
1395                for id in child_ids {
1396                    stack = stack.child(id);
1397                }
1398                ctx.add(stack)
1399            }
1400            ToolBoxOrientation::Horizontal => {
1401                let mut stack = HStack::new().spacing(0.0);
1402                for id in child_ids {
1403                    stack = stack.child(id);
1404                }
1405                ctx.add(stack)
1406            }
1407        };
1408        self.root_child_id = Some(root);
1409        vec![root]
1410    }
1411
1412    fn layout_response(
1413        &self,
1414        proposal: SizeProposal,
1415        ctx: &LayoutContext,
1416    ) -> teksilo_core::widget::LayoutResponse {
1417        if let Some(root) = self.root_child_id
1418            && let Some(size) = ctx.child_size(root, proposal)
1419        {
1420            return (size).into();
1421        }
1422        proposal.resolve(0.0, 0.0).into()
1423    }
1424
1425    fn place_children(
1426        &self,
1427        bounds: Rect,
1428        _proposal: SizeProposal,
1429        children: &mut [WidgetPlacement],
1430        _ctx: &LayoutContext,
1431    ) {
1432        for child in children.iter_mut() {
1433            child.origin = bounds.origin();
1434            child.size = bounds.size();
1435        }
1436    }
1437
1438    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1439        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
1440    }
1441
1442    fn children(&self) -> Vec<WidgetId> {
1443        self.root_child_id.into_iter().collect()
1444    }
1445}
1446
1447// ---------------------------------------------------------------------------
1448// Tests
1449// ---------------------------------------------------------------------------
1450
1451#[cfg(test)]
1452mod tests {
1453    use super::*;
1454    use crate::primitives::TextWidget;
1455    use teksilo_canvas::SizeProposal;
1456    use teksilo_core::accesskit;
1457    use teksilo_core::event::Modifiers;
1458    use teksilo_core::widget_tree::WidgetTree;
1459    use teksilo_i18n::lit;
1460
1461    fn tree() -> WidgetTree {
1462        WidgetTree::new().with_theme(teksilo_core::presets::intui::light())
1463    }
1464
1465    /// Walks the ToolBox widget tree to return the `ToolBoxHeader` id for
1466    /// the given item index. The structure is:
1467    ///     ToolBox → VStack → [header_0, panel_0, header_1, panel_1, …]
1468    fn header_id(tree: &WidgetTree, toolbox: WidgetId, index: usize) -> WidgetId {
1469        let vstack = tree.child_widget(toolbox, 0);
1470        // Two children per item (header + panel). Dividers would add more,
1471        // but tests don't enable them.
1472        tree.child_widget(vstack, index * 2)
1473    }
1474
1475    fn panel_id(tree: &WidgetTree, toolbox: WidgetId, index: usize) -> WidgetId {
1476        let vstack = tree.child_widget(toolbox, 0);
1477        tree.child_widget(vstack, index * 2 + 1)
1478    }
1479
1480    #[test]
1481    fn tool_box_builds_with_first_selected() {
1482        let selected = Signal::new(0_usize);
1483        let mut t = tree();
1484        let tb = t.add(
1485            ToolBox::new(selected.clone())
1486                .item(lit!("Outline"), TextWidget::new(lit!("Outline content")))
1487                .item(lit!("Props"), TextWidget::new(lit!("Props content")))
1488                .item(lit!("Refs"), TextWidget::new(lit!("Refs content"))),
1489        );
1490        t.layout(SizeProposal::exact(300.0, 600.0));
1491
1492        let b = t.bounds(tb);
1493        assert!(b.width > 0.0, "ToolBox width = {}", b.width);
1494        assert!(b.height > 0.0, "ToolBox height = {}", b.height);
1495    }
1496
1497    #[test]
1498    fn clicking_header_changes_selection() {
1499        let selected = Signal::new(0_usize);
1500        let mut t = tree();
1501        let tb = t.add(
1502            ToolBox::new(selected.clone())
1503                .item(lit!("A"), TextWidget::new(lit!("A")))
1504                .item(lit!("B"), TextWidget::new(lit!("B")))
1505                .item(lit!("C"), TextWidget::new(lit!("C"))),
1506        );
1507        t.layout(SizeProposal::exact(300.0, 600.0));
1508
1509        t.click(header_id(&t, tb, 2));
1510        assert_eq!(selected.get(), 2);
1511
1512        t.click(header_id(&t, tb, 0));
1513        assert_eq!(selected.get(), 0);
1514    }
1515
1516    #[test]
1517    fn panel_heights_swap_on_selection_change() {
1518        let selected = Signal::new(0_usize);
1519        let mut t = tree();
1520        let tb = t.add(
1521            ToolBox::new(selected.clone())
1522                .item(lit!("A"), TextWidget::new(lit!("AAAAAAAA")))
1523                .item(lit!("B"), TextWidget::new(lit!("BBBBBBBB"))),
1524        );
1525        t.layout(SizeProposal::exact(300.0, 600.0));
1526
1527        let panel_a_before = t.bounds(panel_id(&t, tb, 0)).height;
1528        let panel_b_before = t.bounds(panel_id(&t, tb, 1)).height;
1529        assert!(
1530            panel_a_before > 0.0,
1531            "active panel should have nonzero height"
1532        );
1533        assert!(panel_b_before < 0.5, "inactive panel should be collapsed");
1534
1535        selected.set(1);
1536        t.layout(SizeProposal::exact(300.0, 600.0));
1537
1538        let panel_a_after = t.bounds(panel_id(&t, tb, 0)).height;
1539        let panel_b_after = t.bounds(panel_id(&t, tb, 1)).height;
1540        assert!(panel_a_after < 0.5, "formerly active panel collapsed");
1541        assert!(panel_b_after > 0.0, "newly active panel expanded");
1542    }
1543
1544    #[test]
1545    fn programmatic_selection_drives_swap_like_click() {
1546        let selected = Signal::new(0_usize);
1547        let mut t = tree();
1548        let tb = t.add(
1549            ToolBox::new(selected.clone())
1550                .item(lit!("A"), TextWidget::new(lit!("AAA")))
1551                .item(lit!("B"), TextWidget::new(lit!("BBB"))),
1552        );
1553        t.layout(SizeProposal::exact(300.0, 600.0));
1554
1555        // Programmatic: no event dispatch — purely via the signal.
1556        selected.set(1);
1557        t.layout(SizeProposal::exact(300.0, 600.0));
1558
1559        let panel_a = t.bounds(panel_id(&t, tb, 0)).height;
1560        let panel_b = t.bounds(panel_id(&t, tb, 1)).height;
1561        assert!(panel_a < 0.5);
1562        assert!(panel_b > 0.0);
1563    }
1564
1565    #[test]
1566    fn disabled_item_ignores_click() {
1567        let selected = Signal::new(0_usize);
1568        let mut t = tree();
1569        let tb = t.add(
1570            ToolBox::new(selected.clone())
1571                .item(lit!("A"), TextWidget::new(lit!("A")))
1572                .add(ToolBoxItem::new(lit!("B"), TextWidget::new(lit!("B"))).enabled(false))
1573                .item(lit!("C"), TextWidget::new(lit!("C"))),
1574        );
1575        t.layout(SizeProposal::exact(300.0, 600.0));
1576
1577        let disabled = header_id(&t, tb, 1);
1578        t.click(disabled);
1579        assert_eq!(selected.get(), 0, "disabled header should not activate");
1580    }
1581
1582    #[test]
1583    fn arrow_down_skips_disabled_header() {
1584        let selected = Signal::new(0_usize);
1585        let mut t = tree();
1586        let tb = t.add(
1587            ToolBox::new(selected.clone())
1588                .item(lit!("A"), TextWidget::new(lit!("A")))
1589                .add(ToolBoxItem::new(lit!("B"), TextWidget::new(lit!("B"))).enabled(false))
1590                .item(lit!("C"), TextWidget::new(lit!("C"))),
1591        );
1592        t.layout(SizeProposal::exact(300.0, 600.0));
1593
1594        // Tab into the toolbox — lands on the first enabled header.
1595        t.press_key(Key::Tab, Modifiers::NONE);
1596        assert_eq!(t.focused(), Some(header_id(&t, tb, 0)));
1597
1598        t.press_key(Key::ArrowDown, Modifiers::NONE);
1599        assert_eq!(t.focused(), Some(header_id(&t, tb, 2)));
1600    }
1601
1602    #[test]
1603    fn home_and_end_jump_to_first_and_last_enabled() {
1604        let selected = Signal::new(1_usize);
1605        let mut t = tree();
1606        let tb = t.add(
1607            ToolBox::new(selected.clone())
1608                .add(ToolBoxItem::new(lit!("Locked"), TextWidget::new(lit!("x"))).enabled(false))
1609                .item(lit!("Middle"), TextWidget::new(lit!("m")))
1610                .item(lit!("Last"), TextWidget::new(lit!("l"))),
1611        );
1612        t.layout(SizeProposal::exact(300.0, 600.0));
1613
1614        // Focus the second (index=1) header so we can test Home/End
1615        // from a middle position.
1616        t.press_key(Key::Tab, Modifiers::NONE);
1617        assert_eq!(t.focused(), Some(header_id(&t, tb, 1)));
1618
1619        t.press_key(Key::End, Modifiers::NONE);
1620        assert_eq!(t.focused(), Some(header_id(&t, tb, 2)));
1621
1622        t.press_key(Key::Home, Modifiers::NONE);
1623        // Home jumps to first *enabled* header — index 0 is disabled, so
1624        // index 1 is first.
1625        assert_eq!(t.focused(), Some(header_id(&t, tb, 1)));
1626    }
1627
1628    #[test]
1629    fn accessibility_marks_selected_expanded_and_controls_panel() {
1630        let selected = Signal::new(0_usize);
1631        let mut t = tree();
1632        let tb = t.add(
1633            ToolBox::new(selected.clone())
1634                .item(lit!("A"), TextWidget::new(lit!("A")))
1635                .item(lit!("B"), TextWidget::new(lit!("B"))),
1636        );
1637        t.layout(SizeProposal::exact(300.0, 600.0));
1638
1639        let h0 = t.accessibility_node(header_id(&t, tb, 0));
1640        let h1 = t.accessibility_node(header_id(&t, tb, 1));
1641        assert!(h0.is_expanded());
1642        assert!(!h1.is_expanded());
1643        assert_eq!(h0.role(), accesskit::Role::Button);
1644
1645        // Swap selection; header 1 should now be expanded.
1646        selected.set(1);
1647        t.layout(SizeProposal::exact(300.0, 600.0));
1648        let h0b = t.accessibility_node(header_id(&t, tb, 0));
1649        let h1b = t.accessibility_node(header_id(&t, tb, 1));
1650        assert!(!h0b.is_expanded());
1651        assert!(h1b.is_expanded());
1652
1653        // Panel role is Region with the item label as name.
1654        let p0 = t.accessibility_node(panel_id(&t, tb, 0));
1655        assert_eq!(p0.role(), accesskit::Role::Region);
1656        assert_eq!(p0.name(), Some("A"));
1657    }
1658
1659    #[test]
1660    fn access_action_expand_selects_item() {
1661        let selected = Signal::new(0_usize);
1662        let mut t = tree();
1663        let tb = t.add(
1664            ToolBox::new(selected.clone())
1665                .item(lit!("A"), TextWidget::new(lit!("A")))
1666                .item(lit!("B"), TextWidget::new(lit!("B")))
1667                .item(lit!("C"), TextWidget::new(lit!("C"))),
1668        );
1669        t.layout(SizeProposal::exact(300.0, 600.0));
1670
1671        let third = header_id(&t, tb, 2);
1672        t.dispatch_event(WidgetEvent::AccessAction {
1673            action: accesskit::Action::Expand,
1674            target: Some(third),
1675            target_node: teksilo_core::accessibility::root_node_id(),
1676            data: None,
1677        });
1678        assert_eq!(selected.get(), 2);
1679    }
1680
1681    #[test]
1682    fn access_action_collapse_is_swallowed() {
1683        let selected = Signal::new(1_usize);
1684        let mut t = tree();
1685        let tb = t.add(
1686            ToolBox::new(selected.clone())
1687                .item(lit!("A"), TextWidget::new(lit!("A")))
1688                .item(lit!("B"), TextWidget::new(lit!("B"))),
1689        );
1690        t.layout(SizeProposal::exact(300.0, 600.0));
1691
1692        // Collapse on the active header should not change `selected`.
1693        let active = header_id(&t, tb, 1);
1694        t.dispatch_event(WidgetEvent::AccessAction {
1695            action: accesskit::Action::Collapse,
1696            target: Some(active),
1697            target_node: teksilo_core::accessibility::root_node_id(),
1698            data: None,
1699        });
1700        assert_eq!(selected.get(), 1);
1701    }
1702
1703    #[test]
1704    fn leading_slot_widget_is_placed_inside_the_header() {
1705        use crate::Button;
1706
1707        let selected = Signal::new(0_usize);
1708        let mut t = tree();
1709        let tb = t.add(
1710            ToolBox::new(selected.clone()).add(
1711                ToolBoxItem::new(lit!("A"), TextWidget::new(lit!("A")))
1712                    .leading(Button::new(lit!("start"))),
1713            ),
1714        );
1715        t.layout(SizeProposal::exact(300.0, 200.0));
1716
1717        let header = header_id(&t, tb, 0);
1718        let header_bounds = t.bounds(header);
1719
1720        fn find_button_inside(t: &WidgetTree, root: WidgetId, outer: WidgetId) -> Option<WidgetId> {
1721            for child in t.children(root) {
1722                if child != outer {
1723                    let info = t.accessibility_node(child);
1724                    if info.role() == teksilo_core::accesskit::Role::Button {
1725                        return Some(child);
1726                    }
1727                }
1728                if let Some(found) = find_button_inside(t, child, outer) {
1729                    return Some(found);
1730                }
1731            }
1732            None
1733        }
1734
1735        let leading_btn = find_button_inside(&t, header, header)
1736            .expect("leading Button should be a descendant of the header");
1737        let btn_bounds = t.bounds(leading_btn);
1738        assert!(
1739            btn_bounds.x >= header_bounds.x && btn_bounds.right() <= header_bounds.right() + 0.01,
1740            "leading button bounds must fit inside header row"
1741        );
1742    }
1743
1744    #[test]
1745    fn trailing_slot_widget_is_placed_inside_the_header() {
1746        use crate::Button;
1747
1748        let selected = Signal::new(0_usize);
1749        let mut t = tree();
1750        let tb = t.add(
1751            ToolBox::new(selected.clone()).add(
1752                ToolBoxItem::new(lit!("A"), TextWidget::new(lit!("A")))
1753                    .trailing(Button::new(lit!("x"))),
1754            ),
1755        );
1756        t.layout(SizeProposal::exact(300.0, 200.0));
1757
1758        let header = header_id(&t, tb, 0);
1759        let header_bounds = t.bounds(header);
1760
1761        // Walk descendants looking for the inner Button (the outer
1762        // header itself also has Role::Button — we want the trailing
1763        // one, characterised by sitting on the trailing edge of the
1764        // header row).
1765        fn find_button_inside(t: &WidgetTree, root: WidgetId, outer: WidgetId) -> Option<WidgetId> {
1766            for child in t.children(root) {
1767                if child != outer {
1768                    let info = t.accessibility_node(child);
1769                    if info.role() == teksilo_core::accesskit::Role::Button {
1770                        return Some(child);
1771                    }
1772                }
1773                if let Some(found) = find_button_inside(t, child, outer) {
1774                    return Some(found);
1775                }
1776            }
1777            None
1778        }
1779
1780        let trailing_btn = find_button_inside(&t, header, header)
1781            .expect("trailing Button should be a descendant of the header");
1782        let btn_bounds = t.bounds(trailing_btn);
1783        assert!(
1784            btn_bounds.x >= header_bounds.x && btn_bounds.right() <= header_bounds.right() + 0.01,
1785            "trailing button bounds must fit inside header row"
1786        );
1787    }
1788
1789    #[test]
1790    fn disabled_header_has_no_click_action() {
1791        let selected = Signal::new(0_usize);
1792        let mut t = tree();
1793        let tb = t.add(
1794            ToolBox::new(selected.clone())
1795                .item(lit!("A"), TextWidget::new(lit!("A")))
1796                .add(ToolBoxItem::new(lit!("B"), TextWidget::new(lit!("B"))).enabled(false)),
1797        );
1798        t.layout(SizeProposal::exact(300.0, 600.0));
1799
1800        let disabled = header_id(&t, tb, 1);
1801        let info = t.accessibility_node(disabled);
1802        assert!(!info.actions().contains(&accesskit::Action::Click));
1803        assert!(!info.actions().contains(&accesskit::Action::Expand));
1804    }
1805
1806    // ─── orientation + drag ────────────────────────────────────────────
1807
1808    #[test]
1809    fn vertical_orientation_stacks_top_to_bottom() {
1810        let selected = Signal::new(0_usize);
1811        let mut t = tree();
1812        let tb = t.add(
1813            ToolBox::new(selected.clone())
1814                .item(lit!("A"), TextWidget::new(lit!("a")))
1815                .item(lit!("B"), TextWidget::new(lit!("b"))),
1816        );
1817        t.layout(SizeProposal::exact(300.0, 600.0));
1818        let h0 = t.bounds(header_id(&t, tb, 0));
1819        let h1 = t.bounds(header_id(&t, tb, 1));
1820        assert!(h1.y > h0.y, "vertical headers stack top→bottom");
1821        // Header is a wide, short row.
1822        assert!(h0.width > h0.height, "vertical header is a horizontal row");
1823    }
1824
1825    #[test]
1826    fn horizontal_orientation_lays_sections_left_to_right() {
1827        let selected = Signal::new(0_usize);
1828        let mut t = tree();
1829        let tb = t.add(
1830            ToolBox::new(selected.clone())
1831                .horizontal()
1832                .item(lit!("Terminal"), TextWidget::new(lit!("term")))
1833                .item(lit!("Problems"), TextWidget::new(lit!("prob")))
1834                .item(lit!("Output"), TextWidget::new(lit!("out"))),
1835        );
1836        t.layout(SizeProposal::exact(900.0, 220.0));
1837
1838        let h0 = t.bounds(header_id(&t, tb, 0));
1839        let h1 = t.bounds(header_id(&t, tb, 1));
1840        let h2 = t.bounds(header_id(&t, tb, 2));
1841        assert!(
1842            h0.x < h1.x && h1.x < h2.x,
1843            "horizontal headers run left→right: {} {} {}",
1844            h0.x,
1845            h1.x,
1846            h2.x
1847        );
1848        // Each header is a tall, narrow vertical strip.
1849        assert!(
1850            h0.height > h0.width,
1851            "horizontal header is a vertical strip ({}×{})",
1852            h0.width,
1853            h0.height
1854        );
1855        assert!(
1856            h0.width <= TOOL_BOX_HEADER_MIN_HEIGHT + 24.0,
1857            "strip stays narrow (got width {})",
1858            h0.width
1859        );
1860    }
1861
1862    #[test]
1863    fn horizontal_collapsed_panel_has_zero_main_extent() {
1864        let selected = Signal::new(0_usize);
1865        let mut t = tree();
1866        let tb = t.add(
1867            ToolBox::new(selected.clone())
1868                .horizontal()
1869                .item(lit!("A"), TextWidget::new(lit!("aaaa")))
1870                .item(lit!("B"), TextWidget::new(lit!("bbbb"))),
1871        );
1872        t.layout(SizeProposal::exact(900.0, 220.0));
1873        // Section 0 is selected → its panel has width; section 1's panel
1874        // collapses to zero width.
1875        assert!(t.bounds(panel_id(&t, tb, 0)).width > 0.0);
1876        assert!(t.bounds(panel_id(&t, tb, 1)).width.abs() < 0.5);
1877    }
1878
1879    #[test]
1880    fn header_drag_hook_fires_with_section_index() {
1881        use std::cell::Cell as StdCell;
1882        let dragged: Rc<StdCell<Option<usize>>> = Rc::new(StdCell::new(None));
1883        let selected = Signal::new(0_usize);
1884        let sink = dragged.clone();
1885        let mut t = tree();
1886        let tb = t.add(
1887            ToolBox::new(selected.clone())
1888                .on_header_drag(move |idx, _ctx| sink.set(Some(idx)))
1889                .item(lit!("A"), TextWidget::new(lit!("a")))
1890                .item(lit!("B"), TextWidget::new(lit!("b"))),
1891        );
1892        t.layout(SizeProposal::exact(300.0, 600.0));
1893
1894        let h1 = t.bounds(header_id(&t, tb, 1));
1895        let from = teksilo_canvas::Point::new(h1.x + h1.width * 0.5, h1.y + h1.height * 0.5);
1896        // Drag well past the threshold to trigger DragPhase::Started.
1897        t.drag(
1898            from,
1899            teksilo_canvas::Point::new(from.x + 120.0, from.y + 40.0),
1900        );
1901        assert_eq!(
1902            dragged.get(),
1903            Some(1),
1904            "dragging header #1 must fire the hook with index 1"
1905        );
1906    }
1907
1908    // ─── fill mode ─────────────────────────────────────────────────────
1909
1910    #[test]
1911    fn fill_active_panel_fills_width_and_leftover_height() {
1912        // A narrow-content section in a tall box: with `.fill(true)` the
1913        // active panel stretches to the full width and grows into the leftover
1914        // height after the headers, so the ToolBox fills its slot exactly.
1915        let selected = Signal::new(0_usize);
1916        let mut t = tree();
1917        let tb = t.add(
1918            ToolBox::new(selected.clone())
1919                .fill(true)
1920                .item(lit!("A"), TextWidget::new(lit!("a")))
1921                .item(lit!("B"), TextWidget::new(lit!("b"))),
1922        );
1923        t.layout(SizeProposal::exact(300.0, 400.0));
1924
1925        // The ToolBox fills the proposed height exactly (no under-fill gap,
1926        // no overflow).
1927        assert!(
1928            (t.bounds(tb).height - 400.0).abs() < 1.0,
1929            "fill ToolBox should occupy its full slot height, got {}",
1930            t.bounds(tb).height
1931        );
1932
1933        let panel_a = t.bounds(panel_id(&t, tb, 0));
1934        // Active panel fills the cross axis (width).
1935        assert!(
1936            panel_a.width > 290.0,
1937            "active panel should fill the width, got {}",
1938            panel_a.width
1939        );
1940        // …and grows into the leftover main-axis space (well beyond a single
1941        // text line).
1942        assert!(
1943            panel_a.height > 200.0,
1944            "active panel should grow into leftover height, got {}",
1945            panel_a.height
1946        );
1947        // Inactive panel stays collapsed.
1948        assert!(
1949            t.bounds(panel_id(&t, tb, 1)).height < 0.5,
1950            "inactive panel collapsed"
1951        );
1952    }
1953
1954    #[test]
1955    fn fill_active_panel_does_not_overflow_oversized_content() {
1956        // A section whose content wants 1000 px in a 200 px box: with
1957        // `.fill(true)` the active panel shrinks to fit (and clips) instead of
1958        // pushing the ToolBox past its slot — so a bottom toolbar inside the
1959        // content never lands below the visible area.
1960        let selected = Signal::new(0_usize);
1961        let mut t = tree();
1962        let tall = FixedSize::new()
1963            .width(120.0_f32)
1964            .height(1000.0_f32)
1965            .child(TextWidget::new(lit!("x")));
1966        let tb = t.add(
1967            ToolBox::new(selected.clone())
1968                .fill(true)
1969                .item(lit!("A"), tall)
1970                .item(lit!("B"), TextWidget::new(lit!("b"))),
1971        );
1972        t.layout(SizeProposal::exact(300.0, 200.0));
1973
1974        assert!(
1975            (t.bounds(tb).height - 200.0).abs() < 1.0,
1976            "fill ToolBox must not overflow its slot, got {}",
1977            t.bounds(tb).height
1978        );
1979        let panel_a = t.bounds(panel_id(&t, tb, 0));
1980        assert!(
1981            panel_a.height < 200.0,
1982            "oversized active panel shrinks to fit, got {}",
1983            panel_a.height
1984        );
1985    }
1986
1987    #[test]
1988    fn non_fill_panel_keeps_natural_size() {
1989        // Without `.fill`, the historical behaviour stands: the active panel
1990        // sizes to its content's natural extent (a short text line), leaving
1991        // the box partly empty rather than stretching.
1992        let selected = Signal::new(0_usize);
1993        let mut t = tree();
1994        let tb = t.add(
1995            ToolBox::new(selected.clone())
1996                .item(lit!("A"), TextWidget::new(lit!("a")))
1997                .item(lit!("B"), TextWidget::new(lit!("b"))),
1998        );
1999        t.layout(SizeProposal::exact(300.0, 400.0));
2000        // Natural mode: the active panel is just a text line tall, far short
2001        // of the 400 px slot.
2002        assert!(
2003            t.bounds(panel_id(&t, tb, 0)).height < 100.0,
2004            "non-fill panel keeps natural height, got {}",
2005            t.bounds(panel_id(&t, tb, 0)).height
2006        );
2007    }
2008
2009    // ─── collapsible mode ──────────────────────────────────────────────
2010
2011    #[test]
2012    fn collapsible_active_header_click_collapses_then_reexpands() {
2013        let selected = Signal::new(0_usize);
2014        let mut t = tree();
2015        let tb = t.add(
2016            ToolBox::new(selected.clone())
2017                .collapsible(true)
2018                .item(lit!("A"), TextWidget::new(lit!("aaa")))
2019                .item(lit!("B"), TextWidget::new(lit!("bbb"))),
2020        );
2021        t.layout(SizeProposal::exact(300.0, 400.0));
2022        assert!(
2023            t.bounds(panel_id(&t, tb, 0)).height > 0.0,
2024            "A starts expanded"
2025        );
2026
2027        // Click the active header → collapse it (all sections closed).
2028        t.click(header_id(&t, tb, 0));
2029        t.layout(SizeProposal::exact(300.0, 400.0));
2030        assert!(
2031            t.bounds(panel_id(&t, tb, 0)).height < 0.5,
2032            "active header click collapses its content"
2033        );
2034        assert!(
2035            t.bounds(panel_id(&t, tb, 1)).height < 0.5,
2036            "B stays collapsed"
2037        );
2038
2039        // Click again → re-expand.
2040        t.click(header_id(&t, tb, 0));
2041        t.layout(SizeProposal::exact(300.0, 400.0));
2042        assert!(
2043            t.bounds(panel_id(&t, tb, 0)).height > 0.0,
2044            "re-expands on next click"
2045        );
2046    }
2047
2048    #[test]
2049    fn non_collapsible_active_header_click_stays_open() {
2050        // Default (exclusive): clicking the active header keeps it open.
2051        let selected = Signal::new(0_usize);
2052        let mut t = tree();
2053        let tb = t.add(
2054            ToolBox::new(selected.clone())
2055                .item(lit!("A"), TextWidget::new(lit!("aaa")))
2056                .item(lit!("B"), TextWidget::new(lit!("bbb"))),
2057        );
2058        t.layout(SizeProposal::exact(300.0, 400.0));
2059        t.click(header_id(&t, tb, 0));
2060        t.layout(SizeProposal::exact(300.0, 400.0));
2061        assert_eq!(selected.get(), 0);
2062        assert!(
2063            t.bounds(panel_id(&t, tb, 0)).height > 0.0,
2064            "non-collapsible active section stays open"
2065        );
2066    }
2067
2068    // ─── tooltip ───────────────────────────────────────────────────────
2069
2070    #[test]
2071    fn tooltip_appears_on_hover() {
2072        let selected = Signal::new(0_usize);
2073        let mut t = tree();
2074        let tb = t.add(ToolBox::new(selected.clone()).add(
2075            ToolBoxItem::new(lit!("A"), TextWidget::new(lit!("content"))).tooltip(lit!("Tip")),
2076        ));
2077        t.layout(SizeProposal::exact(300.0, 200.0));
2078        t.pointer_move(t.bounds(header_id(&t, tb, 0)).center());
2079        t.advance_time(std::time::Duration::from_secs(1));
2080        assert_eq!(
2081            t.active_overlays().len(),
2082            1,
2083            "tooltip should appear on hover"
2084        );
2085        assert!(t.find_by_label("Tip").is_some());
2086    }
2087}