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//! ## Accessibility
21//!
22//! Announces as `Role::Button` with the resolved label as its AT name.
23//! Keyboard: Space / Enter activate; the lone-KeyUp guard prevents spurious
24//! re-activation when a shortcut consumes the KeyDown and returns focus here.
25//!
26//! ```rust
27//! # use teksilo_widgets::{Button, ButtonVariant};
28//! # use teksilo_i18n::lit;
29//! # use teksilo_core::Intent;
30//! let _btn = Button::new(lit!("Save"))
31//! .variant(ButtonVariant::Filled)
32//! .on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.save")));
33//! ```
34
35use std::rc::Rc;
36use teksilo_i18n::lit;
37
38use teksilo_canvas::{Rect, SizeProposal};
39use teksilo_core::accessibility::AccessNodeBuilder;
40use teksilo_core::build_context::BuildContext;
41use teksilo_core::event::{EventResponse, Key, WidgetEvent};
42use teksilo_core::signal::{Prop, Signal};
43use teksilo_core::styles::{ButtonStyle, ButtonStyleConfig, SharedButtonStyle};
44use teksilo_core::widget::{CursorIcon, EventContext, LayoutContext, Widget, WidgetPlacement};
45use teksilo_core::widget_builder::HandlerSet;
46use teksilo_core::widget_id::WidgetId;
47use teksilo_tokens::TextRole;
48
49use crate::primitives::icon_widget::IconWidget;
50use crate::primitives::{HStack, TextWidget, VStack};
51
52/// Closed enum naming the design-language variants of `Button`. See
53/// [`teksilo_core::styles::ButtonVariant`] for the canonical definition.
54///
55/// Int UI does **not** ship filled red "destructive" buttons —
56/// destructive actions in IntelliJ are plain buttons in confirmation
57/// dialogs where the title/body carry the warning. The IntUI default
58/// `RecipeButtonStyle` collapses `Destructive → Filled`, `Tinted /
59/// Outlined → Plain`, and `Link → Ghost` accordingly. Other design
60/// languages (Material 3, macOS) honour the variants distinctly.
61pub use teksilo_core::styles::ButtonVariant;
62use teksilo_i18n::LocalizedString;
63
64/// Internal interaction state.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum InteractionState {
67 Idle,
68 Hovered,
69 Pressed,
70 Focused,
71 Disabled,
72}
73
74/// Build the interaction handler set shared by every activatable button
75/// (`Button`, `IconButton`, `CommandLinkButton`, and any future sibling).
76///
77/// Centralizes the parts that MUST stay identical across the family and
78/// historically drifted when copy-pasted:
79/// - hover/focus state tracking,
80/// - keyboard `Space`/`Enter` activation with the **lone-KeyUp guard**
81/// (a `KeyUp` with no preceding `KeyDown` — e.g. a shortcut consumed
82/// the `KeyDown` and focus returned here — must NOT activate),
83/// - the AT `Click` action.
84///
85/// `on_activate` runs on tap, keyboard activation, and AT click. Callers
86/// bundle their command action (and any extra side effect, e.g.
87/// `IconButton`'s toggle flip) into this single closure so the guard
88/// gates all activation paths uniformly. `focusable` is the node's
89/// focusability (`Button` is always focusable; `IconButton` exposes it).
90pub(crate) fn build_interaction_handlers(
91 interaction: Signal<InteractionState>,
92 on_activate: Rc<dyn Fn(&mut EventContext)>,
93 focusable: bool,
94) -> HandlerSet {
95 let act_tap = on_activate.clone();
96 let act_key = on_activate.clone();
97 let act_access = on_activate;
98 HandlerSet::new()
99 .on_tap({
100 let interaction = interaction.clone();
101 move |_pos: &teksilo_core::TapEvent, ctx: &mut EventContext| {
102 act_tap(ctx);
103 interaction.set(InteractionState::Hovered);
104 }
105 })
106 .on_hover({
107 let interaction = interaction.clone();
108 move |entered: bool, _ctx: &mut EventContext| {
109 interaction.set(if entered {
110 InteractionState::Hovered
111 } else {
112 InteractionState::Idle
113 });
114 }
115 })
116 // Pointer-down press state. The family PROVIDES the Pressed state
117 // on mouse-down so the *theme* decides whether to render it: Int
118 // UI regular buttons have no pressed state (their recipe resolves
119 // pressed → hover), while Int UI icon buttons and other themes do.
120 // Returns `Ignored` so the event still reaches the tap recognizer
121 // and `on_tap` activation fires. Reverts to Hovered on release
122 // only if still Pressed — a drag-out release already went to Idle
123 // via `on_hover(false)`, so the guard leaves it there.
124 .on_pointer_event({
125 let interaction = interaction.clone();
126 move |event: &WidgetEvent, _ctx: &mut EventContext| -> EventResponse {
127 match event {
128 WidgetEvent::PointerDown { .. } => {
129 interaction.set(InteractionState::Pressed);
130 }
131 WidgetEvent::PointerUp { .. }
132 if interaction.get() == InteractionState::Pressed =>
133 {
134 interaction.set(InteractionState::Hovered);
135 }
136 _ => {}
137 }
138 EventResponse::Ignored
139 }
140 })
141 .on_key({
142 let interaction = interaction.clone();
143 move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
144 match event {
145 WidgetEvent::KeyDown {
146 key: Key::Space | Key::Enter,
147 ..
148 } => {
149 interaction.set(InteractionState::Pressed);
150 EventResponse::Handled
151 }
152 WidgetEvent::KeyUp {
153 key: Key::Space | Key::Enter,
154 ..
155 } => {
156 // Lone-KeyUp guard: only activate if we saw the
157 // matching KeyDown (state is Pressed).
158 if interaction.get() != InteractionState::Pressed {
159 return EventResponse::Ignored;
160 }
161 act_key(ctx);
162 interaction.set(InteractionState::Focused);
163 EventResponse::Handled
164 }
165 _ => EventResponse::Ignored,
166 }
167 }
168 })
169 .on_focus({
170 let interaction = interaction.clone();
171 move |gained: bool, _ctx: &mut EventContext| {
172 if gained {
173 if interaction.get() == InteractionState::Idle {
174 interaction.set(InteractionState::Focused);
175 }
176 } else {
177 interaction.set(InteractionState::Idle);
178 }
179 }
180 })
181 .on_access_action(
182 move |action: teksilo_core::accesskit::Action,
183 ctx: &mut EventContext|
184 -> EventResponse {
185 if action == teksilo_core::accesskit::Action::Click {
186 act_access(ctx);
187 EventResponse::Handled
188 } else {
189 EventResponse::Ignored
190 }
191 },
192 )
193 .focusable(focusable)
194 .cursor(CursorIcon::Pointer)
195}
196
197/// Where an optional icon is placed relative to the button label.
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
199pub enum IconLocation {
200 /// No icon (default).
201 #[default]
202 None,
203 /// Icon only, no label.
204 IconOnly,
205 /// Icon to the left of the label (default).
206 Leading,
207 /// Icon to the right of the label.
208 Trailing,
209 /// Icon above the label.
210 Top,
211 /// Icon below the label.
212 Bottom,
213}
214
215/// Type-erased activation closure. Stored as `Box<dyn Fn>` so the
216/// same button type works for any handler — typed intent send,
217/// direct side effect, window mutation, etc.
218type CommandFactory = Box<dyn Fn(&mut EventContext)>;
219
220/// A labelled action trigger; use [`Button::new`] and chain builder methods.
221pub struct Button {
222 /// Button label as a `Prop<String>`. `new(tr!(...))` stores a
223 /// `Prop::Bound` (locale-reactive) when an i18n manager is installed,
224 /// falling back to `Prop::Static` for `lit!(...)` or no manager;
225 /// `label(signal)` overrides with a caller-supplied source. Either
226 /// way the inner `TextWidget` re-renders reactively without rebuilding
227 /// the Button. The accessibility node's `set_name` reads the current
228 /// value via `Prop::get()`, keeping AT in sync with bound updates.
229 label: teksilo_core::signal::Prop<String>,
230 /// Tier-1 design-language variant hint (Filled, Plain, Ghost, …).
231 /// The active [`ButtonStyle`] decides what to do with it.
232 variant: ButtonVariant,
233 /// Optional per-call override for the active [`ButtonStyle`]. When
234 /// `None`, falls through to the theme slot or the
235 /// built-in [`crate::styles::RecipeButtonStyle`] default.
236 style_override: Option<SharedButtonStyle>,
237 action: Option<CommandFactory>,
238 /// Enabled state, static or reactive. Forwarded into the arena via
239 /// `ctx.enabled_when(self_id, self.enabled.clone())` at build time;
240 /// not kept as a runtime snapshot. After `build()` the arena's
241 /// `enabled_state` is the single source of truth — leaves resolve
242 /// colors via `PaintContext::effective_enabled`, events are gated
243 /// by `arena.is_enabled()`, the a11y walker reads it for
244 /// `set_disabled()`.
245 enabled: Prop<bool>,
246 icon: Option<IconWidget>,
247 icon_location: IconLocation,
248 /// Leave the icon's own colour alone instead of tinting it to the label's.
249 /// See [`Button::icon_keeps_color`].
250 icon_keeps_color: bool,
251 tooltip_text: Option<LocalizedString>,
252 /// Optional rich tooltip source (registry key or inline content).
253 /// Mutually exclusive with `tooltip_text` and `composite_tooltip_content`
254 /// — every tooltip setter clears the other two so last-call wins.
255 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
256 /// Optional composite tooltip body. Hosts an arbitrary widget
257 /// tree (charts, grids, conditional rows). Mutually exclusive
258 /// with `tooltip_text` and `rich_tooltip_source` per the
259 /// last-call-wins matrix.
260 composite_tooltip_content: Option<Box<dyn teksilo_core::widget::Widget>>,
261 /// Optional `has_popup` hint used when this button acts as a
262 /// disclosure trigger for a popup (menu, dialog, listbox, etc.).
263 /// Surfaced via `set_has_popup` in `accessibility()`.
264 has_popup: Option<teksilo_core::accesskit::HasPopup>,
265 /// Arbitrary widget rendered to the leading edge of the button's
266 /// content (left in LTR, right in RTL). Composes with `.icon(...)`:
267 /// the order is `[leading_slot, icon+label, trailing_slot]`. Slot
268 /// widgets paint and report a11y on their own — Button does not
269 /// retint them and does not auto-suppress their AT roles. Apps
270 /// whose slot widgets would otherwise pollute the AT tree
271 /// (e.g. ColorSwatch with `Role::ColorWell`) should pass
272 /// `widget.access_hidden(true)` so the Button's
273 /// `Role::Button` stays the single declared role.
274 leading: Option<Box<dyn Widget>>,
275 /// Same shape as `leading`, rendered to the trailing edge.
276 trailing: Option<Box<dyn Widget>>,
277 /// Optional signal reporting whether the button's popup is
278 /// currently visible. Surfaced via `set_expanded` in
279 /// `accessibility()`. Used alongside `has_popup` for the
280 /// standard ARIA disclosure pattern.
281 expanded_signal: Option<Prop<bool>>,
282 /// Optional caller-supplied interaction signal. When set, `build()`
283 /// uses this signal instead of allocating its own — letting an
284 /// external widget (e.g. `PopoverButton`'s disclosure caret)
285 /// observe hover / press / focus / disabled state and match the
286 /// label's color exactly. See [`Button::share_interaction`].
287 shared_interaction: Option<Signal<InteractionState>>,
288 /// Optional caller-supplied label/icon color override. When `Some`,
289 /// both the label text and any icon are bound to this `ColorProp`
290 /// regardless of `style` / interaction state — the auto-derived
291 /// cascade is replaced. Used by chrome that has to match a host's
292 /// enforced text role (e.g. tab-bar overflow dropdown trigger
293 /// inheriting `idle_text_role`). See [`Button::text_role`].
294 text_role_override: Option<teksilo_core::color_prop::ColorProp>,
295 /// Optional per-call override for the label's text style (font, size,
296 /// weight). When `Some`, applied to the inner label `TextWidget` via
297 /// its `.style(...)`; when `None`, the `TextWidget` default is used.
298 /// Accepts a `TextStyleRole`, a `TextStyle`, or a `Signal` of either
299 /// (anything `Into<TextStyleProp>`). See [`Button::text_style`].
300 label_style: Option<teksilo_core::color_prop::TextStyleProp>,
301 /// Interaction state signal — set during build().
302 interaction: Signal<InteractionState>,
303 /// Root child ID — set during build().
304 root_child_id: Option<WidgetId>,
305}
306
307impl Button {
308 /// Construct a button from a `LocalizedString` label. The label may
309 /// come from `tr!(...)` (translated) or `lit!(...)`
310 /// (explicit non-translated). When an `I18nManager` is installed, a
311 /// `tr!(...)` label becomes a `Prop::Bound` that observes the locale
312 /// version signal, so the inner `TextWidget` re-renders on a locale
313 /// switch without rebuilding the Button — matching `TextWidget::new`.
314 /// `lit!(...)` and the no-manager case resolve to a static `String`.
315 pub fn new(label: impl Into<LocalizedString>) -> Self {
316 let ls: LocalizedString = label.into();
317 Self {
318 // `Prop::from(LocalizedString)` yields `Prop::Bound` (reactive)
319 // when a manager is installed, `Prop::Static` otherwise — the
320 // same conversion `TextWidget::new` uses. A locale change then
321 // updates the label live; without this it stayed frozen because
322 // `set_locale` marks the tree dirty (relayout/repaint) but does
323 // NOT rebuild composites.
324 label: teksilo_core::signal::Prop::from(ls),
325 // Int UI default is a Plain (non-primary) button; the caller
326 // opts into `ButtonVariant::Filled` for the one primary action.
327 variant: ButtonVariant::Plain,
328 style_override: None,
329 action: None,
330 enabled: Prop::Static(true),
331 icon: None,
332 icon_location: IconLocation::None,
333 icon_keeps_color: false,
334 tooltip_text: None,
335 rich_tooltip_source: None,
336 composite_tooltip_content: None,
337 has_popup: None,
338 expanded_signal: None,
339 shared_interaction: None,
340 text_role_override: None,
341 label_style: None,
342 leading: None,
343 trailing: None,
344 interaction: Signal::new(InteractionState::Idle),
345 root_child_id: None,
346 }
347 }
348
349 /// Returns the configured visual variant. Used by wrappers like
350 /// [`PopoverButton`](crate::popover_widget::PopoverButton) that
351 /// derive their own chrome colors from the same recipe-resolution
352 /// path the inner Button uses.
353 pub fn current_variant(&self) -> ButtonVariant {
354 self.variant
355 }
356
357 /// Bind the button's internal interaction state to a caller-owned
358 /// `Signal<InteractionState>` instead of letting `build()` allocate
359 /// its own. Used by wrapper widgets like
360 /// [`PopoverButton`](crate::popover_widget::PopoverButton) whose
361 /// disclosure caret needs to match the label's color across hover
362 /// / press / focus / disabled states.
363 ///
364 /// The provided signal is reset to `Disabled` when `enabled == false`
365 /// during `build()` so the shared signal honors the button's
366 /// enabled state without the caller having to seed it.
367 pub fn share_interaction(mut self, signal: Signal<InteractionState>) -> Self {
368 self.shared_interaction = Some(signal);
369 self
370 }
371
372 /// Set the Tier-1 design-language variant. The active
373 /// [`ButtonStyle`] decides whether to honour or remap it (the IntUI
374 /// default `RecipeButtonStyle` collapses Destructive → Filled,
375 /// Tinted/Outlined → Plain, Link → Ghost).
376 pub fn variant(mut self, variant: ButtonVariant) -> Self {
377 self.variant = variant;
378 self
379 }
380
381 /// Override the active [`ButtonStyle`] for this widget instance
382 /// only. Useful for one-off custom-painted buttons (glassmorphism
383 /// CTA, Material-3 ripple, etc.) without forking the Button.
384 pub fn style(mut self, style: impl ButtonStyle) -> Self {
385 self.style_override = Some(Rc::new(style));
386 self
387 }
388
389 /// Bind the button's label to a reactive source — replaces the
390 /// static label captured at `new(...)`. Accepts any
391 /// `impl Into<Prop<String>>`: a `Signal<String>` for live
392 /// updates, or a plain `String` (which is the same as constructing
393 /// the button with that string). Mirrors
394 /// [`TextWidget::text`](crate::primitives::TextWidget::text).
395 /// The inner label `TextWidget` is built with the bound prop, so
396 /// the visible text refreshes without rebuilding the Button. The
397 /// AT node's `set_name` reads the current value via `Prop::get`.
398 ///
399 /// Translation note: derive the signal with
400 /// `state.map(|s| tr!(status_label(value = s)).resolve_now())` for translated
401 /// reactive labels — Button only sees the resolved `String`.
402 pub fn label(mut self, label: impl Into<teksilo_core::signal::Prop<String>>) -> Self {
403 self.label = label.into();
404 self
405 }
406
407 /// Closure invoked on activation. Use `ctx.send_intent(...)` to
408 /// route activation through the Action/Intent system, or inline
409 /// the behavior directly.
410 pub fn on_activate_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
411 self.action = Some(Box::new(f));
412 self
413 }
414
415 /// Whether an activation closure has been attached. Used by wrappers
416 /// (e.g. `PopoverWidget`) that overwrite the activate slot, so they
417 /// can warn when a caller-set handler is about to be discarded.
418 pub(crate) fn has_activate_handler(&self) -> bool {
419 self.action.is_some()
420 }
421
422 /// Attach a tooltip that appears after a hover delay.
423 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
424 self.tooltip_text = Some(text.into());
425 self.rich_tooltip_source = None;
426 self.composite_tooltip_content = None;
427 self
428 }
429
430 /// Attach a rich tooltip resolved from the app-wide tooltip registry.
431 /// The `key` is looked up via
432 /// [`TooltipRegistry`](crate::tooltip::TooltipRegistry) at build
433 /// time; the resolved body text supports inline markup
434 /// (`[label](url)`, `*italic*`, `**bold**`) and the entry's
435 /// shortcut / long-form "more" fields are rendered automatically.
436 ///
437 /// Overrides any previously set plain `.tooltip(...)` text.
438 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
439 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
440 self.tooltip_text = None;
441 self.composite_tooltip_content = None;
442 self
443 }
444
445 /// Attach a rich tooltip driven by inline
446 /// [`TooltipContent`](crate::tooltip::TooltipContent) — for
447 /// one-off tooltips that aren't worth registering in the central
448 /// catalog. Overrides any previously set plain `.tooltip(...)`.
449 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
450 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
451 self.tooltip_text = None;
452 self.composite_tooltip_content = None;
453 self
454 }
455
456 /// Attach a composite tooltip — third tier, hosting an arbitrary
457 /// widget tree (Crusader Kings 3 style: tabbed sections, charts,
458 /// progress bars, conditional rows). Promotes to a focusable
459 /// `Role::Dialog` after the user dwells for the standard
460 /// promotion threshold. Overrides any plain or rich tooltip
461 /// previously set on this button.
462 pub fn composite_tooltip(
463 mut self,
464 content: impl teksilo_core::widget::Widget + 'static,
465 ) -> Self {
466 self.composite_tooltip_content = Some(Box::new(content));
467 self.tooltip_text = None;
468 self.rich_tooltip_source = None;
469 self
470 }
471
472 /// Boxed variant of [`composite_tooltip`](Self::composite_tooltip).
473 /// Used by `Clone` value types (e.g. `ToolbarAction`) that store a
474 /// composite-body factory `Rc<dyn Fn() -> Box<dyn Widget>>` and forward
475 /// the produced box through at build time.
476 pub(crate) fn composite_tooltip_boxed(
477 mut self,
478 content: Box<dyn teksilo_core::widget::Widget>,
479 ) -> Self {
480 self.composite_tooltip_content = Some(content);
481 self.tooltip_text = None;
482 self.rich_tooltip_source = None;
483 self
484 }
485
486 /// Set the enabled state, statically or reactively. Disabled buttons
487 /// ignore input and dim their content (the framework's
488 /// `PaintContext::effective_enabled` propagates through to the
489 /// label/icon leaves). Forwarded into the arena via
490 /// `ctx.enabled_when(self_id, self.enabled.clone())` at build time —
491 /// a bound signal updates live as it changes.
492 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
493 self.enabled = enabled.into();
494 self
495 }
496
497 /// Override the label and icon's tint with a static `ColorProp`.
498 /// When set, the button ignores its `style` and the auto-derived
499 /// idle/hover/press text-role cascade — both the label text and
500 /// any icon are bound directly to this prop instead. Use for chrome
501 /// whose host enforces a single text role across all of its
502 /// sub-widgets (e.g. tab-bar overflow-dropdown triggers that must
503 /// match the strip's `idle_text_role` regardless of hover state).
504 /// Accepts `Color`, `TextRole`, `Signal<Color>`, or `Signal<TextRole>`.
505 pub fn text_role(mut self, role: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
506 self.text_role_override = Some(role.into());
507 self
508 }
509
510 /// Override the label's text style (font, size, weight). By default the
511 /// label uses the inner `TextWidget`'s default style; pass a
512 /// `TextStyleRole` (e.g. `TextStyleRole::BodyBold`), a `TextStyle`, or a
513 /// `Signal` of either to change it — e.g. to make the label bold.
514 /// Orthogonal to [`Button::text_role`], which only sets the color.
515 pub fn text_style(mut self, style: impl Into<teksilo_core::color_prop::TextStyleProp>) -> Self {
516 self.label_style = Some(style.into());
517 self
518 }
519
520 /// Add an icon to the button at the specified location.
521 pub fn icon(mut self, icon: IconWidget, location: IconLocation) -> Self {
522 self.icon = Some(icon);
523 self.icon_location = location;
524 self
525 }
526
527 /// Keep the icon's own colour instead of tinting it to the label's.
528 ///
529 /// The mirror of [`MenuItem::icon_keeps_color`](crate::menu_item::MenuItem::icon_keeps_color),
530 /// and it exists for the same reason: an icon whose colour *is* the information.
531 /// A filter chip carrying a user-chosen tag colour, a legend swatch, a status
532 /// disc — tinting those to the label's foreground destroys the one thing they
533 /// carry, while tinting is exactly right for a glyph that merely repeats the
534 /// label.
535 ///
536 /// Two consequences worth knowing, both inherited from
537 /// [`ColorProp`](teksilo_core::color_prop::ColorProp)'s own rules rather than
538 /// special-cased here:
539 ///
540 /// * The colour must clear contrast against **every** fill the button takes —
541 /// an accent-filled selected state as well as the resting surface.
542 /// * A literal colour **does not dim when the button is disabled**. An icon
543 /// that should dim wants a role instead, and then it does not need this.
544 pub fn icon_keeps_color(mut self) -> Self {
545 self.icon_keeps_color = true;
546 self
547 }
548
549 /// Declare that this button is a disclosure trigger for a
550 /// popup (menu, dialog, listbox, tree, grid). Surfaced via
551 /// `set_has_popup` in the a11y node so screen readers announce
552 /// it as leading into the named popup kind.
553 pub fn has_popup(mut self, kind: teksilo_core::accesskit::HasPopup) -> Self {
554 self.has_popup = Some(kind);
555 self
556 }
557
558 /// Bind a signal reporting whether this button's popup is
559 /// currently visible. The Popover / Dialog wrapper owns the
560 /// signal and flips it on show / dismiss; Button reads it in
561 /// `accessibility()` to publish `set_expanded`. Only
562 /// meaningful alongside `.has_popup(...)`.
563 pub fn expanded_when(mut self, signal: impl Into<Prop<bool>>) -> Self {
564 self.expanded_signal = Some(signal.into());
565 self
566 }
567
568 /// Insert a widget at the leading edge of the button's content
569 /// (left in LTR, right in RTL). Composes with `.icon(...)`: the
570 /// final order is `[leading_slot, icon+label, trailing_slot]`,
571 /// separated by `btn::BUTTON_ICON_LABEL_GAP`. Single-slot —
572 /// calling `.leading(...)` again replaces the previous slot.
573 /// Stack multiple widgets with an explicit `HStack`.
574 ///
575 /// The slot widget paints itself and emits its own a11y. Button
576 /// does **not** retint it (so e.g. a `ColorSwatch` keeps its own
577 /// color through every interaction state). If the slot widget
578 /// declares an AT role of its own — `ColorSwatch` is the canonical
579 /// case (`Role::ColorWell`) — pass `widget.access_hidden(true)`
580 /// so the trigger reads as a single Button node instead of a
581 /// Button containing a redundant ColorWell child.
582 pub fn leading(mut self, widget: impl Widget + 'static) -> Self {
583 self.leading = Some(Box::new(widget));
584 self
585 }
586
587 /// Same as [`leading`](Self::leading) but at the trailing edge
588 /// (right in LTR, left in RTL). Common uses: chevron-down hint
589 /// on disclosure triggers, clear-X on search fields, status
590 /// badges on segmented control segments.
591 pub fn trailing(mut self, widget: impl Widget + 'static) -> Self {
592 self.trailing = Some(Box::new(widget));
593 self
594 }
595
596 /// Construct the label `TextWidget` used inside the button's
597 /// content layout. Always routes through `text(prop)` —
598 /// `Prop::Static` and `Prop::Bound` are both handled uniformly
599 /// by the TextWidget. `new(lit!(""))` seeds the placeholder
600 /// initial text; `text` immediately overwrites it with the
601 /// prop's current value (and tracks updates for `Prop::Bound`).
602 fn make_label_text(&self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> TextWidget {
603 let mut text = TextWidget::new(lit!(""))
604 .text(self.label.clone())
605 .color(color)
606 .single_line()
607 .a11y_hidden();
608 if let Some(style) = &self.label_style {
609 text = text.style(style.clone());
610 }
611 text
612 }
613
614 /// Take the configured icon, size it, and bind its tint to `color`.
615 /// Shared by every icon-bearing `IconLocation` arm so the size /
616 /// color wiring lives in one place.
617 ///
618 /// A non-`None` `icon_location` with no icon set is a programming
619 /// error — `.icon(...)` was never called. In debug builds the
620 /// `debug_assert!` surfaces the mistake (mirroring how `Checkbox`
621 /// asserts a missing accessible label); release falls back to an
622 /// empty path so the button still lays out instead of panicking.
623 fn make_icon(&mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> IconWidget {
624 use crate::styles::recipe_button_style as btn;
625 debug_assert!(
626 self.icon.is_some(),
627 "Button: icon_location is {:?} but no icon was set via .icon(...)",
628 self.icon_location,
629 );
630 let icon = self
631 .icon
632 .take()
633 .unwrap_or_else(|| {
634 IconWidget::from_path(teksilo_canvas::Path::new(), btn::BUTTON_ICON_SIZE)
635 })
636 .icon_size(btn::BUTTON_ICON_SIZE);
637 if self.icon_keeps_color {
638 icon
639 } else {
640 icon.color(color)
641 }
642 }
643
644 /// Assemble the V2 attached-handler set (tap / hover / key / focus /
645 /// access-action) wired to `interaction`. Takes `self.action`. The
646 /// framework gates pointer / key / access events on
647 /// `arena.is_enabled(self_id)` before dispatch and the focus walker
648 /// skips disabled subtrees, so none of these closures need a
649 /// build-time enabled snapshot — that duality was removed in the
650 /// single-sourced-enabled refactor.
651 fn build_handler_set(&mut self, interaction: Signal<InteractionState>) -> HandlerSet {
652 // Bundle the optional command action into the unified
653 // `on_activate` closure consumed by the shared family helper.
654 let action: Rc<Option<CommandFactory>> = Rc::new(self.action.take());
655 let on_activate: Rc<dyn Fn(&mut EventContext)> = Rc::new(move |ctx: &mut EventContext| {
656 if let Some(ref action) = *action {
657 action(ctx);
658 }
659 });
660 build_interaction_handlers(interaction, on_activate, true)
661 }
662}
663
664impl std::fmt::Debug for Button {
665 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
666 f.debug_struct("Button")
667 .field("label", &self.label.get())
668 .field("variant", &self.variant)
669 .field("enabled", &self.enabled.get())
670 .finish()
671 }
672}
673
674// --- Label / icon color resolution ---
675//
676// The active `ButtonStyle` owns chrome (background fill, border, focus
677// ring) but the inner content (label + icon) belongs to the Button
678// itself, so it picks the text role. The mapping is intentionally
679// minimal: `OnAccent` for variants that paint an accent fill, `Primary`
680// for everything else, `Disabled` when the button is disabled. Custom
681// `ButtonStyle` impls that paint a different background can request
682// the Button to use a specific text role via `Button::text_role(...)`.
683
684pub(crate) fn resolve_text_role(variant: ButtonVariant, _state: InteractionState) -> TextRole {
685 // Disabled substitution happens at the leaf paint via
686 // `ColorProp::resolve(theme, ctx.effective_enabled)` — see
687 // `crates/teksilo-core/src/color_prop.rs`. The composite no
688 // longer carries `InteractionState::Disabled`; the framework's
689 // arena enabled-state drives the dim, and the leaves convert it
690 // into `TextRole::Disabled` at paint time.
691 match variant {
692 ButtonVariant::Filled | ButtonVariant::Destructive => TextRole::OnAccent,
693 ButtonVariant::Tinted
694 | ButtonVariant::Outlined
695 | ButtonVariant::Plain
696 | ButtonVariant::Ghost => TextRole::Primary,
697 ButtonVariant::Link => TextRole::Link,
698 }
699}
700
701impl teksilo_core::widget::Widget for Button {
702 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
703 // Layout constants for the inner content (icon size,
704 // icon-label gap) come from the button recipe. The chrome
705 // (padding, corner radius, fill, border) lives on the active
706 // `ButtonStyle` impl.
707 use crate::styles::recipe_button_style as btn;
708 let variant = self.variant;
709 let self_id = ctx.self_id();
710
711 // Forward the enabled state into the arena. After this point the
712 // arena is the single source of truth — events, focus, a11y, and
713 // the leaves' role-resolution all consult
714 // `arena.is_enabled(self_id)` / `PaintContext::effective_enabled`.
715 // The interaction signal no longer carries Disabled: that was
716 // the snapshot duality the architecture refactor removed.
717 ctx.enabled_when(self_id, self.enabled.clone());
718
719 // Reactive view of "is this widget effectively enabled?".
720 let effective_enabled = ctx.effective_enabled_signal(self_id);
721
722 // Create interaction signal — caller-supplied via
723 // `share_interaction` when set (so a wrapping widget's chrome
724 // can mirror the label's color), otherwise allocated locally.
725 // Seeded to Idle; the arena's enabled-state is consulted
726 // separately via `effective_enabled`.
727 let interaction = match self.shared_interaction.take() {
728 Some(shared) => shared,
729 None => ctx.signal(InteractionState::Idle),
730 };
731 self.interaction = interaction.clone();
732
733 // If an `expanded_signal` was wired up (disclosure
734 // pattern — see `.has_popup()` / `.expanded_when()`),
735 // register it with the framework so changes trigger a
736 // repaint/a11y refresh on this button. Without the
737 // binding registration, the signal updates but the
738 // widget's `accessibility()` output won't be re-queried.
739 if let Some(ref expanded_signal) = self.expanded_signal {
740 let self_id = ctx.self_id();
741 let registry = ctx.binding_registry();
742 expanded_signal.register_if_bound(
743 self_id,
744 registry,
745 teksilo_core::binding::BindingLevel::RepaintOnly,
746 );
747 }
748
749 // If `label(signal)` was used, register the prop on the
750 // Button itself at AccessibilityOnly so `set_name` re-runs
751 // when the signal changes. The inner `TextWidget` already
752 // re-renders via its own `text` plumbing — this binding
753 // is purely for the AT name.
754 let self_id = ctx.self_id();
755 let registry = ctx.binding_registry();
756 self.label.register_if_bound(
757 self_id,
758 registry,
759 teksilo_core::binding::BindingLevel::AccessibilityOnly,
760 );
761
762 // Resolve the active `ButtonStyle` (per-call override > theme
763 // slot > IntUI default). Both the label color (immediately below)
764 // and the chrome (`make_body`, further down) consult it. The
765 // lookup reads only `self.style_override` + `ctx.theme()`, so
766 // resolving it here instead of just before `make_body` changes
767 // nothing for existing styles.
768 let style: SharedButtonStyle = self
769 .style_override
770 .clone()
771 .or_else(|| ctx.theme().style_slots.button.clone())
772 .unwrap_or_else(|| Rc::new(crate::styles::RecipeButtonStyle::default()));
773
774 // Label/icon color: a caller-supplied override wins over the
775 // auto cascade. The override replaces ALL states (idle / hover /
776 // press / focus / disabled) — chrome that uses this opts out of
777 // interaction-driven color feedback in exchange for matching a
778 // host's enforced text role. Both label and icon read this same
779 // prop, so a one-line override re-tints the whole button.
780 //
781 // Chrome (background fill, border, focus ring) is no longer
782 // resolved here — the active `ButtonStyle` owns it via
783 // `make_body(cfg, ctx)` below. This widget only resolves the
784 // CONTENT color (label + icon) since that's part of the inner
785 // subtree we hand to the style as `cfg.label`. The active style
786 // may also redirect the content role (`label_text_role`) — e.g.
787 // Material 3 paints text/outlined buttons in the accent color.
788 let text_role: teksilo_core::color_prop::ColorProp =
789 if let Some(ref over) = self.text_role_override {
790 over.clone()
791 } else if let Some(role) = style.label_text_role(variant) {
792 role.into()
793 } else {
794 interaction
795 .map(move |s| resolve_text_role(variant, *s))
796 .into()
797 };
798
799 // Build the content (icon + label) based on icon_location. The
800 // four directional arms (Leading/Trailing/Top/Bottom) share one
801 // body: build the icon + label, then assemble them into an
802 // HStack or VStack in icon-first / text-first order. Icon size /
803 // color wiring is centralized in `make_icon`.
804 let icon_location = self.icon_location;
805 let content_id = match icon_location {
806 IconLocation::None => ctx.add(self.make_label_text(text_role)),
807 IconLocation::IconOnly => {
808 let icon = self.make_icon(text_role);
809 ctx.add(icon)
810 }
811 // Leading | Trailing | Top | Bottom
812 loc => {
813 let icon_first = matches!(loc, IconLocation::Leading | IconLocation::Top);
814 let vertical = matches!(loc, IconLocation::Top | IconLocation::Bottom);
815 let icon = self.make_icon(text_role.clone());
816 let icon_id = ctx.add(icon);
817 let text_id = ctx.add(self.make_label_text(text_role));
818 let (first, second) = if icon_first {
819 (icon_id, text_id)
820 } else {
821 (text_id, icon_id)
822 };
823 let row: Box<dyn Widget> = if vertical {
824 Box::new(
825 VStack::new()
826 .spacing(btn::BUTTON_ICON_LABEL_GAP)
827 .add_child(first)
828 .add_child(second),
829 )
830 } else {
831 Box::new(
832 HStack::new()
833 .spacing(btn::BUTTON_ICON_LABEL_GAP)
834 .add_child(first)
835 .add_child(second),
836 )
837 };
838 ctx.add_boxed(row)
839 }
840 };
841
842 // If leading or trailing slots are set, wrap the icon+label
843 // content in an HStack: `[leading?, content, trailing?]`. When
844 // both slots are absent, the wrap is skipped — the original
845 // content node goes straight into the padding, keeping the
846 // node count identical to the pre-slot Button for the common
847 // case.
848 let content_id = if self.leading.is_some() || self.trailing.is_some() {
849 let mut row = HStack::new().spacing(btn::BUTTON_ICON_LABEL_GAP);
850 if let Some(leading) = self.leading.take() {
851 let id = ctx.add_boxed(leading);
852 row = row.add_child(id);
853 }
854 row = row.add_child(content_id);
855 if let Some(trailing) = self.trailing.take() {
856 let id = ctx.add_boxed(trailing);
857 row = row.add_child(id);
858 }
859 ctx.add(row)
860 } else {
861 content_id
862 };
863
864 // Delegate chrome (background fill, border, focus ring,
865 // padding, min size) to the active `ButtonStyle` (resolved
866 // above). The four boolean signals derive from the local
867 // `interaction` state signal so the style can `.zip` them and
868 // pick a per-state recipe slot.
869 let is_pressed = interaction.map(|s| matches!(s, InteractionState::Pressed));
870 let is_hovered = interaction.map(|s| matches!(s, InteractionState::Hovered));
871 // `:focus-visible`: reveal the focus ring during keyboard navigation
872 // only, not on a mouse click. Gate raw focus on the input-modality
873 // signal (true after a key event, false after pointer-down).
874 let is_focused = interaction
875 .map(|s| matches!(s, InteractionState::Focused))
876 .and(&ctx.focus_visible());
877 // `is_disabled` derives from the arena's effective enabled
878 // state — NOT from the interaction signal. The interaction
879 // signal never carries Disabled anymore (the snapshot-based
880 // duality was removed). Style chrome uses this to pick its
881 // disabled-background role.
882 let is_disabled = effective_enabled.map(|on| !*on);
883 let cfg = ButtonStyleConfig {
884 label: content_id,
885 is_pressed,
886 is_hovered,
887 is_focused,
888 is_disabled,
889 variant,
890 };
891 let root_id = style.make_body(&cfg, ctx);
892
893 // Attach tooltip if configured. The three setters
894 // (`tooltip`, `rich_tooltip*`, `composite_tooltip`) are
895 // mutually exclusive — every setter clears the other two so
896 // exactly one branch runs.
897 if let Some(content) = self.composite_tooltip_content.take() {
898 let delay = ctx.theme().motion.tooltip_delay_heavy;
899 crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
900 } else if let Some(source) = self.rich_tooltip_source.take() {
901 let delay = ctx.theme().motion.tooltip_delay;
902 crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
903 } else if let Some(tooltip_text) = self.tooltip_text.clone() {
904 let delay = ctx.theme().motion.tooltip_delay;
905 crate::tooltip::attach_plain_tooltip(ctx, root_id, tooltip_text, delay);
906 }
907
908 self.root_child_id = Some(root_id);
909
910 ctx.apply_self_handlers(self.build_handler_set(interaction));
911
912 vec![root_id]
913 }
914
915 fn layout_response(
916 &self,
917 proposal: SizeProposal,
918 ctx: &LayoutContext,
919 ) -> teksilo_core::widget::LayoutResponse {
920 // A Button is rigid: it sizes to its content and does NOT shrink in an
921 // over-constrained row (a truncated action label reads
922 // poorly — the desktop convention is to overflow excess actions into a
923 // menu; see `Toolbar`). We therefore take only the content's SIZE and
924 // drop its grow/shrink weights. The label still truncates if a caller
925 // explicitly constrains the button (e.g. via `FixedSize` / `Shrinkable`).
926 match self.root_child_id {
927 Some(root_id) => ctx
928 .child_size(root_id, proposal)
929 .unwrap_or_else(|| proposal.resolve(0.0, 0.0)),
930 None => proposal.resolve(0.0, 0.0),
931 }
932 .into()
933 }
934
935 fn place_children(
936 &self,
937 bounds: Rect,
938 _proposal: SizeProposal,
939 children: &mut [WidgetPlacement],
940 _ctx: &LayoutContext,
941 ) {
942 // Single child fills our bounds
943 for child in children.iter_mut() {
944 child.origin = bounds.origin();
945 child.size = bounds.size();
946 }
947 }
948
949 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
950 builder.set_role(teksilo_core::accesskit::Role::Button);
951 // Read the current label value uniformly through `Prop::get`
952 // — Static returns the captured `String`; Bound returns the
953 // signal's current value. Keeps AT in sync with `label`.
954 builder.set_name(self.label.get());
955 // `set_disabled()` is now driven by the framework's
956 // accessibility walker from `arena.is_enabled(self_id)`. The
957 // composite no longer needs to mirror it — the snapshot path
958 // was redundant with the arena and broke under reactive
959 // `enabled_when(id, signal)` flips.
960 // ARIA disclosure pattern: a button that opens a popup
961 // should declare `has_popup` and, if the wrapper tracks
962 // it, `expanded`. Both are opt-in — regular buttons with
963 // no popup stay silent on these properties.
964 if let Some(kind) = self.has_popup {
965 builder.set_has_popup(kind);
966 }
967 if let Some(ref signal) = self.expanded_signal {
968 builder.set_expanded(signal.get());
969 }
970 builder.add_action(teksilo_core::accesskit::Action::Click);
971 builder.add_action(teksilo_core::accesskit::Action::Focus);
972 }
973
974 fn children(&self) -> Vec<WidgetId> {
975 match self.root_child_id {
976 Some(id) => vec![id],
977 None => Vec::new(),
978 }
979 }
980}
981
982#[cfg(test)]
983mod tests {
984 use super::*;
985 use std::cell::Cell;
986 use std::rc::Rc;
987 use teksilo_core::event::{Modifiers, WidgetEvent};
988 use teksilo_core::widget_tree::WidgetTree;
989
990 #[test]
991 fn focus_ring_only_under_focus_visible() {
992 // `:focus-visible`: the focus ring shows during keyboard navigation
993 // but not when focus arrived via a pointer click. Programmatic focus
994 // leaves `focus_visible` false, so a focused-but-not-keyboard button
995 // shows no ring; a key press flips the modality and reveals it.
996 let theme = teksilo_core::presets::intui::light();
997 let ring = theme.colors.border_focused.to_array();
998 let mut tree = WidgetTree::new().with_theme(theme);
999 let btn = tree.add(Button::new(lit!("T")).on_activate_fn(|_| {}));
1000 tree.layout(SizeProposal::exact(200.0, 80.0));
1001
1002 // Focused, but `focus_visible` is still false → ring gated OFF even
1003 // though the widget holds focus.
1004 tree.focus(btn);
1005 assert!(
1006 !frame_has_color(&tree.render(), ring),
1007 "no focus ring while focus-visible is false (pointer modality)",
1008 );
1009
1010 // A key event flips `focus_visible` true → ring appears (focus held).
1011 tree.press_key(Key::ArrowDown, Modifiers::NONE);
1012 assert!(
1013 frame_has_color(&tree.render(), ring),
1014 "focus ring shows under keyboard modality",
1015 );
1016 }
1017
1018 /// Whether `color` appears in any color-bearing layer of the frame —
1019 /// borders land in `shapes` (stroked SDF quads), `decorations`
1020 /// (`DecorationRect`), or `cosmetic_lines` depending on the widget.
1021 fn frame_has_color(frame: &teksilo_canvas::RenderFrame, color: [f32; 4]) -> bool {
1022 frame.shapes.iter().any(|s| s.color == color)
1023 || frame.decorations.iter().any(|d| d.color == color)
1024 || frame.cosmetic_lines.iter().any(|l| l.color == color)
1025 }
1026
1027 #[test]
1028 fn filled_button_accent_desaturates_when_window_inactive() {
1029 // The Filled button bakes its fill via the theme signal
1030 // (`ColorProp::Bound`), which a plain `theme_signal` resolution would
1031 // freeze at the active accent — so it must resolve against the
1032 // window-active palette to grey out like the paint-resolving controls.
1033 let theme = teksilo_core::presets::intui::light();
1034 let accent = theme.colors.accent.to_array();
1035 let inactive_accent = theme.colors.for_inactive_window().accent.to_array();
1036 assert_ne!(accent, inactive_accent);
1037
1038 let mut tree = WidgetTree::new().with_theme(theme);
1039 tree.add(Button::new(lit!("Save")).variant(ButtonVariant::Filled));
1040 tree.layout(SizeProposal::exact(200.0, 80.0));
1041
1042 // Active: vivid accent fill.
1043 assert!(
1044 frame_has_color(&tree.render(), accent),
1045 "active window: Filled button paints the vivid accent"
1046 );
1047
1048 // Inactive: the fill desaturates with every other accent control.
1049 tree.set_window_active(false);
1050 let frame = tree.render();
1051 assert!(
1052 frame_has_color(&frame, inactive_accent),
1053 "inactive window: Filled button fill desaturates"
1054 );
1055 assert!(
1056 !frame_has_color(&frame, accent),
1057 "inactive window: no vivid accent remains"
1058 );
1059
1060 // Reactivate: vivid accent returns.
1061 tree.set_window_active(true);
1062 assert!(frame_has_color(&tree.render(), accent));
1063 }
1064
1065 #[test]
1066 fn keyup_without_keydown_does_not_fire() {
1067 // Regression for the MessageBox reopen bug: when a shortcut
1068 // consumes Enter's KeyDown (dismissing the modal and restoring
1069 // focus to the trigger button), the trailing KeyUp must not
1070 // re-activate the trigger.
1071 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1072 let fired = Rc::new(Cell::new(0_u32));
1073 let fired_for_btn = fired.clone();
1074 let btn = tree.add(Button::new(lit!("T")).on_activate_fn(move |_ctx| {
1075 fired_for_btn.set(fired_for_btn.get() + 1);
1076 }));
1077 tree.layout(SizeProposal::exact(200.0, 80.0));
1078 tree.focus(btn);
1079
1080 tree.dispatch_event(WidgetEvent::KeyUp {
1081 key: Key::Enter,
1082 modifiers: Modifiers::NONE,
1083 });
1084 assert_eq!(
1085 fired.get(),
1086 0,
1087 "a lone KeyUp (no matching KeyDown) must not activate the button",
1088 );
1089
1090 tree.dispatch_event(WidgetEvent::KeyDown {
1091 key: Key::Enter,
1092 modifiers: Modifiers::NONE,
1093 text: None,
1094 });
1095 tree.dispatch_event(WidgetEvent::KeyUp {
1096 key: Key::Enter,
1097 modifiers: Modifiers::NONE,
1098 });
1099 assert_eq!(
1100 fired.get(),
1101 1,
1102 "a matched KeyDown + KeyUp pair must activate exactly once",
1103 );
1104 }
1105
1106 // Helper: lay out a Target button (left) and an Open trigger (right)
1107 // side by side, then open a click-opened overlay anchored to the
1108 // trigger and parked below the bar. Returns the tree plus the pieces
1109 // the dismiss-passthrough tests assert on.
1110 fn open_overlay_beside_button() -> (
1111 WidgetTree,
1112 teksilo_core::widget_id::WidgetId, // target
1113 teksilo_core::widget_id::WidgetId, // trigger
1114 teksilo_core::widget_id::WidgetId, // overlay content
1115 Rc<Cell<u32>>, // target activations
1116 Rc<Cell<u32>>, // trigger activations
1117 ) {
1118 use teksilo_core::overlay::{
1119 DismissBehavior, OverlayLayer, OverlayPlacement, OverlayRequest,
1120 };
1121
1122 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1123 let target_fired = Rc::new(Cell::new(0_u32));
1124 let tf = target_fired.clone();
1125 let trigger_fired = Rc::new(Cell::new(0_u32));
1126 let gf = trigger_fired.clone();
1127
1128 let target =
1129 tree.add(Button::new(lit!("Target")).on_activate_fn(move |_| tf.set(tf.get() + 1)));
1130 let trigger =
1131 tree.add(Button::new(lit!("Open")).on_activate_fn(move |_| gf.set(gf.get() + 1)));
1132 let content = tree.add(Button::new(lit!("Item")));
1133 let _root = tree.add(
1134 crate::primitives::HStack::new()
1135 .spacing(40.0)
1136 .add_child(target)
1137 .add_child(trigger),
1138 );
1139 tree.layout(SizeProposal::exact(400.0, 200.0));
1140
1141 tree.show_overlay(OverlayRequest {
1142 content_id: content,
1143 anchor: trigger,
1144 placement: OverlayPlacement::Below,
1145 dismiss: DismissBehavior::EscapeOrClickOutside,
1146 layer: OverlayLayer::InTree,
1147 parent_overlay: None,
1148 on_dismiss: None,
1149 fade_duration: None,
1150 });
1151 // Second layout positions the overlay content below the trigger.
1152 tree.layout(SizeProposal::exact(400.0, 200.0));
1153
1154 (tree, target, trigger, content, target_fired, trigger_fired)
1155 }
1156
1157 #[test]
1158 fn dismiss_click_activates_button_beneath() {
1159 // The reported quirk: with a dropdown/menu open, clicking another
1160 // widget should dismiss the overlay AND activate that widget in a
1161 // single click — not require a throwaway first click.
1162 use teksilo_core::event::PointerButton;
1163
1164 let (mut tree, target, _trigger, _content, target_fired, trigger_fired) =
1165 open_overlay_beside_button();
1166
1167 let tb = tree.bounds(target);
1168 let target_center =
1169 teksilo_canvas::Point::new(tb.x + tb.width / 2.0, tb.y + tb.height / 2.0);
1170 // The overlay is parked below the button bar; the dismiss assertion
1171 // after dispatch confirms this click lands outside it.
1172 assert_eq!(tree.active_overlays().len(), 1);
1173
1174 tree.dispatch_event(WidgetEvent::PointerDown {
1175 position: target_center,
1176 button: PointerButton::Primary,
1177 modifiers: Modifiers::NONE,
1178 });
1179 tree.dispatch_event(WidgetEvent::PointerUp {
1180 position: target_center,
1181 button: PointerButton::Primary,
1182 modifiers: Modifiers::NONE,
1183 });
1184
1185 assert!(
1186 tree.active_overlays().is_empty(),
1187 "the press should dismiss the open overlay",
1188 );
1189 assert_eq!(
1190 target_fired.get(),
1191 1,
1192 "the same press should activate the button beneath the dismissed overlay",
1193 );
1194 assert_eq!(trigger_fired.get(), 0);
1195 }
1196
1197 #[test]
1198 fn dismiss_click_on_trigger_is_consumed_not_reactivated() {
1199 // The anchor guard: clicking the trigger that owns an open overlay
1200 // must merely close it. The press is consumed, so it can't reach
1201 // the trigger's own tap handler and reopen what it just closed.
1202 use teksilo_core::event::PointerButton;
1203
1204 let (mut tree, _target, trigger, _content, _target_fired, trigger_fired) =
1205 open_overlay_beside_button();
1206
1207 let gb = tree.bounds(trigger);
1208 let trigger_center =
1209 teksilo_canvas::Point::new(gb.x + gb.width / 2.0, gb.y + gb.height / 2.0);
1210 assert_eq!(tree.active_overlays().len(), 1);
1211
1212 tree.dispatch_event(WidgetEvent::PointerDown {
1213 position: trigger_center,
1214 button: PointerButton::Primary,
1215 modifiers: Modifiers::NONE,
1216 });
1217 tree.dispatch_event(WidgetEvent::PointerUp {
1218 position: trigger_center,
1219 button: PointerButton::Primary,
1220 modifiers: Modifiers::NONE,
1221 });
1222
1223 assert!(
1224 tree.active_overlays().is_empty(),
1225 "clicking the trigger should close its overlay",
1226 );
1227 assert_eq!(
1228 trigger_fired.get(),
1229 0,
1230 "the dismiss press on the anchor must be consumed, not delivered to the trigger",
1231 );
1232 }
1233
1234 #[test]
1235 fn label_updates_at_name_when_signal_changes() {
1236 // Regression for the calendar header use case: a Button bound
1237 // to a `Signal<String>` must (1) display the signal's current
1238 // value and (2) refresh its accessibility name when the
1239 // signal changes — without rebuilding the parent.
1240 use teksilo_core::accessibility::widget_id_to_node_id;
1241 let label = Signal::new("May 2026".to_string());
1242 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1243 let id = tree.add(
1244 Button::new(lit!(""))
1245 .label(label.clone())
1246 .on_activate_fn(|_| {}),
1247 );
1248 tree.layout(SizeProposal::exact(300.0, 80.0));
1249 let target = widget_id_to_node_id(id);
1250 let update = tree.sync_accessibility();
1251 let (_, node) = update
1252 .nodes
1253 .iter()
1254 .find(|(nid, _)| *nid == target)
1255 .expect("button node");
1256 assert_eq!(node.label().unwrap_or_default(), "May 2026");
1257
1258 // Flip the signal — AT name should refresh after the next
1259 // layout pass (the label registration triggers a
1260 // re-evaluation of `accessibility()`).
1261 label.set("2026".to_string());
1262 tree.layout(SizeProposal::exact(300.0, 80.0));
1263 let update = tree.sync_accessibility();
1264 let (_, node) = update
1265 .nodes
1266 .iter()
1267 .find(|(nid, _)| *nid == target)
1268 .expect("button node after relabel");
1269 assert_eq!(node.label().unwrap_or_default(), "2026");
1270 }
1271
1272 #[test]
1273 fn slots_widen_button_to_accommodate_their_intrinsic_size() {
1274 // A button with leading + trailing slots reports a wider
1275 // intrinsic size than the same button without slots — proves
1276 // the slots actually entered the layout pass. Layout uses
1277 // `unspecified()` so each button reports its intrinsic width
1278 // rather than getting stretched to a parent proposal. Both
1279 // sides also clear the theme's `min_width` (~72dp) which
1280 // would otherwise mask the slot contribution on the plain
1281 // button.
1282 use crate::primitives::MinSize;
1283 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1284 let plain = tree.add(Button::new(lit!("X")).on_activate_fn(|_| {}));
1285 let with_slots = tree.add(
1286 Button::new(lit!("X"))
1287 .leading(MinSize::new(120.0, 12.0))
1288 .trailing(MinSize::new(120.0, 12.0))
1289 .on_activate_fn(|_| {}),
1290 );
1291 tree.layout(SizeProposal::unspecified());
1292 let plain_w = tree.bounds(plain).width;
1293 let slot_w = tree.bounds(with_slots).width;
1294 assert!(
1295 slot_w >= plain_w + 200.0,
1296 "expected slot button to be at least 200dp wider than plain (plain={plain_w}, slot={slot_w})",
1297 );
1298 }
1299
1300 #[test]
1301 fn button_is_rigid_and_does_not_shrink_in_a_tight_row() {
1302 // A Button is rigid: in an over-constrained row it keeps its natural
1303 // width (overflows) rather than truncating its action label. The
1304 // desktop convention is to overflow excess actions into a menu (see
1305 // `Toolbar`), not to silently truncate buttons.
1306 use crate::primitives::hstack::HStack;
1307 let mut tree = WidgetTree::new()
1308 .with_theme(teksilo_core::presets::intui::light())
1309 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1310 teksilo_canvas::MockTextBackend::new(),
1311 )));
1312 let btn = tree.add(Button::new(lit!("Save Document As…")).on_activate_fn(|_| {}));
1313 let _row = tree.add(HStack::new().add_child(btn));
1314
1315 tree.layout(SizeProposal::unspecified());
1316 let natural = tree.bounds(btn).width;
1317 // Squeeze the row far below natural — the Button keeps its full width.
1318 tree.layout(SizeProposal::exact(70.0, 40.0));
1319 let squeezed = tree.bounds(btn).width;
1320
1321 assert!(
1322 natural > 100.0,
1323 "expected a wide natural button, got {natural}"
1324 );
1325 assert!(
1326 (squeezed - natural).abs() < 0.5,
1327 "button should stay rigid at its natural width \
1328 (natural={natural}, squeezed={squeezed})"
1329 );
1330 }
1331
1332 #[test]
1333 fn framework_default_blocks_secondary_tap_on_button() {
1334 // Framework default: `TapRecognizer::accept = ButtonMask::PRIMARY`.
1335 // A right-click on a Button does NOT activate. Generalises the
1336 // tab-specific `primary_click_activates_tab_secondary_does_not`
1337 // regression to every widget that wires `on_tap`.
1338 use teksilo_core::event::PointerButton;
1339 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1340 let fired = Rc::new(Cell::new(0_u32));
1341 let fired_for_btn = fired.clone();
1342 let btn = tree.add(Button::new(lit!("T")).on_activate_fn(move |_ctx| {
1343 fired_for_btn.set(fired_for_btn.get() + 1);
1344 }));
1345 tree.layout(SizeProposal::exact(200.0, 80.0));
1346 let center = tree.bounds(btn).center();
1347
1348 tree.pointer_down_button(center, PointerButton::Secondary);
1349 tree.pointer_up_button(center, PointerButton::Secondary);
1350 assert_eq!(fired.get(), 0, "right-click must not activate a Button");
1351
1352 tree.pointer_down_button(center, PointerButton::Middle);
1353 tree.pointer_up_button(center, PointerButton::Middle);
1354 assert_eq!(fired.get(), 0, "middle-click must not activate a Button");
1355
1356 // Sanity: primary click still activates.
1357 tree.pointer_down_button(center, PointerButton::Primary);
1358 tree.pointer_up_button(center, PointerButton::Primary);
1359 assert_eq!(fired.get(), 1, "primary-click must activate a Button");
1360 }
1361
1362 #[test]
1363 fn framework_accept_tap_buttons_secondary_fires_handler() {
1364 // `accept_tap_buttons` opts the auto-wired `TapRecognizer` into
1365 // a wider button set. With `Secondary` allowed, right-click
1366 // activates.
1367 use teksilo_core::event::{ButtonMask, PointerButton};
1368 use teksilo_core::widget_builder::WidgetBuilder;
1369 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1370 let fired = Rc::new(Cell::new(0_u32));
1371 let fired_for_btn = fired.clone();
1372 let btn = tree.add(
1373 Button::new(lit!("T"))
1374 .on_activate_fn(move |_ctx| {
1375 fired_for_btn.set(fired_for_btn.get() + 1);
1376 })
1377 .accept_tap_buttons(ButtonMask::PRIMARY | ButtonMask::SECONDARY),
1378 );
1379 tree.layout(SizeProposal::exact(200.0, 80.0));
1380 let center = tree.bounds(btn).center();
1381
1382 tree.pointer_down_button(center, PointerButton::Secondary);
1383 tree.pointer_up_button(center, PointerButton::Secondary);
1384 assert_eq!(
1385 fired.get(),
1386 1,
1387 "right-click must activate a Button when accept_tap_buttons includes Secondary",
1388 );
1389
1390 tree.pointer_down_button(center, PointerButton::Primary);
1391 tree.pointer_up_button(center, PointerButton::Primary);
1392 assert_eq!(fired.get(), 2, "primary-click still activates");
1393 }
1394
1395 #[test]
1396 fn hidden_slot_marks_swatch_node_as_at_hidden() {
1397 // ColorSwatch declares `Role::ColorWell`. Dropped raw into a
1398 // Button slot it would appear as a redundant ColorWell child
1399 // under the Button's node. `.access_hidden(true)` is the
1400 // documented escape hatch — confirm the swatch's AT node
1401 // carries the hidden flag (AT readers skip nodes flagged
1402 // hidden, even though the node still exists in the tree).
1403 use crate::color_picker::ColorSwatch;
1404 use teksilo_core::accessibility::widget_id_to_node_id;
1405 use teksilo_core::accesskit::Role;
1406 use teksilo_core::widget_builder::WidgetBuilder;
1407 use teksilo_tokens::Color;
1408 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1409 let id = tree.add(
1410 Button::new(lit!("Pick"))
1411 .leading(ColorSwatch::new(Color::RED).access_hidden(true))
1412 .on_activate_fn(|_| {}),
1413 );
1414 tree.layout(SizeProposal::exact(300.0, 80.0));
1415 let target = widget_id_to_node_id(id);
1416 let update = tree.sync_accessibility();
1417 let (_, btn_node) = update
1418 .nodes
1419 .iter()
1420 .find(|(nid, _)| *nid == target)
1421 .expect("button node");
1422 assert_eq!(btn_node.role(), Role::Button);
1423 let color_well_visible = update
1424 .nodes
1425 .iter()
1426 .any(|(_, n)| n.role() == Role::ColorWell && !n.is_hidden());
1427 assert!(
1428 !color_well_visible,
1429 "hidden swatch should not emit a non-hidden ColorWell node",
1430 );
1431 }
1432
1433 #[test]
1434 fn plain_button_is_a_leaf_no_group_node() {
1435 // Regression: a Button's chrome is composed from layout primitives
1436 // (Padding/Center/HStack/…) that emit empty GenericContainer /
1437 // Unknown AT nodes. VoiceOver announces a GenericContainer as
1438 // "group", so the button read as "<label>, button, group". The AT
1439 // walker now collapses presentational nodes — assert the button is
1440 // a clean leaf and no grouping node survives anywhere.
1441 use teksilo_core::accessibility::widget_id_to_node_id;
1442 use teksilo_core::accesskit::Role;
1443 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1444 let id = tree.add(Button::new(lit!("Valider")).on_activate_fn(|_| {}));
1445 tree.layout(SizeProposal::exact(300.0, 80.0));
1446 let _ = tree.render();
1447 let update = tree.sync_accessibility();
1448
1449 assert!(
1450 !update
1451 .nodes
1452 .iter()
1453 .any(|(_, n)| n.role() == Role::GenericContainer),
1454 "no GenericContainer ('group') node should remain in the AT tree"
1455 );
1456
1457 let (_, btn) = update
1458 .nodes
1459 .iter()
1460 .find(|(nid, _)| *nid == widget_id_to_node_id(id))
1461 .expect("button node present");
1462 assert_eq!(btn.role(), Role::Button);
1463 assert_eq!(btn.label(), Some("Valider"));
1464 let has_visible_child = btn.children().iter().any(|cid| {
1465 update
1466 .nodes
1467 .iter()
1468 .find(|(nid, _)| nid == cid)
1469 .is_some_and(|(_, n)| !n.is_hidden())
1470 });
1471 assert!(
1472 !has_visible_child,
1473 "button should expose no visible AT child node (it is a leaf)"
1474 );
1475 }
1476
1477 #[test]
1478 fn theme_slot_supplies_button_style_when_no_override() {
1479 // End-to-end check that `theme.style_slots.button = Some(rc)`
1480 // actually feeds the widget when no per-call `.style(...)`
1481 // override is present. Uses a custom `ButtonStyle` that adds a
1482 // sentinel `RectWidget` we can spot in the rendered frame.
1483 use teksilo_core::styles::{ButtonStyle, ButtonStyleConfig};
1484 use teksilo_tokens::Color;
1485
1486 struct SentinelButton;
1487 impl ButtonStyle for SentinelButton {
1488 fn make_body(
1489 &self,
1490 cfg: &ButtonStyleConfig,
1491 ctx: &mut teksilo_core::build_context::BuildContext,
1492 ) -> teksilo_core::widget_id::WidgetId {
1493 // Distinctive bright-magenta background nobody else paints.
1494 let bg = ctx.add(
1495 crate::primitives::RectWidget::new()
1496 .background(Color::new(1.0, 0.0, 1.0, 1.0))
1497 .corner_radius(teksilo_tokens::CornerRadius::uniform(0.0)),
1498 );
1499 ctx.add(
1500 crate::primitives::ZStack::new()
1501 .add_child(bg)
1502 .add_child(cfg.label),
1503 )
1504 }
1505 }
1506
1507 let mut theme = teksilo_core::presets::intui::light();
1508 theme.style_slots.button = Some(Rc::new(SentinelButton));
1509 let mut tree = WidgetTree::new().with_theme(theme);
1510 let _btn = tree.add(Button::new(lit!("T")).on_activate_fn(|_| {}));
1511 tree.layout(SizeProposal::exact(200.0, 80.0));
1512 let frame = tree.render();
1513
1514 let sentinel = [1.0_f32, 0.0, 1.0, 1.0];
1515 assert!(
1516 frame.shapes.iter().any(|s| s.color == sentinel),
1517 "the theme's `style_slots.button` impl should drive Button chrome \
1518 — saw no sentinel magenta rect in the rendered frame",
1519 );
1520 }
1521
1522 #[test]
1523 fn style_label_text_role_overrides_default_label_color() {
1524 // A `ButtonStyle` returning `Some(role)` from `label_text_role`
1525 // redirects the label/icon color — the Material 3 "text and
1526 // outlined buttons are accent-colored" need. Styles that return
1527 // `None` (the IntUI default) keep the Button's built-in mapping,
1528 // so this is purely additive (the rest of the suite covers the
1529 // default path).
1530 use std::cell::RefCell;
1531 use teksilo_canvas::MockTextBackend;
1532 use teksilo_core::styles::{ButtonStyle, ButtonStyleConfig, ButtonVariant};
1533 use teksilo_tokens::TextRole;
1534
1535 struct LabelRoleSentinel;
1536 impl ButtonStyle for LabelRoleSentinel {
1537 fn make_body(
1538 &self,
1539 cfg: &ButtonStyleConfig,
1540 ctx: &mut teksilo_core::build_context::BuildContext,
1541 ) -> teksilo_core::widget_id::WidgetId {
1542 ctx.add(crate::primitives::ZStack::new().add_child(cfg.label))
1543 }
1544 fn label_text_role(&self, _variant: ButtonVariant) -> Option<TextRole> {
1545 Some(TextRole::Error)
1546 }
1547 }
1548
1549 let want = teksilo_core::presets::intui::light()
1550 .colors
1551 .text_error
1552 .to_array();
1553 let mut theme = teksilo_core::presets::intui::light();
1554 theme.style_slots.button = Some(Rc::new(LabelRoleSentinel));
1555 let mut tree = WidgetTree::new()
1556 .with_theme(theme)
1557 .with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())));
1558 let _btn = tree.add(Button::new(lit!("T")).on_activate_fn(|_| {}));
1559 tree.layout(SizeProposal::exact(200.0, 80.0));
1560 let frame = tree.render();
1561
1562 assert!(
1563 frame.glyphs.iter().any(|g| g.color == want),
1564 "style.label_text_role(...) should drive the label glyph color; \
1565 expected the theme error color {want:?}, saw {:?}",
1566 frame.glyphs.iter().map(|g| g.color).collect::<Vec<_>>(),
1567 );
1568 }
1569
1570 #[test]
1571 fn per_call_style_override_wins_over_theme_slot() {
1572 // When both `Button::style(...)` AND `theme.style_slots.button`
1573 // are set, the per-call wins. Verified by installing a sentinel
1574 // style on the theme then a *different* sentinel via `.style()`.
1575 use teksilo_core::styles::{ButtonStyle, ButtonStyleConfig};
1576 use teksilo_tokens::Color;
1577
1578 struct ThemeSentinel;
1579 impl ButtonStyle for ThemeSentinel {
1580 fn make_body(
1581 &self,
1582 cfg: &ButtonStyleConfig,
1583 ctx: &mut teksilo_core::build_context::BuildContext,
1584 ) -> teksilo_core::widget_id::WidgetId {
1585 let bg = ctx.add(
1586 crate::primitives::RectWidget::new()
1587 .background(Color::new(1.0, 0.0, 1.0, 1.0)) // magenta
1588 .corner_radius(teksilo_tokens::CornerRadius::uniform(0.0)),
1589 );
1590 ctx.add(
1591 crate::primitives::ZStack::new()
1592 .add_child(bg)
1593 .add_child(cfg.label),
1594 )
1595 }
1596 }
1597
1598 struct CallSentinel;
1599 impl ButtonStyle for CallSentinel {
1600 fn make_body(
1601 &self,
1602 cfg: &ButtonStyleConfig,
1603 ctx: &mut teksilo_core::build_context::BuildContext,
1604 ) -> teksilo_core::widget_id::WidgetId {
1605 let bg = ctx.add(
1606 crate::primitives::RectWidget::new()
1607 .background(Color::new(0.0, 1.0, 0.0, 1.0)) // green
1608 .corner_radius(teksilo_tokens::CornerRadius::uniform(0.0)),
1609 );
1610 ctx.add(
1611 crate::primitives::ZStack::new()
1612 .add_child(bg)
1613 .add_child(cfg.label),
1614 )
1615 }
1616 }
1617
1618 let mut theme = teksilo_core::presets::intui::light();
1619 theme.style_slots.button = Some(Rc::new(ThemeSentinel));
1620 let mut tree = WidgetTree::new().with_theme(theme);
1621 let _btn = tree.add(
1622 Button::new(lit!("T"))
1623 .style(CallSentinel)
1624 .on_activate_fn(|_| {}),
1625 );
1626 tree.layout(SizeProposal::exact(200.0, 80.0));
1627 let frame = tree.render();
1628
1629 let magenta = [1.0_f32, 0.0, 1.0, 1.0];
1630 let green = [0.0_f32, 1.0, 0.0, 1.0];
1631 assert!(
1632 frame.shapes.iter().any(|s| s.color == green),
1633 "per-call .style(...) override should drive chrome — no green rect found",
1634 );
1635 assert!(
1636 !frame.shapes.iter().any(|s| s.color == magenta),
1637 "theme slot must be ignored when per-call override is set — magenta should not appear",
1638 );
1639 }
1640}
1641
1642/// [`Button::icon_keeps_color`] — the icon's own colour survives, or it does not.
1643#[cfg(test)]
1644mod icon_color_tests {
1645 use super::*;
1646 use teksilo_core::widget_tree::WidgetTree;
1647
1648 /// A disc in a colour no theme role would ever produce, so finding it in the frame
1649 /// can only mean the icon kept it.
1650 const SWATCH: [f32; 4] = [0.93, 0.29, 0.60, 1.0];
1651
1652 fn swatch_icon() -> IconWidget {
1653 let centre = teksilo_canvas::Point::new(5.0, 5.0);
1654 IconWidget::from_path(teksilo_canvas::Path::circle(centre, 4.5), 10.0).color(
1655 teksilo_tokens::Color::from_rgba(SWATCH[0], SWATCH[1], SWATCH[2], SWATCH[3]),
1656 )
1657 }
1658
1659 fn painted(button: Button) -> bool {
1660 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1661 let _ = tree.add(button);
1662 tree.layout(SizeProposal::exact(240.0, 60.0));
1663 let frame = tree.render();
1664 // An `IconWidget::from_path` lands in `paths`, not `shapes` — the button's
1665 // own chrome is what fills `shapes`.
1666 frame.paths.iter().any(|p| p.color == SWATCH)
1667 || frame.shapes.iter().any(|s| s.color == SWATCH)
1668 || frame.decorations.iter().any(|d| d.color == SWATCH)
1669 }
1670
1671 /// The default: an icon repeats the label, so it takes the label's colour and the
1672 /// button stays one legible unit under every variant and state.
1673 #[test]
1674 fn an_icon_is_tinted_to_the_label_by_default() {
1675 assert!(
1676 !painted(Button::new(lit!("Tag")).icon(swatch_icon(), IconLocation::Leading)),
1677 "the icon kept its own colour without being asked to"
1678 );
1679 }
1680
1681 /// And the opt-out, for an icon whose colour *is* the information — a filter chip
1682 /// carrying a user-chosen tag colour has nothing left if it is tinted away.
1683 #[test]
1684 fn icon_keeps_color_survives_the_buttons_tint() {
1685 assert!(
1686 painted(
1687 Button::new(lit!("Tag"))
1688 .icon(swatch_icon(), IconLocation::Leading)
1689 .icon_keeps_color()
1690 ),
1691 "icon_keeps_color did not reach the painted icon"
1692 );
1693 }
1694}