Skip to main content

teksilo_widgets/
button.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Button — a labelled, activatable action trigger.
5//!
6//! `Button` is the primary action surface in Teksilo. It renders a text
7//! label (optionally with a leading, trailing, top, or bottom icon), fires
8//! a closure on click / Space / Enter / AT click, and advertises seven
9//! design-language variants via [`ButtonVariant`]. Chrome (fill, border,
10//! focus ring, padding) is delegated to the active [`ButtonStyle`]; the
11//! default `RecipeButtonStyle` implements the Int UI token ladder.
12//!
13//! ## When to use
14//!
15//! - Primary action: `.variant(ButtonVariant::Filled)` — one per context.
16//! - Secondary / cancel: default `ButtonVariant::Plain`.
17//! - Danger: `ButtonVariant::Destructive` (IntUI maps this to Filled).
18//! - Text-only link: `ButtonVariant::Link` / `ButtonVariant::Ghost`.
19//!
20//! ## 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}