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}