@effindomv2/fui-rs 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/COMMERCIAL.md +7 -0
  2. package/Cargo.toml +34 -0
  3. package/LICENSE.md +9 -0
  4. package/README.md +88 -0
  5. package/package.json +54 -0
  6. package/scripts/build.sh +227 -0
  7. package/scripts/check-runtime-dependency.sh +41 -0
  8. package/scripts/framework-host-services.ts +40 -0
  9. package/scripts/generate-host-events.ts +17 -0
  10. package/scripts/generate-host-services.ts +28 -0
  11. package/scripts/hostgen/common.ts +73 -0
  12. package/scripts/hostgen/registry.ts +159 -0
  13. package/scripts/hostgen/rust-host-events.ts +171 -0
  14. package/scripts/hostgen/rust-host-services.ts +257 -0
  15. package/src/animation.rs +625 -0
  16. package/src/app.rs +324 -0
  17. package/src/assets.rs +572 -0
  18. package/src/bindings/mod.rs +1 -0
  19. package/src/bindings/ui.rs +705 -0
  20. package/src/bitmap.rs +317 -0
  21. package/src/bridge_callbacks.rs +242 -0
  22. package/src/context_menu_manager.rs +332 -0
  23. package/src/controls/anti_selection_area.rs +54 -0
  24. package/src/controls/button.rs +542 -0
  25. package/src/controls/checkbox.rs +372 -0
  26. package/src/controls/combobox.rs +1574 -0
  27. package/src/controls/context_menu.rs +1367 -0
  28. package/src/controls/control_template_set.rs +46 -0
  29. package/src/controls/control_tokens.rs +596 -0
  30. package/src/controls/dialog.rs +590 -0
  31. package/src/controls/dropdown.rs +1010 -0
  32. package/src/controls/form.rs +229 -0
  33. package/src/controls/internal/button_presenter.rs +142 -0
  34. package/src/controls/internal/checkbox_indicator_presenter.rs +210 -0
  35. package/src/controls/internal/dropdown_chevron_presenter.rs +134 -0
  36. package/src/controls/internal/dropdown_field_presenter.rs +269 -0
  37. package/src/controls/internal/dropdown_option_row_presenter.rs +184 -0
  38. package/src/controls/internal/mod.rs +13 -0
  39. package/src/controls/internal/pressable_indicator_presenter.rs +26 -0
  40. package/src/controls/internal/pressable_labeled_control.rs +492 -0
  41. package/src/controls/internal/radio_indicator_presenter.rs +208 -0
  42. package/src/controls/internal/selectable_popup_list.rs +597 -0
  43. package/src/controls/internal/slider_presenter.rs +282 -0
  44. package/src/controls/internal/switch_indicator_presenter.rs +235 -0
  45. package/src/controls/internal/text_input_core.rs +1074 -0
  46. package/src/controls/internal/text_input_presenter.rs +108 -0
  47. package/src/controls/mod.rs +235 -0
  48. package/src/controls/nav_link.rs +395 -0
  49. package/src/controls/popup.rs +191 -0
  50. package/src/controls/progress_bar.rs +287 -0
  51. package/src/controls/radio_button.rs +348 -0
  52. package/src/controls/radio_group.rs +283 -0
  53. package/src/controls/selection_area.rs +82 -0
  54. package/src/controls/shared.rs +68 -0
  55. package/src/controls/slider.rs +701 -0
  56. package/src/controls/switch.rs +293 -0
  57. package/src/controls/templating.rs +49 -0
  58. package/src/controls/tests.rs +3905 -0
  59. package/src/controls/text_area.rs +207 -0
  60. package/src/controls/text_input.rs +192 -0
  61. package/src/debug.rs +146 -0
  62. package/src/drag_drop.rs +424 -0
  63. package/src/drag_gesture.rs +258 -0
  64. package/src/drawing.rs +385 -0
  65. package/src/event.rs +1603 -0
  66. package/src/external_drop.rs +467 -0
  67. package/src/fetch.rs +500 -0
  68. package/src/ffi.rs +3542 -0
  69. package/src/file.rs +1677 -0
  70. package/src/focus_adorner.rs +307 -0
  71. package/src/focus_visibility.rs +121 -0
  72. package/src/frame_scheduler.rs +159 -0
  73. package/src/frame_signal.rs +64 -0
  74. package/src/generated/ffi.rs +798 -0
  75. package/src/generated/framework_host_services.rs +74 -0
  76. package/src/generated/mod.rs +2 -0
  77. package/src/host_services.rs +162 -0
  78. package/src/image_sampling.rs +78 -0
  79. package/src/keyboard_scroll.rs +137 -0
  80. package/src/keyboard_scroll_tracker.rs +385 -0
  81. package/src/lib.rs +547 -0
  82. package/src/logger.rs +56 -0
  83. package/src/mobile_text_selection_toolbar.rs +1034 -0
  84. package/src/navigation.rs +48 -0
  85. package/src/node/core.rs +2210 -0
  86. package/src/node/custom_drawable.rs +149 -0
  87. package/src/node/flex_box.rs +874 -0
  88. package/src/node/grid.rs +304 -0
  89. package/src/node/helpers.rs +442 -0
  90. package/src/node/image.rs +506 -0
  91. package/src/node/mod.rs +315 -0
  92. package/src/node/scroll_bar.rs +845 -0
  93. package/src/node/scroll_box.rs +514 -0
  94. package/src/node/scroll_state.rs +121 -0
  95. package/src/node/scroll_view.rs +557 -0
  96. package/src/node/svg_node.rs +474 -0
  97. package/src/node/text_node.rs +633 -0
  98. package/src/node/virtual_list.rs +678 -0
  99. package/src/panic_hook.rs +36 -0
  100. package/src/persisted.rs +274 -0
  101. package/src/platform.rs +556 -0
  102. package/src/popup_presenter.rs +344 -0
  103. package/src/selection_handle_adorner.rs +838 -0
  104. package/src/signal.rs +134 -0
  105. package/src/text.rs +1065 -0
  106. package/src/theme.rs +571 -0
  107. package/src/timers.rs +106 -0
  108. package/src/tool_tip.rs +167 -0
  109. package/src/tool_tip_manager.rs +691 -0
  110. package/src/transitions.rs +41 -0
  111. package/src/typography.rs +520 -0
  112. package/src/viewport.rs +108 -0
  113. package/src/worker.rs +308 -0
  114. package/src/worker_job.rs +82 -0
  115. package/src/worker_runtime.rs +172 -0
@@ -0,0 +1,3905 @@
1
+ use super::*;
2
+ use crate::assets;
3
+ use crate::event::{self, PointerEventArgs};
4
+ use crate::ffi::{
5
+ self, Call, KeyEventType, PointerEventType, SemanticCheckedState, SemanticRole,
6
+ TextVerticalAlign, Unit,
7
+ };
8
+ use crate::focus_visibility;
9
+ use crate::generated::ffi::NodeType;
10
+ use crate::node::Node;
11
+ use crate::node::{column, text, Child, FlexBoxSurface};
12
+ use crate::theme::{current_theme, generate_theme, use_custom_theme, use_system_theme};
13
+ use crate::Application;
14
+ use crate::PointerType;
15
+ use crate::ScrollBarVisibility;
16
+ use crate::TextCore;
17
+ use std::cell::Cell;
18
+ use std::rc::Rc;
19
+
20
+ #[derive(Clone)]
21
+ struct TestButtonPresenter {
22
+ content_root: FlexBox,
23
+ label: TextCore,
24
+ border_color: u32,
25
+ }
26
+
27
+ impl TestButtonPresenter {
28
+ fn new(border_color: u32) -> Self {
29
+ let label = TextCore::new("");
30
+ let content_root = flex_box();
31
+ content_root.child(&label);
32
+ Self {
33
+ content_root,
34
+ label,
35
+ border_color,
36
+ }
37
+ }
38
+ }
39
+
40
+ impl ButtonPresenter for TestButtonPresenter {
41
+ fn content_root(&self) -> FlexBox {
42
+ self.content_root.clone()
43
+ }
44
+
45
+ fn label_node(&self) -> TextCore {
46
+ self.label.clone()
47
+ }
48
+
49
+ fn apply(
50
+ &self,
51
+ host: &FlexBox,
52
+ _theme: crate::theme::Theme,
53
+ _state: ButtonVisualState,
54
+ _colors: Option<ButtonColors>,
55
+ ) {
56
+ host.border(3.0, self.border_color);
57
+ self.label.text_color(0x112233FF);
58
+ }
59
+ }
60
+
61
+ struct TestButtonTemplate {
62
+ created: Rc<Cell<u32>>,
63
+ border_color: u32,
64
+ }
65
+
66
+ impl ButtonTemplate for TestButtonTemplate {
67
+ fn create(&self) -> Rc<dyn ButtonPresenter> {
68
+ self.created.set(self.created.get() + 1);
69
+ Rc::new(TestButtonPresenter::new(self.border_color))
70
+ }
71
+ }
72
+
73
+ #[derive(Clone, Default)]
74
+ struct TestTextInputPresenter {
75
+ last_state: Rc<RefCell<Option<TextInputVisualState>>>,
76
+ }
77
+
78
+ impl TextInputPresenter for TestTextInputPresenter {
79
+ fn bind(&self, _host: FlexBox, _editor_host: TextCore, _placeholder_host: FlexBox) {}
80
+
81
+ fn apply(
82
+ &self,
83
+ _theme: crate::theme::Theme,
84
+ state: &TextInputVisualState,
85
+ _colors: Option<TextInputColors>,
86
+ ) {
87
+ *self.last_state.borrow_mut() = Some(*state);
88
+ }
89
+ }
90
+
91
+ struct TestTextInputTemplate {
92
+ created: Rc<Cell<u32>>,
93
+ last_state: Rc<RefCell<Option<TextInputVisualState>>>,
94
+ }
95
+
96
+ impl TextInputTemplate for TestTextInputTemplate {
97
+ fn create(&self) -> Rc<dyn TextInputPresenter> {
98
+ self.created.set(self.created.get() + 1);
99
+ Rc::new(TestTextInputPresenter {
100
+ last_state: self.last_state.clone(),
101
+ })
102
+ }
103
+ }
104
+
105
+ struct TestDropdownFieldTemplate {
106
+ created: Rc<Cell<u32>>,
107
+ }
108
+
109
+ impl DropdownFieldTemplate for TestDropdownFieldTemplate {
110
+ fn create(&self, _sizing: Option<DropdownSizing>) -> Rc<dyn DropdownFieldPresenter> {
111
+ self.created.set(self.created.get() + 1);
112
+ create_default_dropdown_field_presenter(None)
113
+ }
114
+ }
115
+
116
+ struct TestDropdownChevronTemplate {
117
+ created: Rc<Cell<u32>>,
118
+ }
119
+
120
+ impl DropdownChevronTemplate for TestDropdownChevronTemplate {
121
+ fn create(&self, _sizing: Option<DropdownSizing>) -> Rc<dyn DropdownChevronPresenter> {
122
+ self.created.set(self.created.get() + 1);
123
+ create_default_dropdown_chevron_presenter(None)
124
+ }
125
+ }
126
+
127
+ struct TestDropdownOptionRowTemplate {
128
+ created: Rc<Cell<u32>>,
129
+ }
130
+
131
+ impl DropdownOptionRowTemplate for TestDropdownOptionRowTemplate {
132
+ fn create(&self, _sizing: Option<DropdownSizing>) -> Rc<dyn DropdownOptionRowPresenter> {
133
+ self.created.set(self.created.get() + 1);
134
+ create_default_dropdown_option_row_presenter(None)
135
+ }
136
+ }
137
+
138
+ fn press_down(node_ref: &crate::node::NodeRef, click_count: i32) {
139
+ let mut event = PointerEventArgs::new(
140
+ node_ref.handle(),
141
+ PointerEventType::Down,
142
+ 10.0,
143
+ 10.0,
144
+ 0,
145
+ 1,
146
+ PointerType::Mouse,
147
+ 0,
148
+ 1,
149
+ 0.0,
150
+ 0.0,
151
+ 0.0,
152
+ click_count,
153
+ );
154
+ node_ref.handle_pointer_event(&mut event);
155
+ }
156
+
157
+ fn press_up(node_ref: &crate::node::NodeRef) {
158
+ let mut event = PointerEventArgs::new(
159
+ node_ref.handle(),
160
+ PointerEventType::Up,
161
+ 10.0,
162
+ 10.0,
163
+ 0,
164
+ 1,
165
+ PointerType::Mouse,
166
+ 0,
167
+ 0,
168
+ 0.0,
169
+ 0.0,
170
+ 0.0,
171
+ 0,
172
+ );
173
+ node_ref.handle_pointer_event(&mut event);
174
+ }
175
+
176
+ fn press_leave(node_ref: &crate::node::NodeRef) {
177
+ let mut event = PointerEventArgs::new(
178
+ node_ref.handle(),
179
+ PointerEventType::Leave,
180
+ 10.0,
181
+ 10.0,
182
+ 0,
183
+ 1,
184
+ PointerType::Mouse,
185
+ 0,
186
+ 0,
187
+ 0.0,
188
+ 0.0,
189
+ 0.0,
190
+ 0,
191
+ );
192
+ node_ref.handle_pointer_event(&mut event);
193
+ }
194
+
195
+ fn press_enter(node_ref: &crate::node::NodeRef) {
196
+ let mut event = PointerEventArgs::new(
197
+ node_ref.handle(),
198
+ PointerEventType::Enter,
199
+ 10.0,
200
+ 10.0,
201
+ 0,
202
+ 1,
203
+ PointerType::Mouse,
204
+ 0,
205
+ 0,
206
+ 0.0,
207
+ 0.0,
208
+ 0.0,
209
+ 0,
210
+ );
211
+ node_ref.handle_pointer_event(&mut event);
212
+ }
213
+
214
+ fn key_event(event_type: KeyEventType, key: &str, modifiers: u32) -> bool {
215
+ event::__fui_on_key_event(event_type as u32, key.as_ptr(), key.len() as u32, modifiers)
216
+ }
217
+
218
+ fn blur(node_ref: &crate::node::NodeRef) {
219
+ event::__fui_on_focus_changed(node_ref.handle().raw(), false);
220
+ }
221
+
222
+ fn focus(node_ref: &crate::node::NodeRef) {
223
+ event::__fui_on_focus_changed(node_ref.handle().raw(), true);
224
+ }
225
+
226
+ fn child_handles_for_parent(calls: &[Call], parent: u64) -> Vec<u64> {
227
+ calls
228
+ .iter()
229
+ .filter_map(|call| match call {
230
+ Call::NodeAddChild {
231
+ parent: call_parent,
232
+ child,
233
+ } if *call_parent == parent => Some(*child),
234
+ _ => None,
235
+ })
236
+ .collect()
237
+ }
238
+
239
+ fn node_type_for_handle(calls: &[Call], handle: u64) -> Option<NodeType> {
240
+ calls.iter().find_map(|call| match call {
241
+ Call::CreateNode {
242
+ handle: call_handle,
243
+ node_type,
244
+ } if *call_handle == handle => match *node_type {
245
+ 0 => Some(NodeType::FlexBox),
246
+ 1 => Some(NodeType::Text),
247
+ 2 => Some(NodeType::Image),
248
+ 3 => Some(NodeType::Svg),
249
+ 4 => Some(NodeType::ScrollView),
250
+ 5 => Some(NodeType::Grid),
251
+ _ => None,
252
+ },
253
+ _ => None,
254
+ })
255
+ }
256
+
257
+ #[test]
258
+ fn pressable_labeled_child_tree_order_matches_fui_as() {
259
+ ffi::test::reset();
260
+ let checkbox = checkbox("Agree");
261
+ Application::mount(checkbox.clone());
262
+ let calls = ffi::test::take_calls();
263
+ let (root, indicator, gap_node, label_host) = checkbox.test_parts();
264
+ let root_ref = root.retained_node_ref();
265
+ let root_handle = root_ref.handle();
266
+ let indicator_handle = indicator.retained_node_ref().handle().raw();
267
+ let gap_handle = gap_node.retained_node_ref().handle().raw();
268
+ let label_host_handle = label_host.retained_node_ref().handle().raw();
269
+
270
+ let child_handles = child_handles_for_parent(&calls, root_handle.raw());
271
+ assert_eq!(
272
+ child_handles,
273
+ vec![indicator_handle, gap_handle, label_host_handle]
274
+ );
275
+
276
+ let label_child_handles = child_handles_for_parent(&calls, label_host_handle);
277
+ assert_eq!(label_child_handles.len(), 1);
278
+ }
279
+
280
+ #[test]
281
+ fn button_enabled_state_reflects_semantic_disabled_and_blocks_activation() {
282
+ ffi::test::reset();
283
+ let clicks = Rc::new(Cell::new(0));
284
+ let clicks_clone = clicks.clone();
285
+ let button = button("Action");
286
+ button.on_click(move |_event| clicks_clone.set(clicks_clone.get() + 1));
287
+ button.enabled(false);
288
+
289
+ Application::mount(button.clone());
290
+ let calls = ffi::test::take_calls();
291
+ let button_ref = button.retained_node_ref();
292
+ let button_handle = button_ref.handle();
293
+
294
+ assert!(calls.iter().any(|call| matches!(
295
+ call,
296
+ Call::SetInteractive { handle, interactive } if *handle == button_handle.raw() && !*interactive
297
+ )));
298
+ assert!(calls.iter().any(|call| matches!(
299
+ call,
300
+ Call::SetFocusable { handle, focusable, .. } if *handle == button_handle.raw() && !*focusable
301
+ )));
302
+ assert!(calls.iter().any(|call| matches!(
303
+ call,
304
+ Call::SetSemanticDisabled { handle, has_disabled, disabled }
305
+ if *handle == button_handle.raw() && *has_disabled && *disabled
306
+ )));
307
+
308
+ press_down(&button_ref, 1);
309
+ press_up(&button_ref);
310
+ focus(&button_ref);
311
+ key_event(KeyEventType::Down, "Enter", 0);
312
+ key_event(KeyEventType::Down, " ", 0);
313
+ assert_eq!(clicks.get(), 0);
314
+ assert!(calls.iter().any(|call| matches!(
315
+ call,
316
+ Call::SetLayerEffect { handle, opacity, .. }
317
+ if *handle == button_handle.raw() && (*opacity - 0.38).abs() < f32::EPSILON
318
+ )));
319
+ }
320
+
321
+ #[test]
322
+ fn dropdown_presenter_templates_create_once_and_are_stored_in_template_set() {
323
+ let field_created = Rc::new(Cell::new(0));
324
+ let chevron_created = Rc::new(Cell::new(0));
325
+ let option_created = Rc::new(Cell::new(0));
326
+ let templates = ControlTemplateSet {
327
+ dropdown_field: Some(Rc::new(TestDropdownFieldTemplate {
328
+ created: field_created.clone(),
329
+ })),
330
+ dropdown_chevron: Some(Rc::new(TestDropdownChevronTemplate {
331
+ created: chevron_created.clone(),
332
+ })),
333
+ dropdown_option_row: Some(Rc::new(TestDropdownOptionRowTemplate {
334
+ created: option_created.clone(),
335
+ })),
336
+ ..ControlTemplateSet::default()
337
+ };
338
+ use_control_templates(Some(templates));
339
+ let active = get_control_templates().expect("templates should be installed");
340
+ assert!(active.dropdown_field.is_some());
341
+ assert!(active.dropdown_chevron.is_some());
342
+ assert!(active.dropdown_option_row.is_some());
343
+ active
344
+ .dropdown_field
345
+ .as_ref()
346
+ .expect("field template")
347
+ .create(None);
348
+ active
349
+ .dropdown_chevron
350
+ .as_ref()
351
+ .expect("chevron template")
352
+ .create(None);
353
+ active
354
+ .dropdown_option_row
355
+ .as_ref()
356
+ .expect("option row template")
357
+ .create(None);
358
+ assert_eq!(field_created.get(), 1);
359
+ assert_eq!(chevron_created.get(), 1);
360
+ assert_eq!(option_created.get(), 1);
361
+ clear_control_templates();
362
+ }
363
+
364
+ #[test]
365
+ fn default_dropdown_field_presenter_retained_tree_matches_fui_as_shape() {
366
+ ffi::test::reset();
367
+ let presenter = create_default_dropdown_field_presenter(None);
368
+ Application::mount(presenter.root());
369
+ let calls = ffi::test::take_calls();
370
+ let root_handle = presenter.root().retained_node_ref().handle().raw();
371
+ let value_host_handle = presenter.value_host().retained_node_ref().handle().raw();
372
+ let value_node_handle = presenter.value_node().retained_node_ref().handle().raw();
373
+ let chevron_host_handle = presenter.chevron_host().retained_node_ref().handle().raw();
374
+ let child_handles = child_handles_for_parent(&calls, root_handle);
375
+ assert_eq!(child_handles, vec![value_host_handle, chevron_host_handle]);
376
+ let value_host_children = child_handles_for_parent(&calls, value_host_handle);
377
+ assert_eq!(value_host_children, vec![value_node_handle]);
378
+ assert_eq!(
379
+ node_type_for_handle(&calls, root_handle),
380
+ Some(NodeType::FlexBox)
381
+ );
382
+ assert_eq!(
383
+ node_type_for_handle(&calls, value_node_handle),
384
+ Some(NodeType::Text)
385
+ );
386
+ assert!(calls.iter().any(|call| matches!(
387
+ call,
388
+ Call::SetFillWidth { handle, fill } if *handle == value_node_handle && *fill
389
+ )));
390
+ assert!(calls.iter().any(|call| matches!(
391
+ call,
392
+ Call::SetFillHeight { handle, fill } if *handle == value_node_handle && *fill
393
+ )));
394
+ assert!(calls.iter().any(|call| matches!(
395
+ call,
396
+ Call::SetTextLimits { handle, max_chars, max_lines }
397
+ if *handle == value_node_handle && *max_chars == 0 && *max_lines == 1
398
+ )));
399
+ assert!(calls.iter().any(|call| matches!(
400
+ call,
401
+ Call::SetTextWrapping { handle, wrap } if *handle == value_node_handle && !*wrap
402
+ )));
403
+ assert!(calls.iter().any(|call| matches!(
404
+ call,
405
+ Call::SetTextOverflowFade { handle, horizontal, vertical }
406
+ if *handle == value_node_handle && *horizontal && !*vertical
407
+ )));
408
+ assert!(calls.iter().any(|call| matches!(
409
+ call,
410
+ Call::SetTextVerticalAlign { handle, align_enum }
411
+ if *handle == value_node_handle && *align_enum == TextVerticalAlign::Center as u32
412
+ )));
413
+ }
414
+
415
+ #[test]
416
+ fn default_dropdown_field_presenter_apply_matches_fui_as_style_contract() {
417
+ ffi::test::reset();
418
+ let presenter = create_default_dropdown_field_presenter(Some(
419
+ DropdownSizing::new()
420
+ .field_height(44.0)
421
+ .field_font_size(18.0)
422
+ .chevron_box_size(20.0),
423
+ ));
424
+ Application::mount(presenter.root());
425
+ let _ = ffi::test::take_calls();
426
+ let root_handle = presenter.root().retained_node_ref().handle().raw();
427
+ let value_node_handle = presenter.value_node().retained_node_ref().handle().raw();
428
+ let chevron_host_handle = presenter.chevron_host().retained_node_ref().handle().raw();
429
+ let theme = current_theme();
430
+
431
+ presenter.apply(
432
+ theme.clone(),
433
+ &DropdownFieldVisualState::new(true, true, true, true, "Selected"),
434
+ Some(
435
+ DropdownColors::new()
436
+ .background(0x111111FF)
437
+ .border(0x222222FF)
438
+ .text_primary(0x333333FF),
439
+ ),
440
+ );
441
+ let calls = ffi::test::take_calls();
442
+ assert!(calls.iter().any(|call| matches!(
443
+ call,
444
+ Call::SetHeight { handle, value, unit_enum }
445
+ if *handle == root_handle && (*value - 44.0).abs() < f32::EPSILON && *unit_enum == Unit::Pixel as u32
446
+ )));
447
+ assert!(calls.iter().any(|call| matches!(
448
+ call,
449
+ Call::SetBoxStyle { handle, border_width, border_color, .. }
450
+ if *handle == root_handle && (*border_width - 2.0).abs() < f32::EPSILON && *border_color == 0x222222FF
451
+ )));
452
+ assert!(calls.iter().any(|call| matches!(
453
+ call,
454
+ Call::SetBgColor { handle, color } if *handle == root_handle && *color == 0x111111FF
455
+ )));
456
+ assert!(calls.iter().any(|call| matches!(
457
+ call,
458
+ Call::SetLineHeight { handle, line_height }
459
+ if *handle == value_node_handle && (*line_height - 44.0).abs() < f32::EPSILON
460
+ )));
461
+ assert!(calls.iter().any(|call| matches!(
462
+ call,
463
+ Call::SetTextColor { handle, color } if *handle == value_node_handle && *color == 0x333333FF
464
+ )));
465
+ assert!(calls.iter().any(|call| matches!(
466
+ call,
467
+ Call::SetWidth { handle, value, unit_enum }
468
+ if *handle == chevron_host_handle && (*value - 20.0).abs() < f32::EPSILON && *unit_enum == Unit::Pixel as u32
469
+ )));
470
+ assert!(calls.iter().any(|call| matches!(
471
+ call,
472
+ Call::SetHeight { handle, value, unit_enum }
473
+ if *handle == chevron_host_handle && (*value - 20.0).abs() < f32::EPSILON && *unit_enum == Unit::Pixel as u32
474
+ )));
475
+ assert!(!calls.iter().any(|call| matches!(
476
+ call,
477
+ Call::SetText { handle, text } if *handle == value_node_handle && text == "Selected"
478
+ )));
479
+ }
480
+
481
+ #[test]
482
+ fn default_dropdown_chevron_presenter_retained_tree_matches_fui_as_shape() {
483
+ ffi::test::reset();
484
+ let presenter = create_default_dropdown_chevron_presenter(None);
485
+ Application::mount(presenter.root());
486
+ let calls = ffi::test::take_calls();
487
+ let root_handle = presenter.root().retained_node_ref().handle().raw();
488
+ let child_handles = child_handles_for_parent(&calls, root_handle);
489
+ assert_eq!(child_handles.len(), 1);
490
+ assert_eq!(
491
+ node_type_for_handle(&calls, child_handles[0]),
492
+ Some(NodeType::Svg)
493
+ );
494
+ }
495
+
496
+ #[test]
497
+ fn default_dropdown_chevron_presenter_apply_matches_fui_as_style_contract() {
498
+ assets::test_reset();
499
+ ffi::test::reset();
500
+ let presenter = create_default_dropdown_chevron_presenter(Some(
501
+ DropdownSizing::new().chevron_icon_size(18.0),
502
+ ));
503
+ Application::mount(presenter.root());
504
+ let calls = ffi::test::take_calls();
505
+ let root_handle = presenter.root().retained_node_ref().handle().raw();
506
+ let icon_handle = child_handles_for_parent(&calls, root_handle)[0];
507
+ let theme = current_theme();
508
+
509
+ presenter.apply(
510
+ theme.clone(),
511
+ DropdownChevronVisualState::new(true, true, true),
512
+ );
513
+ let calls = ffi::test::take_calls();
514
+ assert!(calls.iter().any(|call| matches!(
515
+ call,
516
+ Call::SetWidth { handle, value, unit_enum }
517
+ if *handle == icon_handle && (*value - 18.0).abs() < f32::EPSILON && *unit_enum == Unit::Pixel as u32
518
+ )));
519
+ assert!(calls.iter().any(|call| matches!(
520
+ call,
521
+ Call::SetHeight { handle, value, unit_enum }
522
+ if *handle == icon_handle && (*value - 18.0).abs() < f32::EPSILON && *unit_enum == Unit::Pixel as u32
523
+ )));
524
+ assert!(calls.iter().any(|call| matches!(
525
+ call,
526
+ Call::SetSvg { handle, tint_color, .. }
527
+ if *handle == icon_handle && *tint_color == theme.colors.text_primary
528
+ )));
529
+ drop(presenter);
530
+ assets::test_reset();
531
+ }
532
+
533
+ #[test]
534
+ fn default_dropdown_option_row_presenter_retained_tree_matches_fui_as_shape() {
535
+ ffi::test::reset();
536
+ let presenter = create_default_dropdown_option_row_presenter(None);
537
+ Application::mount(presenter.root());
538
+ let calls = ffi::test::take_calls();
539
+ let root_handle = presenter.root().retained_node_ref().handle().raw();
540
+ let label_handle = presenter.label_node().retained_node_ref().handle().raw();
541
+ let child_handles = child_handles_for_parent(&calls, root_handle);
542
+ assert_eq!(child_handles, vec![label_handle]);
543
+ assert_eq!(
544
+ node_type_for_handle(&calls, label_handle),
545
+ Some(NodeType::Text)
546
+ );
547
+ assert!(calls.iter().any(|call| matches!(
548
+ call,
549
+ Call::SetFillWidth { handle, fill } if *handle == label_handle && *fill
550
+ )));
551
+ assert!(calls.iter().any(|call| matches!(
552
+ call,
553
+ Call::SetFillHeight { handle, fill } if *handle == label_handle && *fill
554
+ )));
555
+ }
556
+
557
+ #[test]
558
+ fn default_dropdown_option_row_presenter_apply_matches_fui_as_style_contract() {
559
+ ffi::test::reset();
560
+ let presenter = create_default_dropdown_option_row_presenter(Some(
561
+ DropdownSizing::new()
562
+ .option_height(42.0)
563
+ .option_font_size(19.0),
564
+ ));
565
+ assert_eq!(presenter.metrics().height, 42.0);
566
+ assert_eq!(presenter.metrics().font_size, 19.0);
567
+ Application::mount(presenter.root());
568
+ let _ = ffi::test::take_calls();
569
+ let root_handle = presenter.root().retained_node_ref().handle().raw();
570
+ let label_handle = presenter.label_node().retained_node_ref().handle().raw();
571
+ let theme = current_theme();
572
+
573
+ presenter.apply(
574
+ theme.clone(),
575
+ DropdownOptionRowVisualState::new(true, true, true),
576
+ Some(
577
+ DropdownColors::new()
578
+ .accent(0xAABBCCFF)
579
+ .text_primary(0x102030FF),
580
+ ),
581
+ );
582
+ let calls = ffi::test::take_calls();
583
+ assert!(calls.iter().any(|call| matches!(
584
+ call,
585
+ Call::SetBgColor { handle, color }
586
+ if *handle == root_handle && *color == theme.context_menu.item.hover_background
587
+ )));
588
+ assert!(calls.iter().any(|call| matches!(
589
+ call,
590
+ Call::SetTextColor { handle, color } if *handle == label_handle && *color == 0xAABBCCFF
591
+ )));
592
+ assert!(calls.iter().any(|call| matches!(
593
+ call,
594
+ Call::SetTextColor { handle, color } if *handle == label_handle && *color != 0x102030FF
595
+ )));
596
+ }
597
+
598
+ #[test]
599
+ fn button_space_activates_on_key_up_only_and_blur_cancels() {
600
+ ffi::test::reset();
601
+ let clicks = Rc::new(Cell::new(0));
602
+ let clicks_clone = clicks.clone();
603
+ let button = button("Action");
604
+ button.on_click(move |_event| clicks_clone.set(clicks_clone.get() + 1));
605
+ Application::mount(button.clone());
606
+ let _ = ffi::test::take_calls();
607
+ let node_ref = button.retained_node_ref();
608
+
609
+ focus(&node_ref);
610
+ key_event(KeyEventType::Down, "Space", 0);
611
+ assert_eq!(clicks.get(), 0);
612
+ key_event(KeyEventType::Up, "Space", 0);
613
+ assert_eq!(clicks.get(), 1);
614
+
615
+ focus(&node_ref);
616
+ key_event(KeyEventType::Down, "Space", 0);
617
+ focus(&node_ref);
618
+ blur(&node_ref);
619
+ key_event(KeyEventType::Up, "Space", 0);
620
+ assert_eq!(clicks.get(), 1);
621
+ }
622
+
623
+ #[test]
624
+ fn button_repeated_key_down_arms_once_until_key_up() {
625
+ ffi::test::reset();
626
+ let clicks = Rc::new(Cell::new(0));
627
+ let clicks_clone = clicks.clone();
628
+ let button = button("Action");
629
+ button.on_click(move |_event| clicks_clone.set(clicks_clone.get() + 1));
630
+ Application::mount(button.clone());
631
+ let _ = ffi::test::take_calls();
632
+ let node_ref = button.retained_node_ref();
633
+
634
+ focus(&node_ref);
635
+ assert!(key_event(KeyEventType::Down, "Space", 0));
636
+ assert!(key_event(KeyEventType::Down, "Space", 0));
637
+ assert_eq!(clicks.get(), 0);
638
+ assert!(key_event(KeyEventType::Up, "Space", 0));
639
+ assert_eq!(clicks.get(), 1);
640
+ assert!(!key_event(KeyEventType::Up, "Space", 0));
641
+ assert_eq!(clicks.get(), 1);
642
+ }
643
+
644
+ #[test]
645
+ fn button_hover_and_pressed_visual_states_follow_fui_as_lifecycle() {
646
+ ffi::test::reset();
647
+ let theme = current_theme();
648
+ let button = button("Action");
649
+ Application::mount(button.clone());
650
+ let _ = ffi::test::take_calls();
651
+ let node_ref = button.retained_node_ref();
652
+ let button_handle = node_ref.handle().raw();
653
+
654
+ press_enter(&node_ref);
655
+ let calls = ffi::test::take_calls();
656
+ assert!(calls.iter().any(|call| matches!(
657
+ call,
658
+ Call::SetBgColor { handle, color }
659
+ if *handle == button_handle && *color == theme.colors.accent_hovered
660
+ )));
661
+
662
+ press_down(&node_ref, 1);
663
+ let calls = ffi::test::take_calls();
664
+ assert!(calls.iter().any(|call| matches!(
665
+ call,
666
+ Call::SetBgColor { handle, color }
667
+ if *handle == button_handle && *color == theme.colors.accent_pressed
668
+ )));
669
+
670
+ press_up(&node_ref);
671
+ let calls = ffi::test::take_calls();
672
+ assert!(calls.iter().any(|call| matches!(
673
+ call,
674
+ Call::SetBgColor { handle, color }
675
+ if *handle == button_handle && *color == theme.colors.accent_hovered
676
+ )));
677
+
678
+ press_leave(&node_ref);
679
+ let calls = ffi::test::take_calls();
680
+ assert!(calls.iter().any(|call| matches!(
681
+ call,
682
+ Call::SetBgColor { handle, color }
683
+ if *handle == button_handle && *color == theme.colors.accent
684
+ )));
685
+ }
686
+
687
+ #[test]
688
+ fn button_uses_app_level_template_when_no_local_template_is_set() {
689
+ ffi::test::reset();
690
+ clear_control_templates();
691
+ let created = Rc::new(Cell::new(0));
692
+ use_control_templates(Some(ControlTemplateSet {
693
+ button: Some(Rc::new(TestButtonTemplate {
694
+ created: created.clone(),
695
+ border_color: 0xAABBCCFF,
696
+ })),
697
+ ..Default::default()
698
+ }));
699
+
700
+ let button = button("Template action");
701
+ Application::mount(button.clone());
702
+ let calls = ffi::test::take_calls();
703
+ let button_handle = button.retained_node_ref().handle().raw();
704
+ assert_eq!(created.get(), 1);
705
+ assert!(calls.iter().any(|call| matches!(
706
+ call,
707
+ Call::SetBoxStyle { handle, border_width, border_color, .. }
708
+ if *handle == button_handle
709
+ && (*border_width - 3.0).abs() < f32::EPSILON
710
+ && *border_color == 0xAABBCCFF
711
+ )));
712
+
713
+ clear_control_templates();
714
+ }
715
+
716
+ #[test]
717
+ fn button_local_template_replaces_presenter_tree_and_retains_label_updates() {
718
+ ffi::test::reset();
719
+ clear_control_templates();
720
+ let button = button("Before");
721
+ Application::mount(button.clone());
722
+ let _ = ffi::test::take_calls();
723
+
724
+ let created = Rc::new(Cell::new(0));
725
+ button.template(Some(Rc::new(TestButtonTemplate {
726
+ created: created.clone(),
727
+ border_color: 0x445566FF,
728
+ })));
729
+ let calls = ffi::test::take_calls();
730
+ let button_handle = button.retained_node_ref().handle().raw();
731
+ assert_eq!(created.get(), 1);
732
+ assert!(calls.iter().any(|call| matches!(
733
+ call,
734
+ Call::NodeAddChild { parent, .. } if *parent == button_handle
735
+ )));
736
+ assert!(calls.iter().any(|call| matches!(
737
+ call,
738
+ Call::NodeRemoveChild { parent, .. } if *parent == button_handle
739
+ )));
740
+ assert!(calls.iter().any(|call| matches!(
741
+ call,
742
+ Call::SetBoxStyle { handle, border_width, border_color, .. }
743
+ if *handle == button_handle
744
+ && (*border_width - 3.0).abs() < f32::EPSILON
745
+ && *border_color == 0x445566FF
746
+ )));
747
+
748
+ button.text("After");
749
+ let calls = ffi::test::take_calls();
750
+ assert!(calls.iter().any(|call| matches!(
751
+ call,
752
+ Call::SetText { text, .. } if text == "After"
753
+ )));
754
+ }
755
+
756
+ #[test]
757
+ fn pressable_labeled_enabled_forwarding_blocks_activation() {
758
+ ffi::test::reset();
759
+ let checkbox_changes = Rc::new(Cell::new(0));
760
+ let checkbox_changes_clone = checkbox_changes.clone();
761
+ let checkbox = checkbox("Agree");
762
+ checkbox.on_changed(move |_event| {
763
+ checkbox_changes_clone.set(checkbox_changes_clone.get() + 1);
764
+ });
765
+ checkbox.enabled(false);
766
+ Application::mount(checkbox.clone());
767
+ let calls = ffi::test::take_calls();
768
+ let checkbox_ref = checkbox.retained_node_ref();
769
+ let checkbox_handle = checkbox_ref.handle();
770
+
771
+ assert!(calls.iter().any(|call| matches!(
772
+ call,
773
+ Call::SetSemanticDisabled { handle, has_disabled, disabled }
774
+ if *handle == checkbox_handle.raw() && *has_disabled && *disabled
775
+ )));
776
+ press_down(&checkbox_ref, 1);
777
+ press_up(&checkbox_ref);
778
+ focus(&checkbox_ref);
779
+ key_event(KeyEventType::Down, "Space", 0);
780
+ key_event(KeyEventType::Up, "Space", 0);
781
+ assert_eq!(checkbox_changes.get(), 0);
782
+
783
+ ffi::test::reset();
784
+ let radio_changes = Rc::new(Cell::new(0));
785
+ let radio_changes_clone = radio_changes.clone();
786
+ let radio = radio_button("Option");
787
+ radio.on_changed(move |_event| {
788
+ radio_changes_clone.set(radio_changes_clone.get() + 1);
789
+ });
790
+ radio.enabled(false);
791
+ Application::mount(radio.clone());
792
+ let radio_ref = radio.retained_node_ref();
793
+ press_down(&radio_ref, 1);
794
+ press_up(&radio_ref);
795
+ focus(&radio_ref);
796
+ key_event(KeyEventType::Down, "Space", 0);
797
+ key_event(KeyEventType::Up, "Space", 0);
798
+ assert_eq!(radio_changes.get(), 0);
799
+
800
+ ffi::test::reset();
801
+ let switch_changes = Rc::new(Cell::new(0));
802
+ let switch_changes_clone = switch_changes.clone();
803
+ let switch = switch("Toggle");
804
+ switch.on_changed(move |_event| {
805
+ switch_changes_clone.set(switch_changes_clone.get() + 1);
806
+ });
807
+ switch.enabled(false);
808
+ Application::mount(switch.clone());
809
+ let switch_ref = switch.retained_node_ref();
810
+ press_down(&switch_ref, 1);
811
+ press_up(&switch_ref);
812
+ focus(&switch_ref);
813
+ key_event(KeyEventType::Down, "Space", 0);
814
+ key_event(KeyEventType::Up, "Space", 0);
815
+ assert_eq!(switch_changes.get(), 0);
816
+ }
817
+
818
+ #[test]
819
+ fn focus_chrome_tracks_theme_changes() {
820
+ ffi::test::reset();
821
+ use_custom_theme(generate_theme(true, 0x112233FF));
822
+ let theme = current_theme();
823
+
824
+ let button = button("Action");
825
+ Application::mount(button.clone());
826
+ let _ = ffi::test::take_calls();
827
+ let button_handle = button.retained_node_ref().handle().raw();
828
+
829
+ event::__fui_on_focus_changed(button_handle, true);
830
+ let calls = ffi::test::take_calls();
831
+ assert!(calls.iter().any(|call| matches!(
832
+ call,
833
+ Call::SetBoxStyle { handle, border_width, border_color, .. }
834
+ if *handle != button_handle
835
+ && *border_width == 2.0
836
+ && *border_color == theme.colors.focus_ring
837
+ )));
838
+
839
+ use_custom_theme(generate_theme(true, 0x445566FF));
840
+ let calls = ffi::test::take_calls();
841
+ assert!(calls.iter().any(|call| matches!(
842
+ call,
843
+ Call::SetBoxStyle { handle, border_width, border_color, .. }
844
+ if *handle != button_handle
845
+ && *border_width == 2.0
846
+ && *border_color == 0x445566FF
847
+ )));
848
+
849
+ event::__fui_on_focus_changed(button_handle, false);
850
+ let calls = ffi::test::take_calls();
851
+ assert!(calls
852
+ .iter()
853
+ .any(|call| matches!(call, Call::NodeRemoveChild { .. })));
854
+
855
+ use_system_theme();
856
+ }
857
+
858
+ #[test]
859
+ fn pointer_focus_hides_focus_adorner_until_keyboard_input() {
860
+ ffi::test::reset();
861
+ focus_visibility::reset_keyboard_focus_visibility();
862
+ let button = button("Action");
863
+ Application::mount(button.clone());
864
+ let _ = ffi::test::take_calls();
865
+ let button_handle = button.retained_node_ref().handle().raw();
866
+
867
+ event::__fui_on_focus_changed(button_handle, true);
868
+ let calls = ffi::test::take_calls();
869
+ assert!(calls
870
+ .iter()
871
+ .any(|call| matches!(call, Call::NodeAddChild { .. })));
872
+
873
+ event::__fui_on_pointer_event_with_metadata(
874
+ PointerEventType::Down as u32,
875
+ button_handle,
876
+ 10.0,
877
+ 10.0,
878
+ 0,
879
+ 1,
880
+ PointerType::Mouse as u32,
881
+ 0,
882
+ 1,
883
+ 0.0,
884
+ 0.0,
885
+ 0.0,
886
+ 1,
887
+ );
888
+ let calls = ffi::test::take_calls();
889
+ assert!(calls
890
+ .iter()
891
+ .any(|call| matches!(call, Call::NodeRemoveChild { .. })));
892
+
893
+ key_event(KeyEventType::Down, "Tab", 0);
894
+ let calls = ffi::test::take_calls();
895
+ assert!(calls
896
+ .iter()
897
+ .any(|call| matches!(call, Call::NodeAddChild { .. })));
898
+ }
899
+
900
+ #[test]
901
+ fn pressable_focus_visibility_signal_re_shows_focus_adorner() {
902
+ ffi::test::reset();
903
+ focus_visibility::reset_keyboard_focus_visibility();
904
+ let checkbox = checkbox("Agree");
905
+ Application::mount(checkbox.clone());
906
+ let _ = ffi::test::take_calls();
907
+ let node_ref = checkbox.retained_node_ref();
908
+ let handle = node_ref.handle().raw();
909
+
910
+ event::__fui_on_focus_changed(handle, true);
911
+ let calls = ffi::test::take_calls();
912
+ assert!(calls
913
+ .iter()
914
+ .any(|call| matches!(call, Call::NodeAddChild { .. })));
915
+
916
+ focus_visibility::show_keyboard_focus_for_pointer_event(PointerEventType::Down);
917
+ let calls = ffi::test::take_calls();
918
+ assert!(calls
919
+ .iter()
920
+ .any(|call| matches!(call, Call::NodeRemoveChild { .. })));
921
+
922
+ focus_visibility::show_keyboard_focus_for_key_event(KeyEventType::Down, "Tab", 0);
923
+ let calls = ffi::test::take_calls();
924
+ assert!(calls
925
+ .iter()
926
+ .any(|call| matches!(call, Call::NodeAddChild { .. })));
927
+ }
928
+
929
+ #[test]
930
+ fn pointer_leave_clears_pending_activation() {
931
+ ffi::test::reset();
932
+ let checkbox = checkbox("Agree");
933
+ let clicks = Rc::new(Cell::new(0));
934
+ let clicks_clone = clicks.clone();
935
+ checkbox.on_changed(move |_event| clicks_clone.set(clicks_clone.get() + 1));
936
+ Application::mount(checkbox.clone());
937
+ let node_ref = checkbox.retained_node_ref();
938
+
939
+ press_down(&node_ref, 1);
940
+ press_leave(&node_ref);
941
+ press_up(&node_ref);
942
+ assert_eq!(clicks.get(), 0);
943
+ }
944
+
945
+ #[test]
946
+ fn space_activates_on_key_up_only_and_blur_clears_key_state() {
947
+ ffi::test::reset();
948
+ let changes = Rc::new(Cell::new(0));
949
+ let changes_clone = changes.clone();
950
+ let checkbox = checkbox("Agree");
951
+ checkbox.on_changed(move |_event| changes_clone.set(changes_clone.get() + 1));
952
+ Application::mount(checkbox.clone());
953
+ let calls = ffi::test::take_calls();
954
+ let node_ref = checkbox.retained_node_ref();
955
+ let handle = node_ref.handle();
956
+
957
+ focus(&node_ref);
958
+ key_event(KeyEventType::Down, "Space", 0);
959
+ assert_eq!(changes.get(), 0);
960
+ key_event(KeyEventType::Up, "Space", 0);
961
+ assert_eq!(changes.get(), 1);
962
+
963
+ focus(&node_ref);
964
+ key_event(KeyEventType::Down, "Space", 0);
965
+ blur(&node_ref);
966
+ key_event(KeyEventType::Up, "Space", 0);
967
+ assert_eq!(changes.get(), 1);
968
+
969
+ assert!(calls.iter().any(|call| matches!(
970
+ call,
971
+ Call::SetSemanticRole { role_enum, .. } if *role_enum == SemanticRole::Checkbox as u32
972
+ )));
973
+ assert!(calls.iter().any(|call| matches!(
974
+ call,
975
+ Call::SetSemanticChecked { handle: checked_handle, checked_state_enum }
976
+ if *checked_handle == handle.raw() && *checked_state_enum == SemanticCheckedState::False as u32
977
+ )));
978
+ Application::unmount();
979
+ }
980
+
981
+ #[test]
982
+ fn checkbox_tri_state_cycles_false_true_mixed_false() {
983
+ ffi::test::reset();
984
+ let last_state = Rc::new(Cell::new(CheckState::False));
985
+ let last_checked = Rc::new(Cell::new(false));
986
+ let last_state_clone = last_state.clone();
987
+ let last_checked_clone = last_checked.clone();
988
+ let checkbox = checkbox("Agree");
989
+ checkbox.tri_state(true).on_changed(move |event| {
990
+ last_state_clone.set(event.state);
991
+ last_checked_clone.set(event.checked);
992
+ });
993
+ Application::mount(checkbox.clone());
994
+ let node_ref = checkbox.retained_node_ref();
995
+
996
+ press_down(&node_ref, 1);
997
+ press_up(&node_ref);
998
+ assert_eq!(last_state.get(), CheckState::True);
999
+ assert!(last_checked.get());
1000
+
1001
+ press_down(&node_ref, 1);
1002
+ press_up(&node_ref);
1003
+ assert_eq!(last_state.get(), CheckState::Mixed);
1004
+ assert!(!last_checked.get());
1005
+
1006
+ press_down(&node_ref, 1);
1007
+ press_up(&node_ref);
1008
+ assert_eq!(last_state.get(), CheckState::False);
1009
+ assert!(!last_checked.get());
1010
+ Application::unmount();
1011
+ }
1012
+
1013
+ #[test]
1014
+ fn standalone_radio_sets_checked_once_and_does_not_toggle_back() {
1015
+ ffi::test::reset();
1016
+ let changes = Rc::new(Cell::new(0));
1017
+ let last_checked = Rc::new(Cell::new(false));
1018
+ let changes_clone = changes.clone();
1019
+ let last_checked_clone = last_checked.clone();
1020
+ let radio = radio_button("solo");
1021
+ radio.on_changed(move |event| {
1022
+ changes_clone.set(changes_clone.get() + 1);
1023
+ last_checked_clone.set(event.checked);
1024
+ });
1025
+ Application::mount(radio.clone());
1026
+ let node_ref = radio.retained_node_ref();
1027
+
1028
+ press_down(&node_ref, 1);
1029
+ press_up(&node_ref);
1030
+ assert!(radio.is_checked());
1031
+ assert_eq!(changes.get(), 1);
1032
+ assert!(last_checked.get());
1033
+
1034
+ press_down(&node_ref, 1);
1035
+ press_up(&node_ref);
1036
+ assert!(radio.is_checked());
1037
+ assert_eq!(changes.get(), 1);
1038
+ }
1039
+
1040
+ #[test]
1041
+ fn radio_group_add_option_and_selected_value_follow_selection() {
1042
+ ffi::test::reset();
1043
+ let group = radio_group();
1044
+ let alpha = group.add_option("alpha", "Alpha");
1045
+ let beta = group.add_option("beta", "Beta");
1046
+ Application::mount(group.clone());
1047
+
1048
+ assert_eq!(group.selected_index(), -1);
1049
+ assert_eq!(group.selected_value(), "");
1050
+
1051
+ let beta_ref = beta.retained_node_ref();
1052
+ press_down(&beta_ref, 1);
1053
+ press_up(&beta_ref);
1054
+ assert!(!alpha.is_checked());
1055
+ assert!(beta.is_checked());
1056
+ assert_eq!(group.selected_index(), 1);
1057
+ assert_eq!(group.selected_value(), "beta");
1058
+ }
1059
+
1060
+ #[test]
1061
+ fn radio_group_state_survives_after_wrapper_is_dropped() {
1062
+ ffi::test::reset();
1063
+ let last_value = Rc::new(RefCell::new(String::new()));
1064
+ let last_value_clone = last_value.clone();
1065
+ let alpha;
1066
+ let beta;
1067
+
1068
+ {
1069
+ let group = radio_group();
1070
+ alpha = group.add_option("alpha", "Alpha");
1071
+ beta = group.add_option("beta", "Beta");
1072
+ group.select_index(0);
1073
+ group.on_changed(move |event| {
1074
+ *last_value_clone.borrow_mut() = event.value;
1075
+ });
1076
+ Application::mount(group.clone());
1077
+ }
1078
+
1079
+ let beta_ref = beta.retained_node_ref();
1080
+ press_down(&beta_ref, 1);
1081
+ press_up(&beta_ref);
1082
+
1083
+ assert!(!alpha.is_checked());
1084
+ assert!(beta.is_checked());
1085
+ assert_eq!(last_value.borrow().as_str(), "beta");
1086
+ }
1087
+
1088
+ #[test]
1089
+ fn radio_group_reselection_does_not_duplicate_events() {
1090
+ ffi::test::reset();
1091
+ let changes = Rc::new(Cell::new(0));
1092
+ let changes_clone = changes.clone();
1093
+ let group = radio_group();
1094
+ let alpha = group.add_option("alpha", "Alpha");
1095
+ group.add_option("beta", "Beta");
1096
+ group.on_changed(move |_event| {
1097
+ changes_clone.set(changes_clone.get() + 1);
1098
+ });
1099
+ Application::mount(group.clone());
1100
+
1101
+ let alpha_ref = alpha.retained_node_ref();
1102
+ press_down(&alpha_ref, 1);
1103
+ press_up(&alpha_ref);
1104
+ press_down(&alpha_ref, 1);
1105
+ press_up(&alpha_ref);
1106
+ assert_eq!(changes.get(), 1);
1107
+ assert_eq!(group.selected_value(), "alpha");
1108
+ }
1109
+
1110
+ #[test]
1111
+ fn radio_group_arrow_keys_wrap_and_skip_disabled_radios() {
1112
+ ffi::test::reset();
1113
+ let group = radio_group();
1114
+ let alpha = group.add_option("alpha", "Alpha");
1115
+ let beta = group.add_option("beta", "Beta");
1116
+ let gamma = group.add_option("gamma", "Gamma");
1117
+ beta.enabled(false);
1118
+ group.select_index(0);
1119
+ Application::mount(group.clone());
1120
+
1121
+ focus(&alpha.retained_node_ref());
1122
+ let gamma_handle = gamma.retained_node_ref().handle().raw();
1123
+ let _ = ffi::test::take_calls();
1124
+ assert!(key_event(KeyEventType::Down, "ArrowRight", 0));
1125
+ assert_eq!(group.selected_value(), "gamma");
1126
+ let calls = ffi::test::take_calls();
1127
+ assert!(calls.iter().any(|call| matches!(
1128
+ call,
1129
+ Call::RequestFocus { handle } if *handle == gamma_handle
1130
+ )));
1131
+
1132
+ focus(&gamma.retained_node_ref());
1133
+ assert!(key_event(KeyEventType::Down, "ArrowRight", 0));
1134
+ assert_eq!(group.selected_value(), "alpha");
1135
+
1136
+ focus(&alpha.retained_node_ref());
1137
+ assert!(key_event(KeyEventType::Down, "ArrowLeft", 0));
1138
+ assert_eq!(group.selected_value(), "gamma");
1139
+ }
1140
+
1141
+ #[test]
1142
+ fn radio_group_home_end_and_clear_follow_fui_as_behavior() {
1143
+ ffi::test::reset();
1144
+ let group = radio_group();
1145
+ let alpha = group.add_option("alpha", "Alpha");
1146
+ let beta = group.add_option("beta", "Beta");
1147
+ let gamma = group.add_option("gamma", "Gamma");
1148
+ beta.enabled(false);
1149
+ Application::mount(group.clone());
1150
+
1151
+ focus(&gamma.retained_node_ref());
1152
+ let alpha_handle = alpha.retained_node_ref().handle().raw();
1153
+ let _ = ffi::test::take_calls();
1154
+ assert!(key_event(KeyEventType::Down, "Home", 0));
1155
+ assert_eq!(group.selected_value(), "alpha");
1156
+ let calls = ffi::test::take_calls();
1157
+ assert!(calls.iter().any(|call| matches!(
1158
+ call,
1159
+ Call::RequestFocus { handle } if *handle == alpha_handle
1160
+ )));
1161
+
1162
+ focus(&alpha.retained_node_ref());
1163
+ let gamma_handle = gamma.retained_node_ref().handle().raw();
1164
+ let _ = ffi::test::take_calls();
1165
+ assert!(key_event(KeyEventType::Down, "End", 0));
1166
+ assert_eq!(group.selected_value(), "gamma");
1167
+ let calls = ffi::test::take_calls();
1168
+ assert!(calls.iter().any(|call| matches!(
1169
+ call,
1170
+ Call::RequestFocus { handle } if *handle == gamma_handle
1171
+ )));
1172
+
1173
+ group.select_index(-1);
1174
+ assert_eq!(group.selected_index(), -1);
1175
+ assert_eq!(group.selected_value(), "");
1176
+ assert!(!alpha.is_checked());
1177
+ assert!(!gamma.is_checked());
1178
+ }
1179
+
1180
+ #[test]
1181
+ fn radio_group_changed_event_emits_selected_value() {
1182
+ ffi::test::reset();
1183
+ let last_value = Rc::new(RefCell::new(String::new()));
1184
+ let last_value_clone = last_value.clone();
1185
+ let group = radio_group();
1186
+ group.add_option("alpha", "Alpha");
1187
+ let beta = group.add_option("beta", "Beta");
1188
+ group.on_changed(move |event| {
1189
+ *last_value_clone.borrow_mut() = event.value;
1190
+ });
1191
+ Application::mount(group.clone());
1192
+
1193
+ let beta_ref = beta.retained_node_ref();
1194
+ press_down(&beta_ref, 1);
1195
+ press_up(&beta_ref);
1196
+ assert_eq!(last_value.borrow().as_str(), "beta");
1197
+
1198
+ group.select_index(-1);
1199
+ assert_eq!(last_value.borrow().as_str(), "");
1200
+ }
1201
+
1202
+ #[test]
1203
+ fn switch_pointer_activation_toggles_and_announces() {
1204
+ ffi::test::reset();
1205
+ let changes = Rc::new(Cell::new(0));
1206
+ let last_checked = Rc::new(Cell::new(false));
1207
+ let changes_clone = changes.clone();
1208
+ let last_checked_clone = last_checked.clone();
1209
+ let toggle = switch("Toggle");
1210
+ toggle.on_changed(move |event| {
1211
+ changes_clone.set(changes_clone.get() + 1);
1212
+ last_checked_clone.set(event.checked);
1213
+ });
1214
+ Application::mount(toggle.clone());
1215
+ let toggle_ref = toggle.retained_node_ref();
1216
+ let handle = toggle_ref.handle().raw();
1217
+
1218
+ press_down(&toggle_ref, 1);
1219
+ press_up(&toggle_ref);
1220
+ assert!(toggle.is_checked());
1221
+ assert_eq!(changes.get(), 1);
1222
+ assert!(last_checked.get());
1223
+
1224
+ let calls = ffi::test::take_calls();
1225
+ assert!(calls.iter().any(|call| matches!(
1226
+ call,
1227
+ Call::SetSemanticChecked { handle: checked_handle, checked_state_enum }
1228
+ if *checked_handle == handle && *checked_state_enum == SemanticCheckedState::True as u32
1229
+ )));
1230
+ assert!(calls.iter().any(|call| matches!(
1231
+ call,
1232
+ Call::RequestSemanticAnnouncement { handle: announced } if *announced == handle
1233
+ )));
1234
+ }
1235
+
1236
+ #[test]
1237
+ fn switch_space_key_activation_toggles() {
1238
+ ffi::test::reset();
1239
+ let changes = Rc::new(Cell::new(0));
1240
+ let changes_clone = changes.clone();
1241
+ let toggle = switch("Toggle");
1242
+ toggle.on_changed(move |_event| {
1243
+ changes_clone.set(changes_clone.get() + 1);
1244
+ });
1245
+ Application::mount(toggle.clone());
1246
+ let toggle_ref = toggle.retained_node_ref();
1247
+
1248
+ focus(&toggle_ref);
1249
+ assert!(key_event(KeyEventType::Down, "Space", 0));
1250
+ assert!(key_event(KeyEventType::Up, "Space", 0));
1251
+
1252
+ assert!(toggle.is_checked());
1253
+ assert_eq!(changes.get(), 1);
1254
+ }
1255
+
1256
+ #[test]
1257
+ fn switch_programmatic_check_emits_without_semantic_announcement() {
1258
+ ffi::test::reset();
1259
+ let changes = Rc::new(Cell::new(0));
1260
+ let changes_clone = changes.clone();
1261
+ let toggle = switch("Toggle");
1262
+ toggle.on_changed(move |_event| {
1263
+ changes_clone.set(changes_clone.get() + 1);
1264
+ });
1265
+
1266
+ toggle.check(true);
1267
+
1268
+ assert!(toggle.is_checked());
1269
+ assert_eq!(changes.get(), 1);
1270
+ let calls = ffi::test::take_calls();
1271
+ assert!(!calls
1272
+ .iter()
1273
+ .any(|call| matches!(call, Call::RequestSemanticAnnouncement { .. })));
1274
+ }
1275
+
1276
+ #[test]
1277
+ fn checkbox_mixed_is_ignored_or_coerced_when_tri_state_disabled() {
1278
+ ffi::test::reset();
1279
+ let changes = Rc::new(Cell::new(0));
1280
+ let changes_clone = changes.clone();
1281
+ let checkbox = checkbox("Agree");
1282
+ checkbox.on_changed(move |_event| changes_clone.set(changes_clone.get() + 1));
1283
+
1284
+ checkbox.mixed(true);
1285
+ assert_eq!(checkbox.checked_state(), CheckState::False);
1286
+ checkbox.check_state(CheckState::Mixed);
1287
+ assert_eq!(checkbox.checked_state(), CheckState::False);
1288
+ assert_eq!(changes.get(), 0);
1289
+
1290
+ checkbox.tri_state(true).mixed(true);
1291
+ assert_eq!(checkbox.checked_state(), CheckState::Mixed);
1292
+ assert_eq!(changes.get(), 1);
1293
+
1294
+ checkbox.tri_state(false);
1295
+ assert_eq!(checkbox.checked_state(), CheckState::False);
1296
+ assert_eq!(changes.get(), 2);
1297
+ }
1298
+
1299
+ #[derive(Clone)]
1300
+ struct TestCheckboxIndicatorPresenter {
1301
+ root: FlexBox,
1302
+ applied_checked_state: Rc<Cell<SemanticCheckedState>>,
1303
+ applied_state: Rc<RefCell<Option<CheckboxIndicatorVisualState>>>,
1304
+ applied_colors: Rc<Cell<Option<LabeledControlColors>>>,
1305
+ }
1306
+
1307
+ impl TestCheckboxIndicatorPresenter {
1308
+ fn new(
1309
+ applied_checked_state: Rc<Cell<SemanticCheckedState>>,
1310
+ applied_state: Rc<RefCell<Option<CheckboxIndicatorVisualState>>>,
1311
+ applied_colors: Rc<Cell<Option<LabeledControlColors>>>,
1312
+ ) -> Self {
1313
+ Self {
1314
+ root: flex_box(),
1315
+ applied_checked_state,
1316
+ applied_state,
1317
+ applied_colors,
1318
+ }
1319
+ }
1320
+ }
1321
+
1322
+ impl PressableIndicatorPresenter for TestCheckboxIndicatorPresenter {
1323
+ fn root(&self) -> FlexBox {
1324
+ self.root.clone()
1325
+ }
1326
+
1327
+ fn metrics(&self) -> PressableIndicatorMetrics {
1328
+ PressableIndicatorMetrics::new(20.0, 20.0)
1329
+ }
1330
+ }
1331
+
1332
+ impl CheckboxIndicatorPresenter for TestCheckboxIndicatorPresenter {
1333
+ fn apply(
1334
+ &self,
1335
+ _theme: crate::theme::Theme,
1336
+ state: CheckboxIndicatorVisualState,
1337
+ colors: Option<LabeledControlColors>,
1338
+ ) {
1339
+ self.applied_checked_state.set(state.checked_state);
1340
+ *self.applied_state.borrow_mut() = Some(state);
1341
+ self.applied_colors.set(colors);
1342
+ }
1343
+ }
1344
+
1345
+ struct TestCheckboxIndicatorTemplate {
1346
+ created: Rc<Cell<u32>>,
1347
+ applied_checked_state: Rc<Cell<SemanticCheckedState>>,
1348
+ applied_state: Rc<RefCell<Option<CheckboxIndicatorVisualState>>>,
1349
+ applied_colors: Rc<Cell<Option<LabeledControlColors>>>,
1350
+ }
1351
+
1352
+ impl CheckboxIndicatorTemplate for TestCheckboxIndicatorTemplate {
1353
+ fn create(&self, _sizing: Option<LabeledControlSizing>) -> Rc<dyn CheckboxIndicatorPresenter> {
1354
+ self.created.set(self.created.get() + 1);
1355
+ Rc::new(TestCheckboxIndicatorPresenter::new(
1356
+ self.applied_checked_state.clone(),
1357
+ self.applied_state.clone(),
1358
+ self.applied_colors.clone(),
1359
+ ))
1360
+ }
1361
+ }
1362
+
1363
+ #[derive(Clone)]
1364
+ struct TestRadioIndicatorPresenter {
1365
+ root: FlexBox,
1366
+ checked: Rc<Cell<bool>>,
1367
+ }
1368
+
1369
+ impl TestRadioIndicatorPresenter {
1370
+ fn new(checked: Rc<Cell<bool>>) -> Self {
1371
+ Self {
1372
+ root: flex_box(),
1373
+ checked,
1374
+ }
1375
+ }
1376
+ }
1377
+
1378
+ impl PressableIndicatorPresenter for TestRadioIndicatorPresenter {
1379
+ fn root(&self) -> FlexBox {
1380
+ self.root.clone()
1381
+ }
1382
+
1383
+ fn metrics(&self) -> PressableIndicatorMetrics {
1384
+ PressableIndicatorMetrics::new(20.0, 20.0)
1385
+ }
1386
+ }
1387
+
1388
+ impl RadioIndicatorPresenter for TestRadioIndicatorPresenter {
1389
+ fn apply(
1390
+ &self,
1391
+ _theme: crate::theme::Theme,
1392
+ state: RadioIndicatorVisualState,
1393
+ _colors: Option<LabeledControlColors>,
1394
+ ) {
1395
+ self.checked.set(state.checked);
1396
+ }
1397
+ }
1398
+
1399
+ struct TestRadioIndicatorTemplate {
1400
+ created: Rc<Cell<u32>>,
1401
+ checked: Rc<Cell<bool>>,
1402
+ }
1403
+
1404
+ impl RadioIndicatorTemplate for TestRadioIndicatorTemplate {
1405
+ fn create(&self, _sizing: Option<LabeledControlSizing>) -> Rc<dyn RadioIndicatorPresenter> {
1406
+ self.created.set(self.created.get() + 1);
1407
+ Rc::new(TestRadioIndicatorPresenter::new(self.checked.clone()))
1408
+ }
1409
+ }
1410
+
1411
+ #[derive(Clone)]
1412
+ struct TestSwitchIndicatorPresenter {
1413
+ root: FlexBox,
1414
+ checked: Rc<Cell<bool>>,
1415
+ }
1416
+
1417
+ impl TestSwitchIndicatorPresenter {
1418
+ fn new(checked: Rc<Cell<bool>>) -> Self {
1419
+ Self {
1420
+ root: flex_box(),
1421
+ checked,
1422
+ }
1423
+ }
1424
+ }
1425
+
1426
+ impl PressableIndicatorPresenter for TestSwitchIndicatorPresenter {
1427
+ fn root(&self) -> FlexBox {
1428
+ self.root.clone()
1429
+ }
1430
+
1431
+ fn metrics(&self) -> PressableIndicatorMetrics {
1432
+ PressableIndicatorMetrics::new(44.0, 26.0)
1433
+ }
1434
+ }
1435
+
1436
+ impl SwitchIndicatorPresenter for TestSwitchIndicatorPresenter {
1437
+ fn apply(
1438
+ &self,
1439
+ _theme: crate::theme::Theme,
1440
+ state: SwitchIndicatorVisualState,
1441
+ _colors: Option<LabeledControlColors>,
1442
+ ) {
1443
+ self.checked.set(state.checked);
1444
+ }
1445
+ }
1446
+
1447
+ struct TestSwitchIndicatorTemplate {
1448
+ created: Rc<Cell<u32>>,
1449
+ checked: Rc<Cell<bool>>,
1450
+ }
1451
+
1452
+ impl SwitchIndicatorTemplate for TestSwitchIndicatorTemplate {
1453
+ fn create(&self, _sizing: Option<LabeledControlSizing>) -> Rc<dyn SwitchIndicatorPresenter> {
1454
+ self.created.set(self.created.get() + 1);
1455
+ Rc::new(TestSwitchIndicatorPresenter::new(self.checked.clone()))
1456
+ }
1457
+ }
1458
+
1459
+ #[derive(Clone)]
1460
+ struct TestSliderPresenter {
1461
+ root: FlexBox,
1462
+ metrics: SliderPresenterMetrics,
1463
+ last_layout_state: Rc<RefCell<Option<SliderVisualState>>>,
1464
+ last_layout_length: Rc<Cell<f32>>,
1465
+ last_apply_state: Rc<RefCell<Option<SliderVisualState>>>,
1466
+ last_apply_colors: Rc<Cell<Option<SliderColors>>>,
1467
+ }
1468
+
1469
+ impl TestSliderPresenter {
1470
+ fn new(
1471
+ metrics: SliderPresenterMetrics,
1472
+ last_layout_state: Rc<RefCell<Option<SliderVisualState>>>,
1473
+ last_layout_length: Rc<Cell<f32>>,
1474
+ last_apply_state: Rc<RefCell<Option<SliderVisualState>>>,
1475
+ last_apply_colors: Rc<Cell<Option<SliderColors>>>,
1476
+ ) -> Self {
1477
+ Self {
1478
+ root: flex_box(),
1479
+ metrics,
1480
+ last_layout_state,
1481
+ last_layout_length,
1482
+ last_apply_state,
1483
+ last_apply_colors,
1484
+ }
1485
+ }
1486
+ }
1487
+
1488
+ impl SliderPresenter for TestSliderPresenter {
1489
+ fn root(&self) -> FlexBox {
1490
+ self.root.clone()
1491
+ }
1492
+
1493
+ fn metrics(&self) -> SliderPresenterMetrics {
1494
+ self.metrics
1495
+ }
1496
+
1497
+ fn layout(&self, state: SliderVisualState, length: f32) {
1498
+ *self.last_layout_state.borrow_mut() = Some(state);
1499
+ self.last_layout_length.set(length);
1500
+ }
1501
+
1502
+ fn apply(
1503
+ &self,
1504
+ _theme: crate::theme::Theme,
1505
+ state: SliderVisualState,
1506
+ colors: Option<SliderColors>,
1507
+ ) {
1508
+ *self.last_apply_state.borrow_mut() = Some(state);
1509
+ self.last_apply_colors.set(colors);
1510
+ }
1511
+ }
1512
+
1513
+ struct TestSliderTemplate {
1514
+ created: Rc<Cell<u32>>,
1515
+ metrics: SliderPresenterMetrics,
1516
+ last_layout_state: Rc<RefCell<Option<SliderVisualState>>>,
1517
+ last_layout_length: Rc<Cell<f32>>,
1518
+ last_apply_state: Rc<RefCell<Option<SliderVisualState>>>,
1519
+ last_apply_colors: Rc<Cell<Option<SliderColors>>>,
1520
+ }
1521
+
1522
+ impl SliderTemplate for TestSliderTemplate {
1523
+ fn create(&self, _sizing: Option<SliderSizing>) -> Rc<dyn SliderPresenter> {
1524
+ self.created.set(self.created.get() + 1);
1525
+ Rc::new(TestSliderPresenter::new(
1526
+ self.metrics,
1527
+ self.last_layout_state.clone(),
1528
+ self.last_layout_length.clone(),
1529
+ self.last_apply_state.clone(),
1530
+ self.last_apply_colors.clone(),
1531
+ ))
1532
+ }
1533
+ }
1534
+
1535
+ #[test]
1536
+ fn checkbox_template_and_sizing_follow_fui_as_surface() {
1537
+ ffi::test::reset();
1538
+ clear_control_templates();
1539
+ let created = Rc::new(Cell::new(0));
1540
+ let applied_checked_state = Rc::new(Cell::new(SemanticCheckedState::False));
1541
+ let applied_state = Rc::new(RefCell::new(None));
1542
+ let applied_colors = Rc::new(Cell::new(None));
1543
+ let templates = ControlTemplateSet {
1544
+ checkbox_indicator: Some(Rc::new(TestCheckboxIndicatorTemplate {
1545
+ created: created.clone(),
1546
+ applied_checked_state: applied_checked_state.clone(),
1547
+ applied_state: applied_state.clone(),
1548
+ applied_colors: applied_colors.clone(),
1549
+ })),
1550
+ ..Default::default()
1551
+ };
1552
+ use_control_templates(Some(templates));
1553
+ let checkbox = checkbox("Agree");
1554
+
1555
+ checkbox.sizing(Some(LabeledControlSizing::new().label_font_size(21.0)));
1556
+ checkbox.check(true);
1557
+
1558
+ assert_eq!(created.get(), 2);
1559
+ assert_eq!(applied_checked_state.get(), SemanticCheckedState::True);
1560
+ clear_control_templates();
1561
+ }
1562
+
1563
+ #[test]
1564
+ fn checkbox_local_template_replaces_indicator_before_mount() {
1565
+ ffi::test::reset();
1566
+ clear_control_templates();
1567
+ let created = Rc::new(Cell::new(0));
1568
+ let applied_checked_state = Rc::new(Cell::new(SemanticCheckedState::False));
1569
+ let applied_state = Rc::new(RefCell::new(None));
1570
+ let applied_colors = Rc::new(Cell::new(None));
1571
+ let checkbox = checkbox("Local template");
1572
+ checkbox
1573
+ .template(Some(Rc::new(TestCheckboxIndicatorTemplate {
1574
+ created: created.clone(),
1575
+ applied_checked_state: applied_checked_state.clone(),
1576
+ applied_state: applied_state.clone(),
1577
+ applied_colors: applied_colors.clone(),
1578
+ })))
1579
+ .check(true);
1580
+
1581
+ Application::mount(checkbox.clone());
1582
+
1583
+ assert_eq!(created.get(), 1);
1584
+ assert_eq!(applied_checked_state.get(), SemanticCheckedState::True);
1585
+ assert!(applied_state.borrow().is_some());
1586
+ }
1587
+
1588
+ #[test]
1589
+ fn checkbox_presenter_receives_fui_as_visual_state_and_colors() {
1590
+ ffi::test::reset();
1591
+ clear_control_templates();
1592
+ let created = Rc::new(Cell::new(0));
1593
+ let applied_checked_state = Rc::new(Cell::new(SemanticCheckedState::False));
1594
+ let applied_state = Rc::new(RefCell::new(None));
1595
+ let applied_colors = Rc::new(Cell::new(None));
1596
+ use_control_templates(Some(ControlTemplateSet {
1597
+ checkbox_indicator: Some(Rc::new(TestCheckboxIndicatorTemplate {
1598
+ created,
1599
+ applied_checked_state,
1600
+ applied_state: applied_state.clone(),
1601
+ applied_colors: applied_colors.clone(),
1602
+ })),
1603
+ ..Default::default()
1604
+ }));
1605
+ let colors = LabeledControlColors::new()
1606
+ .accent(0xAA00AAFF)
1607
+ .background(0x111111FF)
1608
+ .border(0x222222FF)
1609
+ .text_primary(0x333333FF)
1610
+ .text_muted(0x444444FF);
1611
+ let checkbox = checkbox("Agree");
1612
+ checkbox.tri_state(true).colors(Some(colors));
1613
+ Application::mount(checkbox.clone());
1614
+ let node_ref = checkbox.retained_node_ref();
1615
+
1616
+ press_enter(&node_ref);
1617
+ press_down(&node_ref, 1);
1618
+ checkbox.mixed(true);
1619
+ let state = applied_state.borrow().expect("presenter state");
1620
+ assert_eq!(state.checked_state, SemanticCheckedState::Mixed);
1621
+ assert!(state.hovered);
1622
+ assert!(state.pressed);
1623
+ assert!(state.enabled);
1624
+ assert_eq!(applied_colors.get(), Some(colors));
1625
+
1626
+ focus(&node_ref);
1627
+ blur(&node_ref);
1628
+ let state = applied_state.borrow().expect("presenter state after blur");
1629
+ assert!(!state.pressed);
1630
+
1631
+ checkbox.enabled(false);
1632
+ let state = applied_state
1633
+ .borrow()
1634
+ .expect("presenter state after disabled");
1635
+ assert!(!state.hovered);
1636
+ assert!(!state.pressed);
1637
+ assert!(!state.enabled);
1638
+
1639
+ Application::unmount();
1640
+ clear_control_templates();
1641
+ }
1642
+
1643
+ #[test]
1644
+ fn checkbox_default_presenter_matches_fui_as_mark_opacity() {
1645
+ ffi::test::reset();
1646
+ let checkbox = checkbox("Agree");
1647
+ Application::mount(checkbox.clone());
1648
+ let _ = ffi::test::take_calls();
1649
+
1650
+ checkbox.check(true);
1651
+ let calls = ffi::test::take_calls();
1652
+ assert!(calls.iter().any(|call| matches!(
1653
+ call,
1654
+ Call::SetLayerEffect { opacity, .. } if (*opacity - 1.0).abs() < f32::EPSILON
1655
+ )));
1656
+
1657
+ checkbox.check(false);
1658
+ let calls = ffi::test::take_calls();
1659
+ assert!(calls.iter().any(|call| matches!(
1660
+ call,
1661
+ Call::SetLayerEffect { opacity, .. } if (*opacity - 0.0).abs() < f32::EPSILON
1662
+ )));
1663
+
1664
+ Application::unmount();
1665
+ }
1666
+
1667
+ #[test]
1668
+ fn labeled_control_sizing_and_label_colors_reach_host_calls() {
1669
+ ffi::test::reset();
1670
+ let checkbox = checkbox("Agree");
1671
+ checkbox
1672
+ .sizing(Some(LabeledControlSizing::new().label_font_size(23.0)))
1673
+ .colors(Some(
1674
+ LabeledControlColors::new()
1675
+ .text_primary(0x123456FF)
1676
+ .text_muted(0xABCDEF88),
1677
+ ));
1678
+ Application::mount(checkbox.clone());
1679
+
1680
+ let calls = ffi::test::take_calls();
1681
+ assert!(calls.iter().any(|call| matches!(
1682
+ call,
1683
+ Call::SetFont { size, .. } if (*size - 23.0).abs() < f32::EPSILON
1684
+ )));
1685
+ assert!(calls.iter().any(|call| matches!(
1686
+ call,
1687
+ Call::SetTextColor { color, .. } if *color == 0x123456FF
1688
+ )));
1689
+
1690
+ checkbox.enabled(false);
1691
+ let calls = ffi::test::take_calls();
1692
+ assert!(calls.iter().any(|call| matches!(
1693
+ call,
1694
+ Call::SetTextColor { color, .. } if *color == 0xABCDEF88
1695
+ )));
1696
+
1697
+ Application::unmount();
1698
+ }
1699
+
1700
+ #[test]
1701
+ fn radio_button_template_and_sizing_follow_fui_as_surface() {
1702
+ ffi::test::reset();
1703
+ clear_control_templates();
1704
+ let created = Rc::new(Cell::new(0));
1705
+ let checked = Rc::new(Cell::new(false));
1706
+ let templates = ControlTemplateSet {
1707
+ radio_indicator: Some(Rc::new(TestRadioIndicatorTemplate {
1708
+ created: created.clone(),
1709
+ checked: checked.clone(),
1710
+ })),
1711
+ ..Default::default()
1712
+ };
1713
+ use_control_templates(Some(templates));
1714
+ let radio = radio_button("alpha");
1715
+
1716
+ radio
1717
+ .sizing(Some(LabeledControlSizing::new().label_font_size(19.0)))
1718
+ .check(true);
1719
+
1720
+ assert_eq!(created.get(), 2);
1721
+ assert!(checked.get());
1722
+ clear_control_templates();
1723
+ }
1724
+
1725
+ #[test]
1726
+ fn switch_template_surface_follows_fui_as() {
1727
+ ffi::test::reset();
1728
+ clear_control_templates();
1729
+ let created = Rc::new(Cell::new(0));
1730
+ let checked = Rc::new(Cell::new(false));
1731
+ let templates = ControlTemplateSet {
1732
+ switch_indicator: Some(Rc::new(TestSwitchIndicatorTemplate {
1733
+ created: created.clone(),
1734
+ checked: checked.clone(),
1735
+ })),
1736
+ ..Default::default()
1737
+ };
1738
+ use_control_templates(Some(templates));
1739
+ let toggle = switch("Toggle");
1740
+
1741
+ toggle.check(true);
1742
+
1743
+ assert_eq!(created.get(), 1);
1744
+ assert!(checked.get());
1745
+ clear_control_templates();
1746
+ }
1747
+
1748
+ #[test]
1749
+ fn switch_sizing_updates_default_indicator_and_recreates_custom_template() {
1750
+ ffi::test::reset();
1751
+ clear_control_templates();
1752
+ let toggle = switch("Sized switch");
1753
+ toggle.sizing(Some(
1754
+ LabeledControlSizing::new()
1755
+ .indicator_size(32.0)
1756
+ .label_font_size(18.0),
1757
+ ));
1758
+ Application::mount(toggle.clone());
1759
+ let calls = ffi::test::take_calls();
1760
+ assert!(calls.iter().any(|call| matches!(
1761
+ call,
1762
+ Call::SetHeight { value, unit_enum, .. }
1763
+ if (*value - 32.0).abs() < f32::EPSILON && *unit_enum == Unit::Pixel as u32
1764
+ )));
1765
+
1766
+ let created = Rc::new(Cell::new(0));
1767
+ let checked = Rc::new(Cell::new(false));
1768
+ let toggle = switch("Template switch");
1769
+ toggle
1770
+ .template(Some(Rc::new(TestSwitchIndicatorTemplate {
1771
+ created: created.clone(),
1772
+ checked: checked.clone(),
1773
+ })))
1774
+ .sizing(Some(LabeledControlSizing::new().indicator_size(30.0)));
1775
+ assert_eq!(created.get(), 2);
1776
+ }
1777
+
1778
+ #[test]
1779
+ fn slider_default_presenter_uses_sizing_for_geometry() {
1780
+ ffi::test::reset();
1781
+ let slider = slider();
1782
+ slider
1783
+ .sizing(Some(
1784
+ SliderSizing::new().thumb_size(24.0).track_thickness(8.0),
1785
+ ))
1786
+ .length(120.0);
1787
+ Application::mount(slider.clone());
1788
+ let calls = ffi::test::take_calls();
1789
+ let handle = slider.retained_node_ref().handle().raw();
1790
+
1791
+ assert!(calls.iter().any(|call| matches!(
1792
+ call,
1793
+ Call::SetWidth { handle: call_handle, value, .. }
1794
+ if *call_handle == handle && (*value - 130.0).abs() < f32::EPSILON
1795
+ )));
1796
+ assert!(calls.iter().any(|call| matches!(
1797
+ call,
1798
+ Call::SetHeight { handle: call_handle, value, .. }
1799
+ if *call_handle == handle && (*value - 36.0).abs() < f32::EPSILON
1800
+ )));
1801
+
1802
+ slider.orientation(Orientation::Vertical);
1803
+ let calls = ffi::test::take_calls();
1804
+ assert!(calls.iter().any(|call| matches!(
1805
+ call,
1806
+ Call::SetWidth { handle: call_handle, value, .. }
1807
+ if *call_handle == handle && (*value - 36.0).abs() < f32::EPSILON
1808
+ )));
1809
+ assert!(calls.iter().any(|call| matches!(
1810
+ call,
1811
+ Call::SetHeight { handle: call_handle, value, .. }
1812
+ if *call_handle == handle && (*value - 130.0).abs() < f32::EPSILON
1813
+ )));
1814
+ }
1815
+
1816
+ #[test]
1817
+ fn slider_template_replacement_and_colors_follow_fui_as_surface() {
1818
+ ffi::test::reset();
1819
+ clear_control_templates();
1820
+ let created = Rc::new(Cell::new(0));
1821
+ let last_layout_state = Rc::new(RefCell::new(None));
1822
+ let last_layout_length = Rc::new(Cell::new(0.0));
1823
+ let last_apply_state = Rc::new(RefCell::new(None));
1824
+ let last_apply_colors = Rc::new(Cell::new(None));
1825
+ let slider = slider();
1826
+ Application::mount(slider.clone());
1827
+ let _ = ffi::test::take_calls();
1828
+
1829
+ let template: Rc<dyn SliderTemplate> = Rc::new(TestSliderTemplate {
1830
+ created: created.clone(),
1831
+ metrics: SliderPresenterMetrics::new(26.0, 8.0).with_cross_axis_extra(4.0),
1832
+ last_layout_state: last_layout_state.clone(),
1833
+ last_layout_length: last_layout_length.clone(),
1834
+ last_apply_state: last_apply_state.clone(),
1835
+ last_apply_colors: last_apply_colors.clone(),
1836
+ });
1837
+ let colors = SliderColors::new()
1838
+ .track(0x112233FF)
1839
+ .fill(0x445566FF)
1840
+ .thumb(0x778899FF);
1841
+
1842
+ slider
1843
+ .template(Some(template))
1844
+ .colors(Some(colors))
1845
+ .orientation(Orientation::Vertical)
1846
+ .length(140.0)
1847
+ .value(50.0);
1848
+
1849
+ assert_eq!(created.get(), 1);
1850
+ assert_eq!(last_layout_length.get(), 140.0);
1851
+ let layout_state = last_layout_state.borrow().expect("slider layout state");
1852
+ assert_eq!(layout_state.orientation, Orientation::Vertical);
1853
+ let apply_state = last_apply_state.borrow().expect("slider apply state");
1854
+ assert_eq!(apply_state.value, 50.0);
1855
+ assert_eq!(last_apply_colors.get(), Some(colors));
1856
+
1857
+ let calls = ffi::test::take_calls();
1858
+ assert!(calls
1859
+ .iter()
1860
+ .any(|call| matches!(call, Call::NodeRemoveChild { .. })));
1861
+
1862
+ clear_control_templates();
1863
+ }
1864
+
1865
+ #[test]
1866
+ fn slider_control_template_set_and_local_template_precedence_follow_fui_as() {
1867
+ ffi::test::reset();
1868
+ clear_control_templates();
1869
+ let app_created = Rc::new(Cell::new(0));
1870
+ let local_created = Rc::new(Cell::new(0));
1871
+ let app_layout_state = Rc::new(RefCell::new(None));
1872
+ let app_layout_length = Rc::new(Cell::new(0.0));
1873
+ let app_apply_state = Rc::new(RefCell::new(None));
1874
+ let app_apply_colors = Rc::new(Cell::new(None));
1875
+ use_control_templates(Some(ControlTemplateSet {
1876
+ slider: Some(Rc::new(TestSliderTemplate {
1877
+ created: app_created.clone(),
1878
+ metrics: SliderPresenterMetrics::new(22.0, 7.0),
1879
+ last_layout_state: app_layout_state,
1880
+ last_layout_length: app_layout_length,
1881
+ last_apply_state: app_apply_state,
1882
+ last_apply_colors: app_apply_colors,
1883
+ })),
1884
+ ..Default::default()
1885
+ }));
1886
+
1887
+ let slider = slider();
1888
+ slider.sizing(Some(SliderSizing::new().thumb_size(30.0)));
1889
+ assert_eq!(app_created.get(), 2);
1890
+
1891
+ let local_layout_state = Rc::new(RefCell::new(None));
1892
+ let local_layout_length = Rc::new(Cell::new(0.0));
1893
+ let local_apply_state = Rc::new(RefCell::new(None));
1894
+ let local_apply_colors = Rc::new(Cell::new(None));
1895
+ slider.template(Some(Rc::new(TestSliderTemplate {
1896
+ created: local_created.clone(),
1897
+ metrics: SliderPresenterMetrics::new(28.0, 9.0),
1898
+ last_layout_state: local_layout_state.clone(),
1899
+ last_layout_length: local_layout_length,
1900
+ last_apply_state: local_apply_state,
1901
+ last_apply_colors: local_apply_colors,
1902
+ })));
1903
+ slider
1904
+ .sizing(Some(SliderSizing::new().thumb_size(36.0)))
1905
+ .length(160.0)
1906
+ .value(40.0);
1907
+
1908
+ assert_eq!(app_created.get(), 2);
1909
+ assert_eq!(local_created.get(), 2);
1910
+ let state = local_layout_state
1911
+ .borrow()
1912
+ .expect("local slider template layout state");
1913
+ assert_eq!(state.value, 40.0);
1914
+ clear_control_templates();
1915
+ }
1916
+
1917
+ #[test]
1918
+ fn slider_presenter_receives_hover_drag_and_disabled_state() {
1919
+ ffi::test::reset();
1920
+ clear_control_templates();
1921
+ let last_layout_state = Rc::new(RefCell::new(None));
1922
+ let last_layout_length = Rc::new(Cell::new(0.0));
1923
+ let last_apply_state = Rc::new(RefCell::new(None));
1924
+ let last_apply_colors = Rc::new(Cell::new(None));
1925
+ let slider = slider();
1926
+ slider.template(Some(Rc::new(TestSliderTemplate {
1927
+ created: Rc::new(Cell::new(0)),
1928
+ metrics: SliderPresenterMetrics::new(18.0, 6.0),
1929
+ last_layout_state: last_layout_state.clone(),
1930
+ last_layout_length,
1931
+ last_apply_state: last_apply_state.clone(),
1932
+ last_apply_colors,
1933
+ })));
1934
+ Application::mount(slider.clone());
1935
+ let _ = ffi::test::take_calls();
1936
+ let node_ref = slider.retained_node_ref();
1937
+
1938
+ press_enter(&node_ref);
1939
+ let state = last_apply_state.borrow().expect("hover state");
1940
+ assert!(state.hovered);
1941
+ assert!(!state.dragging);
1942
+ assert!(state.enabled);
1943
+
1944
+ press_down(&node_ref, 1);
1945
+ let state = last_apply_state.borrow().expect("drag state");
1946
+ assert!(state.hovered);
1947
+ assert!(state.dragging);
1948
+
1949
+ press_up(&node_ref);
1950
+ let state = last_apply_state.borrow().expect("released state");
1951
+ assert!(state.hovered);
1952
+ assert!(!state.dragging);
1953
+
1954
+ slider.enabled(false);
1955
+ let state = last_apply_state.borrow().expect("disabled state");
1956
+ assert!(!state.enabled);
1957
+ }
1958
+
1959
+ #[test]
1960
+ fn checkbox_programmatic_state_changes_emit_changed_event() {
1961
+ ffi::test::reset();
1962
+ let changes = Rc::new(Cell::new(0));
1963
+ let changes_clone = changes.clone();
1964
+ let checkbox = checkbox("Agree");
1965
+ checkbox.on_changed(move |_event| changes_clone.set(changes_clone.get() + 1));
1966
+
1967
+ checkbox.check(true);
1968
+ checkbox.check(false);
1969
+ checkbox.check_state(CheckState::True);
1970
+ checkbox.tri_state(true).mixed(true);
1971
+ assert_eq!(changes.get(), 4);
1972
+ let calls = ffi::test::take_calls();
1973
+ assert!(!calls
1974
+ .iter()
1975
+ .any(|call| matches!(call, Call::RequestSemanticAnnouncement { .. })));
1976
+ }
1977
+
1978
+ #[test]
1979
+ fn checkbox_user_activation_emits_changed_and_semantic_announcement() {
1980
+ ffi::test::reset();
1981
+ let changes = Rc::new(Cell::new(0));
1982
+ let last_state = Rc::new(Cell::new(CheckState::False));
1983
+ let changes_clone = changes.clone();
1984
+ let last_state_clone = last_state.clone();
1985
+ let checkbox = checkbox("Agree");
1986
+ checkbox.on_changed(move |event| {
1987
+ changes_clone.set(changes_clone.get() + 1);
1988
+ last_state_clone.set(event.state);
1989
+ });
1990
+ Application::mount(checkbox.clone());
1991
+ let _ = ffi::test::take_calls();
1992
+ let node_ref = checkbox.retained_node_ref();
1993
+ let handle = node_ref.handle().raw();
1994
+
1995
+ press_down(&node_ref, 1);
1996
+ press_up(&node_ref);
1997
+ assert_eq!(changes.get(), 1);
1998
+ assert_eq!(last_state.get(), CheckState::True);
1999
+
2000
+ let calls = ffi::test::take_calls();
2001
+ assert!(calls.iter().any(|call| matches!(
2002
+ call,
2003
+ Call::SetSemanticChecked { handle: checked_handle, checked_state_enum }
2004
+ if *checked_handle == handle && *checked_state_enum == SemanticCheckedState::True as u32
2005
+ )));
2006
+ assert!(calls.iter().any(|call| matches!(
2007
+ call,
2008
+ Call::RequestSemanticAnnouncement { handle: announced } if *announced == handle
2009
+ )));
2010
+ Application::unmount();
2011
+ }
2012
+
2013
+ #[test]
2014
+ fn radio_and_switch_programmatic_changes_emit_without_semantic_announcement() {
2015
+ ffi::test::reset();
2016
+ let radio_changes = Rc::new(Cell::new(0));
2017
+ let switch_changes = Rc::new(Cell::new(0));
2018
+ let radio_changes_clone = radio_changes.clone();
2019
+ let switch_changes_clone = switch_changes.clone();
2020
+ let radio = radio_button("Option");
2021
+ let switch = switch("Toggle");
2022
+ radio.on_changed(move |_event| radio_changes_clone.set(radio_changes_clone.get() + 1));
2023
+ switch.on_changed(move |_event| switch_changes_clone.set(switch_changes_clone.get() + 1));
2024
+
2025
+ radio.checked(true);
2026
+ switch.checked(true);
2027
+
2028
+ assert_eq!(radio_changes.get(), 1);
2029
+ assert_eq!(switch_changes.get(), 1);
2030
+ let calls = ffi::test::take_calls();
2031
+ assert!(!calls
2032
+ .iter()
2033
+ .any(|call| matches!(call, Call::RequestSemanticAnnouncement { .. })));
2034
+ }
2035
+
2036
+ #[test]
2037
+ fn checkbox_persisted_state_restores_and_emits_without_announcement() {
2038
+ ffi::test::reset();
2039
+ let source = checkbox("Agree");
2040
+ source.node_id("checkbox-persisted");
2041
+ source.tri_state(true).check_state(CheckState::Mixed);
2042
+ Application::mount(source.clone());
2043
+ Application::capture_persisted_ui_state();
2044
+ let calls = ffi::test::take_calls();
2045
+ assert!(calls.iter().any(|call| matches!(
2046
+ call,
2047
+ Call::SetPersistedState { node_id, kind, version, payload }
2048
+ if node_id == "checkbox-persisted"
2049
+ && kind == "checkbox-checked-state"
2050
+ && *version == 1
2051
+ && payload == "2"
2052
+ )));
2053
+
2054
+ let changes = Rc::new(Cell::new(0));
2055
+ let changes_clone = changes.clone();
2056
+ let restored = checkbox("Agree");
2057
+ restored.node_id("checkbox-persisted");
2058
+ restored.tri_state(true);
2059
+ restored.on_changed(move |_event| changes_clone.set(changes_clone.get() + 1));
2060
+ Application::mount(restored.clone());
2061
+ let _ = ffi::test::take_calls();
2062
+ Application::restore_persisted_ui_state();
2063
+
2064
+ assert_eq!(restored.checked_state(), CheckState::Mixed);
2065
+ assert_eq!(changes.get(), 1);
2066
+ let calls = ffi::test::take_calls();
2067
+ assert!(!calls
2068
+ .iter()
2069
+ .any(|call| matches!(call, Call::RequestSemanticAnnouncement { .. })));
2070
+ Application::unmount();
2071
+ }
2072
+
2073
+ #[test]
2074
+ fn switch_persisted_state_restores_and_emits_without_announcement() {
2075
+ ffi::test::reset();
2076
+ let source = switch("Toggle");
2077
+ source.node_id("switch-persisted");
2078
+ source.check(true);
2079
+ Application::mount(source.clone());
2080
+ Application::capture_persisted_ui_state();
2081
+ let calls = ffi::test::take_calls();
2082
+ assert!(calls.iter().any(|call| matches!(
2083
+ call,
2084
+ Call::SetPersistedState { node_id, kind, version, payload }
2085
+ if node_id == "switch-persisted"
2086
+ && kind == "switch-checked"
2087
+ && *version == 1
2088
+ && payload == "true"
2089
+ )));
2090
+
2091
+ let changes = Rc::new(Cell::new(0));
2092
+ let changes_clone = changes.clone();
2093
+ let restored = switch("Toggle");
2094
+ restored.node_id("switch-persisted");
2095
+ restored.on_changed(move |_event| changes_clone.set(changes_clone.get() + 1));
2096
+ Application::mount(restored.clone());
2097
+ let _ = ffi::test::take_calls();
2098
+ Application::restore_persisted_ui_state();
2099
+
2100
+ assert!(restored.is_checked());
2101
+ assert_eq!(changes.get(), 1);
2102
+ let calls = ffi::test::take_calls();
2103
+ assert!(!calls
2104
+ .iter()
2105
+ .any(|call| matches!(call, Call::RequestSemanticAnnouncement { .. })));
2106
+ }
2107
+
2108
+ #[test]
2109
+ fn persisted_restore_visits_children_before_parent() {
2110
+ ffi::test::reset();
2111
+ let root = flex_box();
2112
+ let child = switch("Child");
2113
+ root.node_id("restore-order-root").child(&child);
2114
+ child.node_id("restore-order-child").check(true);
2115
+ Application::mount(root.clone());
2116
+ Application::capture_persisted_ui_state();
2117
+ let _ = ffi::test::take_calls();
2118
+
2119
+ let restore_order = Rc::new(RefCell::new(Vec::<&'static str>::new()));
2120
+ let restored_root = flex_box();
2121
+ let restored_child = switch("Child");
2122
+ restored_root
2123
+ .node_id("restore-order-root")
2124
+ .child(&restored_child);
2125
+ restored_child.node_id("restore-order-child");
2126
+ let child_order = restore_order.clone();
2127
+ restored_child.on_changed(move |_event| {
2128
+ child_order.borrow_mut().push("child");
2129
+ });
2130
+ let parent_order = restore_order.clone();
2131
+ restored_root.persist_state(crate::persisted::persisted_value_adapter(
2132
+ "parent-order",
2133
+ crate::persisted::PersistedBoolCodec,
2134
+ 1,
2135
+ || Some(true),
2136
+ move |_| {
2137
+ parent_order.borrow_mut().push("parent");
2138
+ },
2139
+ ));
2140
+ crate::persisted::store_text_state("restore-order-root", "parent-order", 1, "true");
2141
+ Application::mount(restored_root);
2142
+ let _ = ffi::test::take_calls();
2143
+ Application::restore_persisted_ui_state();
2144
+
2145
+ assert_eq!(restore_order.borrow().as_slice(), ["child", "parent"]);
2146
+ }
2147
+
2148
+ #[test]
2149
+ fn radio_group_persisted_state_restores_and_emits_without_announcement() {
2150
+ ffi::test::reset();
2151
+ let source = radio_group();
2152
+ source.node_id("radio-group-persisted");
2153
+ source.add_option("alpha", "Alpha");
2154
+ source.add_option("beta", "Beta");
2155
+ source.add_option("gamma", "Gamma");
2156
+ source.select_index(2);
2157
+ Application::mount(source.clone());
2158
+ Application::capture_persisted_ui_state();
2159
+ let calls = ffi::test::take_calls();
2160
+ assert!(calls.iter().any(|call| matches!(
2161
+ call,
2162
+ Call::SetPersistedState { node_id, kind, version, payload }
2163
+ if node_id == "radio-group-persisted"
2164
+ && kind == "radio-group-selected-index"
2165
+ && *version == 1
2166
+ && payload == "2"
2167
+ )));
2168
+
2169
+ let last_value = Rc::new(RefCell::new(String::new()));
2170
+ let last_value_clone = last_value.clone();
2171
+ let restored = radio_group();
2172
+ restored.node_id("radio-group-persisted");
2173
+ restored.add_option("alpha", "Alpha");
2174
+ restored.add_option("beta", "Beta");
2175
+ restored.add_option("gamma", "Gamma");
2176
+ restored.on_changed(move |event| {
2177
+ *last_value_clone.borrow_mut() = event.value;
2178
+ });
2179
+ Application::mount(restored.clone());
2180
+ let _ = ffi::test::take_calls();
2181
+ Application::restore_persisted_ui_state();
2182
+
2183
+ assert_eq!(restored.selected_index(), 2);
2184
+ assert_eq!(restored.selected_value(), "gamma");
2185
+ assert_eq!(last_value.borrow().as_str(), "gamma");
2186
+ let calls = ffi::test::take_calls();
2187
+ assert!(!calls
2188
+ .iter()
2189
+ .any(|call| matches!(call, Call::RequestSemanticAnnouncement { .. })));
2190
+ }
2191
+
2192
+ #[test]
2193
+ fn slider_programmatic_changes_emit_without_semantic_announcement() {
2194
+ ffi::test::reset();
2195
+ let changes = Rc::new(Cell::new(0));
2196
+ let last_value = Rc::new(Cell::new(25.0));
2197
+ let changes_clone = changes.clone();
2198
+ let last_value_clone = last_value.clone();
2199
+ let slider = slider();
2200
+ slider.value(25.0).on_changed(move |event| {
2201
+ changes_clone.set(changes_clone.get() + 1);
2202
+ last_value_clone.set(event.value);
2203
+ });
2204
+
2205
+ slider.min(30.0);
2206
+ slider.max(80.0);
2207
+ slider.step(7.0);
2208
+ slider.value(63.0);
2209
+
2210
+ assert_eq!(changes.get(), 2);
2211
+ assert_eq!(last_value.get(), 65.0);
2212
+ let calls = ffi::test::take_calls();
2213
+ assert!(!calls
2214
+ .iter()
2215
+ .any(|call| matches!(call, Call::RequestSemanticAnnouncement { .. })));
2216
+ }
2217
+
2218
+ #[test]
2219
+ fn slider_focus_adorner_tracks_keyboard_focus_visibility() {
2220
+ ffi::test::reset();
2221
+ focus_visibility::reset_keyboard_focus_visibility();
2222
+ let slider = slider();
2223
+ Application::mount(slider.clone());
2224
+ let _ = ffi::test::take_calls();
2225
+ let node_ref = slider.retained_node_ref();
2226
+ let handle = node_ref.handle().raw();
2227
+
2228
+ focus_visibility::show_keyboard_focus_for_pointer_event(PointerEventType::Down);
2229
+ let _ = ffi::test::take_calls();
2230
+ event::__fui_on_focus_changed(handle, true);
2231
+ let calls = ffi::test::take_calls();
2232
+ assert!(!calls
2233
+ .iter()
2234
+ .any(|call| matches!(call, Call::NodeAddChild { .. })));
2235
+
2236
+ key_event(KeyEventType::Down, "Tab", 0);
2237
+ let calls = ffi::test::take_calls();
2238
+ assert!(calls
2239
+ .iter()
2240
+ .any(|call| matches!(call, Call::NodeAddChild { .. })));
2241
+ }
2242
+
2243
+ #[test]
2244
+ fn slider_invalid_step_and_length_warn_and_clamp() {
2245
+ ffi::test::reset();
2246
+ let slider = slider();
2247
+ slider.step(0.0).length(10.0);
2248
+ Application::mount(slider.clone());
2249
+ let calls = ffi::test::take_calls();
2250
+ let handle = slider.retained_node_ref().handle().raw();
2251
+
2252
+ assert!(calls.iter().any(|call| matches!(
2253
+ call,
2254
+ Call::Log { category, message }
2255
+ if category == "Warning/Layout"
2256
+ && message == "Slider.step() received 0; clamping to 1.0."
2257
+ )));
2258
+ assert!(calls.iter().any(|call| matches!(
2259
+ call,
2260
+ Call::Log { category, message }
2261
+ if category == "Warning/Layout"
2262
+ && message == "Slider.length() received 10; clamping to a value above the thumb size."
2263
+ )));
2264
+ assert!(calls.iter().any(|call| matches!(
2265
+ call,
2266
+ Call::SetWidth { handle: call_handle, value, .. }
2267
+ if *call_handle == handle && *value == 29.0
2268
+ )));
2269
+ }
2270
+
2271
+ #[test]
2272
+ fn slider_persisted_state_restores_and_emits_without_announcement() {
2273
+ ffi::test::reset();
2274
+ let source = slider();
2275
+ source.node_id("slider-persisted");
2276
+ source.value(63.0);
2277
+ Application::mount(source.clone());
2278
+ Application::capture_persisted_ui_state();
2279
+ let calls = ffi::test::take_calls();
2280
+ assert!(calls.iter().any(|call| matches!(
2281
+ call,
2282
+ Call::SetPersistedState { node_id, kind, version, payload }
2283
+ if node_id == "slider-persisted"
2284
+ && kind == "slider-value"
2285
+ && *version == 1
2286
+ && payload.starts_with("63")
2287
+ )));
2288
+
2289
+ let changes = Rc::new(Cell::new(0));
2290
+ let last_value = Rc::new(Cell::new(0.0));
2291
+ let changes_clone = changes.clone();
2292
+ let last_value_clone = last_value.clone();
2293
+ let restored = slider();
2294
+ restored.node_id("slider-persisted");
2295
+ restored.on_changed(move |event| {
2296
+ changes_clone.set(changes_clone.get() + 1);
2297
+ last_value_clone.set(event.value);
2298
+ });
2299
+ Application::mount(restored.clone());
2300
+ let _ = ffi::test::take_calls();
2301
+ Application::restore_persisted_ui_state();
2302
+
2303
+ assert_eq!(restored.current_value(), 63.0);
2304
+ assert_eq!(changes.get(), 1);
2305
+ assert_eq!(last_value.get(), 63.0);
2306
+ let calls = ffi::test::take_calls();
2307
+ assert!(!calls
2308
+ .iter()
2309
+ .any(|call| matches!(call, Call::RequestSemanticAnnouncement { .. })));
2310
+ }
2311
+
2312
+ #[test]
2313
+ fn slider_programmatic_noop_does_not_emit_duplicate_changed() {
2314
+ ffi::test::reset();
2315
+ let changes = Rc::new(Cell::new(0));
2316
+ let changes_clone = changes.clone();
2317
+ let slider = slider();
2318
+ slider.step(5.0).value(25.0).on_changed(move |_event| {
2319
+ changes_clone.set(changes_clone.get() + 1);
2320
+ });
2321
+
2322
+ slider.value(26.0);
2323
+ slider.value(24.0);
2324
+
2325
+ assert_eq!(changes.get(), 0);
2326
+ }
2327
+
2328
+ #[test]
2329
+ fn slider_default_semantic_label_does_not_overwrite_explicit_label() {
2330
+ ffi::test::reset();
2331
+ let slider = slider();
2332
+ slider
2333
+ .semantic_label("Gain")
2334
+ .orientation(Orientation::Vertical);
2335
+ Application::mount(slider.clone());
2336
+ let calls = ffi::test::take_calls();
2337
+ let handle = slider.retained_node_ref().handle().raw();
2338
+
2339
+ assert!(calls.iter().any(|call| matches!(
2340
+ call,
2341
+ Call::SetSemanticLabel { handle: call_handle, label }
2342
+ if *call_handle == handle && label == "Gain"
2343
+ )));
2344
+ assert!(!calls.iter().any(|call| matches!(
2345
+ call,
2346
+ Call::SetSemanticLabel { handle: call_handle, label }
2347
+ if *call_handle == handle && label == "Vertical slider"
2348
+ )));
2349
+
2350
+ slider.orientation(Orientation::Horizontal);
2351
+ let calls = ffi::test::take_calls();
2352
+ assert!(!calls.iter().any(|call| matches!(
2353
+ call,
2354
+ Call::SetSemanticLabel { handle: call_handle, label }
2355
+ if *call_handle == handle && label == "Slider"
2356
+ )));
2357
+ }
2358
+
2359
+ #[test]
2360
+ fn progress_bar_clamps_value_and_updates_semantics() {
2361
+ ffi::test::reset();
2362
+ let progress = progress_bar();
2363
+ progress.min(20.0).max(80.0).value(120.0);
2364
+
2365
+ assert_eq!(progress.current_value(), 80.0);
2366
+
2367
+ Application::mount(progress.clone());
2368
+ let calls = ffi::test::take_calls();
2369
+ let root_handle = progress.retained_node_ref().handle().raw();
2370
+
2371
+ assert!(calls.iter().any(|call| matches!(
2372
+ call,
2373
+ Call::SetSemanticValueRange { handle, has_value_range, value_now, value_min, value_max }
2374
+ if *handle == root_handle
2375
+ && *has_value_range
2376
+ && *value_now == 80.0
2377
+ && *value_min == 20.0
2378
+ && *value_max == 80.0
2379
+ )));
2380
+ assert!(calls.iter().any(|call| matches!(
2381
+ call,
2382
+ Call::SetSemanticLabel { handle, label }
2383
+ if *handle == root_handle
2384
+ && label == "Progress bar, value 80, range 20 to 80"
2385
+ )));
2386
+ }
2387
+
2388
+ #[test]
2389
+ fn progress_bar_default_semantic_label_does_not_overwrite_explicit_label() {
2390
+ ffi::test::reset();
2391
+ let progress = progress_bar();
2392
+ progress.semantic_label("Install progress");
2393
+ Application::mount(progress.clone());
2394
+ let calls = ffi::test::take_calls();
2395
+ let handle = progress.retained_node_ref().handle().raw();
2396
+
2397
+ assert!(calls.iter().any(|call| matches!(
2398
+ call,
2399
+ Call::SetSemanticLabel { handle: call_handle, label }
2400
+ if *call_handle == handle && label == "Install progress"
2401
+ )));
2402
+
2403
+ progress.value(50.0);
2404
+ let calls = ffi::test::take_calls();
2405
+ assert!(!calls.iter().any(|call| matches!(
2406
+ call,
2407
+ Call::SetSemanticLabel { handle: call_handle, label }
2408
+ if *call_handle == handle && label.starts_with("Progress bar")
2409
+ )));
2410
+ }
2411
+
2412
+ #[test]
2413
+ fn progress_bar_invalid_length_and_thickness_warn_and_clamp() {
2414
+ ffi::test::reset();
2415
+ let progress = progress_bar();
2416
+ progress.length(0.0).thickness(-2.0);
2417
+
2418
+ Application::mount(progress);
2419
+ let calls = ffi::test::take_calls();
2420
+
2421
+ assert!(calls.iter().any(|call| matches!(
2422
+ call,
2423
+ Call::Log { category, message }
2424
+ if category == "Warning/Layout"
2425
+ && message == "ProgressBar.length() received 0; clamping to 1.0."
2426
+ )));
2427
+ assert!(calls.iter().any(|call| matches!(
2428
+ call,
2429
+ Call::Log { category, message }
2430
+ if category == "Warning/Layout"
2431
+ && message == "ProgressBar.thickness() received -2; clamping to 1.0."
2432
+ )));
2433
+ assert!(calls.iter().any(|call| matches!(
2434
+ call,
2435
+ Call::SetWidth { value, unit_enum, .. }
2436
+ if *value == 1.0 && *unit_enum == Unit::Pixel as u32
2437
+ )));
2438
+ assert!(calls.iter().any(|call| matches!(
2439
+ call,
2440
+ Call::SetHeight { value, unit_enum, .. }
2441
+ if *value == 1.0 && *unit_enum == Unit::Pixel as u32
2442
+ )));
2443
+ }
2444
+
2445
+ #[test]
2446
+ fn progress_bar_theme_changes_update_default_colors() {
2447
+ ffi::test::reset();
2448
+ use_custom_theme(generate_theme(true, 0x112233FF));
2449
+ let progress = progress_bar();
2450
+ Application::mount(progress.clone());
2451
+ let _ = ffi::test::take_calls();
2452
+
2453
+ use_custom_theme(generate_theme(false, 0x445566FF));
2454
+ let theme = current_theme();
2455
+ let calls = ffi::test::take_calls();
2456
+
2457
+ assert!(calls.iter().any(|call| matches!(
2458
+ call,
2459
+ Call::SetBgColor { color, .. } if *color == theme.colors.accent
2460
+ )));
2461
+ assert!(calls.iter().any(|call| matches!(
2462
+ call,
2463
+ Call::SetBgColor { color, .. } if *color == theme.colors.scrollbar_track
2464
+ )));
2465
+ assert!(calls.iter().any(|call| matches!(
2466
+ call,
2467
+ Call::SetBoxStyle { border_color, .. } if *border_color == theme.colors.border
2468
+ )));
2469
+
2470
+ use_system_theme();
2471
+ }
2472
+
2473
+ #[test]
2474
+ fn progress_bar_overrides_survive_theme_changes() {
2475
+ ffi::test::reset();
2476
+ let progress = progress_bar();
2477
+ progress
2478
+ .track_color(0x123456FF)
2479
+ .fill_color(0xABCDEF88)
2480
+ .corner_radius(9.0);
2481
+ Application::mount(progress.clone());
2482
+ let calls = ffi::test::take_calls();
2483
+ let root_handle = progress.retained_node_ref().handle().raw();
2484
+ let fill_handle = child_handles_for_parent(&calls, root_handle)
2485
+ .into_iter()
2486
+ .next()
2487
+ .expect("fill handle");
2488
+
2489
+ use_custom_theme(generate_theme(true, 0x445566FF));
2490
+ let calls = ffi::test::take_calls();
2491
+
2492
+ assert!(calls.iter().any(|call| matches!(
2493
+ call,
2494
+ Call::SetBgColor { handle, color }
2495
+ if *handle == root_handle && *color == 0x123456FF
2496
+ )));
2497
+ assert!(calls.iter().any(|call| matches!(
2498
+ call,
2499
+ Call::SetBgColor { handle, color }
2500
+ if *handle == fill_handle && *color == 0xABCDEF88
2501
+ )));
2502
+ assert!(calls.iter().any(|call| matches!(
2503
+ call,
2504
+ Call::SetBoxStyle { handle, radius_tl, radius_tr, radius_br, radius_bl, .. }
2505
+ if (*handle == root_handle || *handle == fill_handle)
2506
+ && *radius_tl == 9.0
2507
+ && *radius_tr == 9.0
2508
+ && *radius_br == 9.0
2509
+ && *radius_bl == 9.0
2510
+ )));
2511
+
2512
+ use_system_theme();
2513
+ }
2514
+
2515
+ #[test]
2516
+ fn phase_4_8_public_forwarding_and_children_surface_compiles_and_builds() {
2517
+ ffi::test::reset();
2518
+
2519
+ let title = text("Mixed child text");
2520
+ let action = button("Mixed child button");
2521
+ let container = column();
2522
+ container
2523
+ .children(vec![Child::from_node(&title), Child::from_node(&action)])
2524
+ .margin(1.0, 2.0, 3.0, 4.0)
2525
+ .padding(5.0, 6.0, 7.0, 8.0);
2526
+
2527
+ let slide = slider();
2528
+ slide
2529
+ .orientation(Orientation::Vertical)
2530
+ .margin(9.0, 10.0, 11.0, 12.0)
2531
+ .fill_width_percent(50.0)
2532
+ .min_height(20.0, Unit::Pixel)
2533
+ .align_self(crate::ffi::AlignSelf::Center)
2534
+ .clip_to_bounds(true)
2535
+ .semantic_label("Forwarded slider");
2536
+
2537
+ let progress = progress_bar();
2538
+ progress
2539
+ .margin(1.0, 0.0, 0.0, 0.0)
2540
+ .opacity(0.75)
2541
+ .position(2.0, 3.0)
2542
+ .semantic_label("Forwarded progress");
2543
+
2544
+ let link = nav_link("/v2/fui-rs/demo/workbench/");
2545
+ link.fill_width()
2546
+ .min_width(12.0, Unit::Pixel)
2547
+ .align_self(crate::ffi::AlignSelf::Start)
2548
+ .clip_to_bounds(true);
2549
+
2550
+ let selection = selection_area();
2551
+ selection
2552
+ .children(vec![Child::from_node(&container), Child::from_node(&slide)])
2553
+ .fill_size()
2554
+ .child(&progress)
2555
+ .child(&link);
2556
+
2557
+ Application::mount(selection.clone());
2558
+ let calls = ffi::test::take_calls();
2559
+
2560
+ assert!(calls.iter().any(
2561
+ |call| matches!(call, Call::SetSemanticLabel { label, .. } if label == "Forwarded slider")
2562
+ ));
2563
+ assert!(calls.iter().any(|call| matches!(
2564
+ call,
2565
+ Call::SetFillWidthPercent { percent, .. } if *percent == 50.0
2566
+ )));
2567
+ assert!(
2568
+ calls
2569
+ .iter()
2570
+ .filter(|call| matches!(call, Call::NodeAddChild { .. }))
2571
+ .count()
2572
+ >= 5
2573
+ );
2574
+ }
2575
+
2576
+ #[test]
2577
+ fn dropdown_select_index_clamps_and_keyboard_selection_emits() {
2578
+ ffi::test::reset();
2579
+ let changed = Rc::new(Cell::new(0));
2580
+ let last_index = Rc::new(Cell::new(-1));
2581
+ let dropdown = dropdown();
2582
+ dropdown
2583
+ .items(vec![
2584
+ DropdownItem::from_value("Calm"),
2585
+ DropdownItem::from_value("Focused"),
2586
+ DropdownItem::from_value("Energetic"),
2587
+ ])
2588
+ .on_changed({
2589
+ let changed = changed.clone();
2590
+ let last_index = last_index.clone();
2591
+ move |event| {
2592
+ changed.set(changed.get() + 1);
2593
+ last_index.set(event.selected_index);
2594
+ }
2595
+ })
2596
+ .select_index(99);
2597
+
2598
+ Application::mount(dropdown.clone());
2599
+ let calls = ffi::test::take_calls();
2600
+ assert_eq!(dropdown.selected_index(), 2);
2601
+ assert!(calls.iter().any(|call| matches!(
2602
+ call,
2603
+ Call::Log { category, message }
2604
+ if category == "Warning/Layout"
2605
+ && message == "Dropdown.selectIndex() received 99; clamping to 2."
2606
+ )));
2607
+
2608
+ focus(&dropdown.retained_node_ref());
2609
+ key_event(KeyEventType::Down, "ArrowDown", 0);
2610
+ key_event(KeyEventType::Down, "Home", 0);
2611
+ key_event(KeyEventType::Down, "Enter", 0);
2612
+
2613
+ assert_eq!(dropdown.selected_index(), 0);
2614
+ assert_eq!(changed.get(), 1);
2615
+ assert_eq!(last_index.get(), 0);
2616
+ }
2617
+
2618
+ #[test]
2619
+ fn dropdown_popup_builds_list_semantics_and_expanded_state() {
2620
+ ffi::test::reset();
2621
+ let dropdown = dropdown();
2622
+ dropdown
2623
+ .items(vec![
2624
+ DropdownItem::new("alpha", "Alpha"),
2625
+ DropdownItem::new("beta", "Beta"),
2626
+ ])
2627
+ .popup_width(280.0)
2628
+ .max_visible_items(4);
2629
+
2630
+ Application::mount(dropdown.clone());
2631
+ let _ = ffi::test::take_calls();
2632
+
2633
+ focus(&dropdown.retained_node_ref());
2634
+ key_event(KeyEventType::Down, "ArrowDown", 0);
2635
+ let calls = ffi::test::take_calls();
2636
+
2637
+ assert!(calls.iter().any(|call| matches!(
2638
+ call,
2639
+ Call::SetSemanticRole { role_enum, .. } if *role_enum == SemanticRole::ComboBox as u32
2640
+ )));
2641
+ assert!(calls.iter().any(|call| matches!(
2642
+ call,
2643
+ Call::SetSemanticRole { role_enum, .. } if *role_enum == SemanticRole::List as u32
2644
+ )));
2645
+ assert!(calls.iter().any(|call| matches!(
2646
+ call,
2647
+ Call::SetSemanticExpanded { has_expanded, is_expanded, .. }
2648
+ if *has_expanded && *is_expanded
2649
+ )));
2650
+ assert!(calls.iter().any(|call| matches!(
2651
+ call,
2652
+ Call::SetSemanticSelected { has_selected, selected, .. }
2653
+ if *has_selected && *selected
2654
+ )));
2655
+ assert!(calls.iter().any(|call| matches!(
2656
+ call,
2657
+ Call::SetWidth { value, .. } if *value == 280.0
2658
+ )));
2659
+ }
2660
+
2661
+ #[test]
2662
+ fn dropdown_disabled_suppresses_open_and_activation() {
2663
+ ffi::test::reset();
2664
+ let changed = Rc::new(Cell::new(0));
2665
+ let dropdown = dropdown();
2666
+ dropdown
2667
+ .items(vec![
2668
+ DropdownItem::from_value("Alpha"),
2669
+ DropdownItem::from_value("Beta"),
2670
+ ])
2671
+ .on_changed({
2672
+ let changed = changed.clone();
2673
+ move |_event| changed.set(changed.get() + 1)
2674
+ })
2675
+ .enabled(false);
2676
+
2677
+ Application::mount(dropdown.clone());
2678
+ let _ = ffi::test::take_calls();
2679
+ focus(&dropdown.retained_node_ref());
2680
+ key_event(KeyEventType::Down, "ArrowDown", 0);
2681
+ let calls = ffi::test::take_calls();
2682
+
2683
+ assert_eq!(dropdown.selected_index(), 0);
2684
+ assert_eq!(changed.get(), 0);
2685
+ assert!(!calls.iter().any(|call| matches!(
2686
+ call,
2687
+ Call::SetSemanticExpanded { has_expanded, is_expanded, .. }
2688
+ if *has_expanded && *is_expanded
2689
+ )));
2690
+ }
2691
+
2692
+ #[test]
2693
+ fn dropdown_pointer_selection_and_escape_close_match_fui_as() {
2694
+ ffi::test::reset();
2695
+ let changed = Rc::new(Cell::new(0));
2696
+ let last_index = Rc::new(Cell::new(-1));
2697
+ let dropdown = dropdown();
2698
+ dropdown
2699
+ .items(vec![
2700
+ DropdownItem::from_value("Alpha"),
2701
+ DropdownItem::from_value("Beta"),
2702
+ DropdownItem::from_value("Gamma"),
2703
+ ])
2704
+ .on_changed({
2705
+ let changed = changed.clone();
2706
+ let last_index = last_index.clone();
2707
+ move |event| {
2708
+ changed.set(changed.get() + 1);
2709
+ last_index.set(event.selected_index);
2710
+ }
2711
+ });
2712
+
2713
+ Application::mount(dropdown.clone());
2714
+ let _ = ffi::test::take_calls();
2715
+ focus(&dropdown.retained_node_ref());
2716
+ key_event(KeyEventType::Down, "ArrowDown", 0);
2717
+ let calls = ffi::test::take_calls();
2718
+ let list_item_handle = calls
2719
+ .iter()
2720
+ .filter_map(|call| match call {
2721
+ Call::SetSemanticRole { handle, role_enum }
2722
+ if *role_enum == SemanticRole::ListItem as u32 =>
2723
+ {
2724
+ Some(*handle)
2725
+ }
2726
+ _ => None,
2727
+ })
2728
+ .nth(1)
2729
+ .expect("dropdown open should create list item semantics");
2730
+
2731
+ event::__fui_on_pointer_event_with_metadata(
2732
+ PointerEventType::Enter as u32,
2733
+ list_item_handle,
2734
+ 10.0,
2735
+ 10.0,
2736
+ 0,
2737
+ 1,
2738
+ PointerType::Mouse as u32,
2739
+ 0,
2740
+ 0,
2741
+ 0.0,
2742
+ 0.0,
2743
+ 0.0,
2744
+ 0,
2745
+ );
2746
+ event::__fui_on_pointer_event_with_metadata(
2747
+ PointerEventType::Up as u32,
2748
+ list_item_handle,
2749
+ 10.0,
2750
+ 10.0,
2751
+ 0,
2752
+ 1,
2753
+ PointerType::Mouse as u32,
2754
+ 0,
2755
+ 0,
2756
+ 0.0,
2757
+ 0.0,
2758
+ 0.0,
2759
+ 0,
2760
+ );
2761
+
2762
+ assert_eq!(changed.get(), 1);
2763
+ assert_eq!(last_index.get(), 1);
2764
+
2765
+ key_event(KeyEventType::Down, "ArrowDown", 0);
2766
+ let _ = ffi::test::take_calls();
2767
+ key_event(KeyEventType::Down, "Escape", 0);
2768
+ let calls = ffi::test::take_calls();
2769
+ assert!(calls.iter().any(|call| matches!(
2770
+ call,
2771
+ Call::SetSemanticExpanded { has_expanded, is_expanded, .. }
2772
+ if *has_expanded && !*is_expanded
2773
+ )));
2774
+ }
2775
+
2776
+ #[test]
2777
+ fn dropdown_scroll_hook_survives_event_router_reset() {
2778
+ ffi::test::reset();
2779
+ let first = dropdown();
2780
+ first.items(vec![DropdownItem::from_value("First")]);
2781
+ Application::mount(first);
2782
+ event::reset();
2783
+
2784
+ let dropdown = dropdown();
2785
+ dropdown.items(vec![
2786
+ DropdownItem::from_value("Alpha"),
2787
+ DropdownItem::from_value("Beta"),
2788
+ ]);
2789
+ Application::mount(dropdown.clone());
2790
+ let _ = ffi::test::take_calls();
2791
+ focus(&dropdown.retained_node_ref());
2792
+ key_event(KeyEventType::Down, "ArrowDown", 0);
2793
+ let _ = ffi::test::take_calls();
2794
+
2795
+ ffi::test::set_viewport(0.0, 0.0);
2796
+ event::dispatch_scroll(
2797
+ dropdown.retained_node_ref().handle(),
2798
+ 0.0,
2799
+ 12.0,
2800
+ 100.0,
2801
+ 200.0,
2802
+ 100.0,
2803
+ 50.0,
2804
+ );
2805
+ let calls = ffi::test::take_calls();
2806
+ assert!(calls.iter().any(|call| matches!(
2807
+ call,
2808
+ Call::SetSemanticExpanded { has_expanded, is_expanded, .. }
2809
+ if *has_expanded && !*is_expanded
2810
+ )));
2811
+ }
2812
+
2813
+ #[test]
2814
+ fn combobox_select_index_clamps_and_keyboard_selection_emits() {
2815
+ ffi::test::reset();
2816
+ let changed = Rc::new(Cell::new(0));
2817
+ let last_index = Rc::new(Cell::new(-1));
2818
+ let combo = combo_box();
2819
+ combo
2820
+ .items(vec!["Calm", "Focused", "Energetic"])
2821
+ .filter_mode(ComboBoxFilterMode::None)
2822
+ .on_changed({
2823
+ let changed = changed.clone();
2824
+ let last_index = last_index.clone();
2825
+ move |event| {
2826
+ changed.set(changed.get() + 1);
2827
+ last_index.set(event.selected_index);
2828
+ }
2829
+ })
2830
+ .select_index(99);
2831
+
2832
+ Application::mount(combo.clone());
2833
+ let calls = ffi::test::take_calls();
2834
+ assert_eq!(combo.selected_index(), 2);
2835
+ assert!(calls.iter().any(|call| matches!(
2836
+ call,
2837
+ Call::Log { category, message }
2838
+ if category == "Warning/Layout"
2839
+ && message == "ComboBox.selectIndex() received 99; clamping to 2."
2840
+ )));
2841
+
2842
+ focus(&combo.retained_node_ref());
2843
+ key_event(KeyEventType::Down, "ArrowDown", 0);
2844
+ key_event(KeyEventType::Down, "Home", 0);
2845
+ key_event(KeyEventType::Down, "Enter", 0);
2846
+
2847
+ assert_eq!(combo.selected_index(), 0);
2848
+ assert_eq!(changed.get(), 1);
2849
+ assert_eq!(last_index.get(), 0);
2850
+ }
2851
+
2852
+ #[test]
2853
+ fn combobox_popup_builds_list_semantics_and_expanded_state() {
2854
+ ffi::test::reset();
2855
+ let combo = combo_box();
2856
+ combo
2857
+ .items(vec!["Alpha", "Beta"])
2858
+ .select_index(0)
2859
+ .popup_width(280.0)
2860
+ .max_visible_items(4);
2861
+
2862
+ Application::mount(combo.clone());
2863
+ let _ = ffi::test::take_calls();
2864
+
2865
+ focus(&combo.retained_node_ref());
2866
+ key_event(KeyEventType::Down, "ArrowDown", 0);
2867
+ let calls = ffi::test::take_calls();
2868
+
2869
+ assert!(calls.iter().any(|call| matches!(
2870
+ call,
2871
+ Call::SetSemanticRole { role_enum, .. } if *role_enum == SemanticRole::ComboBox as u32
2872
+ )));
2873
+ assert!(calls.iter().any(|call| matches!(
2874
+ call,
2875
+ Call::SetSemanticRole { role_enum, .. } if *role_enum == SemanticRole::List as u32
2876
+ )));
2877
+ assert!(calls.iter().any(|call| matches!(
2878
+ call,
2879
+ Call::SetSemanticExpanded { has_expanded, is_expanded, .. }
2880
+ if *has_expanded && *is_expanded
2881
+ )));
2882
+ assert!(calls.iter().any(|call| matches!(
2883
+ call,
2884
+ Call::SetSemanticSelected { has_selected, selected, .. }
2885
+ if *has_selected && *selected
2886
+ )));
2887
+ assert!(calls.iter().any(|call| matches!(
2888
+ call,
2889
+ Call::SetWidth { value, .. } if *value == 280.0
2890
+ )));
2891
+ }
2892
+
2893
+ #[test]
2894
+ fn combobox_filtering_and_exact_commit_match_fui_as() {
2895
+ ffi::test::reset();
2896
+ let changed = Rc::new(Cell::new(0));
2897
+ let combo = combo_box();
2898
+ combo
2899
+ .items(vec!["Alpha", "Beta", "Gamma"])
2900
+ .filter_mode(ComboBoxFilterMode::StartsWith)
2901
+ .commit_mode(ComboBoxCommitMode::SelectExactMatch)
2902
+ .on_changed({
2903
+ let changed = changed.clone();
2904
+ move |_event| changed.set(changed.get() + 1)
2905
+ });
2906
+
2907
+ Application::mount(combo.clone());
2908
+ let _ = ffi::test::take_calls();
2909
+ combo.text("Ga");
2910
+ focus(&combo.retained_node_ref());
2911
+ key_event(KeyEventType::Down, "ArrowDown", 0);
2912
+ key_event(KeyEventType::Down, "Enter", 0);
2913
+
2914
+ assert_eq!(combo.selected_index(), 2);
2915
+ assert_eq!(combo.value(), "Gamma");
2916
+ assert_eq!(changed.get(), 1);
2917
+
2918
+ combo.text("th");
2919
+ key_event(KeyEventType::Down, "ArrowDown", 0);
2920
+ key_event(KeyEventType::Down, "Enter", 0);
2921
+ assert_eq!(combo.selected_index(), -1);
2922
+ assert_eq!(combo.value(), "th");
2923
+ assert_eq!(changed.get(), 1);
2924
+ }
2925
+
2926
+ #[test]
2927
+ fn combobox_editor_key_handling_consumes_text_navigation_like_fui_as() {
2928
+ ffi::test::reset();
2929
+ let combo = combo_box();
2930
+ combo
2931
+ .items(vec!["Alpha", "Apricot", "Banana"])
2932
+ .filter_mode(ComboBoxFilterMode::StartsWith);
2933
+
2934
+ Application::mount(combo.clone());
2935
+ let calls = ffi::test::take_calls();
2936
+ let editor_handle = calls
2937
+ .iter()
2938
+ .find_map(|call| match call {
2939
+ Call::SetSemanticRole {
2940
+ handle, role_enum, ..
2941
+ } if *role_enum == SemanticRole::Textbox as u32 => Some(*handle),
2942
+ _ => None,
2943
+ })
2944
+ .expect("expected combobox editor textbox to be mounted");
2945
+ assert!(calls.iter().any(|call| matches!(
2946
+ call,
2947
+ Call::SetEditorCommandKeys { handle, enabled } if *handle == editor_handle && *enabled
2948
+ )));
2949
+
2950
+ event::__fui_on_focus_changed(editor_handle, true);
2951
+ combo.text("Ap");
2952
+
2953
+ assert!(key_event(KeyEventType::Down, "ArrowLeft", 0));
2954
+ assert!(key_event(
2955
+ KeyEventType::Down,
2956
+ "ArrowRight",
2957
+ ffi::KeyModifier::Shift as u32
2958
+ ));
2959
+ assert!(key_event(KeyEventType::Down, "Home", 0));
2960
+ assert!(key_event(KeyEventType::Down, "PageDown", 0));
2961
+ assert!(key_event(KeyEventType::Down, "ArrowDown", 0));
2962
+ assert!(combo.is_open());
2963
+ assert!(key_event(KeyEventType::Down, "Enter", 0));
2964
+ assert_eq!(combo.selected_index(), 1);
2965
+ assert_eq!(combo.value(), "Apricot");
2966
+ assert!(!key_event(
2967
+ KeyEventType::Down,
2968
+ "ArrowLeft",
2969
+ ffi::KeyModifier::Meta as u32
2970
+ ));
2971
+ }
2972
+
2973
+ #[test]
2974
+ fn combobox_revert_to_selection_commit_restores_committed_label_on_close() {
2975
+ ffi::test::reset();
2976
+ let combo = combo_box();
2977
+ combo
2978
+ .items(vec!["Alpha", "Beta", "Gamma"])
2979
+ .select_index(1)
2980
+ .commit_mode(ComboBoxCommitMode::RevertToSelection);
2981
+
2982
+ Application::mount(combo.clone());
2983
+ let _ = ffi::test::take_calls();
2984
+ focus(&combo.retained_node_ref());
2985
+ key_event(KeyEventType::Down, "ArrowDown", 0);
2986
+ combo.text("Custom");
2987
+ key_event(KeyEventType::Down, "Escape", 0);
2988
+
2989
+ assert_eq!(combo.selected_index(), 1);
2990
+ assert_eq!(combo.value(), "Beta");
2991
+ }
2992
+
2993
+ #[test]
2994
+ fn combobox_closes_popup_when_focus_leaves_for_another_input() {
2995
+ ffi::test::reset();
2996
+ let combo = combo_box();
2997
+ let input = text_input();
2998
+ let root = column();
2999
+ combo.items(vec!["Melbourne", "Sydney"]);
3000
+ root.child(&combo).child(&input);
3001
+
3002
+ Application::mount(root.clone());
3003
+ let _ = ffi::test::take_calls();
3004
+
3005
+ combo.text("Mel");
3006
+ focus(&combo.retained_node_ref());
3007
+ key_event(KeyEventType::Down, "ArrowDown", 0);
3008
+ let calls = ffi::test::take_calls();
3009
+ assert!(calls.iter().any(|call| matches!(
3010
+ call,
3011
+ Call::SetSemanticExpanded {
3012
+ has_expanded,
3013
+ is_expanded,
3014
+ ..
3015
+ } if *has_expanded && *is_expanded
3016
+ )));
3017
+
3018
+ blur(&combo.retained_node_ref());
3019
+ focus(&input.retained_node_ref());
3020
+ Application::flush_renders();
3021
+ let calls = ffi::test::take_calls();
3022
+
3023
+ assert!(calls.iter().any(|call| matches!(
3024
+ call,
3025
+ Call::SetSemanticExpanded {
3026
+ has_expanded,
3027
+ is_expanded,
3028
+ ..
3029
+ } if *has_expanded && !*is_expanded
3030
+ )));
3031
+ }
3032
+
3033
+ #[test]
3034
+ fn combobox_pointer_option_selection_survives_blur_during_popup_click() {
3035
+ ffi::test::reset();
3036
+ let changed = Rc::new(Cell::new(0));
3037
+ let last_value = Rc::new(std::cell::RefCell::new(String::new()));
3038
+ let combo = combo_box();
3039
+ combo.items(vec!["Melbourne", "Sydney"]).on_changed({
3040
+ let changed = changed.clone();
3041
+ let last_value = last_value.clone();
3042
+ move |event| {
3043
+ changed.set(changed.get() + 1);
3044
+ *last_value.borrow_mut() = event.item.value;
3045
+ }
3046
+ });
3047
+
3048
+ Application::mount(combo.clone());
3049
+ let _ = ffi::test::take_calls();
3050
+
3051
+ focus(&combo.retained_node_ref());
3052
+ key_event(KeyEventType::Down, "ArrowDown", 0);
3053
+ let calls = ffi::test::take_calls();
3054
+ let option_handle = calls
3055
+ .iter()
3056
+ .find_map(|call| match call {
3057
+ Call::SetSemanticLabel { handle, label } if label == "Sydney" => Some(*handle),
3058
+ _ => None,
3059
+ })
3060
+ .expect("expected Sydney option semantic label");
3061
+
3062
+ event::__fui_on_pointer_event_with_metadata(
3063
+ PointerEventType::Down as u32,
3064
+ option_handle,
3065
+ 30.0,
3066
+ 72.0,
3067
+ 0,
3068
+ 1,
3069
+ PointerType::Touch as u32,
3070
+ 0,
3071
+ 1,
3072
+ 0.0,
3073
+ 0.0,
3074
+ 0.0,
3075
+ 1,
3076
+ );
3077
+ let calls = ffi::test::take_calls();
3078
+ assert!(calls.iter().any(|call| matches!(
3079
+ call,
3080
+ Call::SetPointerCapture { handle } if *handle == option_handle
3081
+ )));
3082
+ blur(&combo.retained_node_ref());
3083
+ event::__fui_on_pointer_event_with_metadata(
3084
+ PointerEventType::Up as u32,
3085
+ option_handle,
3086
+ 30.0,
3087
+ 72.0,
3088
+ 0,
3089
+ 1,
3090
+ PointerType::Touch as u32,
3091
+ 0,
3092
+ 0,
3093
+ 0.0,
3094
+ 0.0,
3095
+ 0.0,
3096
+ 1,
3097
+ );
3098
+ let calls = ffi::test::take_calls();
3099
+ assert!(calls
3100
+ .iter()
3101
+ .any(|call| matches!(call, Call::ReleasePointerCapture)));
3102
+
3103
+ assert_eq!(combo.selected_index(), 1);
3104
+ assert_eq!(combo.value(), "Sydney");
3105
+ assert_eq!(changed.get(), 1);
3106
+ assert_eq!(&*last_value.borrow(), "Sydney");
3107
+ }
3108
+
3109
+ #[test]
3110
+ fn combobox_closes_after_popup_pointer_cancel_when_editor_blur_was_deferred() {
3111
+ ffi::test::reset();
3112
+ let combo = combo_box();
3113
+ combo.items(vec!["Melbourne", "Sydney"]);
3114
+
3115
+ Application::mount(combo.clone());
3116
+ let _ = ffi::test::take_calls();
3117
+
3118
+ focus(&combo.retained_node_ref());
3119
+ key_event(KeyEventType::Down, "ArrowDown", 0);
3120
+ let calls = ffi::test::take_calls();
3121
+ let option_handle = calls
3122
+ .iter()
3123
+ .find_map(|call| match call {
3124
+ Call::SetSemanticLabel { handle, label } if label == "Sydney" => Some(*handle),
3125
+ _ => None,
3126
+ })
3127
+ .expect("expected Sydney option semantic label");
3128
+
3129
+ event::__fui_on_pointer_event_with_metadata(
3130
+ PointerEventType::Down as u32,
3131
+ option_handle,
3132
+ 30.0,
3133
+ 72.0,
3134
+ 0,
3135
+ 1,
3136
+ PointerType::Touch as u32,
3137
+ 0,
3138
+ 1,
3139
+ 0.0,
3140
+ 0.0,
3141
+ 0.0,
3142
+ 1,
3143
+ );
3144
+ let calls = ffi::test::take_calls();
3145
+ assert!(calls.iter().any(|call| matches!(
3146
+ call,
3147
+ Call::SetPointerCapture { handle } if *handle == option_handle
3148
+ )));
3149
+ blur(&combo.retained_node_ref());
3150
+ event::__fui_on_pointer_event_with_metadata(
3151
+ PointerEventType::Cancel as u32,
3152
+ option_handle,
3153
+ 30.0,
3154
+ 72.0,
3155
+ 0,
3156
+ 1,
3157
+ PointerType::Touch as u32,
3158
+ 0,
3159
+ 0,
3160
+ 0.0,
3161
+ 0.0,
3162
+ 0.0,
3163
+ 1,
3164
+ );
3165
+ Application::flush_renders();
3166
+ let calls = ffi::test::take_calls();
3167
+
3168
+ assert!(!combo.is_open());
3169
+ assert!(calls.iter().any(|call| matches!(
3170
+ call,
3171
+ Call::SetSemanticExpanded {
3172
+ has_expanded,
3173
+ is_expanded,
3174
+ ..
3175
+ } if *has_expanded && !*is_expanded
3176
+ )));
3177
+ }
3178
+
3179
+ #[test]
3180
+ fn text_input_text_replaced_event_updates_value_and_emits_changed() {
3181
+ ffi::test::reset();
3182
+ let input = text_input();
3183
+ let last_text = Rc::new(std::cell::RefCell::new(String::new()));
3184
+ input
3185
+ .node_id("test-text-input")
3186
+ .placeholder("Username or email")
3187
+ .on_changed({
3188
+ let last_text = last_text.clone();
3189
+ move |event| {
3190
+ *last_text.borrow_mut() = event.text;
3191
+ }
3192
+ });
3193
+
3194
+ Application::mount(input.clone());
3195
+ let calls = ffi::test::take_calls();
3196
+ let editor_handle = calls
3197
+ .iter()
3198
+ .find_map(|call| match call {
3199
+ Call::SetNodeId { handle, node_id } if node_id == "test-text-input" => Some(*handle),
3200
+ _ => None,
3201
+ })
3202
+ .expect("expected text input editor node id to be applied");
3203
+
3204
+ event::__fui_on_text_replaced(editor_handle, 0, 0, b"hello".as_ptr(), 5);
3205
+
3206
+ assert_eq!(input.value(), "hello");
3207
+ assert_eq!(&*last_text.borrow(), "hello");
3208
+ Application::unmount();
3209
+ }
3210
+
3211
+ #[test]
3212
+ fn text_input_selection_range_uses_char_indices_and_sends_utf8_bytes() {
3213
+ ffi::test::reset();
3214
+ let input = text_input();
3215
+ input
3216
+ .node_id("emoji-text-input")
3217
+ .text("a😄b")
3218
+ .selection_range(1, 2);
3219
+ Application::mount(input.clone());
3220
+ let calls = ffi::test::take_calls();
3221
+ let editor_handle = calls
3222
+ .iter()
3223
+ .find_map(|call| match call {
3224
+ Call::SetNodeId { handle, node_id } if node_id == "emoji-text-input" => Some(*handle),
3225
+ _ => None,
3226
+ })
3227
+ .expect("expected text input editor node id to be applied");
3228
+ assert_eq!(input.selection_start(), 1);
3229
+ assert_eq!(input.selection_end(), 2);
3230
+ assert_eq!(input.selection_start_byte_offset(), 1);
3231
+ assert_eq!(input.selection_end_byte_offset(), 5);
3232
+ input.selection_range(0, 3);
3233
+ let calls = ffi::test::take_calls();
3234
+ assert!(calls.iter().any(|call| matches!(
3235
+ call,
3236
+ Call::SetTextSelectionRange { handle, start, end }
3237
+ if *handle == editor_handle && *start == 0 && *end == 6
3238
+ )));
3239
+ Application::unmount();
3240
+ }
3241
+
3242
+ #[test]
3243
+ fn text_input_focusable_forwards_to_editor_text_node() {
3244
+ ffi::test::reset();
3245
+ let input = text_input();
3246
+ input
3247
+ .node_id("focusable-text-input")
3248
+ .text("Focusable")
3249
+ .focusable(false, 0);
3250
+ Application::mount(input.clone());
3251
+ let calls = ffi::test::take_calls();
3252
+ let editor_handle = calls
3253
+ .iter()
3254
+ .find_map(|call| match call {
3255
+ Call::SetNodeId { handle, node_id } if node_id == "focusable-text-input" => {
3256
+ Some(*handle)
3257
+ }
3258
+ _ => None,
3259
+ })
3260
+ .expect("expected text input editor node id to be applied");
3261
+
3262
+ assert!(calls.iter().any(|call| matches!(
3263
+ call,
3264
+ Call::SetFocusable { handle, focusable, tab_index }
3265
+ if *handle == editor_handle && !*focusable && *tab_index == 0
3266
+ )));
3267
+
3268
+ input.focusable(true, 7);
3269
+ let calls = ffi::test::take_calls();
3270
+ assert!(calls.iter().any(|call| matches!(
3271
+ call,
3272
+ Call::SetFocusable { handle, focusable, tab_index }
3273
+ if *handle == editor_handle && *focusable && *tab_index == 7
3274
+ )));
3275
+ Application::unmount();
3276
+ }
3277
+
3278
+ #[test]
3279
+ fn text_area_focusable_forwards_to_editor_text_node() {
3280
+ ffi::test::reset();
3281
+ let input = text_area();
3282
+ input
3283
+ .node_id("focusable-text-area")
3284
+ .text("Focusable\narea")
3285
+ .focusable(false, 0);
3286
+ Application::mount(input.clone());
3287
+ let calls = ffi::test::take_calls();
3288
+ let editor_handle = calls
3289
+ .iter()
3290
+ .find_map(|call| match call {
3291
+ Call::SetNodeId { handle, node_id } if node_id == "focusable-text-area" => {
3292
+ Some(*handle)
3293
+ }
3294
+ _ => None,
3295
+ })
3296
+ .expect("expected text area editor node id to be applied");
3297
+
3298
+ assert!(calls.iter().any(|call| matches!(
3299
+ call,
3300
+ Call::SetFocusable { handle, focusable, tab_index }
3301
+ if *handle == editor_handle && !*focusable && *tab_index == 0
3302
+ )));
3303
+ Application::unmount();
3304
+ }
3305
+
3306
+ #[test]
3307
+ fn text_input_keyboard_focus_preserves_logical_end_selection_state() {
3308
+ ffi::test::reset();
3309
+ focus_visibility::reset_keyboard_focus_visibility();
3310
+ let input = text_input();
3311
+ input.node_id("tab-focus-text-input").text("a😄b");
3312
+ Application::mount(input.clone());
3313
+ let calls = ffi::test::take_calls();
3314
+ let editor_handle = calls
3315
+ .iter()
3316
+ .find_map(|call| match call {
3317
+ Call::SetNodeId { handle, node_id } if node_id == "tab-focus-text-input" => {
3318
+ Some(*handle)
3319
+ }
3320
+ _ => None,
3321
+ })
3322
+ .expect("expected text input editor node id to be applied");
3323
+
3324
+ event::__fui_on_focus_changed(editor_handle, true);
3325
+ let _ = ffi::test::take_calls();
3326
+ assert_eq!(input.selection_start(), 3);
3327
+ assert_eq!(input.selection_end(), 3);
3328
+ assert_eq!(input.selection_start_byte_offset(), 6);
3329
+ assert_eq!(input.selection_end_byte_offset(), 6);
3330
+ Application::unmount();
3331
+ }
3332
+
3333
+ #[test]
3334
+ fn text_input_reverse_tab_focus_restores_caret_to_logical_end_after_pointer_focus() {
3335
+ ffi::test::reset();
3336
+ focus_visibility::reset_keyboard_focus_visibility();
3337
+ focus_visibility::show_keyboard_focus_for_pointer_event(PointerEventType::Down);
3338
+ let input = text_input();
3339
+ input
3340
+ .node_id("reverse-tab-focus-text-input")
3341
+ .text("readonly")
3342
+ .read_only(true);
3343
+ Application::mount(input.clone());
3344
+ let calls = ffi::test::take_calls();
3345
+ let editor_handle = calls
3346
+ .iter()
3347
+ .find_map(|call| match call {
3348
+ Call::SetNodeId { handle, node_id } if node_id == "reverse-tab-focus-text-input" => {
3349
+ Some(*handle)
3350
+ }
3351
+ _ => None,
3352
+ })
3353
+ .expect("expected text input editor node id to be applied");
3354
+
3355
+ key_event(KeyEventType::Down, "Shift", 0);
3356
+ event::__fui_on_focus_changed(editor_handle, true);
3357
+ let calls = ffi::test::take_calls();
3358
+ assert!(!calls.iter().any(|call| matches!(
3359
+ call,
3360
+ Call::SetTextSelectionRange { handle, start, end }
3361
+ if *handle == editor_handle && *start == 8 && *end == 8
3362
+ )));
3363
+ assert_eq!(input.selection_start(), 8);
3364
+ assert_eq!(input.selection_end(), 8);
3365
+ Application::unmount();
3366
+ }
3367
+
3368
+ #[test]
3369
+ fn text_input_shell_pointer_down_preserves_existing_selection_in_mock_path() {
3370
+ ffi::test::reset();
3371
+ let input = text_input();
3372
+ input
3373
+ .node_id("shell-pointer-text-input")
3374
+ .text("Read-only sample")
3375
+ .read_only(true)
3376
+ .selection_range(0, 4);
3377
+ Application::mount(input.clone());
3378
+ let _ = ffi::test::take_calls();
3379
+
3380
+ event::__fui_on_pointer_event_with_metadata(
3381
+ PointerEventType::Down as u32,
3382
+ input.retained_node_ref().handle().raw(),
3383
+ 220.0,
3384
+ 12.0,
3385
+ 0,
3386
+ 1,
3387
+ PointerType::Mouse as u32,
3388
+ 0,
3389
+ 1,
3390
+ 0.0,
3391
+ 0.0,
3392
+ 0.0,
3393
+ 1,
3394
+ );
3395
+ let _ = ffi::test::take_calls();
3396
+ assert_eq!(input.selection_start(), 0);
3397
+ assert_eq!(input.selection_end(), 4);
3398
+ Application::unmount();
3399
+ }
3400
+
3401
+ #[test]
3402
+ fn text_input_editor_pointer_down_does_not_bubble_into_shell_document_end_path() {
3403
+ ffi::test::reset();
3404
+ let input = text_input();
3405
+ input
3406
+ .node_id("editor-pointer-text-input")
3407
+ .text("Read-only sample")
3408
+ .read_only(true)
3409
+ .selection_range(0, 4);
3410
+ Application::mount(input.clone());
3411
+ let calls = ffi::test::take_calls();
3412
+ let editor_handle = calls
3413
+ .iter()
3414
+ .find_map(|call| match call {
3415
+ Call::SetNodeId { handle, node_id } if node_id == "editor-pointer-text-input" => {
3416
+ Some(*handle)
3417
+ }
3418
+ _ => None,
3419
+ })
3420
+ .expect("expected text input editor node id to be applied");
3421
+
3422
+ event::__fui_on_pointer_event_with_metadata(
3423
+ PointerEventType::Down as u32,
3424
+ editor_handle,
3425
+ 12.0,
3426
+ 12.0,
3427
+ 0,
3428
+ 1,
3429
+ PointerType::Mouse as u32,
3430
+ 0,
3431
+ 1,
3432
+ 0.0,
3433
+ 0.0,
3434
+ 0.0,
3435
+ 1,
3436
+ );
3437
+ let calls = ffi::test::take_calls();
3438
+ assert!(!calls.iter().any(|call| matches!(
3439
+ call,
3440
+ Call::SetTextSelectionRange { handle, start, end }
3441
+ if *handle == editor_handle && *start == 16 && *end == 16
3442
+ )));
3443
+ assert_eq!(input.selection_start(), 0);
3444
+ assert_eq!(input.selection_end(), 4);
3445
+ Application::unmount();
3446
+ }
3447
+
3448
+ #[test]
3449
+ fn text_input_text_replacement_clamps_selection_bytes_from_char_positions() {
3450
+ ffi::test::reset();
3451
+ let input = text_input();
3452
+ input
3453
+ .node_id("emoji-clamp-input")
3454
+ .text("a😄b")
3455
+ .selection_range(0, 3);
3456
+ Application::mount(input.clone());
3457
+ let calls = ffi::test::take_calls();
3458
+ let editor_handle = calls
3459
+ .iter()
3460
+ .find_map(|call| match call {
3461
+ Call::SetNodeId { handle, node_id } if node_id == "emoji-clamp-input" => Some(*handle),
3462
+ _ => None,
3463
+ })
3464
+ .expect("expected text input editor node id to be applied");
3465
+
3466
+ event::__fui_on_text_replaced(editor_handle, 1, 5, std::ptr::null(), 0);
3467
+
3468
+ assert_eq!(input.value(), "ab");
3469
+ assert_eq!(input.selection_start(), 0);
3470
+ assert_eq!(input.selection_end(), 2);
3471
+ assert_eq!(input.selection_start_byte_offset(), 0);
3472
+ assert_eq!(input.selection_end_byte_offset(), 2);
3473
+ Application::unmount();
3474
+ }
3475
+
3476
+ #[test]
3477
+ fn text_input_accepts_tab_inserts_tab_and_consumes_plain_tab_only() {
3478
+ ffi::test::reset();
3479
+ let input = text_input();
3480
+ input
3481
+ .node_id("accepts-tab-input")
3482
+ .text("ab")
3483
+ .selection_range(1, 1);
3484
+ Application::mount(input.clone());
3485
+ let calls = ffi::test::take_calls();
3486
+ let editor_handle = calls
3487
+ .iter()
3488
+ .find_map(|call| match call {
3489
+ Call::SetNodeId { handle, node_id } if node_id == "accepts-tab-input" => Some(*handle),
3490
+ _ => None,
3491
+ })
3492
+ .expect("expected text input editor node id to be applied");
3493
+
3494
+ event::__fui_on_focus_changed(editor_handle, true);
3495
+ assert!(!key_event(KeyEventType::Down, "Tab", 0));
3496
+ assert_eq!(input.value(), "ab");
3497
+ assert!(key_event(KeyEventType::Down, "ArrowDown", 0));
3498
+ assert!(key_event(
3499
+ KeyEventType::Down,
3500
+ "ArrowLeft",
3501
+ ffi::KeyModifier::Shift as u32
3502
+ ));
3503
+ assert!(key_event(KeyEventType::Down, "Home", 0));
3504
+ assert!(key_event(KeyEventType::Down, "PageDown", 0));
3505
+ assert_eq!(input.value(), "ab");
3506
+
3507
+ input.accepts_tab(true);
3508
+ focus_visibility::show_keyboard_focus_for_pointer_event(PointerEventType::Down);
3509
+ assert!(key_event(KeyEventType::Down, "Tab", 0));
3510
+ assert!(!focus_visibility::keyboard_focus_visible());
3511
+ assert_eq!(input.value(), "a\tb");
3512
+ assert_eq!(input.selection_start(), 2);
3513
+ assert_eq!(input.selection_end(), 2);
3514
+ assert_eq!(input.selection_start_byte_offset(), 2);
3515
+ assert_eq!(input.selection_end_byte_offset(), 2);
3516
+
3517
+ assert!(!key_event(
3518
+ KeyEventType::Down,
3519
+ "Tab",
3520
+ ffi::KeyModifier::Shift as u32
3521
+ ));
3522
+ assert_eq!(input.value(), "a\tb");
3523
+ Application::unmount();
3524
+ }
3525
+
3526
+ #[test]
3527
+ fn text_area_accepts_tab_replaces_selection_and_respects_read_only() {
3528
+ ffi::test::reset();
3529
+ let input = text_area();
3530
+ input
3531
+ .node_id("accepts-tab-text-area")
3532
+ .text("a😄b\nc")
3533
+ .selection_range(1, 3)
3534
+ .accepts_tab(true);
3535
+ Application::mount(input.clone());
3536
+ let calls = ffi::test::take_calls();
3537
+ let editor_handle = calls
3538
+ .iter()
3539
+ .find_map(|call| match call {
3540
+ Call::SetNodeId { handle, node_id } if node_id == "accepts-tab-text-area" => {
3541
+ Some(*handle)
3542
+ }
3543
+ _ => None,
3544
+ })
3545
+ .expect("expected text area editor node id to be applied");
3546
+
3547
+ event::__fui_on_focus_changed(editor_handle, true);
3548
+ assert!(key_event(KeyEventType::Down, "Tab", 0));
3549
+ assert_eq!(input.value(), "a\t\nc");
3550
+ assert_eq!(input.selection_start(), 2);
3551
+ assert_eq!(input.selection_end(), 2);
3552
+ assert_eq!(input.selection_start_byte_offset(), 2);
3553
+ assert_eq!(input.selection_end_byte_offset(), 2);
3554
+
3555
+ input.read_only(true);
3556
+ assert!(!key_event(KeyEventType::Down, "Tab", 0));
3557
+ assert_eq!(input.value(), "a\t\nc");
3558
+ Application::unmount();
3559
+ }
3560
+
3561
+ #[test]
3562
+ fn text_area_uses_multiline_profile_wrapping_and_scroll_chrome() {
3563
+ ffi::test::reset();
3564
+ let input = text_area();
3565
+ input
3566
+ .node_id("text-area-profile")
3567
+ .text("Wide multiline content")
3568
+ .wrapping(false)
3569
+ .vertical_scrollbar_visibility(ScrollBarVisibility::Always)
3570
+ .horizontal_scrollbar_visibility(ScrollBarVisibility::Always);
3571
+ Application::mount(input.clone());
3572
+ let calls = ffi::test::take_calls();
3573
+ let editor_handle = calls
3574
+ .iter()
3575
+ .find_map(|call| match call {
3576
+ Call::SetNodeId { handle, node_id } if node_id == "text-area-profile" => Some(*handle),
3577
+ _ => None,
3578
+ })
3579
+ .expect("expected text area editor node id to be applied");
3580
+
3581
+ assert!(calls.iter().any(|call| matches!(
3582
+ call,
3583
+ Call::SetTextLimits { handle, max_lines, .. }
3584
+ if *handle == editor_handle && *max_lines == 0
3585
+ )));
3586
+ assert!(calls.iter().any(|call| matches!(
3587
+ call,
3588
+ Call::SetTextWrapping { handle, wrap }
3589
+ if *handle == editor_handle && !*wrap
3590
+ )));
3591
+ assert!(calls.iter().any(|call| matches!(
3592
+ call,
3593
+ Call::SetTextVerticalAlign { handle, align_enum }
3594
+ if *handle == editor_handle && *align_enum == TextVerticalAlign::Top as u32
3595
+ )));
3596
+ assert!(calls.iter().any(|call| matches!(
3597
+ call,
3598
+ Call::SetWidth { handle, unit_enum, .. }
3599
+ if *handle == editor_handle && *unit_enum == Unit::Auto as u32
3600
+ )));
3601
+
3602
+ input.wrapping(true);
3603
+ let calls = ffi::test::take_calls();
3604
+ assert!(calls.iter().any(|call| matches!(
3605
+ call,
3606
+ Call::SetTextWrapping { handle, wrap }
3607
+ if *handle == editor_handle && *wrap
3608
+ )));
3609
+ assert!(calls.iter().any(|call| matches!(
3610
+ call,
3611
+ Call::SetWidth { handle, unit_enum, .. }
3612
+ if *handle == editor_handle && *unit_enum == Unit::Percent as u32
3613
+ )));
3614
+ Application::unmount();
3615
+ }
3616
+
3617
+ #[test]
3618
+ fn text_area_readonly_disabled_placeholder_and_line_height_follow_text_input_core() {
3619
+ ffi::test::reset();
3620
+ let input = text_area();
3621
+ input
3622
+ .node_id("text-area-readonly")
3623
+ .placeholder("Notes")
3624
+ .read_only(true)
3625
+ .line_height(26.0);
3626
+ Application::mount(input.clone());
3627
+ let calls = ffi::test::take_calls();
3628
+ let editor_handle = calls
3629
+ .iter()
3630
+ .find_map(|call| match call {
3631
+ Call::SetNodeId { handle, node_id } if node_id == "text-area-readonly" => Some(*handle),
3632
+ _ => None,
3633
+ })
3634
+ .expect("expected text area editor node id to be applied");
3635
+
3636
+ assert!(calls.iter().any(|call| matches!(
3637
+ call,
3638
+ Call::SetEditable { handle, editable }
3639
+ if *handle == editor_handle && !*editable
3640
+ )));
3641
+ assert!(calls.iter().any(|call| matches!(
3642
+ call,
3643
+ Call::SetSelectable { handle, selectable, .. }
3644
+ if *handle == editor_handle && *selectable
3645
+ )));
3646
+ assert!(calls.iter().any(|call| matches!(
3647
+ call,
3648
+ Call::SetLineHeight { handle, line_height }
3649
+ if *handle == editor_handle && (*line_height - 26.0).abs() < f32::EPSILON
3650
+ )));
3651
+
3652
+ event::__fui_on_text_replaced(editor_handle, 0, 0, b"Hello\nworld".as_ptr(), 11);
3653
+ assert_eq!(input.value(), "Hello\nworld");
3654
+
3655
+ event::__fui_on_text_changed(editor_handle, b"alpha\nbeta".as_ptr(), 10);
3656
+ assert_eq!(input.value(), "alpha\nbeta");
3657
+ event::__fui_on_selection_changed(editor_handle, 6, 10);
3658
+ assert_eq!(input.selection_start(), 6);
3659
+ assert_eq!(input.selection_end(), 10);
3660
+
3661
+ event::__fui_on_text_replaced(editor_handle, 0, 11, std::ptr::null(), 0);
3662
+ assert_eq!(input.value(), "");
3663
+ Application::unmount();
3664
+ }
3665
+
3666
+ #[test]
3667
+ fn text_area_reports_internal_scroll_offsets_and_uses_text_area_template_slot() {
3668
+ ffi::test::reset();
3669
+ clear_control_templates();
3670
+ let text_input_created = Rc::new(Cell::new(0));
3671
+ let text_area_created = Rc::new(Cell::new(0));
3672
+ let text_input_state = Rc::new(RefCell::new(None));
3673
+ let text_area_state = Rc::new(RefCell::new(None));
3674
+ use_control_templates(Some(ControlTemplateSet {
3675
+ text_input: Some(Rc::new(TestTextInputTemplate {
3676
+ created: text_input_created.clone(),
3677
+ last_state: text_input_state,
3678
+ })),
3679
+ text_area: Some(Rc::new(TestTextInputTemplate {
3680
+ created: text_area_created.clone(),
3681
+ last_state: text_area_state.clone(),
3682
+ })),
3683
+ ..Default::default()
3684
+ }));
3685
+
3686
+ let input = text_area();
3687
+ input
3688
+ .node_id("text-area-template")
3689
+ .height(120.0, Unit::Pixel);
3690
+ Application::mount(input.clone());
3691
+ let calls = ffi::test::take_calls();
3692
+ let editor_handle = calls
3693
+ .iter()
3694
+ .find_map(|call| match call {
3695
+ Call::SetNodeId { handle, node_id } if node_id == "text-area-template" => Some(*handle),
3696
+ _ => None,
3697
+ })
3698
+ .expect("expected text area editor node id to be applied");
3699
+
3700
+ assert_eq!(text_input_created.get(), 0);
3701
+ assert_eq!(text_area_created.get(), 1);
3702
+ assert!(text_area_state
3703
+ .borrow()
3704
+ .as_ref()
3705
+ .is_some_and(|state| state.multiline));
3706
+
3707
+ input.scroll_to(12.0, 34.0);
3708
+ let calls = ffi::test::take_calls();
3709
+ assert_eq!(input.scroll_offset_x(), 12.0);
3710
+ assert_eq!(input.scroll_offset_y(), 34.0);
3711
+ assert!(calls.iter().any(|call| matches!(
3712
+ call,
3713
+ Call::SetScrollOffset { offset_x, offset_y, .. }
3714
+ if (*offset_x - 12.0).abs() < f32::EPSILON
3715
+ && (*offset_y - 34.0).abs() < f32::EPSILON
3716
+ )));
3717
+ assert!(editor_handle > 0);
3718
+ Application::unmount();
3719
+ clear_control_templates();
3720
+ }
3721
+
3722
+ #[test]
3723
+ fn text_area_shell_pointer_down_preserves_existing_selection_path() {
3724
+ ffi::test::reset();
3725
+ let input = text_area();
3726
+ input
3727
+ .node_id("text-area-shell-pointer")
3728
+ .placeholder("Notes")
3729
+ .text("First\nSecond")
3730
+ .selection_range(0, 5);
3731
+ Application::mount(input.clone());
3732
+ let calls = ffi::test::take_calls();
3733
+ let editor_handle = calls
3734
+ .iter()
3735
+ .find_map(|call| match call {
3736
+ Call::SetNodeId { handle, node_id } if node_id == "text-area-shell-pointer" => {
3737
+ Some(*handle)
3738
+ }
3739
+ _ => None,
3740
+ })
3741
+ .expect("expected text area editor node id to be applied");
3742
+
3743
+ event::__fui_on_pointer_event_with_metadata(
3744
+ PointerEventType::Down as u32,
3745
+ input.retained_node_ref().handle().raw(),
3746
+ 8.0,
3747
+ 8.0,
3748
+ 0,
3749
+ 1,
3750
+ PointerType::Mouse as u32,
3751
+ 0,
3752
+ 1,
3753
+ 0.0,
3754
+ 0.0,
3755
+ 0.0,
3756
+ 1,
3757
+ );
3758
+ let calls = ffi::test::take_calls();
3759
+ assert!(calls.iter().any(|call| matches!(
3760
+ call,
3761
+ Call::SetTextSelectionRange { handle, start, end }
3762
+ if *handle == editor_handle && *start == 0 && *end == 5
3763
+ )));
3764
+ assert_eq!(input.selection_start(), 0);
3765
+ assert_eq!(input.selection_end(), 5);
3766
+ Application::unmount();
3767
+ }
3768
+
3769
+ #[test]
3770
+ fn text_area_inherits_parent_disabled_state_to_editor() {
3771
+ ffi::test::reset();
3772
+ let parent = column();
3773
+ let input = text_area();
3774
+ input.node_id("text-area-disabled").text("Disabled notes");
3775
+ parent.child(&input);
3776
+ Application::mount(parent.clone());
3777
+ let calls = ffi::test::take_calls();
3778
+ let editor_handle = calls
3779
+ .iter()
3780
+ .find_map(|call| match call {
3781
+ Call::SetNodeId { handle, node_id } if node_id == "text-area-disabled" => Some(*handle),
3782
+ _ => None,
3783
+ })
3784
+ .expect("expected text area editor node id to be applied");
3785
+
3786
+ parent.enabled(false);
3787
+ let calls = ffi::test::take_calls();
3788
+ assert!(calls.iter().any(|call| matches!(
3789
+ call,
3790
+ Call::SetEditable { handle, editable }
3791
+ if *handle == editor_handle && !*editable
3792
+ )));
3793
+ assert!(calls.iter().any(|call| matches!(
3794
+ call,
3795
+ Call::SetSelectable { handle, selectable, .. }
3796
+ if *handle == editor_handle && !*selectable
3797
+ )));
3798
+ assert!(calls.iter().any(|call| matches!(
3799
+ call,
3800
+ Call::SetSemanticDisabled { handle, has_disabled, disabled }
3801
+ if *handle == editor_handle && *has_disabled && *disabled
3802
+ )));
3803
+
3804
+ parent.enabled(true);
3805
+ let calls = ffi::test::take_calls();
3806
+ assert!(calls.iter().any(|call| matches!(
3807
+ call,
3808
+ Call::SetEditable { handle, editable }
3809
+ if *handle == editor_handle && *editable
3810
+ )));
3811
+ assert!(calls.iter().any(|call| matches!(
3812
+ call,
3813
+ Call::SetSelectable { handle, selectable, .. }
3814
+ if *handle == editor_handle && *selectable
3815
+ )));
3816
+ assert!(calls.iter().any(|call| matches!(
3817
+ call,
3818
+ Call::SetSemanticDisabled { handle, has_disabled, disabled }
3819
+ if *handle == editor_handle && *has_disabled && !*disabled
3820
+ )));
3821
+ Application::unmount();
3822
+ }
3823
+
3824
+ #[test]
3825
+ fn text_input_persisted_state_restores_and_emits_without_password_capture() {
3826
+ ffi::test::reset();
3827
+ let source = text_input();
3828
+ source.node_id("text-input-persisted").text("hello");
3829
+ Application::mount(source.clone());
3830
+ Application::capture_persisted_ui_state();
3831
+ let calls = ffi::test::take_calls();
3832
+ assert!(calls.iter().any(|call| matches!(
3833
+ call,
3834
+ Call::SetPersistedState { node_id, kind, version, payload }
3835
+ if node_id == "text-input-persisted"
3836
+ && kind == "text-input-value"
3837
+ && *version == 1
3838
+ && payload == "hello"
3839
+ )));
3840
+
3841
+ let changes = Rc::new(Cell::new(0));
3842
+ let changes_clone = changes.clone();
3843
+ let restored = text_input();
3844
+ restored.node_id("text-input-persisted");
3845
+ restored.on_changed(move |_event| changes_clone.set(changes_clone.get() + 1));
3846
+ Application::mount(restored.clone());
3847
+ let _ = ffi::test::take_calls();
3848
+ Application::restore_persisted_ui_state();
3849
+
3850
+ assert_eq!(restored.value(), "hello");
3851
+ assert_eq!(changes.get(), 1);
3852
+
3853
+ let password = text_input();
3854
+ password
3855
+ .node_id("text-input-password-persisted")
3856
+ .password(true)
3857
+ .text("secret");
3858
+ Application::mount(password.clone());
3859
+ Application::capture_persisted_ui_state();
3860
+ let calls = ffi::test::take_calls();
3861
+ assert!(!calls.iter().any(|call| matches!(
3862
+ call,
3863
+ Call::SetPersistedState { node_id, kind, .. }
3864
+ if node_id == "text-input-password-persisted" && kind == "text-input-value"
3865
+ )));
3866
+ Application::unmount();
3867
+ }
3868
+
3869
+ #[test]
3870
+ fn text_input_focus_adorner_tracks_keyboard_focus_visibility() {
3871
+ ffi::test::reset();
3872
+ focus_visibility::reset_keyboard_focus_visibility();
3873
+ let input = text_input();
3874
+ input.node_id("focus-text-input");
3875
+ Application::mount(input.clone());
3876
+ let calls = ffi::test::take_calls();
3877
+ let editor_handle = calls
3878
+ .iter()
3879
+ .find_map(|call| match call {
3880
+ Call::SetNodeId { handle, node_id } if node_id == "focus-text-input" => Some(*handle),
3881
+ _ => None,
3882
+ })
3883
+ .expect("expected text input editor node id to be applied");
3884
+
3885
+ focus_visibility::show_keyboard_focus_for_pointer_event(PointerEventType::Down);
3886
+ let _ = ffi::test::take_calls();
3887
+ event::__fui_on_focus_changed(editor_handle, true);
3888
+ let calls = ffi::test::take_calls();
3889
+ assert!(!calls
3890
+ .iter()
3891
+ .any(|call| matches!(call, Call::NodeAddChild { .. })));
3892
+
3893
+ key_event(KeyEventType::Down, "a", 0);
3894
+ Application::flush_renders();
3895
+ let calls = ffi::test::take_calls();
3896
+ assert!(focus_visibility::keyboard_focus_visible());
3897
+ assert!(calls
3898
+ .iter()
3899
+ .any(|call| matches!(call, Call::NodeAddChild { .. })));
3900
+
3901
+ key_event(KeyEventType::Down, "Tab", 0);
3902
+ Application::flush_renders();
3903
+ assert!(focus_visibility::keyboard_focus_visible());
3904
+ Application::unmount();
3905
+ }