Skip to main content

teksilo_widgets/
button.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Button — a labelled, activatable action trigger.
5//!
6//! `Button` is the primary action surface in Teksilo. It renders a text
7//! label (optionally with a leading, trailing, top, or bottom icon), fires
8//! a closure on click / Space / Enter / AT click, and advertises seven
9//! design-language variants via [`ButtonVariant`]. Chrome (fill, border,
10//! focus ring, padding) is delegated to the active [`ButtonStyle`]; the
11//! default `RecipeButtonStyle` implements the Int UI token ladder.
12//!
13//! ## When to use
14//!
15//! - Primary action: `.variant(ButtonVariant::Filled)` — one per context.
16//! - Secondary / cancel: default `ButtonVariant::Plain`.
17//! - Danger: `ButtonVariant::Destructive` (IntUI maps this to Filled).
18//! - Text-only link: `ButtonVariant::Link` / `ButtonVariant::Ghost`.
19//!
20//! ## Touch and pen
21//!
22//! The pressed visual is the **framework's**, not the button's own: the router
23//! keeps one press record per contact and `Button` mirrors it onto its
24//! `InteractionState` (`bind_press_interaction`). That buys four things a
25//! `PointerDown` / `PointerUp` pair inside a handler cannot see — a press that
26//! slides off its target goes out and comes back on re-entry (WCAG 2.2
27//! SC 2.5.2), a press a pan claimant or an ancestor drag wins is cleared with
28//! no release to hang the reset on, a cancel clears it, and a press inside a
29//! scrollable withholds the visual for 100 ms so a finger that turns out to be
30//! scrolling never flashes a highlight. `docs/touch-and-pen.md` §7.1.
31//!
32//! Activation has always been `on_tap`, so it already lands on the release.
33//! After a mouse or pen release the button rests hovered as it always has;
34//! after a finger release it rests idle, because a finger sends no
35//! hover-leave to correct a hovered state with.
36//!
37//! The whole family — `IconButton`, `CommandLinkButton`, every `Toolbar`
38//! command — shares `build_interaction_handlers` and gets all of this with it.
39//!
40//! ## Accessibility
41//!
42//! Announces as `Role::Button` with the resolved label as its AT name.
43//! Keyboard: Space / Enter activate; the lone-KeyUp guard prevents spurious
44//! re-activation when a shortcut consumes the KeyDown and returns focus here.
45//!
46//! ```rust
47//! # use teksilo_widgets::{Button, ButtonVariant};
48//! # use teksilo_i18n::lit;
49//! # use teksilo_core::Intent;
50//! let _btn = Button::new(lit!("Save"))
51//!     .variant(ButtonVariant::Filled)
52//!     .on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.save")));
53//! ```
54
55use std::rc::Rc;
56use teksilo_i18n::lit;
57
58use teksilo_canvas::{Rect, SizeProposal};
59use teksilo_core::accessibility::AccessNodeBuilder;
60use teksilo_core::build_context::BuildContext;
61use teksilo_core::event::{EventResponse, Key, WidgetEvent};
62use teksilo_core::signal::{Prop, Signal};
63use teksilo_core::styles::{ButtonStyle, ButtonStyleConfig, SharedButtonStyle};
64use teksilo_core::widget::{CursorIcon, EventContext, LayoutContext, Widget, WidgetPlacement};
65use teksilo_core::widget_builder::HandlerSet;
66use teksilo_core::widget_id::WidgetId;
67use teksilo_tokens::TextRole;
68
69use crate::primitives::icon_widget::IconWidget;
70use crate::primitives::{HStack, TextWidget, VStack};
71
72/// Closed enum naming the design-language variants of `Button`. See
73/// [`teksilo_core::styles::ButtonVariant`] for the canonical definition.
74///
75/// Int UI does **not** ship filled red "destructive" buttons —
76/// destructive actions in IntelliJ are plain buttons in confirmation
77/// dialogs where the title/body carry the warning. The IntUI default
78/// `RecipeButtonStyle` collapses `Destructive → Filled`, `Tinted /
79/// Outlined → Plain`, and `Link → Ghost` accordingly. Other design
80/// languages (Material 3, macOS) honour the variants distinctly.
81pub use teksilo_core::styles::ButtonVariant;
82use teksilo_i18n::LocalizedString;
83
84/// Internal interaction state.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum InteractionState {
87    Idle,
88    Hovered,
89    Pressed,
90    Focused,
91    Disabled,
92}
93
94/// Build the interaction handler set shared by every activatable button
95/// (`Button`, `IconButton`, `CommandLinkButton`, and any future sibling).
96///
97/// Centralizes the parts that MUST stay identical across the family and
98/// historically drifted when copy-pasted:
99/// - hover/focus state tracking,
100/// - keyboard `Space`/`Enter` activation with the **lone-KeyUp guard**
101///   (a `KeyUp` with no preceding `KeyDown` — e.g. a shortcut consumed
102///   the `KeyDown` and focus returned here — must NOT activate),
103/// - the AT `Click` action.
104///
105/// `on_activate` runs on tap, keyboard activation, and AT click. Callers
106/// bundle their command action (and any extra side effect, e.g.
107/// `IconButton`'s toggle flip) into this single closure so the guard
108/// gates all activation paths uniformly. `focusable` is the node's
109/// focusability (`Button` is always focusable; `IconButton` exposes it).
110/// A layout-transparent wrapper that lifts its child's **hit** area to the
111/// density's target size at every density, for direct pointers only.
112///
113/// The residue the other three mechanisms cannot serve: a control that is
114/// under 24 dp, is composed out of primitives rather than being its own
115/// `Widget` (so it has no `hit_outset` of its own to implement), and sits
116/// inside something that takes presses (so the miss-only slop pass, which only
117/// re-attributes to a candidate strictly closer than the bubble owner, can
118/// never reach it). The text field's 16 dp clear affordance is the case this
119/// was written for.
120///
121/// Distinct from [`TouchTarget`](crate::primitives::TouchTarget), which is the
122/// wrapper that *moves* things: it reserves layout space and is deliberately
123/// the identity below `TargetDensity::Touch`. This one never moves anything
124/// and is live at every density, because `min_target_conformance` is 24 dp at
125/// every density and is never scaled. The two are candidates for merging into
126/// one wrapper with two modes; they are separate here because `TouchTarget` is
127/// not this package's file.
128pub(crate) struct HitTarget {
129    child: Option<WidgetId>,
130    size: Option<teksilo_canvas::Size>,
131    active: teksilo_core::signal::Prop<bool>,
132    bounds: std::cell::Cell<teksilo_canvas::Size>,
133}
134
135impl HitTarget {
136    pub(crate) fn new() -> Self {
137        Self {
138            child: None,
139            size: None,
140            active: teksilo_core::signal::Prop::Static(true),
141            bounds: std::cell::Cell::new(teksilo_canvas::Size::ZERO),
142        }
143    }
144
145    /// Pin the slot's own size instead of forwarding the child's.
146    ///
147    /// For the shape this exists to serve: the slot has to keep reserving its
148    /// room while the affordance inside it is hidden, so the row does not jump
149    /// when the affordance appears. Without it a dormant child would collapse
150    /// the wrapper to nothing — and, being the wrapper the ring resolves to,
151    /// it would take the outset with it.
152    pub(crate) fn fixed(mut self, width: f32, height: f32) -> Self {
153        self.size = Some(teksilo_canvas::Size::new(width, height));
154        self
155    }
156
157    /// Whether the wrapper currently claims its widened target.
158    ///
159    /// `false` withdraws the outset entirely, because a widened node that then
160    /// refuses the press is a hole punched in whatever is behind it — the same
161    /// rule the splitter handle and the twist arrow apply. Reactive, so a
162    /// clear affordance that comes and goes with the field's contents does not
163    /// need a rebuild.
164    pub(crate) fn active(mut self, active: impl Into<teksilo_core::signal::Prop<bool>>) -> Self {
165        self.active = active.into();
166        self
167    }
168
169    pub(crate) fn child_id(mut self, id: WidgetId) -> Self {
170        self.child = Some(id);
171        self
172    }
173}
174
175impl std::fmt::Debug for HitTarget {
176    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177        f.debug_struct("HitTarget").finish()
178    }
179}
180
181impl Widget for HitTarget {
182    fn build(&mut self, _ctx: &mut BuildContext) -> Vec<WidgetId> {
183        self.child.into_iter().collect()
184    }
185
186    fn layout_response(
187        &self,
188        proposal: SizeProposal,
189        ctx: &LayoutContext,
190    ) -> teksilo_core::widget::LayoutResponse {
191        if let Some(size) = self.size {
192            return size.into();
193        }
194        // Fully transparent: the child's whole response, not just its size, so
195        // a shrinkable or flexible child stays so through the wrapper.
196        self.child
197            .and_then(|id| ctx.child_layout_response(id, proposal))
198            .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into())
199    }
200
201    fn place_children(
202        &self,
203        bounds: Rect,
204        _proposal: SizeProposal,
205        children: &mut [WidgetPlacement],
206        _ctx: &LayoutContext,
207    ) {
208        self.bounds.set(bounds.size());
209        for child in children.iter_mut() {
210            child.origin = bounds.origin();
211            child.size = bounds.size();
212        }
213    }
214
215    fn children(&self) -> Vec<WidgetId> {
216        self.child.into_iter().collect()
217    }
218
219    fn hit_outset(
220        &self,
221        kind: teksilo_tokens::PointerKind,
222        tokens: &teksilo_tokens::InputTokens,
223    ) -> teksilo_canvas::EdgeInsets {
224        if !self.active.get() {
225            return teksilo_canvas::EdgeInsets::ZERO;
226        }
227        target_outset(self.bounds.get(), kind, tokens)
228    }
229}
230
231/// The per-edge hit outset that lifts a control painted at `visual` up to the
232/// density's target size, for the pointer that is asking.
233///
234/// The controls sweep's answer to a control that is genuinely smaller than 24
235/// dp and cannot grow: a 12 dp twist arrow inside a tree row, a 16 dp clear
236/// button inside a text field. The **density rule** forbids routing such a
237/// dimension through [`dp`](teksilo_core::styles::density::dp) at layout time
238/// — that would raise its paint at Compact and break the programme's
239/// Compact-is-unchanged invariant — so the shortfall is made up between the
240/// pointer and the arena instead, which is what [`Widget::hit_outset`] is for
241/// (`docs/density-and-targets.md`).
242///
243/// Zero for a precise pointer, always: a mouse hot-spot is exact and a widened
244/// node would steal clicks from whatever it overlaps. Zero on an axis that is
245/// already at or above the target, so a control that only falls short on one
246/// axis grows only on that one.
247///
248/// Two outset controls close enough for their rings to overlap both claim the
249/// space between them; the arena resolves that by distance to the uninflated
250/// rect, so the boundary lands halfway, which is the answer a user aiming
251/// between them expects.
252///
253/// [`Widget::hit_outset`]: teksilo_core::widget::Widget::hit_outset
254pub(crate) fn target_outset(
255    visual: teksilo_canvas::Size,
256    kind: teksilo_tokens::PointerKind,
257    tokens: &teksilo_tokens::InputTokens,
258) -> teksilo_canvas::EdgeInsets {
259    use teksilo_core::styles::density::dp;
260    use teksilo_tokens::TargetRole;
261
262    if !kind.is_direct() {
263        return teksilo_canvas::EdgeInsets::ZERO;
264    }
265    outset_to(visual, |extent| dp(extent, TargetRole::Target, tokens))
266}
267
268/// Half the shortfall between each of `visual`'s extents and the floor `to`
269/// puts under it, per edge, never negative.
270///
271/// Split out from [`target_outset`] so the shortfall arithmetic is stated once
272/// and a widget that needs a different floor reuses it rather than re-deriving
273/// it. A
274/// non-positive or non-finite extent grows by nothing: there is no meaningful
275/// centre to grow around.
276fn outset_to(
277    visual: teksilo_canvas::Size,
278    floor: impl Fn(f32) -> f32,
279) -> teksilo_canvas::EdgeInsets {
280    let grow = |extent: f32| {
281        if extent > 0.0 && extent.is_finite() {
282            ((floor(extent) - extent) * 0.5).max(0.0)
283        } else {
284            0.0
285        }
286    };
287    teksilo_canvas::EdgeInsets::symmetric(grow(visual.width), grow(visual.height))
288}
289
290/// Drive a button-family control's `Pressed` state from the framework press.
291///
292/// The family used to keep this itself: `PointerDown` set `Pressed`,
293/// `PointerUp` put it back. That is right for a mouse and wrong for a finger
294/// in four ways a handler cannot see — a press that slides off its target, a
295/// press that slides back on, a press a pan claimant takes away with no
296/// release to hang the reset on, and a press that must not light up at all
297/// until the pan has been ruled out. `docs/touch-and-pen.md` §7.1 has the
298/// rules; the router keeps the state and this mirrors it onto the family's
299/// five-state `interaction` signal.
300///
301/// Only the `Pressed` transitions move: this writes `Pressed` when the
302/// framework press lights — and at build time when it is already lit — and,
303/// when it goes out, the resting state below. The `Pressed` guard on that
304/// second branch is what keeps it from overwriting a resting state the
305/// `on_tap` above has already chosen for the release. Hover, focus and the
306/// keyboard `Space`/`Enter` machine set their own states, and the framework
307/// press never moves for a key — it is a *pointer's* record — so nothing here
308/// raises `Pressed` on a key's behalf. It can still clear one, because both
309/// write the same signal: a pointer press that ends while `Space` is held
310/// finds the signal on `Pressed` and rests it, which also disarms the
311/// lone-`KeyUp` guard in `on_key`. Reaching that takes a held key and a
312/// pointer press on one control.
313///
314/// Ending a press with no activation — a slide-off, a cancel, an ancestor
315/// drag winning the arbitration — rests the control on the `hovered` cell
316/// beside the signal, and the two pointer kinds part ways there.
317///
318/// **A contact never writes that cell.** The router refuses a contact the
319/// hover-owner role outright, so `on_hover` never fires for one, and the
320/// `on_tap` write above sits behind `pointer_kind().hovers()`. A finger
321/// therefore leaves the cell exactly as it found it: on a touch-only device
322/// `false`, so a *revoked* contact — the pan claimant's — leaves the control
323/// `Idle`, which is what stops a pan-stolen tap staying lit with nothing
324/// touching it. Where a mouse is resting on the same control the cell is that
325/// mouse's, and the control rests `Hovered` on the strength of a pointer that
326/// really is there.
327///
328/// **A mouse keeps whatever the cell held when it pressed.** A mouse that
329/// pointed at the control before pressing it left the cell `true`, and nothing
330/// clears it while the press lasts: the press holds the pointer capture, so
331/// moves route straight to the owner and no `PointerLeave` — and so no
332/// `on_hover(false)` — is synthesised even while the pointer is off the
333/// control. A mouse press that
334/// ends without activating therefore rests `Hovered` whether it was revoked
335/// under the pointer or had slid off, because the cell records where the
336/// pointer was when it pressed rather than where it is now. The `Idle` branch
337/// is reached under a mouse by a press that never had the hover to begin
338/// with — a `PointerDown` with no `PointerMove` over the control before it.
339pub(crate) fn bind_press_interaction(
340    ctx: &mut BuildContext,
341    interaction: Signal<InteractionState>,
342    hovered: Rc<std::cell::Cell<bool>>,
343) {
344    let pressed = ctx.pressed_signal();
345    // Seed from the live state rather than from `false`: a rebuild that
346    // happens *during* a press must not blink the visual off. Rare, because
347    // `process_pending_rebuilds` defers a rebuild aimed at the widget holding
348    // the capture — and a press owner is the capture owner — but a live drag
349    // session lifts that deferral for the whole tree, so a second contact
350    // dragging elsewhere is enough to land one here.
351    if pressed.get() {
352        interaction.set(InteractionState::Pressed);
353    }
354    ctx.effect(&pressed, move |showing| {
355        if *showing {
356            interaction.set(InteractionState::Pressed);
357        } else if interaction.get() == InteractionState::Pressed {
358            interaction.set(if hovered.get() {
359                InteractionState::Hovered
360            } else {
361                InteractionState::Idle
362            });
363        }
364    });
365}
366
367pub(crate) fn build_interaction_handlers(
368    ctx: &mut BuildContext,
369    interaction: Signal<InteractionState>,
370    on_activate: Rc<dyn Fn(&mut EventContext)>,
371    focusable: bool,
372) -> HandlerSet {
373    let act_tap = on_activate.clone();
374    let act_key = on_activate.clone();
375    let act_access = on_activate;
376    // Whether the pointer is currently over the control, kept beside the
377    // interaction signal so the press binding can restore the *right* resting
378    // state when a press ends without an activation. `interaction` alone
379    // cannot answer it: while the control is `Pressed` the hover truth has
380    // nowhere to live.
381    let hovered = Rc::new(std::cell::Cell::new(false));
382    bind_press_interaction(ctx, interaction.clone(), hovered.clone());
383    HandlerSet::new()
384        .on_tap({
385            let interaction = interaction.clone();
386            let hovered = hovered.clone();
387            move |_pos: &teksilo_core::TapEvent, ctx: &mut EventContext| {
388                act_tap(ctx);
389                // Where the control rests after an activation. A mouse or a
390                // pen is still over it, so it rests hovered exactly as it
391                // always has; a finger is gone the instant it lifts and never
392                // sent a hover-leave to correct a `Hovered` state with, so it
393                // rests idle.
394                interaction.set(if ctx.pointer_kind().hovers() {
395                    hovered.set(true);
396                    InteractionState::Hovered
397                } else {
398                    InteractionState::Idle
399                });
400            }
401        })
402        .on_hover({
403            let interaction = interaction.clone();
404            let hovered = hovered.clone();
405            move |entered: bool, _ctx: &mut EventContext| {
406                hovered.set(entered);
407                interaction.set(if entered {
408                    InteractionState::Hovered
409                } else {
410                    InteractionState::Idle
411                });
412            }
413        })
414        .on_key({
415            let interaction = interaction.clone();
416            move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
417                match event {
418                    WidgetEvent::KeyDown {
419                        key: Key::Space | Key::Enter,
420                        ..
421                    } => {
422                        interaction.set(InteractionState::Pressed);
423                        EventResponse::Handled
424                    }
425                    WidgetEvent::KeyUp {
426                        key: Key::Space | Key::Enter,
427                        ..
428                    } => {
429                        // Lone-KeyUp guard: only activate if we saw the
430                        // matching KeyDown (state is Pressed).
431                        if interaction.get() != InteractionState::Pressed {
432                            return EventResponse::Ignored;
433                        }
434                        act_key(ctx);
435                        interaction.set(InteractionState::Focused);
436                        EventResponse::Handled
437                    }
438                    _ => EventResponse::Ignored,
439                }
440            }
441        })
442        .on_focus({
443            let interaction = interaction.clone();
444            move |gained: bool, _ctx: &mut EventContext| {
445                if gained {
446                    if interaction.get() == InteractionState::Idle {
447                        interaction.set(InteractionState::Focused);
448                    }
449                } else {
450                    interaction.set(InteractionState::Idle);
451                }
452            }
453        })
454        .on_access_action(
455            move |action: teksilo_core::accesskit::Action,
456                  ctx: &mut EventContext|
457                  -> EventResponse {
458                if action == teksilo_core::accesskit::Action::Click {
459                    act_access(ctx);
460                    EventResponse::Handled
461                } else {
462                    EventResponse::Ignored
463                }
464            },
465        )
466        .focusable(focusable)
467        .cursor(CursorIcon::Pointer)
468}
469
470/// Test-only helpers for driving a control with a synthetic contact.
471///
472/// Lives here because the button family is where the framework press first
473/// lands; every other control in the controls sweep reaches it as
474/// `crate::button::press_test_support`.
475#[cfg(test)]
476pub(crate) mod press_test_support {
477    use teksilo_canvas::Point;
478    use teksilo_core::event::Modifiers;
479    use teksilo_core::pointer::{
480        BackendDeviceKey, EventTime, PointerId, PointerIdAllocator, PointerInfo, PointerPhase,
481        PointerSample,
482    };
483    use teksilo_core::widget_tree::WidgetTree;
484
485    /// A brand-new contact id. Every touch press mints one — winit reuses
486    /// `Touch::id`, the allocator does not.
487    pub(crate) fn finger() -> PointerId {
488        use std::sync::atomic::{AtomicU64, Ordering};
489        static NEXT: AtomicU64 = AtomicU64::new(1);
490        PointerIdAllocator::global().begin(
491            BackendDeviceKey::new(0x0B24),
492            NEXT.fetch_add(1, Ordering::Relaxed),
493        )
494    }
495
496    /// One touch sample for `id` at `at`, stamped `ms` into the tree epoch.
497    pub(crate) fn touch(id: PointerId, phase: PointerPhase, at: Point, ms: u64) -> PointerSample {
498        PointerSample {
499            pointer: PointerInfo::touch(id, EventTime::from_millis(ms)),
500            phase,
501            position: at,
502            button: None,
503            modifiers: Modifiers::NONE,
504            coalesced: Vec::new(),
505        }
506    }
507
508    /// Press, then release, at the same point: the whole touch tap.
509    pub(crate) fn touch_tap(tree: &mut WidgetTree, at: Point) {
510        let id = finger();
511        tree.dispatch_pointer(touch(id, PointerPhase::Down, at, 0));
512        tree.dispatch_pointer(touch(id, PointerPhase::Up, at, 30));
513    }
514}
515
516/// Where an optional icon is placed relative to the button label.
517#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
518pub enum IconLocation {
519    /// No icon (default).
520    #[default]
521    None,
522    /// Icon only, no label.
523    IconOnly,
524    /// Icon to the left of the label.
525    Leading,
526    /// Icon to the right of the label.
527    Trailing,
528    /// Icon above the label.
529    Top,
530    /// Icon below the label.
531    Bottom,
532}
533
534/// Type-erased activation closure. Stored as `Box<dyn Fn>` so the
535/// same button type works for any handler — typed intent send,
536/// direct side effect, window mutation, etc.
537type CommandFactory = Box<dyn Fn(&mut EventContext)>;
538
539/// A labelled action trigger; use [`Button::new`] and chain builder methods.
540pub struct Button {
541    /// Button label as a `Prop<String>`. `new(tr!(...))` stores a
542    /// `Prop::Bound` (locale-reactive) when an i18n manager is installed,
543    /// falling back to `Prop::Static` for `lit!(...)` or no manager;
544    /// `label(signal)` overrides with a caller-supplied source. Either
545    /// way the inner `TextWidget` re-renders reactively without rebuilding
546    /// the Button. The accessibility node's `set_name` reads the current
547    /// value via `Prop::get()`, keeping AT in sync with bound updates.
548    label: teksilo_core::signal::Prop<String>,
549    /// Tier-1 design-language variant hint (Filled, Plain, Ghost, …).
550    /// The active [`ButtonStyle`] decides what to do with it.
551    variant: ButtonVariant,
552    /// Optional per-call override for the active [`ButtonStyle`]. When
553    /// `None`, falls through to the theme slot or the
554    /// built-in [`crate::styles::RecipeButtonStyle`] default.
555    style_override: Option<SharedButtonStyle>,
556    action: Option<CommandFactory>,
557    /// Enabled state, static or reactive. Forwarded into the arena via
558    /// `ctx.enabled_when(self_id, self.enabled.clone())` at build time;
559    /// not kept as a runtime snapshot. After `build()` the arena's
560    /// `enabled_state` is the single source of truth — leaves resolve
561    /// colors via `PaintContext::effective_enabled`, events are gated
562    /// by `arena.is_enabled()`, the a11y walker reads it for
563    /// `set_disabled()`.
564    enabled: Prop<bool>,
565    icon: Option<IconWidget>,
566    icon_location: IconLocation,
567    /// Leave the icon's own colour alone instead of tinting it to the label's.
568    /// See [`Button::icon_keeps_color`].
569    icon_keeps_color: bool,
570    tooltip_text: Option<LocalizedString>,
571    /// Optional rich tooltip source (registry key or inline content).
572    /// Mutually exclusive with `tooltip_text` and `composite_tooltip_content`
573    /// — every tooltip setter clears the other two so last-call wins.
574    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
575    /// Optional composite tooltip body. Hosts an arbitrary widget
576    /// tree (charts, grids, conditional rows). Mutually exclusive
577    /// with `tooltip_text` and `rich_tooltip_source` per the
578    /// last-call-wins matrix.
579    composite_tooltip_content: Option<Box<dyn teksilo_core::widget::Widget>>,
580    /// Optional `has_popup` hint used when this button acts as a
581    /// disclosure trigger for a popup (menu, dialog, listbox, etc.).
582    /// Surfaced via `set_has_popup` in `accessibility()`.
583    has_popup: Option<teksilo_core::accesskit::HasPopup>,
584    /// Arbitrary widget rendered to the leading edge of the button's
585    /// content (left in LTR, right in RTL). Composes with `.icon(...)`:
586    /// the order is `[leading_slot, icon+label, trailing_slot]`. Slot
587    /// widgets paint and report a11y on their own — Button does not
588    /// retint them and does not auto-suppress their AT roles. Apps
589    /// whose slot widgets would otherwise pollute the AT tree
590    /// (e.g. ColorSwatch with `Role::ColorWell`) should pass
591    /// `widget.access_hidden(true)` so the Button's
592    /// `Role::Button` stays the single declared role.
593    leading: Option<Box<dyn Widget>>,
594    /// Same shape as `leading`, rendered to the trailing edge.
595    trailing: Option<Box<dyn Widget>>,
596    /// Optional signal reporting whether the button's popup is
597    /// currently visible. Surfaced via `set_expanded` in
598    /// `accessibility()`. Used alongside `has_popup` for the
599    /// standard ARIA disclosure pattern.
600    expanded_signal: Option<Prop<bool>>,
601    /// Optional caller-supplied interaction signal. When set, `build()`
602    /// uses this signal instead of allocating its own — letting an
603    /// external widget (e.g. `PopoverButton`'s disclosure caret)
604    /// observe hover / press / focus / disabled state and match the
605    /// label's color exactly. See [`Button::share_interaction`].
606    shared_interaction: Option<Signal<InteractionState>>,
607    /// Optional caller-supplied label/icon color override. When `Some`,
608    /// both the label text and any icon are bound to this `ColorProp`
609    /// regardless of `style` / interaction state — the auto-derived
610    /// cascade is replaced. Used by chrome that has to match a host's
611    /// enforced text role (e.g. tab-bar overflow dropdown trigger
612    /// inheriting `idle_text_role`). See [`Button::text_role`].
613    text_role_override: Option<teksilo_core::color_prop::ColorProp>,
614    /// Optional per-call override for the label's text style (font, size,
615    /// weight). When `Some`, applied to the inner label `TextWidget` via
616    /// its `.style(...)`; when `None`, the `TextWidget` default is used.
617    /// Accepts a `TextStyleRole`, a `TextStyle`, or a `Signal` of either
618    /// (anything `Into<TextStyleProp>`). See [`Button::text_style`].
619    label_style: Option<teksilo_core::color_prop::TextStyleProp>,
620    /// Interaction state signal — set during build().
621    interaction: Signal<InteractionState>,
622    /// Root child ID — set during build().
623    root_child_id: Option<WidgetId>,
624}
625
626impl Button {
627    /// Construct a button from a `LocalizedString` label. The label may
628    /// come from `tr!(...)` (translated) or `lit!(...)`
629    /// (explicit non-translated). When an `I18nManager` is installed, a
630    /// `tr!(...)` label becomes a `Prop::Bound` that observes the locale
631    /// version signal, so the inner `TextWidget` re-renders on a locale
632    /// switch without rebuilding the Button — matching `TextWidget::new`.
633    /// `lit!(...)` and the no-manager case resolve to a static `String`.
634    pub fn new(label: impl Into<LocalizedString>) -> Self {
635        let ls: LocalizedString = label.into();
636        Self {
637            // `Prop::from(LocalizedString)` yields `Prop::Bound` (reactive)
638            // when a manager is installed, `Prop::Static` otherwise — the
639            // same conversion `TextWidget::new` uses. A locale change then
640            // updates the label live; without this it stayed frozen because
641            // `set_locale` marks the tree dirty (relayout/repaint) but does
642            // NOT rebuild composites.
643            label: teksilo_core::signal::Prop::from(ls),
644            // Int UI default is a Plain (non-primary) button; the caller
645            // opts into `ButtonVariant::Filled` for the one primary action.
646            variant: ButtonVariant::Plain,
647            style_override: None,
648            action: None,
649            enabled: Prop::Static(true),
650            icon: None,
651            icon_location: IconLocation::None,
652            icon_keeps_color: false,
653            tooltip_text: None,
654            rich_tooltip_source: None,
655            composite_tooltip_content: None,
656            has_popup: None,
657            expanded_signal: None,
658            shared_interaction: None,
659            text_role_override: None,
660            label_style: None,
661            leading: None,
662            trailing: None,
663            interaction: Signal::new(InteractionState::Idle),
664            root_child_id: None,
665        }
666    }
667
668    /// Returns the configured visual variant. Used by wrappers like
669    /// [`PopoverButton`](crate::popover_widget::PopoverButton) that
670    /// derive their own chrome colors from the same recipe-resolution
671    /// path the inner Button uses.
672    pub fn current_variant(&self) -> ButtonVariant {
673        self.variant
674    }
675
676    /// Bind the button's internal interaction state to a caller-owned
677    /// `Signal<InteractionState>` instead of letting `build()` allocate
678    /// its own. Used by wrapper widgets like
679    /// [`PopoverButton`](crate::popover_widget::PopoverButton) whose
680    /// disclosure caret needs to match the label's color across hover
681    /// / press / focus / disabled states.
682    ///
683    /// The provided signal never carries `Disabled`: the arena's
684    /// enabled-state is the single source of truth since the
685    /// single-sourced-enabled refactor, so a wrapper mirrors the disabled
686    /// look from `ButtonStyleConfig::is_disabled` (or lets its own leaves
687    /// dim at paint via `PaintContext::effective_enabled`) rather than
688    /// from this signal.
689    pub fn share_interaction(mut self, signal: Signal<InteractionState>) -> Self {
690        self.shared_interaction = Some(signal);
691        self
692    }
693
694    /// Set the Tier-1 design-language variant. The active
695    /// [`ButtonStyle`] decides whether to honour or remap it (the IntUI
696    /// default `RecipeButtonStyle` collapses Destructive → Filled,
697    /// Tinted/Outlined → Plain, Link → Ghost).
698    pub fn variant(mut self, variant: ButtonVariant) -> Self {
699        self.variant = variant;
700        self
701    }
702
703    /// Override the active [`ButtonStyle`] for this widget instance
704    /// only. Useful for one-off custom-painted buttons (glassmorphism
705    /// CTA, Material-3 ripple, etc.) without forking the Button.
706    pub fn style(mut self, style: impl ButtonStyle) -> Self {
707        self.style_override = Some(Rc::new(style));
708        self
709    }
710
711    /// Bind the button's label to a reactive source — replaces the
712    /// static label captured at `new(...)`. Accepts any
713    /// `impl Into<Prop<String>>`: a `Signal<String>` for live
714    /// updates, or a plain `String` (which is the same as constructing
715    /// the button with that string). Mirrors
716    /// [`TextWidget::text`](crate::primitives::TextWidget::text).
717    /// The inner label `TextWidget` is built with the bound prop, so
718    /// the visible text refreshes without rebuilding the Button. The
719    /// AT node's `set_name` reads the current value via `Prop::get`.
720    ///
721    /// Translation note: derive the signal with
722    /// `state.map(|s| tr!(status_label(value = s)).resolve_now())` for translated
723    /// reactive labels — Button only sees the resolved `String`.
724    pub fn label(mut self, label: impl Into<teksilo_core::signal::Prop<String>>) -> Self {
725        self.label = label.into();
726        self
727    }
728
729    /// Closure invoked on activation. Use `ctx.send_intent(...)` to
730    /// route activation through the Action/Intent system, or inline
731    /// the behavior directly.
732    pub fn on_activate_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
733        self.action = Some(Box::new(f));
734        self
735    }
736
737    /// Whether an activation closure has been attached. Used by wrappers
738    /// (e.g. `PopoverWidget`) that overwrite the activate slot, so they
739    /// can warn when a caller-set handler is about to be discarded.
740    pub(crate) fn has_activate_handler(&self) -> bool {
741        self.action.is_some()
742    }
743
744    /// Attach a tooltip that appears after a hover delay.
745    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
746        self.tooltip_text = Some(text.into());
747        self.rich_tooltip_source = None;
748        self.composite_tooltip_content = None;
749        self
750    }
751
752    /// Attach a rich tooltip resolved from the app-wide tooltip registry.
753    /// The `key` is looked up via
754    /// [`TooltipRegistry`](crate::tooltip::TooltipRegistry) at build
755    /// time; the resolved body text supports inline markup
756    /// (`[label](url)`, `*italic*`, `**bold**`) and the entry's
757    /// shortcut / long-form "more" fields are rendered automatically.
758    ///
759    /// Overrides any previously set plain `.tooltip(...)` text.
760    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
761        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
762        self.tooltip_text = None;
763        self.composite_tooltip_content = None;
764        self
765    }
766
767    /// Attach a rich tooltip driven by inline
768    /// [`TooltipContent`](crate::tooltip::TooltipContent) — for
769    /// one-off tooltips that aren't worth registering in the central
770    /// catalog. Overrides any previously set plain `.tooltip(...)`.
771    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
772        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
773        self.tooltip_text = None;
774        self.composite_tooltip_content = None;
775        self
776    }
777
778    /// Attach a composite tooltip — third tier, hosting an arbitrary
779    /// widget tree (Crusader Kings 3 style: tabbed sections, charts,
780    /// progress bars, conditional rows). Promotes to a focusable
781    /// `Role::Dialog` after the user dwells for the standard
782    /// promotion threshold. Overrides any plain or rich tooltip
783    /// previously set on this button.
784    pub fn composite_tooltip(
785        mut self,
786        content: impl teksilo_core::widget::Widget + 'static,
787    ) -> Self {
788        self.composite_tooltip_content = Some(Box::new(content));
789        self.tooltip_text = None;
790        self.rich_tooltip_source = None;
791        self
792    }
793
794    /// Boxed variant of [`composite_tooltip`](Self::composite_tooltip).
795    /// Used by `Clone` value types (e.g. `ToolbarAction`) that store a
796    /// composite-body factory `Rc<dyn Fn() -> Box<dyn Widget>>` and forward
797    /// the produced box through at build time.
798    pub(crate) fn composite_tooltip_boxed(
799        mut self,
800        content: Box<dyn teksilo_core::widget::Widget>,
801    ) -> Self {
802        self.composite_tooltip_content = Some(content);
803        self.tooltip_text = None;
804        self.rich_tooltip_source = None;
805        self
806    }
807
808    /// Set the enabled state, statically or reactively. Disabled buttons
809    /// ignore input and dim their content (the framework's
810    /// `PaintContext::effective_enabled` propagates through to the
811    /// label/icon leaves). Forwarded into the arena via
812    /// `ctx.enabled_when(self_id, self.enabled.clone())` at build time —
813    /// a bound signal updates live as it changes.
814    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
815        self.enabled = enabled.into();
816        self
817    }
818
819    /// Override the label and icon's tint with a static `ColorProp`.
820    /// When set, the button ignores its `style` and the auto-derived
821    /// idle/hover/press text-role cascade — both the label text and
822    /// any icon are bound directly to this prop instead. Use for chrome
823    /// whose host enforces a single text role across all of its
824    /// sub-widgets (e.g. tab-bar overflow-dropdown triggers that must
825    /// match the strip's `idle_text_role` regardless of hover state).
826    /// Accepts `Color`, `TextRole`, `Signal<Color>`, or `Signal<TextRole>`.
827    pub fn text_role(mut self, role: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
828        self.text_role_override = Some(role.into());
829        self
830    }
831
832    /// Override the label's text style (font, size, weight). By default the
833    /// label uses the inner `TextWidget`'s default style; pass a
834    /// `TextStyleRole` (e.g. `TextStyleRole::BodyBold`), a `TextStyle`, or a
835    /// `Signal` of either to change it — e.g. to make the label bold.
836    /// Orthogonal to [`Button::text_role`], which only sets the color.
837    pub fn text_style(mut self, style: impl Into<teksilo_core::color_prop::TextStyleProp>) -> Self {
838        self.label_style = Some(style.into());
839        self
840    }
841
842    /// Add an icon to the button at the specified location.
843    pub fn icon(mut self, icon: IconWidget, location: IconLocation) -> Self {
844        self.icon = Some(icon);
845        self.icon_location = location;
846        self
847    }
848
849    /// Keep the icon's own colour instead of tinting it to the label's.
850    ///
851    /// The mirror of [`MenuItem::icon_keeps_color`](crate::menu_item::MenuItem::icon_keeps_color),
852    /// and it exists for the same reason: an icon whose colour *is* the information.
853    /// A filter chip carrying a user-chosen tag colour, a legend swatch, a status
854    /// disc — tinting those to the label's foreground destroys the one thing they
855    /// carry, while tinting is exactly right for a glyph that merely repeats the
856    /// label.
857    ///
858    /// Two consequences worth knowing, both inherited from
859    /// [`ColorProp`](teksilo_core::color_prop::ColorProp)'s own rules rather than
860    /// special-cased here:
861    ///
862    /// * The colour must clear contrast against **every** fill the button takes —
863    ///   an accent-filled selected state as well as the resting surface.
864    /// * A literal colour **does not dim when the button is disabled**. An icon
865    ///   that should dim wants a role instead, and then it does not need this.
866    pub fn icon_keeps_color(mut self) -> Self {
867        self.icon_keeps_color = true;
868        self
869    }
870
871    /// Declare that this button is a disclosure trigger for a
872    /// popup (menu, dialog, listbox, tree, grid). Surfaced via
873    /// `set_has_popup` in the a11y node so screen readers announce
874    /// it as leading into the named popup kind.
875    pub fn has_popup(mut self, kind: teksilo_core::accesskit::HasPopup) -> Self {
876        self.has_popup = Some(kind);
877        self
878    }
879
880    /// Bind a signal reporting whether this button's popup is
881    /// currently visible. The Popover / Dialog wrapper owns the
882    /// signal and flips it on show / dismiss; Button reads it in
883    /// `accessibility()` to publish `set_expanded`. Only
884    /// meaningful alongside `.has_popup(...)`.
885    pub fn expanded_when(mut self, signal: impl Into<Prop<bool>>) -> Self {
886        self.expanded_signal = Some(signal.into());
887        self
888    }
889
890    /// Insert a widget at the leading edge of the button's content
891    /// (left in LTR, right in RTL). Composes with `.icon(...)`: the
892    /// final order is `[leading_slot, icon+label, trailing_slot]`,
893    /// separated by `btn::BUTTON_ICON_LABEL_GAP`. Single-slot —
894    /// calling `.leading(...)` again replaces the previous slot.
895    /// Stack multiple widgets with an explicit `HStack`.
896    ///
897    /// The slot widget paints itself and emits its own a11y. Button
898    /// does **not** retint it (so e.g. a `ColorSwatch` keeps its own
899    /// color through every interaction state). If the slot widget
900    /// declares an AT role of its own — `ColorSwatch` is the canonical
901    /// case (`Role::ColorWell`) — pass `widget.access_hidden(true)`
902    /// so the trigger reads as a single Button node instead of a
903    /// Button containing a redundant ColorWell child.
904    pub fn leading(mut self, widget: impl Widget + 'static) -> Self {
905        self.leading = Some(Box::new(widget));
906        self
907    }
908
909    /// Same as [`leading`](Self::leading) but at the trailing edge
910    /// (right in LTR, left in RTL). Common uses: chevron-down hint
911    /// on disclosure triggers, clear-X on search fields, status
912    /// badges on segmented control segments.
913    pub fn trailing(mut self, widget: impl Widget + 'static) -> Self {
914        self.trailing = Some(Box::new(widget));
915        self
916    }
917
918    /// Construct the label `TextWidget` used inside the button's
919    /// content layout. Always routes through `text(prop)` —
920    /// `Prop::Static` and `Prop::Bound` are both handled uniformly
921    /// by the TextWidget. `new(lit!(""))` seeds the placeholder
922    /// initial text; `text` immediately overwrites it with the
923    /// prop's current value (and tracks updates for `Prop::Bound`).
924    fn make_label_text(&self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> TextWidget {
925        let mut text = TextWidget::new(lit!(""))
926            .text(self.label.clone())
927            .color(color)
928            .single_line()
929            .a11y_hidden();
930        if let Some(style) = &self.label_style {
931            text = text.style(style.clone());
932        }
933        text
934    }
935
936    /// Take the configured icon, size it, and bind its tint to `color`.
937    /// Shared by every icon-bearing `IconLocation` arm so the size /
938    /// color wiring lives in one place.
939    ///
940    /// A non-`None` `icon_location` with no icon set is a programming
941    /// error — `.icon(...)` was never called. In debug builds the
942    /// `debug_assert!` surfaces the mistake (mirroring how `Checkbox`
943    /// asserts a missing accessible label); release falls back to an
944    /// empty path so the button still lays out instead of panicking.
945    fn make_icon(&mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> IconWidget {
946        use crate::styles::recipe_button_style as btn;
947        debug_assert!(
948            self.icon.is_some(),
949            "Button: icon_location is {:?} but no icon was set via .icon(...)",
950            self.icon_location,
951        );
952        let icon = self
953            .icon
954            .take()
955            .unwrap_or_else(|| {
956                IconWidget::from_path(teksilo_canvas::Path::new(), btn::BUTTON_ICON_SIZE)
957            })
958            .icon_size(btn::BUTTON_ICON_SIZE);
959        if self.icon_keeps_color {
960            icon
961        } else {
962            icon.color(color)
963        }
964    }
965
966    /// Assemble the V2 attached-handler set (tap / hover / key / focus /
967    /// access-action) wired to `interaction`. Takes `self.action`. The
968    /// framework gates pointer / key / access events on
969    /// `arena.is_enabled(self_id)` before dispatch and the focus walker
970    /// skips disabled subtrees, so none of these closures need a
971    /// build-time enabled snapshot — that duality was removed in the
972    /// single-sourced-enabled refactor.
973    fn build_handler_set(
974        &mut self,
975        ctx: &mut BuildContext,
976        interaction: Signal<InteractionState>,
977    ) -> HandlerSet {
978        // Bundle the optional command action into the unified
979        // `on_activate` closure consumed by the shared family helper.
980        let action: Rc<Option<CommandFactory>> = Rc::new(self.action.take());
981        let on_activate: Rc<dyn Fn(&mut EventContext)> = Rc::new(move |ctx: &mut EventContext| {
982            if let Some(ref action) = *action {
983                action(ctx);
984            }
985        });
986        build_interaction_handlers(ctx, interaction, on_activate, true)
987    }
988}
989
990impl std::fmt::Debug for Button {
991    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
992        f.debug_struct("Button")
993            .field("label", &self.label.get())
994            .field("variant", &self.variant)
995            .field("enabled", &self.enabled.get())
996            .finish()
997    }
998}
999
1000// --- Label / icon color resolution ---
1001//
1002// The active `ButtonStyle` owns chrome (background fill, border, focus
1003// ring) but the inner content (label + icon) belongs to the Button
1004// itself, so it picks the text role. The mapping is intentionally
1005// minimal: `OnAccent` for variants that paint an accent fill, `Primary`
1006// for everything else, `Disabled` when the button is disabled. Custom
1007// `ButtonStyle` impls that paint a different background can request
1008// the Button to use a specific text role via `Button::text_role(...)`.
1009
1010pub(crate) fn resolve_text_role(variant: ButtonVariant, _state: InteractionState) -> TextRole {
1011    // Disabled substitution happens at the leaf paint via
1012    // `ColorProp::resolve(theme, ctx.effective_enabled)` — see
1013    // `crates/teksilo-core/src/color_prop.rs`. The composite no
1014    // longer carries `InteractionState::Disabled`; the framework's
1015    // arena enabled-state drives the dim, and the leaves convert it
1016    // into `TextRole::Disabled` at paint time.
1017    match variant {
1018        ButtonVariant::Filled | ButtonVariant::Destructive => TextRole::OnAccent,
1019        ButtonVariant::Tinted
1020        | ButtonVariant::Outlined
1021        | ButtonVariant::Plain
1022        | ButtonVariant::Ghost => TextRole::Primary,
1023        ButtonVariant::Link => TextRole::Link,
1024    }
1025}
1026
1027impl teksilo_core::widget::Widget for Button {
1028    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1029        // Layout constants for the inner content (icon size,
1030        // icon-label gap) come from the button recipe. The chrome
1031        // (padding, corner radius, fill, border) lives on the active
1032        // `ButtonStyle` impl.
1033        use crate::styles::recipe_button_style as btn;
1034        let variant = self.variant;
1035        let self_id = ctx.self_id();
1036
1037        // Forward the enabled state into the arena. After this point the
1038        // arena is the single source of truth — events, focus, a11y, and
1039        // the leaves' role-resolution all consult
1040        // `arena.is_enabled(self_id)` / `PaintContext::effective_enabled`.
1041        // The interaction signal no longer carries Disabled: that was
1042        // the snapshot duality the architecture refactor removed.
1043        ctx.enabled_when(self_id, self.enabled.clone());
1044
1045        // Reactive view of "is this widget effectively enabled?".
1046        let effective_enabled = ctx.effective_enabled_signal(self_id);
1047
1048        // Create interaction signal — caller-supplied via
1049        // `share_interaction` when set (so a wrapping widget's chrome
1050        // can mirror the label's color), otherwise allocated locally.
1051        // Seeded to Idle; the arena's enabled-state is consulted
1052        // separately via `effective_enabled`.
1053        let interaction = match self.shared_interaction.take() {
1054            Some(shared) => shared,
1055            None => ctx.signal(InteractionState::Idle),
1056        };
1057        self.interaction = interaction.clone();
1058
1059        // If an `expanded_signal` was wired up (disclosure
1060        // pattern — see `.has_popup()` / `.expanded_when()`),
1061        // register it with the framework so changes trigger a
1062        // repaint/a11y refresh on this button. Without the
1063        // binding registration, the signal updates but the
1064        // widget's `accessibility()` output won't be re-queried.
1065        if let Some(ref expanded_signal) = self.expanded_signal {
1066            let self_id = ctx.self_id();
1067            let registry = ctx.binding_registry();
1068            expanded_signal.register_if_bound(
1069                self_id,
1070                registry,
1071                teksilo_core::binding::BindingLevel::RepaintOnly,
1072            );
1073        }
1074
1075        // If `label(signal)` was used, register the prop on the
1076        // Button itself at AccessibilityOnly so `set_name` re-runs
1077        // when the signal changes. The inner `TextWidget` already
1078        // re-renders via its own `text` plumbing — this binding
1079        // is purely for the AT name.
1080        let self_id = ctx.self_id();
1081        let registry = ctx.binding_registry();
1082        self.label.register_if_bound(
1083            self_id,
1084            registry,
1085            teksilo_core::binding::BindingLevel::AccessibilityOnly,
1086        );
1087
1088        // Resolve the active `ButtonStyle` (per-call override > theme
1089        // slot > IntUI default). Both the label color (immediately below)
1090        // and the chrome (`make_body`, further down) consult it. The
1091        // lookup reads only `self.style_override` + `ctx.theme()`, so
1092        // resolving it here instead of just before `make_body` changes
1093        // nothing for existing styles.
1094        let style: SharedButtonStyle = self
1095            .style_override
1096            .clone()
1097            .or_else(|| ctx.theme().style_slots.button.clone())
1098            .unwrap_or_else(|| {
1099                Rc::new(crate::styles::RecipeButtonStyle::for_tokens(
1100                    &ctx.theme().input,
1101                ))
1102            });
1103
1104        // Label/icon color: a caller-supplied override wins over the
1105        // auto cascade. The override replaces ALL states (idle / hover /
1106        // press / focus / disabled) — chrome that uses this opts out of
1107        // interaction-driven color feedback in exchange for matching a
1108        // host's enforced text role. Both label and icon read this same
1109        // prop, so a one-line override re-tints the whole button.
1110        //
1111        // Chrome (background fill, border, focus ring) is no longer
1112        // resolved here — the active `ButtonStyle` owns it via
1113        // `make_body(cfg, ctx)` below. This widget only resolves the
1114        // CONTENT color (label + icon) since that's part of the inner
1115        // subtree we hand to the style as `cfg.label`. The active style
1116        // may also redirect the content role (`label_text_role`) — e.g.
1117        // Material 3 paints text/outlined buttons in the accent color.
1118        let text_role: teksilo_core::color_prop::ColorProp =
1119            if let Some(ref over) = self.text_role_override {
1120                over.clone()
1121            } else if let Some(role) = style.label_text_role(variant) {
1122                role.into()
1123            } else {
1124                interaction
1125                    .map(move |s| resolve_text_role(variant, *s))
1126                    .into()
1127            };
1128
1129        // Build the content (icon + label) based on icon_location. The
1130        // four directional arms (Leading/Trailing/Top/Bottom) share one
1131        // body: build the icon + label, then assemble them into an
1132        // HStack or VStack in icon-first / text-first order. Icon size /
1133        // color wiring is centralized in `make_icon`.
1134        let icon_location = self.icon_location;
1135        let content_id = match icon_location {
1136            IconLocation::None => ctx.add(self.make_label_text(text_role)),
1137            IconLocation::IconOnly => {
1138                let icon = self.make_icon(text_role);
1139                ctx.add(icon)
1140            }
1141            // Leading | Trailing | Top | Bottom
1142            loc => {
1143                let icon_first = matches!(loc, IconLocation::Leading | IconLocation::Top);
1144                let vertical = matches!(loc, IconLocation::Top | IconLocation::Bottom);
1145                let icon = self.make_icon(text_role.clone());
1146                let icon_id = ctx.add(icon);
1147                let text_id = ctx.add(self.make_label_text(text_role));
1148                let (first, second) = if icon_first {
1149                    (icon_id, text_id)
1150                } else {
1151                    (text_id, icon_id)
1152                };
1153                let row: Box<dyn Widget> = if vertical {
1154                    Box::new(
1155                        VStack::new()
1156                            .spacing(btn::BUTTON_ICON_LABEL_GAP)
1157                            .child(first)
1158                            .child(second),
1159                    )
1160                } else {
1161                    Box::new(
1162                        HStack::new()
1163                            .spacing(btn::BUTTON_ICON_LABEL_GAP)
1164                            .child(first)
1165                            .child(second),
1166                    )
1167                };
1168                ctx.add_boxed(row)
1169            }
1170        };
1171
1172        // If leading or trailing slots are set, wrap the icon+label
1173        // content in an HStack: `[leading?, content, trailing?]`. When
1174        // both slots are absent, the wrap is skipped — the original
1175        // content node goes straight into the padding, keeping the
1176        // node count identical to the pre-slot Button for the common
1177        // case.
1178        let content_id = if self.leading.is_some() || self.trailing.is_some() {
1179            let mut row = HStack::new().spacing(btn::BUTTON_ICON_LABEL_GAP);
1180            if let Some(leading) = self.leading.take() {
1181                let id = ctx.add_boxed(leading);
1182                row = row.child(id);
1183            }
1184            row = row.child(content_id);
1185            if let Some(trailing) = self.trailing.take() {
1186                let id = ctx.add_boxed(trailing);
1187                row = row.child(id);
1188            }
1189            ctx.add(row)
1190        } else {
1191            content_id
1192        };
1193
1194        // Delegate chrome (background fill, border, focus ring,
1195        // padding, min size) to the active `ButtonStyle` (resolved
1196        // above). The four boolean signals derive from the local
1197        // `interaction` state signal so the style can `.zip` them and
1198        // pick a per-state recipe slot.
1199        let is_pressed = interaction.map(|s| matches!(s, InteractionState::Pressed));
1200        let is_hovered = interaction.map(|s| matches!(s, InteractionState::Hovered));
1201        // `:focus-visible`: reveal the focus ring during keyboard navigation
1202        // only, not on a mouse click. Gate raw focus on the input-modality
1203        // signal (true after a key event, false after pointer-down).
1204        let is_focused = interaction
1205            .map(|s| matches!(s, InteractionState::Focused))
1206            .and(&ctx.focus_visible());
1207        // `is_disabled` derives from the arena's effective enabled
1208        // state — NOT from the interaction signal. The interaction
1209        // signal never carries Disabled anymore (the snapshot-based
1210        // duality was removed). Style chrome uses this to pick its
1211        // disabled-background role.
1212        let is_disabled = effective_enabled.map(|on| !*on);
1213        let cfg = ButtonStyleConfig {
1214            label: content_id,
1215            is_pressed,
1216            is_hovered,
1217            is_focused,
1218            is_disabled,
1219            variant,
1220        };
1221        let root_id = style.make_body(&cfg, ctx);
1222
1223        // Attach tooltip if configured. The three setters
1224        // (`tooltip`, `rich_tooltip*`, `composite_tooltip`) are
1225        // mutually exclusive — every setter clears the other two so
1226        // exactly one branch runs.
1227        if let Some(content) = self.composite_tooltip_content.take() {
1228            let delay = ctx.theme().motion.tooltip_delay_heavy;
1229            crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
1230        } else if let Some(source) = self.rich_tooltip_source.take() {
1231            let delay = ctx.theme().motion.tooltip_delay;
1232            crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
1233        } else if let Some(tooltip_text) = self.tooltip_text.clone() {
1234            let delay = ctx.theme().motion.tooltip_delay;
1235            crate::tooltip::attach_plain_tooltip(ctx, root_id, tooltip_text, delay);
1236        }
1237
1238        self.root_child_id = Some(root_id);
1239
1240        let handlers = self.build_handler_set(ctx, interaction);
1241        ctx.apply_self_handlers(handlers);
1242
1243        vec![root_id]
1244    }
1245
1246    fn layout_response(
1247        &self,
1248        proposal: SizeProposal,
1249        ctx: &LayoutContext,
1250    ) -> teksilo_core::widget::LayoutResponse {
1251        // A Button is rigid: it sizes to its content and does NOT shrink in an
1252        // over-constrained row (a truncated action label reads
1253        // poorly — the desktop convention is to overflow excess actions into a
1254        // menu; see `Toolbar`). We therefore take only the content's SIZE and
1255        // drop its grow/shrink weights. The label still truncates if a caller
1256        // explicitly constrains the button (e.g. via `FixedSize` / `Shrinkable`).
1257        match self.root_child_id {
1258            Some(root_id) => ctx
1259                .child_size(root_id, proposal)
1260                .unwrap_or_else(|| proposal.resolve(0.0, 0.0)),
1261            None => proposal.resolve(0.0, 0.0),
1262        }
1263        .into()
1264    }
1265
1266    fn place_children(
1267        &self,
1268        bounds: Rect,
1269        _proposal: SizeProposal,
1270        children: &mut [WidgetPlacement],
1271        _ctx: &LayoutContext,
1272    ) {
1273        // Single child fills our bounds
1274        for child in children.iter_mut() {
1275            child.origin = bounds.origin();
1276            child.size = bounds.size();
1277        }
1278    }
1279
1280    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1281        builder.set_role(teksilo_core::accesskit::Role::Button);
1282        // Read the current label value uniformly through `Prop::get`
1283        // — Static returns the captured `String`; Bound returns the
1284        // signal's current value. Keeps AT in sync with `label`.
1285        builder.set_name(self.label.get());
1286        // `set_disabled()` is now driven by the framework's
1287        // accessibility walker from `arena.is_enabled(self_id)`. The
1288        // composite no longer needs to mirror it — the snapshot path
1289        // was redundant with the arena and broke under reactive
1290        // `enabled_when(id, signal)` flips.
1291        // ARIA disclosure pattern: a button that opens a popup
1292        // should declare `has_popup` and, if the wrapper tracks
1293        // it, `expanded`. Both are opt-in — regular buttons with
1294        // no popup stay silent on these properties.
1295        if let Some(kind) = self.has_popup {
1296            builder.set_has_popup(kind);
1297        }
1298        if let Some(ref signal) = self.expanded_signal {
1299            builder.set_expanded(signal.get());
1300        }
1301        builder.add_action(teksilo_core::accesskit::Action::Click);
1302        builder.add_action(teksilo_core::accesskit::Action::Focus);
1303    }
1304
1305    fn children(&self) -> Vec<WidgetId> {
1306        match self.root_child_id {
1307            Some(id) => vec![id],
1308            None => Vec::new(),
1309        }
1310    }
1311}
1312
1313#[cfg(test)]
1314mod tests {
1315    use super::*;
1316    use std::cell::{Cell, RefCell};
1317    use std::rc::Rc;
1318    use teksilo_core::event::{Modifiers, WidgetEvent};
1319    use teksilo_core::widget_tree::WidgetTree;
1320
1321    #[test]
1322    fn focus_ring_only_under_focus_visible() {
1323        // `:focus-visible`: the focus ring shows during keyboard navigation
1324        // but not when focus arrived via a pointer click. Programmatic focus
1325        // leaves `focus_visible` false, so a focused-but-not-keyboard button
1326        // shows no ring; a key press flips the modality and reveals it.
1327        let theme = teksilo_core::presets::intui::light();
1328        let ring = theme.colors.border_focused.to_array();
1329        let mut tree = WidgetTree::new().with_theme(theme);
1330        let btn = tree.add(Button::new(lit!("T")).on_activate_fn(|_| {}));
1331        tree.layout(SizeProposal::exact(200.0, 80.0));
1332
1333        // Focused, but `focus_visible` is still false → ring gated OFF even
1334        // though the widget holds focus.
1335        tree.focus(btn);
1336        assert!(
1337            !frame_has_color(&tree.render(), ring),
1338            "no focus ring while focus-visible is false (pointer modality)",
1339        );
1340
1341        // A key event flips `focus_visible` true → ring appears (focus held).
1342        tree.press_key(Key::ArrowDown, Modifiers::NONE);
1343        assert!(
1344            frame_has_color(&tree.render(), ring),
1345            "focus ring shows under keyboard modality",
1346        );
1347    }
1348
1349    /// Whether `color` appears in any color-bearing layer of the frame —
1350    /// borders land in `shapes` (stroked SDF quads), `decorations`
1351    /// (`DecorationRect`), or `cosmetic_lines` depending on the widget.
1352    fn frame_has_color(frame: &teksilo_canvas::RenderFrame, color: [f32; 4]) -> bool {
1353        frame.shapes.iter().any(|s| s.color == color)
1354            || frame.decorations.iter().any(|d| d.color == color)
1355            || frame.cosmetic_lines.iter().any(|l| l.color == color)
1356    }
1357
1358    #[test]
1359    fn filled_button_accent_desaturates_when_window_inactive() {
1360        // The Filled button bakes its fill via the theme signal
1361        // (`ColorProp::Bound`), which a plain `theme_signal` resolution would
1362        // freeze at the active accent — so it must resolve against the
1363        // window-active palette to grey out like the paint-resolving controls.
1364        let theme = teksilo_core::presets::intui::light();
1365        let accent = theme.colors.accent.to_array();
1366        let inactive_accent = theme.colors.for_inactive_window().accent.to_array();
1367        assert_ne!(accent, inactive_accent);
1368
1369        let mut tree = WidgetTree::new().with_theme(theme);
1370        tree.add(Button::new(lit!("Save")).variant(ButtonVariant::Filled));
1371        tree.layout(SizeProposal::exact(200.0, 80.0));
1372
1373        // Active: vivid accent fill.
1374        assert!(
1375            frame_has_color(&tree.render(), accent),
1376            "active window: Filled button paints the vivid accent"
1377        );
1378
1379        // Inactive: the fill desaturates with every other accent control.
1380        tree.set_window_active(false);
1381        let frame = tree.render();
1382        assert!(
1383            frame_has_color(&frame, inactive_accent),
1384            "inactive window: Filled button fill desaturates"
1385        );
1386        assert!(
1387            !frame_has_color(&frame, accent),
1388            "inactive window: no vivid accent remains"
1389        );
1390
1391        // Reactivate: vivid accent returns.
1392        tree.set_window_active(true);
1393        assert!(frame_has_color(&tree.render(), accent));
1394    }
1395
1396    #[test]
1397    fn keyup_without_keydown_does_not_fire() {
1398        // Regression for the MessageBox reopen bug: when a shortcut
1399        // consumes Enter's KeyDown (dismissing the modal and restoring
1400        // focus to the trigger button), the trailing KeyUp must not
1401        // re-activate the trigger.
1402        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1403        let fired = Rc::new(Cell::new(0_u32));
1404        let fired_for_btn = fired.clone();
1405        let btn = tree.add(Button::new(lit!("T")).on_activate_fn(move |_ctx| {
1406            fired_for_btn.set(fired_for_btn.get() + 1);
1407        }));
1408        tree.layout(SizeProposal::exact(200.0, 80.0));
1409        tree.focus(btn);
1410
1411        tree.dispatch_event(WidgetEvent::KeyUp {
1412            key: Key::Enter,
1413            modifiers: Modifiers::NONE,
1414        });
1415        assert_eq!(
1416            fired.get(),
1417            0,
1418            "a lone KeyUp (no matching KeyDown) must not activate the button",
1419        );
1420
1421        tree.dispatch_event(WidgetEvent::KeyDown {
1422            key: Key::Enter,
1423            modifiers: Modifiers::NONE,
1424            text: None,
1425        });
1426        tree.dispatch_event(WidgetEvent::KeyUp {
1427            key: Key::Enter,
1428            modifiers: Modifiers::NONE,
1429        });
1430        assert_eq!(
1431            fired.get(),
1432            1,
1433            "a matched KeyDown + KeyUp pair must activate exactly once",
1434        );
1435    }
1436
1437    // Helper: lay out a Target button (left) and an Open trigger (right)
1438    // side by side, then open a click-opened overlay anchored to the
1439    // trigger and parked below the bar. Returns the tree plus the pieces
1440    // the dismiss-passthrough tests assert on.
1441    fn open_overlay_beside_button() -> (
1442        WidgetTree,
1443        teksilo_core::widget_id::WidgetId, // target
1444        teksilo_core::widget_id::WidgetId, // trigger
1445        teksilo_core::widget_id::WidgetId, // overlay content
1446        Rc<Cell<u32>>,                     // target activations
1447        Rc<Cell<u32>>,                     // trigger activations
1448    ) {
1449        use teksilo_core::overlay::{
1450            DismissBehavior, OverlayLayer, OverlayPlacement, OverlayRequest,
1451        };
1452
1453        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1454        let target_fired = Rc::new(Cell::new(0_u32));
1455        let tf = target_fired.clone();
1456        let trigger_fired = Rc::new(Cell::new(0_u32));
1457        let gf = trigger_fired.clone();
1458
1459        let target =
1460            tree.add(Button::new(lit!("Target")).on_activate_fn(move |_| tf.set(tf.get() + 1)));
1461        let trigger =
1462            tree.add(Button::new(lit!("Open")).on_activate_fn(move |_| gf.set(gf.get() + 1)));
1463        let content = tree.add(Button::new(lit!("Item")));
1464        let _root = tree.add(
1465            crate::primitives::HStack::new()
1466                .spacing(40.0)
1467                .child(target)
1468                .child(trigger),
1469        );
1470        tree.layout(SizeProposal::exact(400.0, 200.0));
1471
1472        tree.show_overlay(OverlayRequest {
1473            content_id: content,
1474            anchor: trigger,
1475            placement: OverlayPlacement::Below,
1476            dismiss: DismissBehavior::EscapeOrClickOutside,
1477            layer: OverlayLayer::InTree,
1478            parent_overlay: None,
1479            on_dismiss: None,
1480            fade_duration: None,
1481        });
1482        // Second layout positions the overlay content below the trigger.
1483        tree.layout(SizeProposal::exact(400.0, 200.0));
1484
1485        (tree, target, trigger, content, target_fired, trigger_fired)
1486    }
1487
1488    #[test]
1489    fn dismiss_click_activates_button_beneath() {
1490        // The reported quirk: with a dropdown/menu open, clicking another
1491        // widget should dismiss the overlay AND activate that widget in a
1492        // single click — not require a throwaway first click.
1493        use teksilo_core::event::PointerButton;
1494
1495        let (mut tree, target, _trigger, _content, target_fired, trigger_fired) =
1496            open_overlay_beside_button();
1497
1498        let tb = tree.bounds(target);
1499        let target_center =
1500            teksilo_canvas::Point::new(tb.x + tb.width / 2.0, tb.y + tb.height / 2.0);
1501        // The overlay is parked below the button bar; the dismiss assertion
1502        // after dispatch confirms this click lands outside it.
1503        assert_eq!(tree.active_overlays().len(), 1);
1504
1505        tree.dispatch_event(WidgetEvent::pointer_down(
1506            target_center,
1507            PointerButton::Primary,
1508            Modifiers::NONE,
1509        ));
1510        tree.dispatch_event(WidgetEvent::pointer_up(
1511            target_center,
1512            PointerButton::Primary,
1513            Modifiers::NONE,
1514        ));
1515
1516        assert!(
1517            tree.active_overlays().is_empty(),
1518            "the press should dismiss the open overlay",
1519        );
1520        assert_eq!(
1521            target_fired.get(),
1522            1,
1523            "the same press should activate the button beneath the dismissed overlay",
1524        );
1525        assert_eq!(trigger_fired.get(), 0);
1526    }
1527
1528    #[test]
1529    fn dismiss_click_on_trigger_is_consumed_not_reactivated() {
1530        // The anchor guard: clicking the trigger that owns an open overlay
1531        // must merely close it. The press is consumed, so it can't reach
1532        // the trigger's own tap handler and reopen what it just closed.
1533        use teksilo_core::event::PointerButton;
1534
1535        let (mut tree, _target, trigger, _content, _target_fired, trigger_fired) =
1536            open_overlay_beside_button();
1537
1538        let gb = tree.bounds(trigger);
1539        let trigger_center =
1540            teksilo_canvas::Point::new(gb.x + gb.width / 2.0, gb.y + gb.height / 2.0);
1541        assert_eq!(tree.active_overlays().len(), 1);
1542
1543        tree.dispatch_event(WidgetEvent::pointer_down(
1544            trigger_center,
1545            PointerButton::Primary,
1546            Modifiers::NONE,
1547        ));
1548        tree.dispatch_event(WidgetEvent::pointer_up(
1549            trigger_center,
1550            PointerButton::Primary,
1551            Modifiers::NONE,
1552        ));
1553
1554        assert!(
1555            tree.active_overlays().is_empty(),
1556            "clicking the trigger should close its overlay",
1557        );
1558        assert_eq!(
1559            trigger_fired.get(),
1560            0,
1561            "the dismiss press on the anchor must be consumed, not delivered to the trigger",
1562        );
1563    }
1564
1565    #[test]
1566    fn label_updates_at_name_when_signal_changes() {
1567        // Regression for the calendar header use case: a Button bound
1568        // to a `Signal<String>` must (1) display the signal's current
1569        // value and (2) refresh its accessibility name when the
1570        // signal changes — without rebuilding the parent.
1571        use teksilo_core::accessibility::widget_id_to_node_id;
1572        let label = Signal::new("May 2026".to_string());
1573        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1574        let id = tree.add(
1575            Button::new(lit!(""))
1576                .label(label.clone())
1577                .on_activate_fn(|_| {}),
1578        );
1579        tree.layout(SizeProposal::exact(300.0, 80.0));
1580        let target = widget_id_to_node_id(id);
1581        let update = tree.sync_accessibility();
1582        let (_, node) = update
1583            .nodes
1584            .iter()
1585            .find(|(nid, _)| *nid == target)
1586            .expect("button node");
1587        assert_eq!(node.label().unwrap_or_default(), "May 2026");
1588
1589        // Flip the signal — AT name should refresh after the next
1590        // layout pass (the label registration triggers a
1591        // re-evaluation of `accessibility()`).
1592        label.set("2026".to_string());
1593        tree.layout(SizeProposal::exact(300.0, 80.0));
1594        let update = tree.sync_accessibility();
1595        let (_, node) = update
1596            .nodes
1597            .iter()
1598            .find(|(nid, _)| *nid == target)
1599            .expect("button node after relabel");
1600        assert_eq!(node.label().unwrap_or_default(), "2026");
1601    }
1602
1603    #[test]
1604    fn slots_widen_button_to_accommodate_their_intrinsic_size() {
1605        // A button with leading + trailing slots reports a wider
1606        // intrinsic size than the same button without slots — proves
1607        // the slots actually entered the layout pass. Layout uses
1608        // `unspecified()` so each button reports its intrinsic width
1609        // rather than getting stretched to a parent proposal. Both
1610        // sides also clear the theme's `min_width` (~72dp) which
1611        // would otherwise mask the slot contribution on the plain
1612        // button.
1613        use crate::primitives::MinSize;
1614        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1615        let plain = tree.add(Button::new(lit!("X")).on_activate_fn(|_| {}));
1616        let with_slots = tree.add(
1617            Button::new(lit!("X"))
1618                .leading(MinSize::new(120.0, 12.0))
1619                .trailing(MinSize::new(120.0, 12.0))
1620                .on_activate_fn(|_| {}),
1621        );
1622        tree.layout(SizeProposal::unspecified());
1623        let plain_w = tree.bounds(plain).width;
1624        let slot_w = tree.bounds(with_slots).width;
1625        assert!(
1626            slot_w >= plain_w + 200.0,
1627            "expected slot button to be at least 200dp wider than plain (plain={plain_w}, slot={slot_w})",
1628        );
1629    }
1630
1631    #[test]
1632    fn button_is_rigid_and_does_not_shrink_in_a_tight_row() {
1633        // A Button is rigid: in an over-constrained row it keeps its natural
1634        // width (overflows) rather than truncating its action label. The
1635        // desktop convention is to overflow excess actions into a menu (see
1636        // `Toolbar`), not to silently truncate buttons.
1637        use crate::primitives::hstack::HStack;
1638        let mut tree = WidgetTree::new()
1639            .with_theme(teksilo_core::presets::intui::light())
1640            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1641                teksilo_canvas::MockTextBackend::new(),
1642            )));
1643        let btn = tree.add(Button::new(lit!("Save Document As…")).on_activate_fn(|_| {}));
1644        let _row = tree.add(HStack::new().child(btn));
1645
1646        tree.layout(SizeProposal::unspecified());
1647        let natural = tree.bounds(btn).width;
1648        // Squeeze the row far below natural — the Button keeps its full width.
1649        tree.layout(SizeProposal::exact(70.0, 40.0));
1650        let squeezed = tree.bounds(btn).width;
1651
1652        assert!(
1653            natural > 100.0,
1654            "expected a wide natural button, got {natural}"
1655        );
1656        assert!(
1657            (squeezed - natural).abs() < 0.5,
1658            "button should stay rigid at its natural width \
1659             (natural={natural}, squeezed={squeezed})"
1660        );
1661    }
1662
1663    #[test]
1664    fn framework_default_blocks_secondary_tap_on_button() {
1665        // Framework default: `TapRecognizer::accept = ButtonMask::PRIMARY`.
1666        // A right-click on a Button does NOT activate. Generalises the
1667        // tab-specific `primary_click_activates_tab_secondary_does_not`
1668        // regression to every widget that wires `on_tap`.
1669        use teksilo_core::event::PointerButton;
1670        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1671        let fired = Rc::new(Cell::new(0_u32));
1672        let fired_for_btn = fired.clone();
1673        let btn = tree.add(Button::new(lit!("T")).on_activate_fn(move |_ctx| {
1674            fired_for_btn.set(fired_for_btn.get() + 1);
1675        }));
1676        tree.layout(SizeProposal::exact(200.0, 80.0));
1677        let center = tree.bounds(btn).center();
1678
1679        tree.pointer_down_button(center, PointerButton::Secondary);
1680        tree.pointer_up_button(center, PointerButton::Secondary);
1681        assert_eq!(fired.get(), 0, "right-click must not activate a Button");
1682
1683        tree.pointer_down_button(center, PointerButton::Middle);
1684        tree.pointer_up_button(center, PointerButton::Middle);
1685        assert_eq!(fired.get(), 0, "middle-click must not activate a Button");
1686
1687        // Sanity: primary click still activates.
1688        tree.pointer_down_button(center, PointerButton::Primary);
1689        tree.pointer_up_button(center, PointerButton::Primary);
1690        assert_eq!(fired.get(), 1, "primary-click must activate a Button");
1691    }
1692
1693    #[test]
1694    fn framework_accept_tap_buttons_secondary_fires_handler() {
1695        // `accept_tap_buttons` opts the auto-wired `TapRecognizer` into
1696        // a wider button set. With `Secondary` allowed, right-click
1697        // activates.
1698        use teksilo_core::event::{ButtonMask, PointerButton};
1699        use teksilo_core::widget_builder::WidgetBuilder;
1700        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1701        let fired = Rc::new(Cell::new(0_u32));
1702        let fired_for_btn = fired.clone();
1703        let btn = tree.add(
1704            Button::new(lit!("T"))
1705                .on_activate_fn(move |_ctx| {
1706                    fired_for_btn.set(fired_for_btn.get() + 1);
1707                })
1708                .accept_tap_buttons(ButtonMask::PRIMARY | ButtonMask::SECONDARY),
1709        );
1710        tree.layout(SizeProposal::exact(200.0, 80.0));
1711        let center = tree.bounds(btn).center();
1712
1713        tree.pointer_down_button(center, PointerButton::Secondary);
1714        tree.pointer_up_button(center, PointerButton::Secondary);
1715        assert_eq!(
1716            fired.get(),
1717            1,
1718            "right-click must activate a Button when accept_tap_buttons includes Secondary",
1719        );
1720
1721        tree.pointer_down_button(center, PointerButton::Primary);
1722        tree.pointer_up_button(center, PointerButton::Primary);
1723        assert_eq!(fired.get(), 2, "primary-click still activates");
1724    }
1725
1726    #[test]
1727    fn hidden_slot_marks_swatch_node_as_at_hidden() {
1728        // ColorSwatch declares `Role::ColorWell`. Dropped raw into a
1729        // Button slot it would appear as a redundant ColorWell child
1730        // under the Button's node. `.access_hidden(true)` is the
1731        // documented escape hatch — confirm the swatch's AT node
1732        // carries the hidden flag (AT readers skip nodes flagged
1733        // hidden, even though the node still exists in the tree).
1734        use crate::color_picker::ColorSwatch;
1735        use teksilo_core::accessibility::widget_id_to_node_id;
1736        use teksilo_core::accesskit::Role;
1737        use teksilo_core::widget_builder::WidgetBuilder;
1738        use teksilo_tokens::Color;
1739        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1740        let id = tree.add(
1741            Button::new(lit!("Pick"))
1742                .leading(ColorSwatch::new(Color::RED).access_hidden(true))
1743                .on_activate_fn(|_| {}),
1744        );
1745        tree.layout(SizeProposal::exact(300.0, 80.0));
1746        let target = widget_id_to_node_id(id);
1747        let update = tree.sync_accessibility();
1748        let (_, btn_node) = update
1749            .nodes
1750            .iter()
1751            .find(|(nid, _)| *nid == target)
1752            .expect("button node");
1753        assert_eq!(btn_node.role(), Role::Button);
1754        let color_well_visible = update
1755            .nodes
1756            .iter()
1757            .any(|(_, n)| n.role() == Role::ColorWell && !n.is_hidden());
1758        assert!(
1759            !color_well_visible,
1760            "hidden swatch should not emit a non-hidden ColorWell node",
1761        );
1762    }
1763
1764    #[test]
1765    fn plain_button_is_a_leaf_no_group_node() {
1766        // Regression: a Button's chrome is composed from layout primitives
1767        // (Padding/Center/HStack/…) that emit empty GenericContainer /
1768        // Unknown AT nodes. VoiceOver announces a GenericContainer as
1769        // "group", so the button read as "<label>, button, group". The AT
1770        // walker now collapses presentational nodes — assert the button is
1771        // a clean leaf and no grouping node survives anywhere.
1772        use teksilo_core::accessibility::widget_id_to_node_id;
1773        use teksilo_core::accesskit::Role;
1774        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1775        let id = tree.add(Button::new(lit!("Valider")).on_activate_fn(|_| {}));
1776        tree.layout(SizeProposal::exact(300.0, 80.0));
1777        let _ = tree.render();
1778        let update = tree.sync_accessibility();
1779
1780        assert!(
1781            !update
1782                .nodes
1783                .iter()
1784                .any(|(_, n)| n.role() == Role::GenericContainer),
1785            "no GenericContainer ('group') node should remain in the AT tree"
1786        );
1787
1788        let (_, btn) = update
1789            .nodes
1790            .iter()
1791            .find(|(nid, _)| *nid == widget_id_to_node_id(id))
1792            .expect("button node present");
1793        assert_eq!(btn.role(), Role::Button);
1794        assert_eq!(btn.label(), Some("Valider"));
1795        let has_visible_child = btn.children().iter().any(|cid| {
1796            update
1797                .nodes
1798                .iter()
1799                .find(|(nid, _)| nid == cid)
1800                .is_some_and(|(_, n)| !n.is_hidden())
1801        });
1802        assert!(
1803            !has_visible_child,
1804            "button should expose no visible AT child node (it is a leaf)"
1805        );
1806    }
1807
1808    #[test]
1809    fn theme_slot_supplies_button_style_when_no_override() {
1810        // End-to-end check that `theme.style_slots.button = Some(rc)`
1811        // actually feeds the widget when no per-call `.style(...)`
1812        // override is present. Uses a custom `ButtonStyle` that adds a
1813        // sentinel `RectWidget` we can spot in the rendered frame.
1814        use teksilo_core::styles::{ButtonStyle, ButtonStyleConfig};
1815        use teksilo_tokens::Color;
1816
1817        struct SentinelButton;
1818        impl ButtonStyle for SentinelButton {
1819            fn make_body(
1820                &self,
1821                cfg: &ButtonStyleConfig,
1822                ctx: &mut teksilo_core::build_context::BuildContext,
1823            ) -> teksilo_core::widget_id::WidgetId {
1824                // Distinctive bright-magenta background nobody else paints.
1825                let bg = ctx.add(
1826                    crate::primitives::RectWidget::new()
1827                        .background(Color::new(1.0, 0.0, 1.0, 1.0))
1828                        .corner_radius(teksilo_tokens::CornerRadius::uniform(0.0)),
1829                );
1830                ctx.add(crate::primitives::ZStack::new().child(bg).child(cfg.label))
1831            }
1832        }
1833
1834        let mut theme = teksilo_core::presets::intui::light();
1835        theme.style_slots.button = Some(Rc::new(SentinelButton));
1836        let mut tree = WidgetTree::new().with_theme(theme);
1837        let _btn = tree.add(Button::new(lit!("T")).on_activate_fn(|_| {}));
1838        tree.layout(SizeProposal::exact(200.0, 80.0));
1839        let frame = tree.render();
1840
1841        let sentinel = [1.0_f32, 0.0, 1.0, 1.0];
1842        assert!(
1843            frame.shapes.iter().any(|s| s.color == sentinel),
1844            "the theme's `style_slots.button` impl should drive Button chrome \
1845             — saw no sentinel magenta rect in the rendered frame",
1846        );
1847    }
1848
1849    #[test]
1850    fn style_label_text_role_overrides_default_label_color() {
1851        // A `ButtonStyle` returning `Some(role)` from `label_text_role`
1852        // redirects the label/icon color — the Material 3 "text and
1853        // outlined buttons are accent-colored" need. Styles that return
1854        // `None` (the IntUI default) keep the Button's built-in mapping,
1855        // so this is purely additive (the rest of the suite covers the
1856        // default path).
1857        use std::cell::RefCell;
1858        use teksilo_canvas::MockTextBackend;
1859        use teksilo_core::styles::{ButtonStyle, ButtonStyleConfig, ButtonVariant};
1860        use teksilo_tokens::TextRole;
1861
1862        struct LabelRoleSentinel;
1863        impl ButtonStyle for LabelRoleSentinel {
1864            fn make_body(
1865                &self,
1866                cfg: &ButtonStyleConfig,
1867                ctx: &mut teksilo_core::build_context::BuildContext,
1868            ) -> teksilo_core::widget_id::WidgetId {
1869                ctx.add(crate::primitives::ZStack::new().child(cfg.label))
1870            }
1871            fn label_text_role(&self, _variant: ButtonVariant) -> Option<TextRole> {
1872                Some(TextRole::Error)
1873            }
1874        }
1875
1876        let want = teksilo_core::presets::intui::light()
1877            .colors
1878            .text_error
1879            .to_array();
1880        let mut theme = teksilo_core::presets::intui::light();
1881        theme.style_slots.button = Some(Rc::new(LabelRoleSentinel));
1882        let mut tree = WidgetTree::new()
1883            .with_theme(theme)
1884            .with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())));
1885        let _btn = tree.add(Button::new(lit!("T")).on_activate_fn(|_| {}));
1886        tree.layout(SizeProposal::exact(200.0, 80.0));
1887        let frame = tree.render();
1888
1889        assert!(
1890            frame.glyphs.iter().any(|g| g.color == want),
1891            "style.label_text_role(...) should drive the label glyph color; \
1892             expected the theme error color {want:?}, saw {:?}",
1893            frame.glyphs.iter().map(|g| g.color).collect::<Vec<_>>(),
1894        );
1895    }
1896
1897    #[test]
1898    fn per_call_style_override_wins_over_theme_slot() {
1899        // When both `Button::style(...)` AND `theme.style_slots.button`
1900        // are set, the per-call wins. Verified by installing a sentinel
1901        // style on the theme then a *different* sentinel via `.style()`.
1902        use teksilo_core::styles::{ButtonStyle, ButtonStyleConfig};
1903        use teksilo_tokens::Color;
1904
1905        struct ThemeSentinel;
1906        impl ButtonStyle for ThemeSentinel {
1907            fn make_body(
1908                &self,
1909                cfg: &ButtonStyleConfig,
1910                ctx: &mut teksilo_core::build_context::BuildContext,
1911            ) -> teksilo_core::widget_id::WidgetId {
1912                let bg = ctx.add(
1913                    crate::primitives::RectWidget::new()
1914                        .background(Color::new(1.0, 0.0, 1.0, 1.0)) // magenta
1915                        .corner_radius(teksilo_tokens::CornerRadius::uniform(0.0)),
1916                );
1917                ctx.add(crate::primitives::ZStack::new().child(bg).child(cfg.label))
1918            }
1919        }
1920
1921        struct CallSentinel;
1922        impl ButtonStyle for CallSentinel {
1923            fn make_body(
1924                &self,
1925                cfg: &ButtonStyleConfig,
1926                ctx: &mut teksilo_core::build_context::BuildContext,
1927            ) -> teksilo_core::widget_id::WidgetId {
1928                let bg = ctx.add(
1929                    crate::primitives::RectWidget::new()
1930                        .background(Color::new(0.0, 1.0, 0.0, 1.0)) // green
1931                        .corner_radius(teksilo_tokens::CornerRadius::uniform(0.0)),
1932                );
1933                ctx.add(crate::primitives::ZStack::new().child(bg).child(cfg.label))
1934            }
1935        }
1936
1937        let mut theme = teksilo_core::presets::intui::light();
1938        theme.style_slots.button = Some(Rc::new(ThemeSentinel));
1939        let mut tree = WidgetTree::new().with_theme(theme);
1940        let _btn = tree.add(
1941            Button::new(lit!("T"))
1942                .style(CallSentinel)
1943                .on_activate_fn(|_| {}),
1944        );
1945        tree.layout(SizeProposal::exact(200.0, 80.0));
1946        let frame = tree.render();
1947
1948        let magenta = [1.0_f32, 0.0, 1.0, 1.0];
1949        let green = [0.0_f32, 1.0, 0.0, 1.0];
1950        assert!(
1951            frame.shapes.iter().any(|s| s.color == green),
1952            "per-call .style(...) override should drive chrome — no green rect found",
1953        );
1954        assert!(
1955            !frame.shapes.iter().any(|s| s.color == magenta),
1956            "theme slot must be ignored when per-call override is set — magenta should not appear",
1957        );
1958    }
1959    // -----------------------------------------------------------------
1960    // The framework press (docs/touch-and-pen.md §7.1)
1961    // -----------------------------------------------------------------
1962
1963    /// A `ButtonStyle` that hands the interaction signals its chrome reads back
1964    /// to the test, so the press *visual* and the resting state can be asserted
1965    /// through the surface a real style sees rather than through the router's
1966    /// own bookkeeping.
1967    struct PressProbe(Rc<RefCell<Option<(Signal<bool>, Signal<bool>)>>>);
1968
1969    impl teksilo_core::styles::ButtonStyle for PressProbe {
1970        fn make_body(
1971            &self,
1972            cfg: &teksilo_core::styles::ButtonStyleConfig,
1973            ctx: &mut BuildContext,
1974        ) -> WidgetId {
1975            *self.0.borrow_mut() = Some((cfg.is_pressed.clone(), cfg.is_hovered.clone()));
1976            ctx.add(crate::primitives::ZStack::new().child(cfg.label))
1977        }
1978    }
1979
1980    /// A button, its press-visual signal, its hover-visual signal, and how many
1981    /// times it activated.
1982    fn probed_button_with_hover() -> (
1983        WidgetTree,
1984        WidgetId,
1985        Signal<bool>,
1986        Signal<bool>,
1987        Rc<Cell<u32>>,
1988    ) {
1989        let probe: Rc<RefCell<Option<(Signal<bool>, Signal<bool>)>>> = Rc::new(RefCell::new(None));
1990        let hits = Rc::new(Cell::new(0_u32));
1991        let counter = hits.clone();
1992        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1993        let btn = tree.add(
1994            Button::new(lit!("Save"))
1995                .style(PressProbe(probe.clone()))
1996                .on_activate_fn(move |_| counter.set(counter.get() + 1)),
1997        );
1998        tree.layout(SizeProposal::exact(200.0, 80.0));
1999        let (pressed, hovered) = probe.borrow().clone().expect("style ran");
2000        (tree, btn, pressed, hovered, hits)
2001    }
2002
2003    /// A button, its press-visual signal, and how many times it activated.
2004    fn probed_button() -> (WidgetTree, WidgetId, Signal<bool>, Rc<Cell<u32>>) {
2005        let (tree, btn, pressed, _hovered, hits) = probed_button_with_hover();
2006        (tree, btn, pressed, hits)
2007    }
2008
2009    fn mouse_at(tree: &mut WidgetTree, at: teksilo_canvas::Point, down: bool) {
2010        let event = if down {
2011            WidgetEvent::pointer_down(
2012                at,
2013                teksilo_core::event::PointerButton::Primary,
2014                Modifiers::NONE,
2015            )
2016        } else {
2017            WidgetEvent::pointer_up(
2018                at,
2019                teksilo_core::event::PointerButton::Primary,
2020                Modifiers::NONE,
2021            )
2022        };
2023        tree.dispatch_event(event);
2024    }
2025
2026    /// The mouse path, unchanged: press lights the visual, release puts it out
2027    /// and activates once.
2028    #[test]
2029    fn a_mouse_click_presses_then_activates_on_release() {
2030        let (mut tree, btn, pressed, hits) = probed_button();
2031        let at = tree.bounds(btn).center();
2032        tree.dispatch_event(WidgetEvent::pointer_move(at));
2033        mouse_at(&mut tree, at, true);
2034        assert!(pressed.get(), "a mouse press lights the pressed visual");
2035        assert_eq!(hits.get(), 0, "nothing has activated on the press");
2036        mouse_at(&mut tree, at, false);
2037        assert!(!pressed.get(), "the release puts the visual out");
2038        assert_eq!(hits.get(), 1, "activation lands on the release");
2039    }
2040
2041    /// A finger: the same two steps, with no hover anywhere in them.
2042    #[test]
2043    fn a_touch_tap_activates_on_release() {
2044        use super::press_test_support::{finger, touch};
2045        use teksilo_core::pointer::PointerPhase;
2046
2047        let (mut tree, btn, pressed, hits) = probed_button();
2048        let at = tree.bounds(btn).center();
2049        let id = finger();
2050        tree.dispatch_pointer(touch(id, PointerPhase::Down, at, 0));
2051        assert!(pressed.get(), "a contact on a button lights it at once");
2052        assert_eq!(hits.get(), 0, "a press is not an activation");
2053        tree.dispatch_pointer(touch(id, PointerPhase::Up, at, 40));
2054        assert_eq!(hits.get(), 1, "the release activates");
2055        assert!(!pressed.get(), "and clears the visual");
2056    }
2057
2058    /// WCAG 2.2 SC 2.5.2: sliding off abandons the press and sliding back on
2059    /// restores the *visual*.
2060    ///
2061    /// The activation does not come back with it, and that is the framework's
2062    /// contract rather than this control's choice:
2063    /// [`TapRecognizer`](teksilo_core::gesture::TapRecognizer) clears its
2064    /// recorded press position the moment the pointer leaves the tap boundary
2065    /// (`gesture/tap.rs`, the `Move` arm), so the failure is terminal, while
2066    /// the router's press record is reversible. Pinned here so a later change
2067    /// to either half has to change this test deliberately —
2068    /// `docs/touch-and-pen.md` §7.1 currently says the two "can never
2069    /// disagree", which holds for the predicate but not for its latching.
2070    #[test]
2071    fn a_touch_press_disarms_on_slide_off_and_re_arms_on_re_entry() {
2072        use super::press_test_support::{finger, touch};
2073        use teksilo_core::pointer::PointerPhase;
2074
2075        let (mut tree, btn, pressed, hits) = probed_button();
2076        let bounds = tree.bounds(btn);
2077        let at = bounds.center();
2078        let away = teksilo_canvas::Point::new(at.x, bounds.y + bounds.height + 60.0);
2079        let id = finger();
2080        tree.dispatch_pointer(touch(id, PointerPhase::Down, at, 0));
2081        assert!(pressed.get());
2082        tree.dispatch_pointer(touch(id, PointerPhase::Move, away, 20));
2083        assert!(!pressed.get(), "the press slid off its target");
2084        tree.dispatch_pointer(touch(id, PointerPhase::Move, at, 40));
2085        assert!(pressed.get(), "and came back — the visual is reversible");
2086        tree.dispatch_pointer(touch(id, PointerPhase::Up, at, 60));
2087        assert_eq!(
2088            hits.get(),
2089            0,
2090            "the tap recognizer's failure is terminal, so the release that \
2091             follows an excursion activates nothing",
2092        );
2093    }
2094
2095    /// A release that lands off the button activates nothing and leaves no
2096    /// visual behind.
2097    #[test]
2098    fn a_touch_release_off_the_button_activates_nothing() {
2099        use super::press_test_support::{finger, touch};
2100        use teksilo_core::pointer::PointerPhase;
2101
2102        let (mut tree, btn, pressed, hits) = probed_button();
2103        let bounds = tree.bounds(btn);
2104        let at = bounds.center();
2105        let away = teksilo_canvas::Point::new(at.x, bounds.y + bounds.height + 60.0);
2106        let id = finger();
2107        tree.dispatch_pointer(touch(id, PointerPhase::Down, at, 0));
2108        tree.dispatch_pointer(touch(id, PointerPhase::Move, away, 20));
2109        tree.dispatch_pointer(touch(id, PointerPhase::Up, away, 40));
2110        assert_eq!(hits.get(), 0, "a slid-off release is not an activation");
2111        assert!(!pressed.get());
2112    }
2113
2114    /// Where the button comes to rest **after an activation**, in both
2115    /// directions.
2116    ///
2117    /// A mouse or a pen is still over the control when it lifts, so the button
2118    /// rests hovered exactly as it always has. A finger is gone the instant it
2119    /// lifts and never sends the hover-leave that would correct a `Hovered`
2120    /// state, so it rests idle — leaving a finger-tapped button lit is the
2121    /// stuck-highlight every touch port of a desktop toolkit ships first.
2122    ///
2123    /// This is the decision inside `on_tap`, and it is asserted through
2124    /// `ButtonStyleConfig::is_hovered` — the signal a style's chrome actually
2125    /// reads — rather than through the interaction enum, because the enum is
2126    /// the button's private business and the tint is not.
2127    #[test]
2128    fn a_mouse_release_rests_hovered_and_a_finger_release_rests_idle() {
2129        use super::press_test_support::touch_tap;
2130
2131        let (mut tree, btn, pressed, hovered, hits) = probed_button_with_hover();
2132        let at = tree.bounds(btn).center();
2133        tree.dispatch_event(WidgetEvent::pointer_move(at));
2134        assert!(hovered.get(), "the pointer arrived over the button");
2135        mouse_at(&mut tree, at, true);
2136        assert!(
2137            pressed.get() && !hovered.get(),
2138            "pressed supersedes hovered"
2139        );
2140        mouse_at(&mut tree, at, false);
2141        assert_eq!(hits.get(), 1, "the release activated");
2142        assert!(!pressed.get(), "and put the press visual out");
2143        assert!(
2144            hovered.get(),
2145            "a mouse that clicked a button is still on it, so the button rests hovered",
2146        );
2147
2148        // The same release, made by a finger. A fresh tree: the mouse above
2149        // still owns a hover this one must not inherit.
2150        let (mut tree, btn, pressed, hovered, hits) = probed_button_with_hover();
2151        let at = tree.bounds(btn).center();
2152        touch_tap(&mut tree, at);
2153        assert_eq!(hits.get(), 1, "the contact activated on its release");
2154        assert!(!pressed.get());
2155        assert!(
2156            !hovered.get(),
2157            "a finger leaves nothing behind, so the button must rest idle",
2158        );
2159    }
2160
2161    /// Where the button comes to rest when the press ends with **no**
2162    /// activation — the other decision site, in `bind_press_interaction`.
2163    ///
2164    /// A pan claimant or an ancestor drag winning the arbitration revokes the
2165    /// press with no release to hang a reset on, so the binding has to restore
2166    /// the resting state itself. A mouse is still sitting on the control and
2167    /// must go back to hovered; a finger has no hover to go back to and must go
2168    /// to idle. Getting either wrong is invisible until it is on screen: a
2169    /// mouse-cancelled button that resets to idle loses its hover tint until
2170    /// the pointer moves again, and a finger-cancelled one that resets to
2171    /// hovered stays lit with nothing touching it.
2172    #[test]
2173    fn a_press_taken_away_rests_hovered_under_a_mouse_and_idle_under_a_finger() {
2174        use super::press_test_support::{finger, touch};
2175        use teksilo_core::pointer::{CancelReason, PointerId, PointerPhase};
2176
2177        let (mut tree, btn, pressed, hovered, hits) = probed_button_with_hover();
2178        let at = tree.bounds(btn).center();
2179        tree.dispatch_event(WidgetEvent::pointer_move(at));
2180        mouse_at(&mut tree, at, true);
2181        assert!(pressed.get());
2182        tree.cancel_pointer(
2183            PointerId::MOUSE,
2184            CancelReason::PeerClaimed,
2185            &mut teksilo_core::window::NoopWindowOps,
2186        );
2187        assert_eq!(hits.get(), 0, "a revoked press activates nothing");
2188        assert!(!pressed.get(), "and the press visual goes out");
2189        assert!(
2190            hovered.get(),
2191            "the mouse never left the button, so it rests hovered",
2192        );
2193
2194        let (mut tree, btn, pressed, hovered, hits) = probed_button_with_hover();
2195        let at = tree.bounds(btn).center();
2196        let id = finger();
2197        tree.dispatch_pointer(touch(id, PointerPhase::Down, at, 0));
2198        assert!(pressed.get());
2199        tree.cancel_pointer(
2200            id,
2201            CancelReason::PeerClaimed,
2202            &mut teksilo_core::window::NoopWindowOps,
2203        );
2204        assert_eq!(hits.get(), 0);
2205        assert!(!pressed.get());
2206        assert!(
2207            !hovered.get(),
2208            "a finger hovers nothing, so a revoked contact must leave the button idle",
2209        );
2210    }
2211
2212    /// A rebuild that lands **during** a press must not blink the press visual
2213    /// off — the third decision in `bind_press_interaction`, and the reason it
2214    /// seeds the interaction signal from the live press instead of from
2215    /// `false`.
2216    ///
2217    /// Every `build()` allocates a fresh interaction signal and derives a fresh
2218    /// `is_pressed` for the style from it, while the press itself lives on the
2219    /// arena node and outlives any number of rebuilds. So a button rebuilt with
2220    /// a contact still on it comes back reading `Idle` unless the binding
2221    /// re-seeds it, and the chrome goes dark under a finger that never lifted.
2222    ///
2223    /// Reaching that needs a **live drag session**, and not by contrivance:
2224    /// `process_pending_rebuilds` defers any rebuild aimed at a widget holding
2225    /// a pointer capture, and a press owner *is* the capture owner
2226    /// (`adopt_press_owner`). The one documented exception is a drag — "a
2227    /// mid-drag rebuild is safe regardless of topology" — which lifts the
2228    /// deferral for every widget at once. Two contacts is what puts a real app
2229    /// there: one finger dragging a row while another rests on a button, which
2230    /// a data-driven rebuild then reaches.
2231    ///
2232    /// The probe is re-read after the rebuild, and the builds are counted, so
2233    /// the assertion cannot pass on the handles the *first* pass published.
2234    #[test]
2235    fn a_rebuild_during_a_press_keeps_the_press_visual_lit() {
2236        use super::press_test_support::{finger, touch};
2237        use teksilo_core::drag_payload::DragPayload;
2238        use teksilo_core::gesture::DragPhase;
2239        use teksilo_core::pointer::PointerPhase;
2240        use teksilo_core::widget::LayoutResponse;
2241
2242        /// `PressProbe`'s counting twin: republishes the config's press signal
2243        /// on every build, and says how many builds there have been.
2244        struct CountingProbe(Rc<RefCell<Option<Signal<bool>>>>, Rc<Cell<u32>>);
2245
2246        impl teksilo_core::styles::ButtonStyle for CountingProbe {
2247            fn make_body(
2248                &self,
2249                cfg: &teksilo_core::styles::ButtonStyleConfig,
2250                ctx: &mut BuildContext,
2251            ) -> WidgetId {
2252                *self.0.borrow_mut() = Some(cfg.is_pressed.clone());
2253                self.1.set(self.1.get() + 1);
2254                ctx.add(crate::primitives::ZStack::new().child(cfg.label))
2255            }
2256        }
2257
2258        /// The other contact's target: anything that opens a drag session.
2259        #[derive(Debug)]
2260        struct DragSource(Rc<Cell<bool>>);
2261
2262        impl Widget for DragSource {
2263            fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
2264                let self_id = ctx.self_id();
2265                let started = self.0.clone();
2266                ctx.apply_self_handlers(HandlerSet::new().on_drag(
2267                    move |phase, ctx: &mut EventContext| {
2268                        if let DragPhase::Started { .. } = phase {
2269                            started.set(true);
2270                            ctx.start_drag(self_id, DragPayload::typed(42_u32));
2271                        }
2272                    },
2273                ));
2274                Vec::new()
2275            }
2276
2277            fn layout_response(&self, _p: SizeProposal, _c: &LayoutContext) -> LayoutResponse {
2278                teksilo_canvas::Size::new(120.0, 80.0).into()
2279            }
2280        }
2281
2282        let probe: Rc<RefCell<Option<Signal<bool>>>> = Rc::new(RefCell::new(None));
2283        let builds = Rc::new(Cell::new(0_u32));
2284        let dragging = Rc::new(Cell::new(false));
2285
2286        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2287        let btn = tree.add(
2288            Button::new(lit!("Save"))
2289                .style(CountingProbe(probe.clone(), builds.clone()))
2290                .on_activate_fn(|_| {}),
2291        );
2292        let idle_probe: Rc<RefCell<Option<Signal<bool>>>> = Rc::new(RefCell::new(None));
2293        let builds_idle = Rc::new(Cell::new(0_u32));
2294        let idle = tree.add(
2295            Button::new(lit!("Open"))
2296                .style(CountingProbe(idle_probe.clone(), builds_idle.clone()))
2297                .on_activate_fn(|_| {}),
2298        );
2299        let src = tree.add(DragSource(dragging.clone()));
2300        let _row = tree.add(HStack::new().child(btn).child(idle).child(src));
2301        tree.layout(SizeProposal::exact(400.0, 100.0));
2302        let first_pass = builds.get();
2303        assert_eq!(first_pass, 1, "the style ran once for the first build");
2304
2305        // One finger on the button.
2306        let on_button = tree.bounds(btn).center();
2307        let held = finger();
2308        tree.dispatch_pointer(touch(held, PointerPhase::Down, on_button, 0));
2309        assert!(
2310            probe.borrow().clone().expect("style ran").get(),
2311            "the contact lit the press visual",
2312        );
2313
2314        // A second finger opens a drag elsewhere, which is what lets a rebuild
2315        // through while the first contact is still down.
2316        let on_source = tree.bounds(src).center();
2317        let dragger = finger();
2318        tree.dispatch_pointer(touch(dragger, PointerPhase::Down, on_source, 5));
2319        for (step, ms) in [(60.0_f32, 20_u64), (90.0, 30)] {
2320            let to = teksilo_canvas::Point::new(on_source.x + step, on_source.y);
2321            tree.dispatch_pointer(touch(dragger, PointerPhase::Move, to, ms));
2322        }
2323        assert!(dragging.get(), "the second contact opened a drag session");
2324        assert!(
2325            tree.is_pressed(btn),
2326            "the first contact still holds the button's press",
2327        );
2328
2329        // Now the rebuild — a data change, a bound signal at `Rebuild`, a
2330        // parent re-emitting its children. It lands with the finger still down.
2331        tree.arena_mark_needs_rebuild_for_testing(btn);
2332        tree.arena_mark_needs_rebuild_for_testing(idle);
2333        tree.layout(SizeProposal::exact(400.0, 100.0));
2334        assert!(
2335            builds.get() > first_pass,
2336            "the rebuild never reached the style, so there is no second config to read",
2337        );
2338        assert!(
2339            tree.is_pressed(btn),
2340            "the router still holds the press across the rebuild",
2341        );
2342
2343        let after = probe.borrow().clone().expect("the style ran again");
2344        assert!(
2345            after.get(),
2346            "the rebuilt button handed its style a config saying it is not pressed, \
2347             while the finger holding it has not lifted",
2348        );
2349
2350        // …and the seed reads the live press rather than lighting every rebuild
2351        // up: the untouched button rebuilt in the same pass comes back dark.
2352        assert!(
2353            !tree.is_pressed(idle),
2354            "nothing is pressing the second button"
2355        );
2356        assert!(
2357            builds_idle.get() > 1,
2358            "the second button's rebuild never reached the style either",
2359        );
2360        assert!(
2361            !idle_probe.borrow().clone().expect("style ran").get(),
2362            "an unpressed button must not come out of a rebuild looking pressed",
2363        );
2364    }
2365
2366    /// Keyboard activation is untouched by the press migration: `Space` still
2367    /// drives the pressed visual through the family's own key machine, and the
2368    /// lone-`KeyUp` guard still holds.
2369    #[test]
2370    fn keyboard_activation_is_unchanged_by_the_framework_press() {
2371        let (mut tree, btn, pressed, hits) = probed_button();
2372        tree.focus(btn);
2373        tree.dispatch_event(WidgetEvent::KeyDown {
2374            key: Key::Space,
2375            modifiers: Modifiers::NONE,
2376            text: Key::Space.to_text().map(str::to_string),
2377        });
2378        assert!(pressed.get(), "Space holds the button pressed");
2379        tree.dispatch_event(WidgetEvent::KeyUp {
2380            key: Key::Space,
2381            modifiers: Modifiers::NONE,
2382        });
2383        assert_eq!(hits.get(), 1);
2384        assert!(!pressed.get());
2385        // A stray KeyUp with no matching KeyDown must not activate.
2386        tree.dispatch_event(WidgetEvent::KeyUp {
2387            key: Key::Space,
2388            modifiers: Modifiers::NONE,
2389        });
2390        assert_eq!(hits.get(), 1, "the lone-KeyUp guard still holds");
2391    }
2392
2393    /// The Button's own node is the target the audit measures, and it clears
2394    /// the 24 dp conformance floor at Compact — the density every existing
2395    /// layout golden was recorded at.
2396    #[test]
2397    fn a_compact_button_clears_the_conformance_floor() {
2398        let theme = teksilo_core::presets::intui::light();
2399        let floor = theme.input.min_target_conformance;
2400        let mut tree = WidgetTree::new().with_theme(theme);
2401        let btn = tree.add(Button::new(lit!("Save")).on_activate_fn(|_| {}));
2402        tree.layout(SizeProposal::exact(400.0, 200.0));
2403        let b = tree.bounds(btn);
2404        assert!(
2405            b.width >= floor && b.height >= floor,
2406            "a Compact Button measured {}x{}, under the {floor} dp floor",
2407            b.width,
2408            b.height,
2409        );
2410    }
2411}
2412
2413/// [`Button::icon_keeps_color`] — the icon's own colour survives, or it does not.
2414#[cfg(test)]
2415mod icon_color_tests {
2416    use super::*;
2417    use teksilo_core::widget_tree::WidgetTree;
2418
2419    /// A disc in a colour no theme role would ever produce, so finding it in the frame
2420    /// can only mean the icon kept it.
2421    const SWATCH: [f32; 4] = [0.93, 0.29, 0.60, 1.0];
2422
2423    fn swatch_icon() -> IconWidget {
2424        let centre = teksilo_canvas::Point::new(5.0, 5.0);
2425        IconWidget::from_path(teksilo_canvas::Path::circle(centre, 4.5), 10.0).color(
2426            teksilo_tokens::Color::from_rgba(SWATCH[0], SWATCH[1], SWATCH[2], SWATCH[3]),
2427        )
2428    }
2429
2430    fn painted(button: Button) -> bool {
2431        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2432        let _ = tree.add(button);
2433        tree.layout(SizeProposal::exact(240.0, 60.0));
2434        let frame = tree.render();
2435        // An `IconWidget::from_path` lands in `paths`, not `shapes` — the button's
2436        // own chrome is what fills `shapes`.
2437        frame.paths.iter().any(|p| p.color == SWATCH)
2438            || frame.shapes.iter().any(|s| s.color == SWATCH)
2439            || frame.decorations.iter().any(|d| d.color == SWATCH)
2440    }
2441
2442    /// The default: an icon repeats the label, so it takes the label's colour and the
2443    /// button stays one legible unit under every variant and state.
2444    #[test]
2445    fn an_icon_is_tinted_to_the_label_by_default() {
2446        assert!(
2447            !painted(Button::new(lit!("Tag")).icon(swatch_icon(), IconLocation::Leading)),
2448            "the icon kept its own colour without being asked to"
2449        );
2450    }
2451
2452    /// And the opt-out, for an icon whose colour *is* the information — a filter chip
2453    /// carrying a user-chosen tag colour has nothing left if it is tinted away.
2454    #[test]
2455    fn icon_keeps_color_survives_the_buttons_tint() {
2456        assert!(
2457            painted(
2458                Button::new(lit!("Tag"))
2459                    .icon(swatch_icon(), IconLocation::Leading)
2460                    .icon_keeps_color()
2461            ),
2462            "icon_keeps_color did not reach the painted icon"
2463        );
2464    }
2465}