teksilo-widgets 0.9.0

Widget library for Teksilo — over a hundred widgets and layout primitives, from Button to TreeTableView.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 FernTech

//! IconButton — a square, icon-only, flat-surface button.
//!
//! Five sizes covering both **embedded** use (inside another widget's
//! trailing slot — TextInput's clear-X, ComboBox's chevron, SearchField's
//! magnifier) and **stand-alone** use (toolbars, rich menus, hero CTAs).
//! The `.embedded()` flag opts into the JetBrains "built-in" look —
//! dimmer icon at rest (Secondary), brightening on hover (Primary),
//! flashing accent on press — so an IconButton living inside a TextInput
//! doesn't compete visually with the field's text. Without the flag the
//! icon stays at full visual weight (Primary at rest), the right default
//! for stand-alone toolbar / menu rows.
//!
//! ```rust
//! # use teksilo_widgets::{IconButton};
//! # use teksilo_widgets::primitives::IconWidget;
//! # use teksilo_i18n::lit;
//! # use teksilo_core::Intent;
//! # const MY_SVG: &str = "<svg xmlns='http://www.w3.org/2000/svg'/>";
//! // Stand-alone toolbar use — full-weight icon.
//! let _w = IconButton::new(IconWidget::from_svg(MY_SVG))
//!     .toolbar()
//!     .tooltip(lit!("Save"))
//!     .on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.save")));
//!
//! // Embedded inside a TextInput's trailing slot — dim until hover.
//! let _w = IconButton::clear()
//!     .embedded()
//!     .on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.clear")));
//! ```
//!
//! ## Predefined constructors
//!
//! Common roles ship with the appropriate icon and an i18n tooltip
//! (which doubles as the AT name). They are size- and mode-agnostic —
//! call `.embedded()`, `.toolbar()`, `.large()`, etc. to configure:
//!
//! ```rust
//! # use teksilo_widgets::IconButton;
//! # use teksilo_core::signal::Signal;
//! # let visible = Signal::new(false);
//! let _w = IconButton::browse().embedded();           // 24 dp, dim — TextInput trailing
//! let _w = IconButton::clear().embedded();            // 24 dp, dim — clear-X
//! let _w = IconButton::search().toolbar();            // 40 dp, full weight — toolbar
//! let _w = IconButton::visibility_toggle(visible);    // password-field eye toggle
//! ```
//!
//! ## Bistate
//!
//! Two distinct toggle modes:
//!
//! - [`IconButton::toggle`] — surface-tint bistate: clicking flips the
//!   bound `Signal<bool>`; while `true`, the background reads as
//!   `SurfaceRole::Selected` ("on"). Same icon throughout. The
//!   pin-this-row / select-this-tool pattern.
//! - [`IconButton::toggle_with_icon`] — surface-tint **and** icon-swap
//!   bistate: same surface flip plus the icon glyph swaps to a second
//!   icon. The visibility-toggle pattern (eye ↔ eye-off).
//!
//! ## Slot convention
//!
//! Host widgets that accept icon buttons follow the `trailing_slot`
//! convention established by [`TabWidget`](crate::tab_widget::TabWidget):
//!
//! ```rust
//! # use teksilo_widgets::{IconButton, TextInput};
//! # use teksilo_widgets::primitives::HStack;
//! # use teksilo_core::signal::Signal;
//! # use teksilo_core::Intent;
//! # let value = Signal::new(String::new());
//! let _w = TextInput::new(value)
//!     .trailing_slot(HStack::new().spacing(0.0)
//!         .child(IconButton::clear().embedded().on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.clear"))))
//!         .child(IconButton::browse().embedded().on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.browse"))))
//!     );
//! ```

use std::rc::Rc;
use std::sync::OnceLock;

use teksilo_canvas::{Path, Rect, SizeProposal};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::binding::BindingLevel;
use teksilo_core::build_context::BuildContext;
use teksilo_core::signal::{Prop, Signal};
use teksilo_core::styles::{IconButtonStyleConfig, SharedIconButtonStyle};
use teksilo_core::widget::{EventContext, LayoutContext, WidgetPlacement};
use teksilo_core::widget_id::WidgetId;
use teksilo_tokens::TextRole;

use crate::primitives::Switcher;
use crate::primitives::icon_widget::IconWidget;

/// Size variant for [`IconButton`]. See [`teksilo_core::styles::IconButtonSize`]
/// for the canonical definition. Variants are calibrated to the
/// IntelliJ Int UI scale (Compact 22 dp, Default 24 dp, Toolbar 30 dp,
/// Large 40 dp, Hero 50 dp).
pub use teksilo_core::styles::IconButtonSize;

use crate::button::InteractionState;
use teksilo_i18n::LocalizedString;

/// Type-erased action factory — captures the concrete command type.
type ActionFactory = Box<dyn Fn(&mut EventContext)>;

/// A square, icon-only, flat-surface button. See module docs for
/// embedded vs stand-alone modes, the five sizes, and the two bistate
/// toggle modes.
pub struct IconButton {
    // Configuration (set via builder)
    icon: IconWidget,
    tooltip_text: Option<LocalizedString>,
    /// Optional rich tooltip source — registry key or inline content.
    /// Mutually exclusive with `tooltip_text` and `composite_tooltip_content`.
    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
    /// Optional composite tooltip body (CK3-style widget tree).
    /// Mutually exclusive with the other two tooltip slots.
    composite_tooltip_content: Option<Box<dyn teksilo_core::widget::Widget>>,
    /// Enabled state, static or reactive. Forwarded into the arena via
    /// `ctx.enabled_when(self_id, self.enabled.clone())` at build time;
    /// not kept as a runtime snapshot. After `build()` the arena's
    /// `enabled_state` is the single source of truth; the leaves
    /// (icon, label) read it through `PaintContext::effective_enabled`
    /// for color resolution, event dispatch reads it via
    /// `arena.is_enabled()` for gating, the a11y walker reads it for
    /// `set_disabled()`.
    enabled: Prop<bool>,
    size: IconButtonSize,
    /// Embedded mode — Secondary-at-rest icon color, the JetBrains
    /// "built-in" look. Default `false` (stand-alone, full-weight icon).
    embedded: bool,
    action: Option<ActionFactory>,
    /// Whether the button takes keyboard focus on Tab navigation.
    /// `true` (default): focusable when enabled. `false`: never
    /// focusable — used for the close-button-inside-a-tab pattern
    /// (Firefox / Chrome convention: Tab moves between *tabs*, not
    /// onto each tab's close button).
    focusable: bool,

    // Toggle support
    toggled: Option<Signal<bool>>,
    /// Optional alternate icon for the icon-swap toggle mode set via
    /// [`IconButton::toggle_with_icon`]. When `None`, surface-tint-only
    /// toggle mode applies (set via [`IconButton::toggle`]).
    toggled_icon: Option<IconWidget>,

    // Disclosure support — wired up by `PopoverIconButton` so AT
    // announces the button as a menu / popup trigger and reflects the
    // open state. Both fields are opt-in via `.has_popup(...)` /
    // `.expanded_when(...)`.
    has_popup: Option<teksilo_core::accesskit::HasPopup>,
    expanded_signal: Option<Prop<bool>>,

    /// Optional caller-supplied interaction signal. When set, `build()`
    /// uses this signal instead of allocating its own — letting an
    /// external widget (e.g. `PopoverIconButton`'s disclosure caret)
    /// observe hover / press / focus / disabled state and match the
    /// icon's color exactly. See [`IconButton::share_interaction`].
    shared_interaction: Option<Signal<InteractionState>>,

    /// Optional caller-supplied icon-color override. When `Some`, the
    /// icon's tint is bound to this `ColorProp` (a `Color`, role, or
    /// `Signal<Color>`) regardless of `embedded` / interaction state —
    /// the auto-derived idle/hover/press cascade is replaced. Used by
    /// chrome that has to read with a host's text-role rather than the
    /// IconButton's stand-alone palette (e.g. tab-bar scroll arrows
    /// inheriting `idle_text_role`).
    icon_role_override: Option<teksilo_core::color_prop::ColorProp>,

    /// Per-call style override. When `None`, falls back to the IntUI
    /// default `RecipeIconButtonStyle`.
    style_override: Option<SharedIconButtonStyle>,

    // Build state (set in build())
    interaction: Signal<InteractionState>,
    root_child_id: Option<WidgetId>,
}

impl IconButton {
    /// Create an icon button from a custom icon. Defaults to
    /// `IconButtonSize::Default` (24 dp) and stand-alone visual mode.
    /// Apply `.embedded()` for the JetBrains "built-in" dim look,
    /// and one of the size methods (`.large()` / `.toolbar()` /
    /// `.hero()`) or `.size(...)` to pick a different size.
    pub fn new(icon: IconWidget) -> Self {
        Self {
            icon,
            tooltip_text: None,
            rich_tooltip_source: None,
            composite_tooltip_content: None,
            enabled: Prop::Static(true),
            size: IconButtonSize::Default,
            embedded: false,
            action: None,
            focusable: true,
            toggled: None,
            toggled_icon: None,
            has_popup: None,
            expanded_signal: None,
            shared_interaction: None,
            icon_role_override: None,
            style_override: None,
            interaction: Signal::new(InteractionState::Idle),
            root_child_id: None,
        }
    }

    /// Per-call style override. Replaces the theme-wide default
    /// `IconButtonStyle` for just this IconButton instance — same role
    /// as `Button::style(...)`. The override fully owns the background +
    /// border + size composition; icon coloring stays on the widget.
    pub fn style(mut self, style: impl teksilo_core::styles::IconButtonStyle) -> Self {
        self.style_override = Some(Rc::new(style));
        self
    }

    /// Per-call style override from an already-shared
    /// [`SharedIconButtonStyle`] (`Rc<dyn IconButtonStyle>`). Same effect as
    /// [`style`](Self::style) but takes the erased handle directly, so a host
    /// (e.g. a `Toolbar` applying one style to all its icon buttons) can share a
    /// single `Rc` instead of cloning a concrete style per button.
    pub fn style_shared(mut self, style: SharedIconButtonStyle) -> Self {
        self.style_override = Some(style);
        self
    }

    /// Returns the configured size variant. Used by wrappers like
    /// [`PopoverIconButton`](crate::popover_widget::PopoverIconButton)
    /// that need to reason about the trigger's footprint at build time
    /// (e.g. to skip a corner decoration that wouldn't fit at Compact).
    pub fn size_variant(&self) -> IconButtonSize {
        self.size
    }

    /// Returns whether the button is in the JetBrains "built-in" /
    /// embedded color profile (Secondary at rest). Mirror getter to
    /// [`size_variant`](Self::size_variant) for wrappers that want to
    /// derive their own chrome colors from the same icon role.
    pub fn is_embedded(&self) -> bool {
        self.embedded
    }

    /// Bind the button's internal interaction state to a caller-owned
    /// `Signal<InteractionState>` instead of letting `build()` allocate
    /// its own. Used by wrapper widgets like
    /// [`PopoverIconButton`](crate::popover_widget::PopoverIconButton)
    /// whose disclosure caret needs to match the icon's color across
    /// hover / press / focus / disabled states.
    ///
    /// The provided signal is reset to `Disabled` when `enabled == false`
    /// during `build()` so the shared signal honors the button's
    /// enabled state without the caller having to seed it.
    pub fn share_interaction(mut self, signal: Signal<InteractionState>) -> Self {
        self.shared_interaction = Some(signal);
        self
    }

    /// Opt into the **embedded** visual treatment — the JetBrains
    /// "built-in button" look. Icon dims to `Secondary` at rest,
    /// brightens to `Primary` on hover, flashes `Accent` on press —
    /// designed to live inside another widget's trailing slot
    /// (TextInput's clear-X, ComboBox's chevron) without competing
    /// visually with the host's content. Default mode is stand-alone
    /// (icon at full visual weight, `Primary` always).
    pub fn embedded(mut self) -> Self {
        self.embedded = true;
        self
    }

    /// Override the icon's tint with a static `ColorProp`. When set,
    /// the icon ignores `embedded` and the auto-derived idle/hover/press
    /// role cascade — its color is bound directly to this prop instead.
    /// Use for chrome whose host enforces a single text role across all
    /// of its sub-widgets (e.g. tab-bar scroll arrows that must match
    /// the tab strip's `idle_text_role` regardless of hover state).
    /// Accepts `Color`, `TextRole`, `Signal<Color>`, or `Signal<TextRole>`.
    ///
    /// It replaces the *interaction* cascade (idle / hover / press / focus),
    /// **not** the disabled substitution: a role passed here still resolves to
    /// [`TextRole::Disabled`] in a disabled subtree, like every other
    /// role-derived color (see [`ColorProp::resolve`](teksilo_core::ColorProp::resolve)).
    /// That is what a disabled
    /// control should look like. When the tint is semantic *state* that stays
    /// true even though the button can't be pressed — a save/sync indicator, a
    /// validation badge — wrap it: `.icon_role(ColorProp::undimmed(role))`.
    pub fn icon_role(mut self, role: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
        self.icon_role_override = Some(role.into());
        self
    }

    /// Whether the button takes keyboard focus. Default `true` —
    /// the button is focusable when enabled. Set to `false` for
    /// embedded-control patterns where the parent owns focus and
    /// keyboard interaction goes through the parent (e.g. the
    /// close button inside a tab header — Tab moves between tabs,
    /// not onto their close buttons).
    pub fn focusable(mut self, on: bool) -> Self {
        self.focusable = on;
        self
    }

    /// Attach a tooltip that appears after a hover delay. Required —
    /// the tooltip text doubles as the AT name for icon-only buttons.
    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
        self.tooltip_text = Some(text.into());
        self.rich_tooltip_source = None;
        self.composite_tooltip_content = None;
        self
    }

    /// Attach a rich tooltip resolved from the app-wide tooltip
    /// registry. See [`Button::rich_tooltip`](crate::button::Button::rich_tooltip).
    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
        self.tooltip_text = None;
        self.composite_tooltip_content = None;
        self
    }

    /// Attach a rich tooltip driven by inline `TooltipContent`.
    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
        self.tooltip_text = None;
        self.composite_tooltip_content = None;
        self
    }

    /// Attach a composite tooltip — third tier, hosting an arbitrary
    /// widget tree. See [`Button::composite_tooltip`](crate::button::Button::composite_tooltip).
    pub fn composite_tooltip(
        mut self,
        content: impl teksilo_core::widget::Widget + 'static,
    ) -> Self {
        self.composite_tooltip_content = Some(Box::new(content));
        self.tooltip_text = None;
        self.rich_tooltip_source = None;
        self
    }

    /// Attach a composite tooltip from an already-boxed widget — the boxed twin
    /// of [`composite_tooltip`](Self::composite_tooltip), for hosts that build
    /// the body via a `Fn() -> Box<dyn Widget>` factory (e.g. a `ToolbarAction`).
    pub fn composite_tooltip_boxed(
        mut self,
        content: Box<dyn teksilo_core::widget::Widget>,
    ) -> Self {
        self.composite_tooltip_content = Some(content);
        self.tooltip_text = None;
        self.rich_tooltip_source = None;
        self
    }

    /// Set the enabled state, statically or reactively. Disabled
    /// buttons ignore input and dim their icon (handled by the
    /// framework's `PaintContext::effective_enabled`). Forwarded into
    /// the arena via `ctx.enabled_when(self_id, self.enabled.clone())`
    /// at build time — a bound signal updates live as it changes.
    ///
    /// For a reactive enabled state — e.g. a toolbar button that
    /// enables only when the caret is inside a table — pass a
    /// `Signal<bool>` here, or call `ctx.enabled_when(button_id,
    /// my_signal)` from the composing widget's `build()` instead of
    /// (or in addition to) this builder. Both routes write to the
    /// same arena `enabled_state`; an external `enabled_when`
    /// registered after this builder runs wins (last-write semantics)
    /// and updates reactively from the signal.
    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
        self.enabled = enabled.into();
        self
    }

    /// Set the size variant. Most callers prefer the named shortcuts
    /// [`large`](Self::large) / [`toolbar`](Self::toolbar) /
    /// [`hero`](Self::hero); use `.size(...)` for `Compact` or for
    /// programmatic size selection.
    pub fn size(mut self, size: IconButtonSize) -> Self {
        self.size = size;
        self
    }

    /// Shortcut for `.size(IconButtonSize::Toolbar)` (30 dp) — the
    /// IntelliJ side-toolbar density (left / right / top window edges).
    pub fn toolbar(mut self) -> Self {
        self.size = IconButtonSize::Toolbar;
        self
    }

    /// Shortcut for `.size(IconButtonSize::Large)` (40 dp) —
    /// emphasized stand-alone buttons in rich menus and detail panes.
    pub fn large(mut self) -> Self {
        self.size = IconButtonSize::Large;
        self
    }

    /// Shortcut for `.size(IconButtonSize::Hero)` (50 dp) — hero /
    /// landing-screen CTAs.
    pub fn hero(mut self) -> Self {
        self.size = IconButtonSize::Hero;
        self
    }

    /// Closure invoked on activation. Fires after the toggle signal
    /// (if any) is flipped, so apps observing the closure see the
    /// post-flip state.
    pub fn on_activate_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
        self.action = Some(Box::new(f));
        self
    }

    /// Whether an activation closure has been attached. Used by wrappers
    /// (e.g. `PopoverWidget`) that overwrite the activate slot, so they
    /// can warn when a caller-set handler is about to be discarded.
    pub(crate) fn has_activate_handler(&self) -> bool {
        self.action.is_some()
    }

    /// Enable **surface-tint** bistate: clicking flips `state` and the
    /// background reads as `SurfaceRole::Selected` while `state == true`.
    /// The icon glyph is unchanged. Pin / select / lock-toggle pattern.
    /// `on_activate_fn`, if any, still fires after the flip.
    ///
    /// For the eye / eye-off pattern where the icon glyph also changes,
    /// use [`toggle_with_icon`](Self::toggle_with_icon) instead.
    pub fn toggle(mut self, state: Signal<bool>) -> Self {
        self.toggled = Some(state);
        self.toggled_icon = None;
        self
    }

    /// Enable **surface-tint plus icon-swap** bistate: clicking flips
    /// `state`, the background flips to `Selected`, **and** the icon
    /// swaps to `toggled_icon`. The visibility-toggle pattern (eye ↔
    /// eye-off). For surface-only bistate (icon stays the same), use
    /// [`toggle`](Self::toggle).
    pub fn toggle_with_icon(mut self, state: Signal<bool>, toggled_icon: IconWidget) -> Self {
        self.toggled = Some(state);
        self.toggled_icon = Some(toggled_icon);
        self
    }

    /// Declare that this button is a disclosure trigger for a popup
    /// (menu, dialog, listbox, …). Surfaced via `set_has_popup` in
    /// the a11y node so screen readers announce it as opening the
    /// named popup kind. Wired automatically by
    /// [`PopoverIconButton`](crate::popover_widget::PopoverIconButton).
    pub fn has_popup(mut self, kind: teksilo_core::accesskit::HasPopup) -> Self {
        self.has_popup = Some(kind);
        self
    }

    /// Bind a signal reporting whether this button's popup is
    /// currently visible. The popover wrapper owns the signal and
    /// flips it on show / dismiss; IconButton reads it in
    /// `accessibility()` to publish `set_expanded`. Only meaningful
    /// alongside [`has_popup`](Self::has_popup).
    pub fn expanded_when(mut self, signal: impl Into<Prop<bool>>) -> Self {
        self.expanded_signal = Some(signal.into());
        self
    }

    // ── Predefined constructors ─────────────────────────────────────────
    //
    // Each ships a standard icon and an i18n tooltip. They are size-
    // and mode-agnostic — chain `.embedded()` for the dim look,
    // `.toolbar()` / `.large()` / `.hero()` for the size.

    /// Browse button (ellipsis icon). Opens a file/directory chooser.
    pub fn browse() -> Self {
        Self::new((BuiltInIcons::global().browse)())
            .tooltip(teksilo_i18n::tr_widget!(a11y_builtin_browse()))
    }

    /// Expand button (diagonal resize arrows). Enlarges a constrained field.
    pub fn expand() -> Self {
        Self::new((BuiltInIcons::global().expand)())
            .tooltip(teksilo_i18n::tr_widget!(a11y_builtin_expand()))
    }

    /// Search button (magnifier icon). Triggers a search.
    pub fn search() -> Self {
        Self::new((BuiltInIcons::global().search)())
            .tooltip(teksilo_i18n::tr_widget!(a11y_builtin_search()))
    }

    /// Copy button (clipboard icon). Copies the field content.
    pub fn copy() -> Self {
        Self::new((BuiltInIcons::global().copy)())
            .tooltip(teksilo_i18n::tr_widget!(a11y_builtin_copy()))
    }

    /// Clear button (X icon). Clears the field content.
    pub fn clear() -> Self {
        Self::new((BuiltInIcons::global().clear)())
            .tooltip(teksilo_i18n::tr_widget!(a11y_builtin_clear()))
    }

    /// Add button (plus icon). Adds a new entry.
    pub fn add() -> Self {
        Self::new((BuiltInIcons::global().add)())
            .tooltip(teksilo_i18n::tr_widget!(a11y_builtin_add()))
    }

    /// Notification bell. Used by
    /// [`NotificationCenterButton`](crate::notification::NotificationCenterButton)
    /// — the bell-icon trigger that opens the notification log popover.
    pub fn bell() -> Self {
        Self::new((BuiltInIcons::global().bell)())
            .tooltip(teksilo_i18n::tr_widget!(a11y_builtin_bell()))
    }

    /// Menu / hamburger button (three horizontal bars). Used by the
    /// collapsible [`MenuBar`](crate::menu_bar::MenuBar) as the
    /// collapsed representation that reveals the bar when activated.
    /// Advertises `HasPopup::Menu` for assistive technology.
    pub fn menu() -> Self {
        Self::new((BuiltInIcons::global().menu)())
            .tooltip(teksilo_i18n::tr_widget!(a11y_builtin_menu()))
            .has_popup(teksilo_core::accesskit::HasPopup::Menu)
    }

    /// "More actions" / overflow button — three **vertical** dots (the kebab
    /// `⋮`). The conventional trigger for a per-item options menu (view-header
    /// `…`, list-row overflow). Advertises `HasPopup::Menu` for assistive
    /// technology. Pair with a `PopoverIconButton` + `MenuList` (use `.bare()`
    /// so the menu isn't wrapped in a second popover surface).
    pub fn more() -> Self {
        Self::new((BuiltInIcons::global().more)())
            .tooltip(teksilo_i18n::tr_widget!(a11y_builtin_more()))
            .has_popup(teksilo_core::accesskit::HasPopup::Menu)
    }

    /// Visibility toggle (eye / eye-off). Toggles password visibility.
    /// Uses the icon-swap bistate mode internally — the icon advertises
    /// the **expected action**, matching the prevailing password-field
    /// convention (1Password, Bitwarden, KeePass, Chrome, GitHub):
    /// `eye` (open) while the value is hidden, suggesting "click to
    /// reveal"; `eye_off` (closed) once revealed, suggesting "click to
    /// hide". `set_toggled` still reports the literal current state, so
    /// AT readers are not misled.
    ///
    /// For a current-state-instead semantics (icon shows what IS),
    /// build your own with [`toggle_with_icon`](Self::toggle_with_icon)
    /// and the eye glyphs in the opposite order.
    ///
    /// The `visible` signal is flipped on each click. The host widget reads
    /// it to decide whether to mask or show the text.
    pub fn visibility_toggle(visible: Signal<bool>) -> Self {
        let icons = BuiltInIcons::global();
        Self::new((icons.eye)())
            .toggle_with_icon(visible, (icons.eye_off)())
            .tooltip(teksilo_i18n::tr_widget!(a11y_builtin_visibility()))
    }
}

impl std::fmt::Debug for IconButton {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("IconButton")
            .field("enabled", &self.enabled.get())
            .field("size", &self.size)
            .field("embedded", &self.embedded)
            .finish()
    }
}

// ── Icon coloring ───────────────────────────────────────────────────────────
//
// Background / border / size composition lives in the active
// `IconButtonStyle` (default: `RecipeIconButtonStyle`). The widget retains
// only icon coloring policy — embedded mode dims to `Secondary` at rest
// (the JetBrains "built-in" look), stand-alone mode stays at `Primary`
// always so toolbar / menu icons read at full weight.

pub(crate) fn resolve_icon_role_embedded(state: InteractionState) -> TextRole {
    match state {
        InteractionState::Idle | InteractionState::Focused => TextRole::Secondary,
        InteractionState::Hovered => TextRole::Primary,
        InteractionState::Pressed => TextRole::Accent,
        InteractionState::Disabled => TextRole::Disabled,
    }
}

pub(crate) fn resolve_icon_role_standalone(state: InteractionState) -> TextRole {
    match state {
        InteractionState::Disabled => TextRole::Disabled,
        _ => TextRole::Primary,
    }
}

/// Per-size icon dimension. The two smallest buttons (Compact 22,
/// Default 24) share the standard `icon_size` (16 dp); Toolbar / Large
/// / Hero scale up via dedicated tokens so a 50 dp button doesn't
/// carry a tiny 16 dp glyph.
fn resolve_icon_size(size: IconButtonSize) -> f32 {
    use crate::styles::recipe_icon_button_style as icon_dims;
    match size {
        IconButtonSize::Compact | IconButtonSize::Default => icon_dims::ICON_BUTTON_ICON_SIZE,
        IconButtonSize::Toolbar => icon_dims::ICON_BUTTON_ICON_SIZE_TOOLBAR,
        IconButtonSize::Large => icon_dims::ICON_BUTTON_ICON_SIZE_LARGE,
        IconButtonSize::Hero => icon_dims::ICON_BUTTON_ICON_SIZE_HERO,
    }
}

// ── Widget trait ─────────────────────────────────────────────────────────────

impl teksilo_core::widget::Widget for IconButton {
    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
        let embedded = self.embedded;
        let icon_size = resolve_icon_size(self.size);
        let size = self.size;
        let self_id = ctx.self_id();

        // Forward the enabled state into the arena. After this point
        // the arena is the single source of truth — events, focus,
        // a11y, and the leaves' role-resolution all consult
        // `arena.is_enabled(self_id)` / `PaintContext::effective_enabled`.
        // We never carry an `InteractionState::Disabled` in the
        // interaction signal: that was the snapshot duality the
        // architecture refactor removed.
        ctx.enabled_when(self_id, self.enabled.clone());

        // Reactive view of "is this widget effectively enabled?",
        // factoring this node and every ancestor's `enabled_state`.
        // Used both to derive `is_disabled` for the style chrome and
        // to flip the cursor between Pointer (enabled) / Default
        // (disabled).
        let effective_enabled = ctx.effective_enabled_signal(self_id);

        // Interaction signal — caller-supplied via `share_interaction`
        // when set (so a wrapping widget's chrome can mirror the icon's
        // color), otherwise allocated locally. Seeded to Idle; the
        // arena's enabled-state is consulted separately.
        let interaction = match self.shared_interaction.take() {
            Some(shared) => shared,
            None => ctx.signal(InteractionState::Idle),
        };
        self.interaction = interaction.clone();

        // Register toggled signal for repaint + a11y refresh if present.
        // AccessibilityOnly pushes a fresh set_toggled() into the a11y
        // tree on every flip without forcing a relayout.
        if let Some(ref toggled) = self.toggled {
            let self_id = ctx.self_id();
            let registry = ctx.binding_registry();
            toggled.bind_to(self_id, registry, BindingLevel::RepaintOnly);
            toggled.bind_to(self_id, registry, BindingLevel::AccessibilityOnly);
        }

        // Register the popover-open signal so AT picks up `set_expanded`
        // flips when a wrapping `PopoverIconButton` toggles the popover.
        // No relayout — AccessibilityOnly is enough.
        if let Some(ref expanded) = self.expanded_signal {
            let self_id = ctx.self_id();
            let registry = ctx.binding_registry();
            expanded.register_if_bound(self_id, registry, BindingLevel::AccessibilityOnly);
        }

        // Icon color: a caller-supplied override wins over the auto
        // cascade. It replaces the interaction states (idle / hover /
        // press / focus) — chrome that uses this opts out of
        // interaction-driven color feedback in exchange for matching a
        // host's enforced text role. It does NOT opt out of the disabled
        // substitution, which happens later, at paint, inside
        // `ColorProp::resolve`: a role passed here still dims in a
        // disabled subtree. Callers whose tint is semantic state rather
        // than chrome pass `ColorProp::undimmed(role)`.
        let icon_color: teksilo_core::color_prop::ColorProp =
            if let Some(ref over) = self.icon_role_override {
                over.clone()
            } else if embedded {
                interaction.map(|s| resolve_icon_role_embedded(*s)).into()
            } else {
                interaction.map(|s| resolve_icon_role_standalone(*s)).into()
            };

        // Build the icon content. Icon-swap toggle (eye / eye-off) only
        // applies when a `toggled_icon` was provided via
        // `toggle_with_icon`; surface-tint-only toggle keeps the same
        // glyph throughout.
        let icon_content_id = if let (Some(toggled), Some(_)) =
            (self.toggled.as_ref(), self.toggled_icon.as_ref())
        {
            let toggled_index = toggled.map(|v| if *v { 1 } else { 0 });
            let primary_icon =
                std::mem::replace(&mut self.icon, IconWidget::from_path(Path::new(), 0.0))
                    .icon_size(icon_size)
                    .color(icon_color.clone());
            let alt_icon = self
                .toggled_icon
                .take()
                .expect("toggled_icon checked above")
                .icon_size(icon_size)
                .color(icon_color);
            ctx.add(
                Switcher::new(toggled_index)
                    .child(primary_icon)
                    .child(alt_icon),
            )
        } else {
            let icon = std::mem::replace(&mut self.icon, IconWidget::from_path(Path::new(), 0.0))
                .icon_size(icon_size)
                .color(icon_color);
            ctx.add(icon)
        };

        // Delegate background + border + size to the active style.
        // The four boolean signals derive from `interaction`; `is_on` is
        // populated only when a `toggled` signal is bound (drives the
        // bistate `Selected` background mode).
        let style: SharedIconButtonStyle = self
            .style_override
            .clone()
            .or_else(|| ctx.theme().style_slots.icon_button.clone())
            .unwrap_or_else(|| Rc::new(crate::styles::RecipeIconButtonStyle::default()));
        let is_pressed = interaction.map(|s| matches!(s, InteractionState::Pressed));
        let is_hovered = interaction.map(|s| matches!(s, InteractionState::Hovered));
        // `:focus-visible`: reveal the focus ring during keyboard navigation
        // only, not on a mouse click. Gate raw focus on the input-modality
        // signal (true after a key event, false after pointer-down).
        let is_focused = interaction
            .map(|s| matches!(s, InteractionState::Focused))
            .and(&ctx.focus_visible());
        // `is_disabled` derives from the arena's effective enabled
        // state — NOT from the interaction signal (which never
        // carries Disabled anymore). Style chrome uses this to pick
        // its disabled-background role.
        let is_disabled = effective_enabled.map(|on| !*on);
        let cfg = IconButtonStyleConfig {
            icon: icon_content_id,
            is_pressed,
            is_hovered,
            is_focused,
            is_disabled,
            is_on: self.toggled.clone(),
            size,
        };
        let root_id = style.make_body(&cfg, ctx);

        // Tooltip — three mutually-exclusive setters; setters clear
        // the others so exactly one branch runs.
        if let Some(content) = self.composite_tooltip_content.take() {
            let delay = ctx.theme().motion.tooltip_delay_heavy;
            crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
        } else if let Some(source) = self.rich_tooltip_source.clone() {
            // Clone, not take: `accessibility()` needs the source to
            // resolve the accessible name (an IconButton has no label,
            // so its AT name comes from the tooltip). Taking it here left
            // the rich-tooltip a11y name resolution reading a `None`
            // source — falling back to the literal "Button".
            let delay = ctx.theme().motion.tooltip_delay;
            crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
        } else if let Some(tooltip_text) = self.tooltip_text.clone() {
            let delay = ctx.theme().motion.tooltip_delay;
            crate::tooltip::attach_plain_tooltip(ctx, root_id, tooltip_text, delay);
        }

        self.root_child_id = Some(root_id);

        // --- V2 attached handlers ---
        // Bundle the optional action AND the toggle flip into the single
        // `on_activate` closure consumed by the shared button-family
        // helper (`build_interaction_handlers`). Routing the toggle
        // through the helper means the lone-KeyUp guard now gates the
        // toggle too — a stray KeyUp can no longer flip the toggle.
        // (Framework gates dispatch on `arena.is_enabled`, so no inline
        // enabled check is needed.)
        let action: std::rc::Rc<Option<ActionFactory>> = std::rc::Rc::new(self.action.take());
        let toggled = self.toggled.clone();
        let on_activate: std::rc::Rc<dyn Fn(&mut EventContext)> =
            std::rc::Rc::new(move |ctx: &mut EventContext| {
                if let Some(ref toggled) = toggled {
                    toggled.set(!toggled.get());
                }
                if let Some(ref action) = *action {
                    action(ctx);
                }
            });
        // The focus walker skips disabled subtrees on its own; the static
        // `self.focusable` flag is the caller's intent (e.g. a
        // close-button-inside-tab wants `false`).
        let handler_set =
            crate::button::build_interaction_handlers(interaction, on_activate, self.focusable);

        ctx.apply_self_handlers(handler_set);

        vec![root_id]
    }

    fn layout_response(
        &self,
        proposal: SizeProposal,
        ctx: &LayoutContext,
    ) -> teksilo_core::widget::LayoutResponse {
        // Rigid like `Button`: size to content, no shrink (see Button's note).
        match self.root_child_id {
            Some(root_id) => ctx
                .child_size(root_id, proposal)
                .unwrap_or_else(|| proposal.resolve(0.0, 0.0)),
            None => proposal.resolve(0.0, 0.0),
        }
        .into()
    }

    fn place_children(
        &self,
        bounds: Rect,
        _proposal: SizeProposal,
        children: &mut [WidgetPlacement],
        _ctx: &LayoutContext,
    ) {
        for child in children.iter_mut() {
            child.origin = bounds.origin();
            child.size = bounds.size();
        }
    }

    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
        builder.set_role(teksilo_core::accesskit::Role::Button);
        // The accessible name is sourced from whichever tooltip flavor
        // is configured. Plain text is used directly; for a rich tooltip
        // we use the inline content's body text, or — for a registry
        // *key* — the registered content's text resolved from the
        // tooltip registry; for a composite tooltip the caller must
        // provide an explicit `.access_label(...)` via the
        // accessibility-overrides API (no text to source from).
        let rich_name: Option<String> = self.rich_tooltip_source.as_ref().and_then(|s| match s {
            crate::tooltip::RichTooltipSource::Content(c) => Some(c.text.resolve_now()),
            crate::tooltip::RichTooltipSource::Key(key) => {
                crate::tooltip::with_tooltip_registry(|reg| {
                    reg.get(key).map(|c| c.text.resolve_now())
                })
                .flatten()
            }
        });
        debug_assert!(
            self.tooltip_text.is_some()
                || rich_name.is_some()
                || self.rich_tooltip_source.is_some()
                || self.composite_tooltip_content.is_some(),
            "IconButton: expected a tooltip (used as the accessible name). \
             Use .tooltip(tr!(…)) or a predefined constructor like IconButton::clear(). \
             For rich/composite tooltips, also pair with `.access_label(...)`."
        );
        // Set a name only when we resolved a real one. Never fall back
        // to a literal "Button" — a misleading name ("Button, button")
        // is worse for screen-reader users than an unnamed node, and the
        // composite / unresolved-key paths are expected to carry an
        // explicit `.access_label(...)` (enforced by the debug_assert),
        // which the override layer applies after this method.
        if let Some(text) = self
            .tooltip_text
            .as_ref()
            .map(|t| t.resolve_now())
            .or(rich_name)
        {
            builder.set_name(text);
        }
        // Note: `set_disabled()` is now driven by the framework's
        // accessibility walker from `arena.is_enabled(self_id)`. The
        // composite no longer needs to mirror it — the snapshot path
        // was redundant with the arena and broke under reactive
        // `enabled_when(id, signal)` flips.
        if let Some(ref toggled) = self.toggled {
            builder.set_toggled(toggled.get());
        }
        // ARIA disclosure pattern: a button that opens a popup
        // declares `has_popup` and, when the wrapper tracks it,
        // `expanded`. Both are opt-in — regular icon buttons stay
        // silent on these properties.
        if let Some(kind) = self.has_popup {
            builder.set_has_popup(kind);
        }
        if let Some(ref signal) = self.expanded_signal {
            builder.set_expanded(signal.get());
        }
        builder.add_action(teksilo_core::accesskit::Action::Click);
        builder.add_action(teksilo_core::accesskit::Action::Focus);
    }

    fn children(&self) -> Vec<WidgetId> {
        self.root_child_id.into_iter().collect()
    }
}

// ── Overridable icon set ────────────────────────────────────────────────────
//
// Default icons are real SVGs embedded via `include_str!` and parsed once
// via `LazyLock`. The `res!` macro cannot be used here because it emits
// `::teksilo::` paths and teksilo-widgets sits below teksilo in the
// dependency graph.
//
// Applications can replace the default icon set globally at startup via
// `BuiltInIcons::set_global(custom_set)`.

/// Icon factory set for predefined built-in buttons.
///
/// Each field is a function pointer that creates an [`IconWidget`].
/// The default implementation uses SVG icons embedded in teksilo-widgets.
///
/// # Overriding
///
/// Call [`BuiltInIcons::set_global`] at app startup (before creating any
/// built-in buttons) to replace the default icon set:
///
/// ```rust
/// # use teksilo_widgets::{BuiltInIcons};
/// # use teksilo_widgets::primitives::IconWidget;
/// # const MY_BROWSE_SVG: &str = "<svg xmlns='http://www.w3.org/2000/svg'/>";
/// # const MY_CLEAR_SVG: &str = "<svg xmlns='http://www.w3.org/2000/svg'/>";
/// BuiltInIcons::set_global(BuiltInIcons {
///     browse: || IconWidget::from_svg(MY_BROWSE_SVG),
///     clear: || IconWidget::from_svg(MY_CLEAR_SVG),
///     ..BuiltInIcons::defaults()
/// });
/// ```
pub struct BuiltInIcons {
    pub browse: fn() -> IconWidget,
    pub expand: fn() -> IconWidget,
    pub search: fn() -> IconWidget,
    pub copy: fn() -> IconWidget,
    pub clear: fn() -> IconWidget,
    pub add: fn() -> IconWidget,
    pub bell: fn() -> IconWidget,
    pub eye: fn() -> IconWidget,
    pub eye_off: fn() -> IconWidget,
    pub menu: fn() -> IconWidget,
    pub more: fn() -> IconWidget,
}

static GLOBAL_ICONS: OnceLock<BuiltInIcons> = OnceLock::new();

impl BuiltInIcons {
    /// Return the default icon set (SVGs embedded in teksilo-widgets).
    pub fn defaults() -> Self {
        Self {
            browse: default_browse_icon,
            expand: default_expand_icon,
            search: default_search_icon,
            copy: default_copy_icon,
            clear: default_clear_icon,
            add: default_add_icon,
            bell: default_bell_icon,
            eye: default_eye_icon,
            eye_off: default_eye_off_icon,
            menu: default_menu_icon,
            more: default_more_icon,
        }
    }

    /// Set the global icon set. Call at app startup before creating any
    /// built-in buttons. Can only be set **once**: the global is a
    /// process-wide [`OnceLock`], so the first set wins and any later
    /// call is ignored (and warns). It is also locked in the first time
    /// `global()` reads it, so set it before any built-in
    /// button is created. Use [`defaults()`](Self::defaults) with struct
    /// update syntax to override only specific icons.
    pub fn set_global(icons: Self) {
        if GLOBAL_ICONS.set(icons).is_err() {
            // A second `set_global` (or one after the first `global()`
            // read) silently has no effect — that is almost always a
            // startup-ordering bug, so make it loud.
            debug_assert!(
                false,
                "BuiltInIcons::set_global called more than once (or after the icon set was \
                 first read); the later call is ignored"
            );
            warn_icons_already_set();
        }
    }

    /// Access the registered global icon set, falling back to the
    /// compiled-in SVG defaults. Intended for widgets in this crate
    /// that need a themed icon without binding to a specific asset
    /// path — e.g. the clear button inside `TextInput`. Applications
    /// still use `set_global(..)` to override the defaults.
    pub(crate) fn global() -> &'static Self {
        GLOBAL_ICONS.get_or_init(Self::defaults)
    }
}

/// One-shot stderr warning when `BuiltInIcons::set_global` is called
/// after the global set was already locked in. Thread-local flag keeps
/// it from repeating. (Stderr rather than `log::warn!` to avoid adding a
/// `log` dependency to teksilo-widgets — this is a setup error, matching
/// the `toast` install warning convention.)
fn warn_icons_already_set() {
    thread_local! {
        static WARNED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
    }
    WARNED.with(|w| {
        if !w.get() {
            eprintln!(
                "[teksilo-widgets::icon_button] BuiltInIcons::set_global(...) called after the \
                 icon set was already locked in — the later call was ignored. Call set_global \
                 once at startup, before any built-in button is created."
            );
            w.set(true);
        }
    });
}

// ── Default SVG icons ───────────────────────────────────────────────────────

fn default_browse_icon() -> IconWidget {
    IconWidget::from_svg(include_str!("../resources/icons/builtin-browse.svg"))
}

fn default_expand_icon() -> IconWidget {
    IconWidget::from_svg(include_str!("../resources/icons/builtin-expand.svg"))
}

fn default_search_icon() -> IconWidget {
    IconWidget::from_svg(include_str!("../resources/icons/builtin-search.svg"))
}

fn default_copy_icon() -> IconWidget {
    IconWidget::from_svg(include_str!("../resources/icons/builtin-copy.svg"))
}

fn default_clear_icon() -> IconWidget {
    IconWidget::from_svg(include_str!("../resources/icons/builtin-clear.svg"))
}

fn default_add_icon() -> IconWidget {
    IconWidget::from_svg(include_str!("../resources/icons/builtin-add.svg"))
}

fn default_bell_icon() -> IconWidget {
    IconWidget::from_svg(include_str!("../resources/icons/builtin-bell.svg"))
}

fn default_eye_icon() -> IconWidget {
    IconWidget::from_svg(include_str!("../resources/icons/builtin-eye.svg"))
}

fn default_eye_off_icon() -> IconWidget {
    IconWidget::from_svg(include_str!("../resources/icons/builtin-eye-off.svg"))
}

fn default_menu_icon() -> IconWidget {
    IconWidget::from_svg(include_str!("../resources/icons/builtin-menu.svg"))
}

fn default_more_icon() -> IconWidget {
    IconWidget::from_svg(include_str!("../resources/icons/builtin-more.svg"))
}

// ── Tests ───────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use teksilo_core::event::{Key, Modifiers, WidgetEvent};
    use teksilo_core::signal::Signal;
    use teksilo_core::widget_tree::WidgetTree;
    use teksilo_i18n::lit;

    /// A `KeyUp` with no preceding `KeyDown` (e.g. a shortcut consumed
    /// the `KeyDown` and focus returned here) must NOT activate — it
    /// must neither fire the action nor flip the toggle. Before the
    /// shared `build_interaction_handlers` migration, IconButton lacked
    /// the lone-KeyUp guard that Button had, so a stray KeyUp toggled
    /// and fired. This is the regression test for that fix.
    #[test]
    fn icon_button_lone_keyup_does_not_activate() {
        let fired = std::rc::Rc::new(std::cell::Cell::new(0u32));
        let toggle = Signal::new(false);
        let f = fired.clone();
        let mut tree = WidgetTree::new();
        let btn = tree.add(
            IconButton::add()
                .tooltip(lit!("Add"))
                .toggle(toggle.clone())
                .on_activate_fn(move |_| f.set(f.get() + 1)),
        );
        tree.layout(teksilo_canvas::SizeProposal::exact(100.0, 100.0));
        tree.focus(btn);

        // Lone KeyUp — must be a no-op.
        tree.dispatch_event(WidgetEvent::KeyUp {
            key: Key::Enter,
            modifiers: Modifiers::NONE,
        });
        assert_eq!(fired.get(), 0, "lone KeyUp must not fire the action");
        assert!(!toggle.get(), "lone KeyUp must not flip the toggle");

        // Sanity: a full KeyDown+KeyUp DOES activate.
        tree.dispatch_event(WidgetEvent::KeyDown {
            key: Key::Enter,
            modifiers: Modifiers::NONE,
            text: None,
        });
        tree.dispatch_event(WidgetEvent::KeyUp {
            key: Key::Enter,
            modifiers: Modifiers::NONE,
        });
        assert_eq!(fired.get(), 1, "full KeyDown+KeyUp fires the action once");
        assert!(toggle.get(), "full KeyDown+KeyUp flips the toggle");
    }

    /// Int UI icon buttons have a distinct pressed (mouse-down) state
    /// (unlike regular buttons). The shared `build_interaction_handlers`
    /// now feeds the Pressed state on pointer-down, so the icon recipe's
    /// pressed background renders on mouse-down — not only on keyboard
    /// activation. Idle must NOT show the pressed background.
    #[test]
    fn icon_button_flashes_pressed_background_on_pointer_down() {
        use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};

        let theme = teksilo_core::presets::intui::light();
        let mut tree = WidgetTree::new();
        tree.set_theme(theme.clone());
        let btn = tree.add(IconButton::add().tooltip(lit!("Add")));
        tree.layout(teksilo_canvas::SizeProposal::exact(100.0, 100.0));

        let pressed = teksilo_tokens::SurfaceRole::Pressed
            .resolve(&theme.colors)
            .to_array();

        // Idle: no pressed background.
        let frame = tree.render();
        assert!(
            !frame.shapes.iter().any(|s| s.color == pressed),
            "idle IconButton must not render the pressed background"
        );

        // Pointer-down inside the button → pressed flash.
        let b = tree.bounds(btn);
        let center = teksilo_canvas::Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0);
        tree.dispatch_event(WidgetEvent::PointerDown {
            position: center,
            button: PointerButton::Primary,
            modifiers: Modifiers::NONE,
        });
        tree.layout(teksilo_canvas::SizeProposal::exact(100.0, 100.0));
        let frame = tree.render();
        assert!(
            frame.shapes.iter().any(|s| s.color == pressed),
            "IconButton must render the pressed background on pointer-down \
             (Int UI icon buttons have a distinct pressed state); got shape \
             colors = {:?}",
            frame.shapes.iter().map(|s| s.color).collect::<Vec<_>>()
        );
    }

    /// Regression: a registry-*key* rich tooltip used to expose the
    /// literal accessible name "Button". It must resolve the registered
    /// content's text from the tooltip registry instead.
    #[test]
    fn icon_button_rich_tooltip_key_resolves_at_name_from_registry() {
        crate::tooltip::install_tooltip_registry(vec![crate::tooltip::TooltipContent::new(
            "docs",
            lit!("Documentation"),
        )]);
        let mut tree = WidgetTree::new();
        let btn = tree.add(IconButton::add().rich_tooltip("docs"));
        tree.layout(teksilo_canvas::SizeProposal::exact(100.0, 100.0));
        assert_eq!(
            tree.accessibility_node(btn).name(),
            Some("Documentation"),
            "registry-key rich tooltip must resolve the AT name from the \
             registry, never fall back to the literal \"Button\""
        );
    }

    /// Reactive enabled-state via `ctx.enabled_when(btn_id, signal)`
    /// must dim the IconButton's icon when the signal flips to false —
    /// regression test for the FormatToolbar bug where the
    /// table-operation buttons stayed full-color despite the framework
    /// correctly gating their events. Until the enabled-state
    /// architecture refactor (this commit and the framework commit
    /// that preceded it) the icon's color was driven by an internal
    /// `InteractionState::Disabled` seed captured at build time,
    /// which never updated from a later `enabled_when` call.
    #[test]
    fn icon_button_enabled_when_signal_dims_icon_color() {
        let theme = teksilo_core::presets::intui::light();
        let mut tree = WidgetTree::new();
        tree.set_theme(theme.clone());

        let is_enabled = Signal::new(true);
        let btn_id = tree.add(IconButton::new(IconWidget::checkmark(24.0)).tooltip(lit!("test")));
        tree.enabled_when(btn_id, is_enabled.clone());
        tree.layout(teksilo_canvas::SizeProposal::exact(100.0, 40.0));

        let primary = theme.colors.text_primary.to_array();
        let disabled = theme.colors.text_disabled.to_array();
        let frame = tree.render();
        assert!(
            frame.paths.iter().any(|p| p.color == primary),
            "enabled IconButton must render its icon at text_primary; \
             got path colors = {:?}",
            frame.paths.iter().map(|p| p.color).collect::<Vec<_>>()
        );

        is_enabled.set(false);
        tree.layout(teksilo_canvas::SizeProposal::exact(100.0, 40.0));
        let frame = tree.render();
        assert!(
            frame.paths.iter().any(|p| p.color == disabled),
            "after flipping enabled→false, IconButton's icon must \
             render at text_disabled (the FormatToolbar bug as a unit \
             test); got path colors = {:?}",
            frame.paths.iter().map(|p| p.color).collect::<Vec<_>>()
        );

        is_enabled.set(true);
        tree.layout(teksilo_canvas::SizeProposal::exact(100.0, 40.0));
        let frame = tree.render();
        assert!(
            frame.paths.iter().any(|p| p.color == primary),
            "flipping enabled→true must restore the primary color"
        );
    }

    /// Static `.enabled(false)` builder must route through the arena —
    /// after migration, the snapshot path is gone and the only correct
    /// behavior is that `arena.is_enabled(btn_id)` returns false.
    #[test]
    fn icon_button_static_enabled_false_propagates_to_arena() {
        let mut tree = WidgetTree::new();
        let btn_id = tree.add(
            IconButton::new(IconWidget::checkmark(24.0))
                .tooltip(lit!("test"))
                .enabled(false),
        );
        tree.layout(teksilo_canvas::SizeProposal::exact(100.0, 40.0));
        assert!(
            !tree.is_enabled(btn_id),
            "IconButton::enabled(false) must propagate to the arena"
        );
    }
}