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