Skip to main content

teksilo_widgets/
icon_button.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! IconButton — a square, icon-only, flat-surface button.
5//!
6//! Five sizes covering both **embedded** use (inside another widget's
7//! trailing slot — TextInput's clear-X, ComboBox's chevron, SearchField's
8//! magnifier) and **stand-alone** use (toolbars, rich menus, hero CTAs).
9//! The `.embedded()` flag opts into the JetBrains "built-in" look —
10//! dimmer icon at rest (Secondary), brightening on hover (Primary),
11//! flashing accent on press — so an IconButton living inside a TextInput
12//! doesn't compete visually with the field's text. Without the flag the
13//! icon stays at full visual weight (Primary at rest), the right default
14//! for stand-alone toolbar / menu rows.
15//!
16//! ```rust
17//! # use teksilo_widgets::{IconButton};
18//! # use teksilo_widgets::primitives::IconWidget;
19//! # use teksilo_i18n::lit;
20//! # use teksilo_core::Intent;
21//! # const MY_SVG: &str = "<svg xmlns='http://www.w3.org/2000/svg'/>";
22//! // Stand-alone toolbar use — full-weight icon.
23//! let _w = IconButton::new(IconWidget::from_svg(MY_SVG))
24//!     .toolbar()
25//!     .tooltip(lit!("Save"))
26//!     .on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.save")));
27//!
28//! // Embedded inside a TextInput's trailing slot — dim until hover.
29//! let _w = IconButton::clear()
30//!     .embedded()
31//!     .on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.clear")));
32//! ```
33//!
34//! ## Predefined constructors
35//!
36//! Common roles ship with the appropriate icon and an i18n tooltip
37//! (which doubles as the AT name). They are size- and mode-agnostic —
38//! call `.embedded()`, `.toolbar()`, `.large()`, etc. to configure:
39//!
40//! ```rust
41//! # use teksilo_widgets::IconButton;
42//! # use teksilo_core::signal::Signal;
43//! # let visible = Signal::new(false);
44//! let _w = IconButton::browse().embedded();           // 24 dp, dim — TextInput trailing
45//! let _w = IconButton::clear().embedded();            // 24 dp, dim — clear-X
46//! let _w = IconButton::search().toolbar();            // 40 dp, full weight — toolbar
47//! let _w = IconButton::visibility_toggle(visible);    // password-field eye toggle
48//! ```
49//!
50//! ## Bistate
51//!
52//! Two distinct toggle modes:
53//!
54//! - [`IconButton::toggle`] — surface-tint bistate: clicking flips the
55//!   bound `Signal<bool>`; while `true`, the background reads as
56//!   `SurfaceRole::Selected` ("on"). Same icon throughout. The
57//!   pin-this-row / select-this-tool pattern.
58//! - [`IconButton::toggle_with_icon`] — surface-tint **and** icon-swap
59//!   bistate: same surface flip plus the icon glyph swaps to a second
60//!   icon. The visibility-toggle pattern (eye ↔ eye-off).
61//!
62//! ## Slot convention
63//!
64//! Host widgets that accept icon buttons follow the `trailing_slot`
65//! convention established by [`TabWidget`](crate::tab_widget::TabWidget):
66//!
67//! ```rust
68//! # use teksilo_widgets::{IconButton, TextInput};
69//! # use teksilo_widgets::primitives::HStack;
70//! # use teksilo_core::signal::Signal;
71//! # use teksilo_core::Intent;
72//! # let value = Signal::new(String::new());
73//! let _w = TextInput::new(value)
74//!     .trailing_slot(HStack::new().spacing(0.0)
75//!         .child(IconButton::clear().embedded().on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.clear"))))
76//!         .child(IconButton::browse().embedded().on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.browse"))))
77//!     );
78//! ```
79
80use std::rc::Rc;
81use std::sync::OnceLock;
82
83use teksilo_canvas::{Path, Rect, SizeProposal};
84use teksilo_core::accessibility::AccessNodeBuilder;
85use teksilo_core::binding::BindingLevel;
86use teksilo_core::build_context::BuildContext;
87use teksilo_core::signal::{Prop, Signal};
88use teksilo_core::styles::{IconButtonStyleConfig, SharedIconButtonStyle};
89use teksilo_core::widget::{EventContext, LayoutContext, WidgetPlacement};
90use teksilo_core::widget_id::WidgetId;
91use teksilo_tokens::TextRole;
92
93use crate::primitives::Switcher;
94use crate::primitives::icon_widget::IconWidget;
95
96/// Size variant for [`IconButton`]. See [`teksilo_core::styles::IconButtonSize`]
97/// for the canonical definition. Variants are calibrated to the
98/// IntelliJ Int UI scale (Compact 22 dp, Default 24 dp, Toolbar 30 dp,
99/// Large 40 dp, Hero 50 dp).
100pub use teksilo_core::styles::IconButtonSize;
101
102use crate::button::InteractionState;
103use teksilo_i18n::LocalizedString;
104
105/// Type-erased action factory — captures the concrete command type.
106type ActionFactory = Box<dyn Fn(&mut EventContext)>;
107
108/// A square, icon-only, flat-surface button. See module docs for
109/// embedded vs stand-alone modes, the five sizes, and the two bistate
110/// toggle modes.
111pub struct IconButton {
112    // Configuration (set via builder)
113    icon: IconWidget,
114    tooltip_text: Option<LocalizedString>,
115    /// Optional rich tooltip source — registry key or inline content.
116    /// Mutually exclusive with `tooltip_text` and `composite_tooltip_content`.
117    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
118    /// Optional composite tooltip body (CK3-style widget tree).
119    /// Mutually exclusive with the other two tooltip slots.
120    composite_tooltip_content: Option<Box<dyn teksilo_core::widget::Widget>>,
121    /// Enabled state, static or reactive. Forwarded into the arena via
122    /// `ctx.enabled_when(self_id, self.enabled.clone())` at build time;
123    /// not kept as a runtime snapshot. After `build()` the arena's
124    /// `enabled_state` is the single source of truth; the leaves
125    /// (icon, label) read it through `PaintContext::effective_enabled`
126    /// for color resolution, event dispatch reads it via
127    /// `arena.is_enabled()` for gating, the a11y walker reads it for
128    /// `set_disabled()`.
129    enabled: Prop<bool>,
130    size: IconButtonSize,
131    /// Embedded mode — Secondary-at-rest icon color, the JetBrains
132    /// "built-in" look. Default `false` (stand-alone, full-weight icon).
133    embedded: bool,
134    action: Option<ActionFactory>,
135    /// Whether the button takes keyboard focus on Tab navigation.
136    /// `true` (default): focusable when enabled. `false`: never
137    /// focusable — used for the close-button-inside-a-tab pattern
138    /// (Firefox / Chrome convention: Tab moves between *tabs*, not
139    /// onto each tab's close button).
140    focusable: bool,
141
142    // Toggle support
143    toggled: Option<Signal<bool>>,
144    /// Optional alternate icon for the icon-swap toggle mode set via
145    /// [`IconButton::toggle_with_icon`]. When `None`, surface-tint-only
146    /// toggle mode applies (set via [`IconButton::toggle`]).
147    toggled_icon: Option<IconWidget>,
148
149    // Disclosure support — wired up by `PopoverIconButton` so AT
150    // announces the button as a menu / popup trigger and reflects the
151    // open state. Both fields are opt-in via `.has_popup(...)` /
152    // `.expanded_when(...)`.
153    has_popup: Option<teksilo_core::accesskit::HasPopup>,
154    expanded_signal: Option<Prop<bool>>,
155
156    /// Optional caller-supplied interaction signal. When set, `build()`
157    /// uses this signal instead of allocating its own — letting an
158    /// external widget (e.g. `PopoverIconButton`'s disclosure caret)
159    /// observe hover / press / focus / disabled state and match the
160    /// icon's color exactly. See [`IconButton::share_interaction`].
161    shared_interaction: Option<Signal<InteractionState>>,
162
163    /// Optional caller-supplied icon-color override. When `Some`, the
164    /// icon's tint is bound to this `ColorProp` (a `Color`, role, or
165    /// `Signal<Color>`) regardless of `embedded` / interaction state —
166    /// the auto-derived idle/hover/press cascade is replaced. Used by
167    /// chrome that has to read with a host's text-role rather than the
168    /// IconButton's stand-alone palette (e.g. tab-bar scroll arrows
169    /// inheriting `idle_text_role`).
170    icon_role_override: Option<teksilo_core::color_prop::ColorProp>,
171
172    /// Per-call style override. When `None`, falls back to the IntUI
173    /// default `RecipeIconButtonStyle`.
174    style_override: Option<SharedIconButtonStyle>,
175
176    // Build state (set in build())
177    interaction: Signal<InteractionState>,
178    root_child_id: Option<WidgetId>,
179}
180
181impl IconButton {
182    /// Create an icon button from a custom icon. Defaults to
183    /// `IconButtonSize::Default` (24 dp) and stand-alone visual mode.
184    /// Apply `.embedded()` for the JetBrains "built-in" dim look,
185    /// and one of the size methods (`.large()` / `.toolbar()` /
186    /// `.hero()`) or `.size(...)` to pick a different size.
187    pub fn new(icon: IconWidget) -> Self {
188        Self {
189            icon,
190            tooltip_text: None,
191            rich_tooltip_source: None,
192            composite_tooltip_content: None,
193            enabled: Prop::Static(true),
194            size: IconButtonSize::Default,
195            embedded: false,
196            action: None,
197            focusable: true,
198            toggled: None,
199            toggled_icon: None,
200            has_popup: None,
201            expanded_signal: None,
202            shared_interaction: None,
203            icon_role_override: None,
204            style_override: None,
205            interaction: Signal::new(InteractionState::Idle),
206            root_child_id: None,
207        }
208    }
209
210    /// Per-call style override. Replaces the theme-wide default
211    /// `IconButtonStyle` for just this IconButton instance — same role
212    /// as `Button::style(...)`. The override fully owns the background +
213    /// border + size composition; icon coloring stays on the widget.
214    pub fn style(mut self, style: impl teksilo_core::styles::IconButtonStyle) -> Self {
215        self.style_override = Some(Rc::new(style));
216        self
217    }
218
219    /// Per-call style override from an already-shared
220    /// [`SharedIconButtonStyle`] (`Rc<dyn IconButtonStyle>`). Same effect as
221    /// [`style`](Self::style) but takes the erased handle directly, so a host
222    /// (e.g. a `Toolbar` applying one style to all its icon buttons) can share a
223    /// single `Rc` instead of cloning a concrete style per button.
224    pub fn style_shared(mut self, style: SharedIconButtonStyle) -> Self {
225        self.style_override = Some(style);
226        self
227    }
228
229    /// Returns the configured size variant. Used by wrappers like
230    /// [`PopoverIconButton`](crate::popover_widget::PopoverIconButton)
231    /// that need to reason about the trigger's footprint at build time
232    /// (e.g. to skip a corner decoration that wouldn't fit at Compact).
233    pub fn size_variant(&self) -> IconButtonSize {
234        self.size
235    }
236
237    /// Returns whether the button is in the JetBrains "built-in" /
238    /// embedded color profile (Secondary at rest). Mirror getter to
239    /// [`size_variant`](Self::size_variant) for wrappers that want to
240    /// derive their own chrome colors from the same icon role.
241    pub fn is_embedded(&self) -> bool {
242        self.embedded
243    }
244
245    /// Bind the button's internal interaction state to a caller-owned
246    /// `Signal<InteractionState>` instead of letting `build()` allocate
247    /// its own. Used by wrapper widgets like
248    /// [`PopoverIconButton`](crate::popover_widget::PopoverIconButton)
249    /// whose disclosure caret needs to match the icon's color across
250    /// hover / press / focus / disabled states.
251    ///
252    /// The provided signal is reset to `Disabled` when `enabled == false`
253    /// during `build()` so the shared signal honors the button's
254    /// enabled state without the caller having to seed it.
255    pub fn share_interaction(mut self, signal: Signal<InteractionState>) -> Self {
256        self.shared_interaction = Some(signal);
257        self
258    }
259
260    /// Opt into the **embedded** visual treatment — the JetBrains
261    /// "built-in button" look. Icon dims to `Secondary` at rest,
262    /// brightens to `Primary` on hover, flashes `Accent` on press —
263    /// designed to live inside another widget's trailing slot
264    /// (TextInput's clear-X, ComboBox's chevron) without competing
265    /// visually with the host's content. Default mode is stand-alone
266    /// (icon at full visual weight, `Primary` always).
267    pub fn embedded(mut self) -> Self {
268        self.embedded = true;
269        self
270    }
271
272    /// Override the icon's tint with a static `ColorProp`. When set,
273    /// the icon ignores `embedded` and the auto-derived idle/hover/press
274    /// role cascade — its color is bound directly to this prop instead.
275    /// Use for chrome whose host enforces a single text role across all
276    /// of its sub-widgets (e.g. tab-bar scroll arrows that must match
277    /// the tab strip's `idle_text_role` regardless of hover state).
278    /// Accepts `Color`, `TextRole`, `Signal<Color>`, or `Signal<TextRole>`.
279    ///
280    /// It replaces the *interaction* cascade (idle / hover / press / focus),
281    /// **not** the disabled substitution: a role passed here still resolves to
282    /// [`TextRole::Disabled`] in a disabled subtree, like every other
283    /// role-derived color (see [`ColorProp::resolve`](teksilo_core::ColorProp::resolve)).
284    /// That is what a disabled
285    /// control should look like. When the tint is semantic *state* that stays
286    /// true even though the button can't be pressed — a save/sync indicator, a
287    /// validation badge — wrap it: `.icon_role(ColorProp::undimmed(role))`.
288    pub fn icon_role(mut self, role: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
289        self.icon_role_override = Some(role.into());
290        self
291    }
292
293    /// Whether the button takes keyboard focus. Default `true` —
294    /// the button is focusable when enabled. Set to `false` for
295    /// embedded-control patterns where the parent owns focus and
296    /// keyboard interaction goes through the parent (e.g. the
297    /// close button inside a tab header — Tab moves between tabs,
298    /// not onto their close buttons).
299    pub fn focusable(mut self, on: bool) -> Self {
300        self.focusable = on;
301        self
302    }
303
304    /// Attach a tooltip that appears after a hover delay. Required —
305    /// the tooltip text doubles as the AT name for icon-only buttons.
306    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
307        self.tooltip_text = Some(text.into());
308        self.rich_tooltip_source = None;
309        self.composite_tooltip_content = None;
310        self
311    }
312
313    /// Attach a rich tooltip resolved from the app-wide tooltip
314    /// registry. See [`Button::rich_tooltip`](crate::button::Button::rich_tooltip).
315    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
316        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
317        self.tooltip_text = None;
318        self.composite_tooltip_content = None;
319        self
320    }
321
322    /// Attach a rich tooltip driven by inline `TooltipContent`.
323    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
324        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
325        self.tooltip_text = None;
326        self.composite_tooltip_content = None;
327        self
328    }
329
330    /// Attach a composite tooltip — third tier, hosting an arbitrary
331    /// widget tree. See [`Button::composite_tooltip`](crate::button::Button::composite_tooltip).
332    pub fn composite_tooltip(
333        mut self,
334        content: impl teksilo_core::widget::Widget + 'static,
335    ) -> Self {
336        self.composite_tooltip_content = Some(Box::new(content));
337        self.tooltip_text = None;
338        self.rich_tooltip_source = None;
339        self
340    }
341
342    /// Attach a composite tooltip from an already-boxed widget — the boxed twin
343    /// of [`composite_tooltip`](Self::composite_tooltip), for hosts that build
344    /// the body via a `Fn() -> Box<dyn Widget>` factory (e.g. a `ToolbarAction`).
345    pub fn composite_tooltip_boxed(
346        mut self,
347        content: Box<dyn teksilo_core::widget::Widget>,
348    ) -> Self {
349        self.composite_tooltip_content = Some(content);
350        self.tooltip_text = None;
351        self.rich_tooltip_source = None;
352        self
353    }
354
355    /// Set the enabled state, statically or reactively. Disabled
356    /// buttons ignore input and dim their icon (handled by the
357    /// framework's `PaintContext::effective_enabled`). Forwarded into
358    /// the arena via `ctx.enabled_when(self_id, self.enabled.clone())`
359    /// at build time — a bound signal updates live as it changes.
360    ///
361    /// For a reactive enabled state — e.g. a toolbar button that
362    /// enables only when the caret is inside a table — pass a
363    /// `Signal<bool>` here, or call `ctx.enabled_when(button_id,
364    /// my_signal)` from the composing widget's `build()` instead of
365    /// (or in addition to) this builder. Both routes write to the
366    /// same arena `enabled_state`; an external `enabled_when`
367    /// registered after this builder runs wins (last-write semantics)
368    /// and updates reactively from the signal.
369    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
370        self.enabled = enabled.into();
371        self
372    }
373
374    /// Set the size variant. Most callers prefer the named shortcuts
375    /// [`large`](Self::large) / [`toolbar`](Self::toolbar) /
376    /// [`hero`](Self::hero); use `.size(...)` for `Compact` or for
377    /// programmatic size selection.
378    pub fn size(mut self, size: IconButtonSize) -> Self {
379        self.size = size;
380        self
381    }
382
383    /// Shortcut for `.size(IconButtonSize::Toolbar)` (30 dp) — the
384    /// IntelliJ side-toolbar density (left / right / top window edges).
385    pub fn toolbar(mut self) -> Self {
386        self.size = IconButtonSize::Toolbar;
387        self
388    }
389
390    /// Shortcut for `.size(IconButtonSize::Large)` (40 dp) —
391    /// emphasized stand-alone buttons in rich menus and detail panes.
392    pub fn large(mut self) -> Self {
393        self.size = IconButtonSize::Large;
394        self
395    }
396
397    /// Shortcut for `.size(IconButtonSize::Hero)` (50 dp) — hero /
398    /// landing-screen CTAs.
399    pub fn hero(mut self) -> Self {
400        self.size = IconButtonSize::Hero;
401        self
402    }
403
404    /// Closure invoked on activation. Fires after the toggle signal
405    /// (if any) is flipped, so apps observing the closure see the
406    /// post-flip state.
407    pub fn on_activate_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
408        self.action = Some(Box::new(f));
409        self
410    }
411
412    /// Whether an activation closure has been attached. Used by wrappers
413    /// (e.g. `PopoverWidget`) that overwrite the activate slot, so they
414    /// can warn when a caller-set handler is about to be discarded.
415    pub(crate) fn has_activate_handler(&self) -> bool {
416        self.action.is_some()
417    }
418
419    /// Enable **surface-tint** bistate: clicking flips `state` and the
420    /// background reads as `SurfaceRole::Selected` while `state == true`.
421    /// The icon glyph is unchanged. Pin / select / lock-toggle pattern.
422    /// `on_activate_fn`, if any, still fires after the flip.
423    ///
424    /// For the eye / eye-off pattern where the icon glyph also changes,
425    /// use [`toggle_with_icon`](Self::toggle_with_icon) instead.
426    pub fn toggle(mut self, state: Signal<bool>) -> Self {
427        self.toggled = Some(state);
428        self.toggled_icon = None;
429        self
430    }
431
432    /// Enable **surface-tint plus icon-swap** bistate: clicking flips
433    /// `state`, the background flips to `Selected`, **and** the icon
434    /// swaps to `toggled_icon`. The visibility-toggle pattern (eye ↔
435    /// eye-off). For surface-only bistate (icon stays the same), use
436    /// [`toggle`](Self::toggle).
437    pub fn toggle_with_icon(mut self, state: Signal<bool>, toggled_icon: IconWidget) -> Self {
438        self.toggled = Some(state);
439        self.toggled_icon = Some(toggled_icon);
440        self
441    }
442
443    /// Declare that this button is a disclosure trigger for a popup
444    /// (menu, dialog, listbox, …). Surfaced via `set_has_popup` in
445    /// the a11y node so screen readers announce it as opening the
446    /// named popup kind. Wired automatically by
447    /// [`PopoverIconButton`](crate::popover_widget::PopoverIconButton).
448    pub fn has_popup(mut self, kind: teksilo_core::accesskit::HasPopup) -> Self {
449        self.has_popup = Some(kind);
450        self
451    }
452
453    /// Bind a signal reporting whether this button's popup is
454    /// currently visible. The popover wrapper owns the signal and
455    /// flips it on show / dismiss; IconButton reads it in
456    /// `accessibility()` to publish `set_expanded`. Only meaningful
457    /// alongside [`has_popup`](Self::has_popup).
458    pub fn expanded_when(mut self, signal: impl Into<Prop<bool>>) -> Self {
459        self.expanded_signal = Some(signal.into());
460        self
461    }
462
463    // ── Predefined constructors ─────────────────────────────────────────
464    //
465    // Each ships a standard icon and an i18n tooltip. They are size-
466    // and mode-agnostic — chain `.embedded()` for the dim look,
467    // `.toolbar()` / `.large()` / `.hero()` for the size.
468
469    /// Browse button (ellipsis icon). Opens a file/directory chooser.
470    pub fn browse() -> Self {
471        Self::new((BuiltInIcons::global().browse)())
472            .tooltip(teksilo_i18n::tr_widget!(a11y_builtin_browse()))
473    }
474
475    /// Expand button (diagonal resize arrows). Enlarges a constrained field.
476    pub fn expand() -> Self {
477        Self::new((BuiltInIcons::global().expand)())
478            .tooltip(teksilo_i18n::tr_widget!(a11y_builtin_expand()))
479    }
480
481    /// Search button (magnifier icon). Triggers a search.
482    pub fn search() -> Self {
483        Self::new((BuiltInIcons::global().search)())
484            .tooltip(teksilo_i18n::tr_widget!(a11y_builtin_search()))
485    }
486
487    /// Copy button (clipboard icon). Copies the field content.
488    pub fn copy() -> Self {
489        Self::new((BuiltInIcons::global().copy)())
490            .tooltip(teksilo_i18n::tr_widget!(a11y_builtin_copy()))
491    }
492
493    /// Clear button (X icon). Clears the field content.
494    pub fn clear() -> Self {
495        Self::new((BuiltInIcons::global().clear)())
496            .tooltip(teksilo_i18n::tr_widget!(a11y_builtin_clear()))
497    }
498
499    /// Add button (plus icon). Adds a new entry.
500    pub fn add() -> Self {
501        Self::new((BuiltInIcons::global().add)())
502            .tooltip(teksilo_i18n::tr_widget!(a11y_builtin_add()))
503    }
504
505    /// Notification bell. Used by
506    /// [`NotificationCenterButton`](crate::notification::NotificationCenterButton)
507    /// — the bell-icon trigger that opens the notification log popover.
508    pub fn bell() -> Self {
509        Self::new((BuiltInIcons::global().bell)())
510            .tooltip(teksilo_i18n::tr_widget!(a11y_builtin_bell()))
511    }
512
513    /// Menu / hamburger button (three horizontal bars). Used by the
514    /// collapsible [`MenuBar`](crate::menu_bar::MenuBar) as the
515    /// collapsed representation that reveals the bar when activated.
516    /// Advertises `HasPopup::Menu` for assistive technology.
517    pub fn menu() -> Self {
518        Self::new((BuiltInIcons::global().menu)())
519            .tooltip(teksilo_i18n::tr_widget!(a11y_builtin_menu()))
520            .has_popup(teksilo_core::accesskit::HasPopup::Menu)
521    }
522
523    /// "More actions" / overflow button — three **vertical** dots (the kebab
524    /// `⋮`). The conventional trigger for a per-item options menu (view-header
525    /// `…`, list-row overflow). Advertises `HasPopup::Menu` for assistive
526    /// technology. Pair with a `PopoverIconButton` + `MenuList` (use `.bare()`
527    /// so the menu isn't wrapped in a second popover surface).
528    pub fn more() -> Self {
529        Self::new((BuiltInIcons::global().more)())
530            .tooltip(teksilo_i18n::tr_widget!(a11y_builtin_more()))
531            .has_popup(teksilo_core::accesskit::HasPopup::Menu)
532    }
533
534    /// Visibility toggle (eye / eye-off). Toggles password visibility.
535    /// Uses the icon-swap bistate mode internally — the icon advertises
536    /// the **expected action**, matching the prevailing password-field
537    /// convention (1Password, Bitwarden, KeePass, Chrome, GitHub):
538    /// `eye` (open) while the value is hidden, suggesting "click to
539    /// reveal"; `eye_off` (closed) once revealed, suggesting "click to
540    /// hide". `set_toggled` still reports the literal current state, so
541    /// AT readers are not misled.
542    ///
543    /// For a current-state-instead semantics (icon shows what IS),
544    /// build your own with [`toggle_with_icon`](Self::toggle_with_icon)
545    /// and the eye glyphs in the opposite order.
546    ///
547    /// The `visible` signal is flipped on each click. The host widget reads
548    /// it to decide whether to mask or show the text.
549    pub fn visibility_toggle(visible: Signal<bool>) -> Self {
550        let icons = BuiltInIcons::global();
551        Self::new((icons.eye)())
552            .toggle_with_icon(visible, (icons.eye_off)())
553            .tooltip(teksilo_i18n::tr_widget!(a11y_builtin_visibility()))
554    }
555}
556
557impl std::fmt::Debug for IconButton {
558    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
559        f.debug_struct("IconButton")
560            .field("enabled", &self.enabled.get())
561            .field("size", &self.size)
562            .field("embedded", &self.embedded)
563            .finish()
564    }
565}
566
567// ── Icon coloring ───────────────────────────────────────────────────────────
568//
569// Background / border / size composition lives in the active
570// `IconButtonStyle` (default: `RecipeIconButtonStyle`). The widget retains
571// only icon coloring policy — embedded mode dims to `Secondary` at rest
572// (the JetBrains "built-in" look), stand-alone mode stays at `Primary`
573// always so toolbar / menu icons read at full weight.
574
575pub(crate) fn resolve_icon_role_embedded(state: InteractionState) -> TextRole {
576    match state {
577        InteractionState::Idle | InteractionState::Focused => TextRole::Secondary,
578        InteractionState::Hovered => TextRole::Primary,
579        InteractionState::Pressed => TextRole::Accent,
580        InteractionState::Disabled => TextRole::Disabled,
581    }
582}
583
584pub(crate) fn resolve_icon_role_standalone(state: InteractionState) -> TextRole {
585    match state {
586        InteractionState::Disabled => TextRole::Disabled,
587        _ => TextRole::Primary,
588    }
589}
590
591/// Per-size icon dimension. The two smallest buttons (Compact 22,
592/// Default 24) share the standard `icon_size` (16 dp); Toolbar / Large
593/// / Hero scale up via dedicated tokens so a 50 dp button doesn't
594/// carry a tiny 16 dp glyph.
595fn resolve_icon_size(size: IconButtonSize) -> f32 {
596    use crate::styles::recipe_icon_button_style as icon_dims;
597    match size {
598        IconButtonSize::Compact | IconButtonSize::Default => icon_dims::ICON_BUTTON_ICON_SIZE,
599        IconButtonSize::Toolbar => icon_dims::ICON_BUTTON_ICON_SIZE_TOOLBAR,
600        IconButtonSize::Large => icon_dims::ICON_BUTTON_ICON_SIZE_LARGE,
601        IconButtonSize::Hero => icon_dims::ICON_BUTTON_ICON_SIZE_HERO,
602    }
603}
604
605// ── Widget trait ─────────────────────────────────────────────────────────────
606
607impl teksilo_core::widget::Widget for IconButton {
608    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
609        let embedded = self.embedded;
610        let icon_size = resolve_icon_size(self.size);
611        let size = self.size;
612        let self_id = ctx.self_id();
613
614        // Forward the enabled state into the arena. After this point
615        // the arena is the single source of truth — events, focus,
616        // a11y, and the leaves' role-resolution all consult
617        // `arena.is_enabled(self_id)` / `PaintContext::effective_enabled`.
618        // We never carry an `InteractionState::Disabled` in the
619        // interaction signal: that was the snapshot duality the
620        // architecture refactor removed.
621        ctx.enabled_when(self_id, self.enabled.clone());
622
623        // Reactive view of "is this widget effectively enabled?",
624        // factoring this node and every ancestor's `enabled_state`.
625        // Used both to derive `is_disabled` for the style chrome and
626        // to flip the cursor between Pointer (enabled) / Default
627        // (disabled).
628        let effective_enabled = ctx.effective_enabled_signal(self_id);
629
630        // Interaction signal — caller-supplied via `share_interaction`
631        // when set (so a wrapping widget's chrome can mirror the icon's
632        // color), otherwise allocated locally. Seeded to Idle; the
633        // arena's enabled-state is consulted separately.
634        let interaction = match self.shared_interaction.take() {
635            Some(shared) => shared,
636            None => ctx.signal(InteractionState::Idle),
637        };
638        self.interaction = interaction.clone();
639
640        // Register toggled signal for repaint + a11y refresh if present.
641        // AccessibilityOnly pushes a fresh set_toggled() into the a11y
642        // tree on every flip without forcing a relayout.
643        if let Some(ref toggled) = self.toggled {
644            let self_id = ctx.self_id();
645            let registry = ctx.binding_registry();
646            toggled.bind_to(self_id, registry, BindingLevel::RepaintOnly);
647            toggled.bind_to(self_id, registry, BindingLevel::AccessibilityOnly);
648        }
649
650        // Register the popover-open signal so AT picks up `set_expanded`
651        // flips when a wrapping `PopoverIconButton` toggles the popover.
652        // No relayout — AccessibilityOnly is enough.
653        if let Some(ref expanded) = self.expanded_signal {
654            let self_id = ctx.self_id();
655            let registry = ctx.binding_registry();
656            expanded.register_if_bound(self_id, registry, BindingLevel::AccessibilityOnly);
657        }
658
659        // Icon color: a caller-supplied override wins over the auto
660        // cascade. It replaces the interaction states (idle / hover /
661        // press / focus) — chrome that uses this opts out of
662        // interaction-driven color feedback in exchange for matching a
663        // host's enforced text role. It does NOT opt out of the disabled
664        // substitution, which happens later, at paint, inside
665        // `ColorProp::resolve`: a role passed here still dims in a
666        // disabled subtree. Callers whose tint is semantic state rather
667        // than chrome pass `ColorProp::undimmed(role)`.
668        let icon_color: teksilo_core::color_prop::ColorProp =
669            if let Some(ref over) = self.icon_role_override {
670                over.clone()
671            } else if embedded {
672                interaction.map(|s| resolve_icon_role_embedded(*s)).into()
673            } else {
674                interaction.map(|s| resolve_icon_role_standalone(*s)).into()
675            };
676
677        // Build the icon content. Icon-swap toggle (eye / eye-off) only
678        // applies when a `toggled_icon` was provided via
679        // `toggle_with_icon`; surface-tint-only toggle keeps the same
680        // glyph throughout.
681        let icon_content_id = if let (Some(toggled), Some(_)) =
682            (self.toggled.as_ref(), self.toggled_icon.as_ref())
683        {
684            let toggled_index = toggled.map(|v| if *v { 1 } else { 0 });
685            let primary_icon =
686                std::mem::replace(&mut self.icon, IconWidget::from_path(Path::new(), 0.0))
687                    .icon_size(icon_size)
688                    .color(icon_color.clone());
689            let alt_icon = self
690                .toggled_icon
691                .take()
692                .expect("toggled_icon checked above")
693                .icon_size(icon_size)
694                .color(icon_color);
695            ctx.add(
696                Switcher::new(toggled_index)
697                    .child(primary_icon)
698                    .child(alt_icon),
699            )
700        } else {
701            let icon = std::mem::replace(&mut self.icon, IconWidget::from_path(Path::new(), 0.0))
702                .icon_size(icon_size)
703                .color(icon_color);
704            ctx.add(icon)
705        };
706
707        // Delegate background + border + size to the active style.
708        // The four boolean signals derive from `interaction`; `is_on` is
709        // populated only when a `toggled` signal is bound (drives the
710        // bistate `Selected` background mode).
711        let style: SharedIconButtonStyle = self
712            .style_override
713            .clone()
714            .or_else(|| ctx.theme().style_slots.icon_button.clone())
715            .unwrap_or_else(|| Rc::new(crate::styles::RecipeIconButtonStyle::default()));
716        let is_pressed = interaction.map(|s| matches!(s, InteractionState::Pressed));
717        let is_hovered = interaction.map(|s| matches!(s, InteractionState::Hovered));
718        // `:focus-visible`: reveal the focus ring during keyboard navigation
719        // only, not on a mouse click. Gate raw focus on the input-modality
720        // signal (true after a key event, false after pointer-down).
721        let is_focused = interaction
722            .map(|s| matches!(s, InteractionState::Focused))
723            .and(&ctx.focus_visible());
724        // `is_disabled` derives from the arena's effective enabled
725        // state — NOT from the interaction signal (which never
726        // carries Disabled anymore). Style chrome uses this to pick
727        // its disabled-background role.
728        let is_disabled = effective_enabled.map(|on| !*on);
729        let cfg = IconButtonStyleConfig {
730            icon: icon_content_id,
731            is_pressed,
732            is_hovered,
733            is_focused,
734            is_disabled,
735            is_on: self.toggled.clone(),
736            size,
737        };
738        let root_id = style.make_body(&cfg, ctx);
739
740        // Tooltip — three mutually-exclusive setters; setters clear
741        // the others so exactly one branch runs.
742        if let Some(content) = self.composite_tooltip_content.take() {
743            let delay = ctx.theme().motion.tooltip_delay_heavy;
744            crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
745        } else if let Some(source) = self.rich_tooltip_source.clone() {
746            // Clone, not take: `accessibility()` needs the source to
747            // resolve the accessible name (an IconButton has no label,
748            // so its AT name comes from the tooltip). Taking it here left
749            // the rich-tooltip a11y name resolution reading a `None`
750            // source — falling back to the literal "Button".
751            let delay = ctx.theme().motion.tooltip_delay;
752            crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
753        } else if let Some(tooltip_text) = self.tooltip_text.clone() {
754            let delay = ctx.theme().motion.tooltip_delay;
755            crate::tooltip::attach_plain_tooltip(ctx, root_id, tooltip_text, delay);
756        }
757
758        self.root_child_id = Some(root_id);
759
760        // --- V2 attached handlers ---
761        // Bundle the optional action AND the toggle flip into the single
762        // `on_activate` closure consumed by the shared button-family
763        // helper (`build_interaction_handlers`). Routing the toggle
764        // through the helper means the lone-KeyUp guard now gates the
765        // toggle too — a stray KeyUp can no longer flip the toggle.
766        // (Framework gates dispatch on `arena.is_enabled`, so no inline
767        // enabled check is needed.)
768        let action: std::rc::Rc<Option<ActionFactory>> = std::rc::Rc::new(self.action.take());
769        let toggled = self.toggled.clone();
770        let on_activate: std::rc::Rc<dyn Fn(&mut EventContext)> =
771            std::rc::Rc::new(move |ctx: &mut EventContext| {
772                if let Some(ref toggled) = toggled {
773                    toggled.set(!toggled.get());
774                }
775                if let Some(ref action) = *action {
776                    action(ctx);
777                }
778            });
779        // The focus walker skips disabled subtrees on its own; the static
780        // `self.focusable` flag is the caller's intent (e.g. a
781        // close-button-inside-tab wants `false`).
782        let handler_set =
783            crate::button::build_interaction_handlers(interaction, on_activate, self.focusable);
784
785        ctx.apply_self_handlers(handler_set);
786
787        vec![root_id]
788    }
789
790    fn layout_response(
791        &self,
792        proposal: SizeProposal,
793        ctx: &LayoutContext,
794    ) -> teksilo_core::widget::LayoutResponse {
795        // Rigid like `Button`: size to content, no shrink (see Button's note).
796        match self.root_child_id {
797            Some(root_id) => ctx
798                .child_size(root_id, proposal)
799                .unwrap_or_else(|| proposal.resolve(0.0, 0.0)),
800            None => proposal.resolve(0.0, 0.0),
801        }
802        .into()
803    }
804
805    fn place_children(
806        &self,
807        bounds: Rect,
808        _proposal: SizeProposal,
809        children: &mut [WidgetPlacement],
810        _ctx: &LayoutContext,
811    ) {
812        for child in children.iter_mut() {
813            child.origin = bounds.origin();
814            child.size = bounds.size();
815        }
816    }
817
818    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
819        builder.set_role(teksilo_core::accesskit::Role::Button);
820        // The accessible name is sourced from whichever tooltip flavor
821        // is configured. Plain text is used directly; for a rich tooltip
822        // we use the inline content's body text, or — for a registry
823        // *key* — the registered content's text resolved from the
824        // tooltip registry; for a composite tooltip the caller must
825        // provide an explicit `.access_label(...)` via the
826        // accessibility-overrides API (no text to source from).
827        let rich_name: Option<String> = self.rich_tooltip_source.as_ref().and_then(|s| match s {
828            crate::tooltip::RichTooltipSource::Content(c) => Some(c.text.resolve_now()),
829            crate::tooltip::RichTooltipSource::Key(key) => {
830                crate::tooltip::with_tooltip_registry(|reg| {
831                    reg.get(key).map(|c| c.text.resolve_now())
832                })
833                .flatten()
834            }
835        });
836        debug_assert!(
837            self.tooltip_text.is_some()
838                || rich_name.is_some()
839                || self.rich_tooltip_source.is_some()
840                || self.composite_tooltip_content.is_some(),
841            "IconButton: expected a tooltip (used as the accessible name). \
842             Use .tooltip(tr!(…)) or a predefined constructor like IconButton::clear(). \
843             For rich/composite tooltips, also pair with `.access_label(...)`."
844        );
845        // Set a name only when we resolved a real one. Never fall back
846        // to a literal "Button" — a misleading name ("Button, button")
847        // is worse for screen-reader users than an unnamed node, and the
848        // composite / unresolved-key paths are expected to carry an
849        // explicit `.access_label(...)` (enforced by the debug_assert),
850        // which the override layer applies after this method.
851        if let Some(text) = self
852            .tooltip_text
853            .as_ref()
854            .map(|t| t.resolve_now())
855            .or(rich_name)
856        {
857            builder.set_name(text);
858        }
859        // Note: `set_disabled()` is now driven by the framework's
860        // accessibility walker from `arena.is_enabled(self_id)`. The
861        // composite no longer needs to mirror it — the snapshot path
862        // was redundant with the arena and broke under reactive
863        // `enabled_when(id, signal)` flips.
864        if let Some(ref toggled) = self.toggled {
865            builder.set_toggled(toggled.get());
866        }
867        // ARIA disclosure pattern: a button that opens a popup
868        // declares `has_popup` and, when the wrapper tracks it,
869        // `expanded`. Both are opt-in — regular icon buttons stay
870        // silent on these properties.
871        if let Some(kind) = self.has_popup {
872            builder.set_has_popup(kind);
873        }
874        if let Some(ref signal) = self.expanded_signal {
875            builder.set_expanded(signal.get());
876        }
877        builder.add_action(teksilo_core::accesskit::Action::Click);
878        builder.add_action(teksilo_core::accesskit::Action::Focus);
879    }
880
881    fn children(&self) -> Vec<WidgetId> {
882        self.root_child_id.into_iter().collect()
883    }
884}
885
886// ── Overridable icon set ────────────────────────────────────────────────────
887//
888// Default icons are real SVGs embedded via `include_str!` and parsed once
889// via `LazyLock`. The `res!` macro cannot be used here because it emits
890// `::teksilo::` paths and teksilo-widgets sits below teksilo in the
891// dependency graph.
892//
893// Applications can replace the default icon set globally at startup via
894// `BuiltInIcons::set_global(custom_set)`.
895
896/// Icon factory set for predefined built-in buttons.
897///
898/// Each field is a function pointer that creates an [`IconWidget`].
899/// The default implementation uses SVG icons embedded in teksilo-widgets.
900///
901/// # Overriding
902///
903/// Call [`BuiltInIcons::set_global`] at app startup (before creating any
904/// built-in buttons) to replace the default icon set:
905///
906/// ```rust
907/// # use teksilo_widgets::{BuiltInIcons};
908/// # use teksilo_widgets::primitives::IconWidget;
909/// # const MY_BROWSE_SVG: &str = "<svg xmlns='http://www.w3.org/2000/svg'/>";
910/// # const MY_CLEAR_SVG: &str = "<svg xmlns='http://www.w3.org/2000/svg'/>";
911/// BuiltInIcons::set_global(BuiltInIcons {
912///     browse: || IconWidget::from_svg(MY_BROWSE_SVG),
913///     clear: || IconWidget::from_svg(MY_CLEAR_SVG),
914///     ..BuiltInIcons::defaults()
915/// });
916/// ```
917pub struct BuiltInIcons {
918    pub browse: fn() -> IconWidget,
919    pub expand: fn() -> IconWidget,
920    pub search: fn() -> IconWidget,
921    pub copy: fn() -> IconWidget,
922    pub clear: fn() -> IconWidget,
923    pub add: fn() -> IconWidget,
924    pub bell: fn() -> IconWidget,
925    pub eye: fn() -> IconWidget,
926    pub eye_off: fn() -> IconWidget,
927    pub menu: fn() -> IconWidget,
928    pub more: fn() -> IconWidget,
929}
930
931static GLOBAL_ICONS: OnceLock<BuiltInIcons> = OnceLock::new();
932
933impl BuiltInIcons {
934    /// Return the default icon set (SVGs embedded in teksilo-widgets).
935    pub fn defaults() -> Self {
936        Self {
937            browse: default_browse_icon,
938            expand: default_expand_icon,
939            search: default_search_icon,
940            copy: default_copy_icon,
941            clear: default_clear_icon,
942            add: default_add_icon,
943            bell: default_bell_icon,
944            eye: default_eye_icon,
945            eye_off: default_eye_off_icon,
946            menu: default_menu_icon,
947            more: default_more_icon,
948        }
949    }
950
951    /// Set the global icon set. Call at app startup before creating any
952    /// built-in buttons. Can only be set **once**: the global is a
953    /// process-wide [`OnceLock`], so the first set wins and any later
954    /// call is ignored (and warns). It is also locked in the first time
955    /// `global()` reads it, so set it before any built-in
956    /// button is created. Use [`defaults()`](Self::defaults) with struct
957    /// update syntax to override only specific icons.
958    pub fn set_global(icons: Self) {
959        if GLOBAL_ICONS.set(icons).is_err() {
960            // A second `set_global` (or one after the first `global()`
961            // read) silently has no effect — that is almost always a
962            // startup-ordering bug, so make it loud.
963            debug_assert!(
964                false,
965                "BuiltInIcons::set_global called more than once (or after the icon set was \
966                 first read); the later call is ignored"
967            );
968            warn_icons_already_set();
969        }
970    }
971
972    /// Access the registered global icon set, falling back to the
973    /// compiled-in SVG defaults. Intended for widgets in this crate
974    /// that need a themed icon without binding to a specific asset
975    /// path — e.g. the clear button inside `TextInput`. Applications
976    /// still use `set_global(..)` to override the defaults.
977    pub(crate) fn global() -> &'static Self {
978        GLOBAL_ICONS.get_or_init(Self::defaults)
979    }
980}
981
982/// One-shot stderr warning when `BuiltInIcons::set_global` is called
983/// after the global set was already locked in. Thread-local flag keeps
984/// it from repeating. (Stderr rather than `log::warn!` to avoid adding a
985/// `log` dependency to teksilo-widgets — this is a setup error, matching
986/// the `toast` install warning convention.)
987fn warn_icons_already_set() {
988    thread_local! {
989        static WARNED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
990    }
991    WARNED.with(|w| {
992        if !w.get() {
993            eprintln!(
994                "[teksilo-widgets::icon_button] BuiltInIcons::set_global(...) called after the \
995                 icon set was already locked in — the later call was ignored. Call set_global \
996                 once at startup, before any built-in button is created."
997            );
998            w.set(true);
999        }
1000    });
1001}
1002
1003// ── Default SVG icons ───────────────────────────────────────────────────────
1004
1005fn default_browse_icon() -> IconWidget {
1006    IconWidget::from_svg(include_str!("../resources/icons/builtin-browse.svg"))
1007}
1008
1009fn default_expand_icon() -> IconWidget {
1010    IconWidget::from_svg(include_str!("../resources/icons/builtin-expand.svg"))
1011}
1012
1013fn default_search_icon() -> IconWidget {
1014    IconWidget::from_svg(include_str!("../resources/icons/builtin-search.svg"))
1015}
1016
1017fn default_copy_icon() -> IconWidget {
1018    IconWidget::from_svg(include_str!("../resources/icons/builtin-copy.svg"))
1019}
1020
1021fn default_clear_icon() -> IconWidget {
1022    IconWidget::from_svg(include_str!("../resources/icons/builtin-clear.svg"))
1023}
1024
1025fn default_add_icon() -> IconWidget {
1026    IconWidget::from_svg(include_str!("../resources/icons/builtin-add.svg"))
1027}
1028
1029fn default_bell_icon() -> IconWidget {
1030    IconWidget::from_svg(include_str!("../resources/icons/builtin-bell.svg"))
1031}
1032
1033fn default_eye_icon() -> IconWidget {
1034    IconWidget::from_svg(include_str!("../resources/icons/builtin-eye.svg"))
1035}
1036
1037fn default_eye_off_icon() -> IconWidget {
1038    IconWidget::from_svg(include_str!("../resources/icons/builtin-eye-off.svg"))
1039}
1040
1041fn default_menu_icon() -> IconWidget {
1042    IconWidget::from_svg(include_str!("../resources/icons/builtin-menu.svg"))
1043}
1044
1045fn default_more_icon() -> IconWidget {
1046    IconWidget::from_svg(include_str!("../resources/icons/builtin-more.svg"))
1047}
1048
1049// ── Tests ───────────────────────────────────────────────────────────────────
1050
1051#[cfg(test)]
1052mod tests {
1053    use super::*;
1054    use teksilo_core::event::{Key, Modifiers, WidgetEvent};
1055    use teksilo_core::signal::Signal;
1056    use teksilo_core::widget_tree::WidgetTree;
1057    use teksilo_i18n::lit;
1058
1059    /// A `KeyUp` with no preceding `KeyDown` (e.g. a shortcut consumed
1060    /// the `KeyDown` and focus returned here) must NOT activate — it
1061    /// must neither fire the action nor flip the toggle. Before the
1062    /// shared `build_interaction_handlers` migration, IconButton lacked
1063    /// the lone-KeyUp guard that Button had, so a stray KeyUp toggled
1064    /// and fired. This is the regression test for that fix.
1065    #[test]
1066    fn icon_button_lone_keyup_does_not_activate() {
1067        let fired = std::rc::Rc::new(std::cell::Cell::new(0u32));
1068        let toggle = Signal::new(false);
1069        let f = fired.clone();
1070        let mut tree = WidgetTree::new();
1071        let btn = tree.add(
1072            IconButton::add()
1073                .tooltip(lit!("Add"))
1074                .toggle(toggle.clone())
1075                .on_activate_fn(move |_| f.set(f.get() + 1)),
1076        );
1077        tree.layout(teksilo_canvas::SizeProposal::exact(100.0, 100.0));
1078        tree.focus(btn);
1079
1080        // Lone KeyUp — must be a no-op.
1081        tree.dispatch_event(WidgetEvent::KeyUp {
1082            key: Key::Enter,
1083            modifiers: Modifiers::NONE,
1084        });
1085        assert_eq!(fired.get(), 0, "lone KeyUp must not fire the action");
1086        assert!(!toggle.get(), "lone KeyUp must not flip the toggle");
1087
1088        // Sanity: a full KeyDown+KeyUp DOES activate.
1089        tree.dispatch_event(WidgetEvent::KeyDown {
1090            key: Key::Enter,
1091            modifiers: Modifiers::NONE,
1092            text: None,
1093        });
1094        tree.dispatch_event(WidgetEvent::KeyUp {
1095            key: Key::Enter,
1096            modifiers: Modifiers::NONE,
1097        });
1098        assert_eq!(fired.get(), 1, "full KeyDown+KeyUp fires the action once");
1099        assert!(toggle.get(), "full KeyDown+KeyUp flips the toggle");
1100    }
1101
1102    /// Int UI icon buttons have a distinct pressed (mouse-down) state
1103    /// (unlike regular buttons). The shared `build_interaction_handlers`
1104    /// now feeds the Pressed state on pointer-down, so the icon recipe's
1105    /// pressed background renders on mouse-down — not only on keyboard
1106    /// activation. Idle must NOT show the pressed background.
1107    #[test]
1108    fn icon_button_flashes_pressed_background_on_pointer_down() {
1109        use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
1110
1111        let theme = teksilo_core::presets::intui::light();
1112        let mut tree = WidgetTree::new();
1113        tree.set_theme(theme.clone());
1114        let btn = tree.add(IconButton::add().tooltip(lit!("Add")));
1115        tree.layout(teksilo_canvas::SizeProposal::exact(100.0, 100.0));
1116
1117        let pressed = teksilo_tokens::SurfaceRole::Pressed
1118            .resolve(&theme.colors)
1119            .to_array();
1120
1121        // Idle: no pressed background.
1122        let frame = tree.render();
1123        assert!(
1124            !frame.shapes.iter().any(|s| s.color == pressed),
1125            "idle IconButton must not render the pressed background"
1126        );
1127
1128        // Pointer-down inside the button → pressed flash.
1129        let b = tree.bounds(btn);
1130        let center = teksilo_canvas::Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0);
1131        tree.dispatch_event(WidgetEvent::PointerDown {
1132            position: center,
1133            button: PointerButton::Primary,
1134            modifiers: Modifiers::NONE,
1135        });
1136        tree.layout(teksilo_canvas::SizeProposal::exact(100.0, 100.0));
1137        let frame = tree.render();
1138        assert!(
1139            frame.shapes.iter().any(|s| s.color == pressed),
1140            "IconButton must render the pressed background on pointer-down \
1141             (Int UI icon buttons have a distinct pressed state); got shape \
1142             colors = {:?}",
1143            frame.shapes.iter().map(|s| s.color).collect::<Vec<_>>()
1144        );
1145    }
1146
1147    /// Regression: a registry-*key* rich tooltip used to expose the
1148    /// literal accessible name "Button". It must resolve the registered
1149    /// content's text from the tooltip registry instead.
1150    #[test]
1151    fn icon_button_rich_tooltip_key_resolves_at_name_from_registry() {
1152        crate::tooltip::install_tooltip_registry(vec![crate::tooltip::TooltipContent::new(
1153            "docs",
1154            lit!("Documentation"),
1155        )]);
1156        let mut tree = WidgetTree::new();
1157        let btn = tree.add(IconButton::add().rich_tooltip("docs"));
1158        tree.layout(teksilo_canvas::SizeProposal::exact(100.0, 100.0));
1159        assert_eq!(
1160            tree.accessibility_node(btn).name(),
1161            Some("Documentation"),
1162            "registry-key rich tooltip must resolve the AT name from the \
1163             registry, never fall back to the literal \"Button\""
1164        );
1165    }
1166
1167    /// Reactive enabled-state via `ctx.enabled_when(btn_id, signal)`
1168    /// must dim the IconButton's icon when the signal flips to false —
1169    /// regression test for the FormatToolbar bug where the
1170    /// table-operation buttons stayed full-color despite the framework
1171    /// correctly gating their events. Until the enabled-state
1172    /// architecture refactor (this commit and the framework commit
1173    /// that preceded it) the icon's color was driven by an internal
1174    /// `InteractionState::Disabled` seed captured at build time,
1175    /// which never updated from a later `enabled_when` call.
1176    #[test]
1177    fn icon_button_enabled_when_signal_dims_icon_color() {
1178        let theme = teksilo_core::presets::intui::light();
1179        let mut tree = WidgetTree::new();
1180        tree.set_theme(theme.clone());
1181
1182        let is_enabled = Signal::new(true);
1183        let btn_id = tree.add(IconButton::new(IconWidget::checkmark(24.0)).tooltip(lit!("test")));
1184        tree.enabled_when(btn_id, is_enabled.clone());
1185        tree.layout(teksilo_canvas::SizeProposal::exact(100.0, 40.0));
1186
1187        let primary = theme.colors.text_primary.to_array();
1188        let disabled = theme.colors.text_disabled.to_array();
1189        let frame = tree.render();
1190        assert!(
1191            frame.paths.iter().any(|p| p.color == primary),
1192            "enabled IconButton must render its icon at text_primary; \
1193             got path colors = {:?}",
1194            frame.paths.iter().map(|p| p.color).collect::<Vec<_>>()
1195        );
1196
1197        is_enabled.set(false);
1198        tree.layout(teksilo_canvas::SizeProposal::exact(100.0, 40.0));
1199        let frame = tree.render();
1200        assert!(
1201            frame.paths.iter().any(|p| p.color == disabled),
1202            "after flipping enabled→false, IconButton's icon must \
1203             render at text_disabled (the FormatToolbar bug as a unit \
1204             test); got path colors = {:?}",
1205            frame.paths.iter().map(|p| p.color).collect::<Vec<_>>()
1206        );
1207
1208        is_enabled.set(true);
1209        tree.layout(teksilo_canvas::SizeProposal::exact(100.0, 40.0));
1210        let frame = tree.render();
1211        assert!(
1212            frame.paths.iter().any(|p| p.color == primary),
1213            "flipping enabled→true must restore the primary color"
1214        );
1215    }
1216
1217    /// Static `.enabled(false)` builder must route through the arena —
1218    /// after migration, the snapshot path is gone and the only correct
1219    /// behavior is that `arena.is_enabled(btn_id)` returns false.
1220    #[test]
1221    fn icon_button_static_enabled_false_propagates_to_arena() {
1222        let mut tree = WidgetTree::new();
1223        let btn_id = tree.add(
1224            IconButton::new(IconWidget::checkmark(24.0))
1225                .tooltip(lit!("test"))
1226                .enabled(false),
1227        );
1228        tree.layout(teksilo_canvas::SizeProposal::exact(100.0, 40.0));
1229        assert!(
1230            !tree.is_enabled(btn_id),
1231            "IconButton::enabled(false) must propagate to the arena"
1232        );
1233    }
1234}