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}