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}