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