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