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