1
//! Single-line text input widget with placeholder and two-way data binding.
2
//!
3
//! The main entry point is [`TextInput`], which holds the editable state
4
//! ([`TextInputState`]) together with per-platform default styles.  Call
5
//! [`TextInput::dom()`] to obtain a renderable [`Dom`] node.
6
//!
7
//! The widget is a `contenteditable` host: the container carries the flag and
8
//! the tab index, so the engine's `TextEditManager` owns the caret, the
9
//! selection and the buffer. Caret and selection are display-list items driven
10
//! by that manager — the widget contributes no cursor node — and edits run
11
//! through `record_text_input` / `apply_text_changeset`. [`TextInputState`] is a
12
//! *mirror* of that state, refreshed from the engine's changesets so the public
13
//! callbacks keep the shape existing hosts bind against.
14
//!
15
//! Both the value and the placeholder are `<p>` blocks wrapping a bare text
16
//! node: a [`NodeType::Text`](azul_core::dom::NodeType::Text) node is always
17
//! inline-level and owns no rect, so box-model properties on one are inert and
18
//! nothing bounds or clips the line.
19
//!
20
//! For higher-level text-input management (IME, clipboard, undo) see
21
//! `layout/src/managers/text_input.rs`.
22

            
23
use alloc::{string::String, vec::Vec};
24

            
25
use azul_core::{
26
    callbacks::{CoreCallback, CoreCallbackData, Update},
27
    dom::{Dom, DomNodeId},
28
    refany::RefAny,
29
    task::OptionTimerId,
30
};
31
#[allow(clippy::wildcard_imports)] // widget/render module pulls in the css property/value types it builds with
32
use azul_css::{
33
    dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec},
34
    props::{
35
        basic::*,
36
        layout::*,
37
        property::{CssProperty, *},
38
        style::*,
39
    },
40
    *,
41
};
42
use azul_css::css::BoxOrStatic;
43

            
44
use crate::callbacks::{Callback, CallbackInfo};
45

            
46
const BACKGROUND_COLOR: ColorU = ColorU {
47
    r: 255,
48
    g: 255,
49
    b: 255,
50
    a: 255,
51
}; // white
52
const BLACK: ColorU = ColorU {
53
    r: 0,
54
    g: 0,
55
    b: 0,
56
    a: 255,
57
};
58
const TEXT_COLOR: StyleTextColor = StyleTextColor { inner: BLACK }; // black
59
const COLOR_9B9B9B: ColorU = ColorU {
60
    r: 155,
61
    g: 155,
62
    b: 155,
63
    a: 255,
64
}; // #9b9b9b
65
const COLOR_4286F4: ColorU = ColorU {
66
    r: 66,
67
    g: 134,
68
    b: 244,
69
    a: 255,
70
}; // #4286f4
71
const COLOR_4C4C4C: ColorU = ColorU {
72
    r: 76,
73
    g: 76,
74
    b: 76,
75
    a: 255,
76
}; // #4C4C4C
77

            
78
const BACKGROUND_THEME_LIGHT: &[StyleBackgroundContent] =
79
    &[StyleBackgroundContent::Color(BACKGROUND_COLOR)];
80
const BACKGROUND_COLOR_LIGHT: StyleBackgroundContentVec =
81
    StyleBackgroundContentVec::from_const_slice(BACKGROUND_THEME_LIGHT);
82

            
83
const SANS_SERIF_STR: &str = "system:ui";
84
const SANS_SERIF: AzString = AzString::from_const_str(SANS_SERIF_STR);
85
const SANS_SERIF_FAMILIES: &[StyleFontFamily] = &[StyleFontFamily::System(SANS_SERIF)];
86
const SANS_SERIF_FAMILY: StyleFontFamilyVec =
87
    StyleFontFamilyVec::from_const_slice(SANS_SERIF_FAMILIES);
88

            
89
// -- container style
90

            
91
#[cfg(target_os = "windows")]
92
static TEXT_INPUT_CONTAINER_PROPS: &[CssPropertyWithConditions] = &[
93
    CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Relative)),
94
    CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Text)),
95
    CssPropertyWithConditions::simple(CssProperty::const_box_sizing(LayoutBoxSizing::BorderBox)),
96
    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1))),
97
    CssPropertyWithConditions::simple(CssProperty::const_background_content(
98
        BACKGROUND_COLOR_LIGHT,
99
    )),
100
    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
101
        inner: COLOR_4C4C4C,
102
    })),
103
    CssPropertyWithConditions::simple(CssProperty::const_padding_left(
104
        LayoutPaddingLeft::const_px(2),
105
    )),
106
    CssPropertyWithConditions::simple(CssProperty::const_padding_right(
107
        LayoutPaddingRight::const_px(2),
108
    )),
109
    CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(
110
        1,
111
    ))),
112
    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
113
        LayoutPaddingBottom::const_px(1),
114
    )),
115
    // border: 1px solid #484c52;
116
    CssPropertyWithConditions::simple(CssProperty::const_border_top_width(
117
        LayoutBorderTopWidth::const_px(1),
118
    )),
119
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(
120
        LayoutBorderBottomWidth::const_px(1),
121
    )),
122
    CssPropertyWithConditions::simple(CssProperty::const_border_left_width(
123
        LayoutBorderLeftWidth::const_px(1),
124
    )),
125
    CssPropertyWithConditions::simple(CssProperty::const_border_right_width(
126
        LayoutBorderRightWidth::const_px(1),
127
    )),
128
    CssPropertyWithConditions::simple(CssProperty::const_border_top_style(StyleBorderTopStyle {
129
        inner: BorderStyle::Inset,
130
    })),
131
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(
132
        StyleBorderBottomStyle {
133
            inner: BorderStyle::Inset,
134
        },
135
    )),
136
    CssPropertyWithConditions::simple(CssProperty::const_border_left_style(StyleBorderLeftStyle {
137
        inner: BorderStyle::Inset,
138
    })),
139
    CssPropertyWithConditions::simple(CssProperty::const_border_right_style(
140
        StyleBorderRightStyle {
141
            inner: BorderStyle::Inset,
142
        },
143
    )),
144
    CssPropertyWithConditions::simple(CssProperty::const_border_top_color(StyleBorderTopColor {
145
        inner: COLOR_9B9B9B,
146
    })),
147
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(
148
        StyleBorderBottomColor {
149
            inner: COLOR_9B9B9B,
150
        },
151
    )),
152
    CssPropertyWithConditions::simple(CssProperty::const_border_left_color(StyleBorderLeftColor {
153
        inner: COLOR_9B9B9B,
154
    })),
155
    CssPropertyWithConditions::simple(CssProperty::const_border_right_color(
156
        StyleBorderRightColor {
157
            inner: COLOR_9B9B9B,
158
        },
159
    )),
160
    CssPropertyWithConditions::simple(CssProperty::const_overflow_x(LayoutOverflow::Hidden)),
161
    CssPropertyWithConditions::simple(CssProperty::const_overflow_y(LayoutOverflow::Hidden)),
162
    CssPropertyWithConditions::simple(CssProperty::const_justify_content(
163
        LayoutJustifyContent::Center,
164
    )),
165
    // Hover(border-color: #4c4c4c;)
166
    CssPropertyWithConditions::on_hover(CssProperty::const_border_top_color(StyleBorderTopColor {
167
        inner: COLOR_4C4C4C,
168
    })),
169
    CssPropertyWithConditions::on_hover(CssProperty::const_border_bottom_color(
170
        StyleBorderBottomColor {
171
            inner: COLOR_4C4C4C,
172
        },
173
    )),
174
    CssPropertyWithConditions::on_hover(CssProperty::const_border_left_color(
175
        StyleBorderLeftColor {
176
            inner: COLOR_4C4C4C,
177
        },
178
    )),
179
    CssPropertyWithConditions::on_hover(CssProperty::const_border_right_color(
180
        StyleBorderRightColor {
181
            inner: COLOR_4C4C4C,
182
        },
183
    )),
184
    // Focus(border-color: #4286f4;)
185
    CssPropertyWithConditions::on_focus(CssProperty::const_border_top_color(StyleBorderTopColor {
186
        inner: COLOR_4286F4,
187
    })),
188
    CssPropertyWithConditions::on_focus(CssProperty::const_border_bottom_color(
189
        StyleBorderBottomColor {
190
            inner: COLOR_4286F4,
191
        },
192
    )),
193
    CssPropertyWithConditions::on_focus(CssProperty::const_border_left_color(
194
        StyleBorderLeftColor {
195
            inner: COLOR_4286F4,
196
        },
197
    )),
198
    CssPropertyWithConditions::on_focus(CssProperty::const_border_right_color(
199
        StyleBorderRightColor {
200
            inner: COLOR_4286F4,
201
        },
202
    )),
203
];
204

            
205
#[cfg(target_os = "linux")]
206
static TEXT_INPUT_CONTAINER_PROPS: &[CssPropertyWithConditions] = &[
207
    CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Relative)),
208
    CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Text)),
209
    CssPropertyWithConditions::simple(CssProperty::const_box_sizing(LayoutBoxSizing::BorderBox)),
210
    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(11))),
211
    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1))),
212
    CssPropertyWithConditions::simple(CssProperty::const_background_content(
213
        BACKGROUND_COLOR_LIGHT,
214
    )),
215
    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
216
        inner: COLOR_4C4C4C,
217
    })),
218
    CssPropertyWithConditions::simple(CssProperty::const_padding_left(
219
        LayoutPaddingLeft::const_px(2),
220
    )),
221
    CssPropertyWithConditions::simple(CssProperty::const_padding_right(
222
        LayoutPaddingRight::const_px(2),
223
    )),
224
    CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(
225
        1,
226
    ))),
227
    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
228
        LayoutPaddingBottom::const_px(1),
229
    )),
230
    // border: 1px solid #484c52;
231
    CssPropertyWithConditions::simple(CssProperty::const_border_top_width(
232
        LayoutBorderTopWidth::const_px(1),
233
    )),
234
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(
235
        LayoutBorderBottomWidth::const_px(1),
236
    )),
237
    CssPropertyWithConditions::simple(CssProperty::const_border_left_width(
238
        LayoutBorderLeftWidth::const_px(1),
239
    )),
240
    CssPropertyWithConditions::simple(CssProperty::const_border_right_width(
241
        LayoutBorderRightWidth::const_px(1),
242
    )),
243
    CssPropertyWithConditions::simple(CssProperty::const_border_top_style(StyleBorderTopStyle {
244
        inner: BorderStyle::Inset,
245
    })),
246
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(
247
        StyleBorderBottomStyle {
248
            inner: BorderStyle::Inset,
249
        },
250
    )),
251
    CssPropertyWithConditions::simple(CssProperty::const_border_left_style(StyleBorderLeftStyle {
252
        inner: BorderStyle::Inset,
253
    })),
254
    CssPropertyWithConditions::simple(CssProperty::const_border_right_style(
255
        StyleBorderRightStyle {
256
            inner: BorderStyle::Inset,
257
        },
258
    )),
259
    CssPropertyWithConditions::simple(CssProperty::const_border_top_color(StyleBorderTopColor {
260
        inner: COLOR_9B9B9B,
261
    })),
262
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(
263
        StyleBorderBottomColor {
264
            inner: COLOR_9B9B9B,
265
        },
266
    )),
267
    CssPropertyWithConditions::simple(CssProperty::const_border_left_color(StyleBorderLeftColor {
268
        inner: COLOR_9B9B9B,
269
    })),
270
    CssPropertyWithConditions::simple(CssProperty::const_border_right_color(
271
        StyleBorderRightColor {
272
            inner: COLOR_9B9B9B,
273
        },
274
    )),
275
    CssPropertyWithConditions::simple(CssProperty::const_overflow_x(LayoutOverflow::Hidden)),
276
    CssPropertyWithConditions::simple(CssProperty::const_overflow_y(LayoutOverflow::Hidden)),
277
    CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Left)),
278
    CssPropertyWithConditions::simple(CssProperty::const_justify_content(
279
        LayoutJustifyContent::Center,
280
    )),
281
    CssPropertyWithConditions::simple(CssProperty::const_font_family(SANS_SERIF_FAMILY)),
282
    // Hover(border-color: #4286f4;)
283
    CssPropertyWithConditions::on_hover(CssProperty::const_border_top_color(StyleBorderTopColor {
284
        inner: COLOR_4286F4,
285
    })),
286
    CssPropertyWithConditions::on_hover(CssProperty::const_border_bottom_color(
287
        StyleBorderBottomColor {
288
            inner: COLOR_4286F4,
289
        },
290
    )),
291
    CssPropertyWithConditions::on_hover(CssProperty::const_border_left_color(
292
        StyleBorderLeftColor {
293
            inner: COLOR_4286F4,
294
        },
295
    )),
296
    CssPropertyWithConditions::on_hover(CssProperty::const_border_right_color(
297
        StyleBorderRightColor {
298
            inner: COLOR_4286F4,
299
        },
300
    )),
301
    // Focus(border-color: #4286f4;)
302
    CssPropertyWithConditions::on_focus(CssProperty::const_border_top_color(StyleBorderTopColor {
303
        inner: COLOR_4286F4,
304
    })),
305
    CssPropertyWithConditions::on_focus(CssProperty::const_border_bottom_color(
306
        StyleBorderBottomColor {
307
            inner: COLOR_4286F4,
308
        },
309
    )),
310
    CssPropertyWithConditions::on_focus(CssProperty::const_border_left_color(
311
        StyleBorderLeftColor {
312
            inner: COLOR_4286F4,
313
        },
314
    )),
315
    CssPropertyWithConditions::on_focus(CssProperty::const_border_right_color(
316
        StyleBorderRightColor {
317
            inner: COLOR_4286F4,
318
        },
319
    )),
320
];
321

            
322
// Mobile (Android / iOS) inherit the macOS-style container — same flex
323
// box-sizing and background; touch-target padding is the user's concern.
324
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
325
static TEXT_INPUT_CONTAINER_PROPS: &[CssPropertyWithConditions] = &[
326
    CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Relative)),
327
    CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Text)),
328
    CssPropertyWithConditions::simple(CssProperty::const_box_sizing(LayoutBoxSizing::BorderBox)),
329
    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1))),
330
    CssPropertyWithConditions::simple(CssProperty::const_background_content(
331
        BACKGROUND_COLOR_LIGHT,
332
    )),
333
    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
334
        inner: COLOR_4C4C4C,
335
    })),
336
    CssPropertyWithConditions::simple(CssProperty::const_padding_left(
337
        LayoutPaddingLeft::const_px(2),
338
    )),
339
    CssPropertyWithConditions::simple(CssProperty::const_padding_right(
340
        LayoutPaddingRight::const_px(2),
341
    )),
342
    CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(
343
        1,
344
    ))),
345
    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
346
        LayoutPaddingBottom::const_px(1),
347
    )),
348
    // border: 1px solid #484c52;
349
    CssPropertyWithConditions::simple(CssProperty::const_border_top_width(
350
        LayoutBorderTopWidth::const_px(1),
351
    )),
352
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(
353
        LayoutBorderBottomWidth::const_px(1),
354
    )),
355
    CssPropertyWithConditions::simple(CssProperty::const_border_left_width(
356
        LayoutBorderLeftWidth::const_px(1),
357
    )),
358
    CssPropertyWithConditions::simple(CssProperty::const_border_right_width(
359
        LayoutBorderRightWidth::const_px(1),
360
    )),
361
    CssPropertyWithConditions::simple(CssProperty::const_border_top_style(StyleBorderTopStyle {
362
        inner: BorderStyle::Inset,
363
    })),
364
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(
365
        StyleBorderBottomStyle {
366
            inner: BorderStyle::Inset,
367
        },
368
    )),
369
    CssPropertyWithConditions::simple(CssProperty::const_border_left_style(StyleBorderLeftStyle {
370
        inner: BorderStyle::Inset,
371
    })),
372
    CssPropertyWithConditions::simple(CssProperty::const_border_right_style(
373
        StyleBorderRightStyle {
374
            inner: BorderStyle::Inset,
375
        },
376
    )),
377
    CssPropertyWithConditions::simple(CssProperty::const_border_top_color(StyleBorderTopColor {
378
        inner: COLOR_9B9B9B,
379
    })),
380
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(
381
        StyleBorderBottomColor {
382
            inner: COLOR_9B9B9B,
383
        },
384
    )),
385
    CssPropertyWithConditions::simple(CssProperty::const_border_left_color(StyleBorderLeftColor {
386
        inner: COLOR_9B9B9B,
387
    })),
388
    CssPropertyWithConditions::simple(CssProperty::const_border_right_color(
389
        StyleBorderRightColor {
390
            inner: COLOR_9B9B9B,
391
        },
392
    )),
393
    CssPropertyWithConditions::simple(CssProperty::const_overflow_x(LayoutOverflow::Hidden)),
394
    CssPropertyWithConditions::simple(CssProperty::const_overflow_y(LayoutOverflow::Hidden)),
395
    CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Left)),
396
    CssPropertyWithConditions::simple(CssProperty::const_justify_content(
397
        LayoutJustifyContent::Center,
398
    )),
399
    // Hover(border-color: #4286f4;)
400
    CssPropertyWithConditions::on_hover(CssProperty::const_border_top_color(StyleBorderTopColor {
401
        inner: COLOR_4286F4,
402
    })),
403
    CssPropertyWithConditions::on_hover(CssProperty::const_border_bottom_color(
404
        StyleBorderBottomColor {
405
            inner: COLOR_4286F4,
406
        },
407
    )),
408
    CssPropertyWithConditions::on_hover(CssProperty::const_border_left_color(
409
        StyleBorderLeftColor {
410
            inner: COLOR_4286F4,
411
        },
412
    )),
413
    CssPropertyWithConditions::on_hover(CssProperty::const_border_right_color(
414
        StyleBorderRightColor {
415
            inner: COLOR_4286F4,
416
        },
417
    )),
418
    // Focus(border-color: #4286f4;)
419
    CssPropertyWithConditions::on_focus(CssProperty::const_border_top_color(StyleBorderTopColor {
420
        inner: COLOR_4286F4,
421
    })),
422
    CssPropertyWithConditions::on_focus(CssProperty::const_border_bottom_color(
423
        StyleBorderBottomColor {
424
            inner: COLOR_4286F4,
425
        },
426
    )),
427
    CssPropertyWithConditions::on_focus(CssProperty::const_border_left_color(
428
        StyleBorderLeftColor {
429
            inner: COLOR_4286F4,
430
        },
431
    )),
432
    CssPropertyWithConditions::on_focus(CssProperty::const_border_right_color(
433
        StyleBorderRightColor {
434
            inner: COLOR_4286F4,
435
        },
436
    )),
437
];
438

            
439
// -- label style
440
//
441
// The label is the `<p>` block wrapping the value text, so it is the box that
442
// bounds the line and clips against the container's `overflow: hidden`.
443
// `white-space: pre` keeps a single-line field on one line and preserves the
444
// spaces the user typed.
445

            
446
#[cfg(target_os = "windows")]
447
static TEXT_INPUT_LABEL_PROPS: &[CssPropertyWithConditions] = &[
448
    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Block)),
449
    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
450
    CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Relative)),
451
    CssPropertyWithConditions::simple(CssProperty::const_overflow_x(LayoutOverflow::Hidden)),
452
    CssPropertyWithConditions::simple(CssProperty::const_overflow_y(LayoutOverflow::Hidden)),
453
    CssPropertyWithConditions::simple(CssProperty::WhiteSpace(StyleWhiteSpaceValue::Exact(
454
        StyleWhiteSpace::Pre,
455
    ))),
456
    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(11))),
457
    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
458
        inner: COLOR_4C4C4C,
459
    })),
460
    CssPropertyWithConditions::simple(CssProperty::const_font_family(SANS_SERIF_FAMILY)),
461
];
462

            
463
#[cfg(target_os = "linux")]
464
static TEXT_INPUT_LABEL_PROPS: &[CssPropertyWithConditions] = &[
465
    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Block)),
466
    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
467
    CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Relative)),
468
    CssPropertyWithConditions::simple(CssProperty::const_overflow_x(LayoutOverflow::Hidden)),
469
    CssPropertyWithConditions::simple(CssProperty::const_overflow_y(LayoutOverflow::Hidden)),
470
    CssPropertyWithConditions::simple(CssProperty::WhiteSpace(StyleWhiteSpaceValue::Exact(
471
        StyleWhiteSpace::Pre,
472
    ))),
473
    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(11))),
474
    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
475
        inner: COLOR_4C4C4C,
476
    })),
477
    CssPropertyWithConditions::simple(CssProperty::const_font_family(SANS_SERIF_FAMILY)),
478
];
479

            
480
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
481
static TEXT_INPUT_LABEL_PROPS: &[CssPropertyWithConditions] = &[
482
    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Block)),
483
    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
484
    CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Relative)),
485
    CssPropertyWithConditions::simple(CssProperty::const_overflow_x(LayoutOverflow::Hidden)),
486
    CssPropertyWithConditions::simple(CssProperty::const_overflow_y(LayoutOverflow::Hidden)),
487
    CssPropertyWithConditions::simple(CssProperty::WhiteSpace(StyleWhiteSpaceValue::Exact(
488
        StyleWhiteSpace::Pre,
489
    ))),
490
    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(11))),
491
    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
492
        inner: COLOR_4C4C4C,
493
    })),
494
    CssPropertyWithConditions::simple(CssProperty::const_font_family(SANS_SERIF_FAMILY)),
495
];
496

            
497
// --- placeholder
498
//
499
// An absolutely-positioned `<p>` overlay inside the editable container. It is
500
// marked `contenteditable="false"` so the engine's inheritance walk stops at it
501
// and the prompt never becomes part of the buffer, and it is toggled with
502
// `display` as well as `opacity`: a hidden-but-laid-out overlay would still own
503
// the container's first inline layout, which is what
504
// `LayoutWindow::reshape_text_node` picks up when it looks for the IFC to write
505
// an edit into.
506

            
507
#[cfg(target_os = "windows")]
508
static TEXT_INPUT_PLACEHOLDER_PROPS: &[CssPropertyWithConditions] = &[
509
    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Block)),
510
    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
511
    CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Absolute)),
512
    CssPropertyWithConditions::simple(CssProperty::const_top(LayoutTop::const_px(2))),
513
    CssPropertyWithConditions::simple(CssProperty::const_left(LayoutLeft::const_px(2))),
514
    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(11))),
515
    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
516
        inner: COLOR_4C4C4C,
517
    })),
518
    CssPropertyWithConditions::simple(CssProperty::const_font_family(SANS_SERIF_FAMILY)),
519
    CssPropertyWithConditions::simple(CssProperty::const_opacity(StyleOpacity::const_new(100))),
520
];
521

            
522
#[cfg(target_os = "linux")]
523
static TEXT_INPUT_PLACEHOLDER_PROPS: &[CssPropertyWithConditions] = &[
524
    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Block)),
525
    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
526
    CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Absolute)),
527
    CssPropertyWithConditions::simple(CssProperty::const_top(LayoutTop::const_px(2))),
528
    CssPropertyWithConditions::simple(CssProperty::const_left(LayoutLeft::const_px(2))),
529
    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(11))),
530
    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
531
        inner: COLOR_4C4C4C,
532
    })),
533
    CssPropertyWithConditions::simple(CssProperty::const_font_family(SANS_SERIF_FAMILY)),
534
    CssPropertyWithConditions::simple(CssProperty::const_opacity(StyleOpacity::const_new(100))),
535
];
536

            
537
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
538
static TEXT_INPUT_PLACEHOLDER_PROPS: &[CssPropertyWithConditions] = &[
539
    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Block)),
540
    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
541
    CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Absolute)),
542
    CssPropertyWithConditions::simple(CssProperty::const_top(LayoutTop::const_px(2))),
543
    CssPropertyWithConditions::simple(CssProperty::const_left(LayoutLeft::const_px(2))),
544
    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(11))),
545
    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
546
        inner: COLOR_4C4C4C,
547
    })),
548
    CssPropertyWithConditions::simple(CssProperty::const_font_family(SANS_SERIF_FAMILY)),
549
    CssPropertyWithConditions::simple(CssProperty::const_opacity(StyleOpacity::const_new(100))),
550
];
551

            
552
/// Single-line text input widget with platform-native styling.
553
///
554
/// Use [`TextInput::create()`] to build an instance, configure it with the
555
/// `with_*` / `set_*` builder methods, and call [`TextInput::dom()`] to
556
/// obtain a renderable DOM tree.
557
#[derive(Debug, Clone, PartialEq, Eq)]
558
#[repr(C)]
559
pub struct TextInput {
560
    pub text_input_state: TextInputStateWrapper,
561
    pub placeholder_style: CssPropertyWithConditionsVec,
562
    pub container_style: CssPropertyWithConditionsVec,
563
    pub label_style: CssPropertyWithConditionsVec,
564
}
565

            
566
/// Editable state of a text input (text buffer, cursor position, selection).
567
#[derive(Debug, Clone, PartialEq, Eq)]
568
#[repr(C)]
569
pub struct TextInputState {
570
    pub text: U32Vec, // Vec<char>
571
    pub placeholder: OptionString,
572
    pub max_len: usize,
573
    pub selection: OptionTextInputSelection,
574
    pub cursor_pos: usize,
575
}
576

            
577
/// [`TextInputState`] together with optional user callbacks and cursor animation state.
578
#[derive(Debug, Clone, PartialEq, Eq)]
579
#[repr(C)]
580
pub struct TextInputStateWrapper {
581
    pub inner: TextInputState,
582
    pub on_text_input: OptionTextInputOnTextInput,
583
    pub on_virtual_key_down: OptionTextInputOnVirtualKeyDown,
584
    pub on_focus_lost: OptionTextInputOnFocusLost,
585
    pub update_text_input_before_calling_focus_lost_fn: bool,
586
    pub update_text_input_before_calling_vk_down_fn: bool,
587
    pub cursor_animation: OptionTimerId,
588
}
589

            
590
/// Return value from a text-input callback indicating whether the framework
591
/// should update and whether the input was valid.
592
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
593
#[repr(C)]
594
pub struct OnTextInputReturn {
595
    pub update: Update,
596
    pub valid: TextInputValid,
597
}
598

            
599
/// Whether the text input accepted or rejected the most recent edit.
600
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
601
#[repr(C)]
602
pub enum TextInputValid {
603
    Yes,
604
    No,
605
}
606

            
607
// The text input field has a special return which specifies
608
// whether the text input should handle the character
609
pub type TextInputOnTextInputCallbackType =
610
    extern "C" fn(RefAny, CallbackInfo, TextInputState) -> OnTextInputReturn;
611
impl_widget_callback!(
612
    TextInputOnTextInput,
613
    OptionTextInputOnTextInput,
614
    TextInputOnTextInputCallback,
615
    TextInputOnTextInputCallbackType
616
);
617

            
618
azul_core::impl_managed_callback! {
619
    wrapper:        TextInputOnTextInputCallback,
620
    info_ty:        CallbackInfo,
621
    return_ty:      OnTextInputReturn,
622
    default_ret:    OnTextInputReturn { update: Update::DoNothing, valid: TextInputValid::Yes },
623
    invoker_static: TEXT_INPUT_ON_TEXT_INPUT_INVOKER,
624
    invoker_ty:     AzTextInputOnTextInputCallbackInvoker,
625
    thunk_fn:       az_text_input_on_text_input_callback_thunk,
626
    setter_fn:      AzApp_setTextInputOnTextInputCallbackInvoker,
627
    from_handle_fn: AzTextInputOnTextInputCallback_createFromHostHandle,
628
    extra_args:     [ state: TextInputState ],
629
}
630

            
631
pub type TextInputOnVirtualKeyDownCallbackType =
632
    extern "C" fn(RefAny, CallbackInfo, TextInputState) -> OnTextInputReturn;
633
impl_widget_callback!(
634
    TextInputOnVirtualKeyDown,
635
    OptionTextInputOnVirtualKeyDown,
636
    TextInputOnVirtualKeyDownCallback,
637
    TextInputOnVirtualKeyDownCallbackType
638
);
639

            
640
azul_core::impl_managed_callback! {
641
    wrapper:        TextInputOnVirtualKeyDownCallback,
642
    info_ty:        CallbackInfo,
643
    return_ty:      OnTextInputReturn,
644
    default_ret:    OnTextInputReturn { update: Update::DoNothing, valid: TextInputValid::Yes },
645
    invoker_static: TEXT_INPUT_ON_VIRTUAL_KEY_DOWN_INVOKER,
646
    invoker_ty:     AzTextInputOnVirtualKeyDownCallbackInvoker,
647
    thunk_fn:       az_text_input_on_virtual_key_down_callback_thunk,
648
    setter_fn:      AzApp_setTextInputOnVirtualKeyDownCallbackInvoker,
649
    from_handle_fn: AzTextInputOnVirtualKeyDownCallback_createFromHostHandle,
650
    extra_args:     [ state: TextInputState ],
651
}
652

            
653
pub type TextInputOnFocusLostCallbackType =
654
    extern "C" fn(RefAny, CallbackInfo, TextInputState) -> Update;
655
impl_widget_callback!(
656
    TextInputOnFocusLost,
657
    OptionTextInputOnFocusLost,
658
    TextInputOnFocusLostCallback,
659
    TextInputOnFocusLostCallbackType
660
);
661

            
662
azul_core::impl_managed_callback! {
663
    wrapper:        TextInputOnFocusLostCallback,
664
    info_ty:        CallbackInfo,
665
    return_ty:      Update,
666
    default_ret:    Update::DoNothing,
667
    invoker_static: TEXT_INPUT_ON_FOCUS_LOST_INVOKER,
668
    invoker_ty:     AzTextInputOnFocusLostCallbackInvoker,
669
    thunk_fn:       az_text_input_on_focus_lost_callback_thunk,
670
    setter_fn:      AzApp_setTextInputOnFocusLostCallbackInvoker,
671
    from_handle_fn: AzTextInputOnFocusLostCallback_createFromHostHandle,
672
    extra_args:     [ state: TextInputState ],
673
}
674
#[allow(variant_size_differences)] // repr(C,u8) FFI enum: boxing the large variant would change the C ABI (api.json bindings); size disparity accepted
675
#[derive(Copy, Debug, Clone, Hash, PartialEq, Eq)]
676
#[repr(C, u8)]
677
pub enum TextInputSelection {
678
    All,
679
    FromTo(TextInputSelectionRange),
680
}
681

            
682
azul_css::impl_option!(
683
    TextInputSelection,
684
    OptionTextInputSelection,
685
    copy = false,
686
    [Debug, Clone, Hash, PartialEq, Eq]
687
);
688

            
689
#[derive(Copy, Debug, Clone, Hash, PartialEq, Eq)]
690
#[repr(C)]
691
pub struct TextInputSelectionRange {
692
    pub dir_from: usize,
693
    pub dir_to: usize,
694
}
695

            
696
impl Default for TextInput {
697
441
    fn default() -> Self {
698
441
        Self {
699
441
            text_input_state: TextInputStateWrapper::default(),
700
441
            placeholder_style: CssPropertyWithConditionsVec::from_const_slice(
701
441
                TEXT_INPUT_PLACEHOLDER_PROPS,
702
441
            ),
703
441
            container_style: CssPropertyWithConditionsVec::from_const_slice(
704
441
                TEXT_INPUT_CONTAINER_PROPS,
705
441
            ),
706
441
            label_style: CssPropertyWithConditionsVec::from_const_slice(TEXT_INPUT_LABEL_PROPS),
707
441
        }
708
441
    }
709
}
710

            
711
impl Default for TextInputState {
712
616
    fn default() -> Self {
713
616
        Self {
714
616
            text: Vec::new().into(),
715
616
            placeholder: None.into(),
716
616
            max_len: 50,
717
616
            selection: None.into(),
718
616
            cursor_pos: 0,
719
616
        }
720
616
    }
721
}
722

            
723
impl TextInputState {
724
187
    #[must_use] pub fn get_text(&self) -> String {
725
187
        self.text
726
187
            .iter()
727
51267
            .filter_map(|c| core::char::from_u32(*c))
728
187
            .collect()
729
187
    }
730
}
731

            
732
impl Default for TextInputStateWrapper {
733
441
    fn default() -> Self {
734
441
        Self {
735
441
            inner: TextInputState::default(),
736
441
            on_text_input: None.into(),
737
441
            on_virtual_key_down: None.into(),
738
441
            on_focus_lost: None.into(),
739
441
            update_text_input_before_calling_focus_lost_fn: true,
740
441
            update_text_input_before_calling_vk_down_fn: true,
741
441
            cursor_animation: None.into(),
742
441
        }
743
441
    }
744
}
745

            
746
impl TextInput {
747
299
    #[must_use] pub fn create() -> Self {
748
299
        Self::default()
749
299
    }
750

            
751
140
    #[must_use] pub fn with_text(mut self, text: AzString) -> Self {
752
140
        self.set_text(text);
753
140
        self
754
140
    }
755

            
756
    // owned AzString passed by value per the azul FFI / api.json setter convention.
757
    #[allow(clippy::needless_pass_by_value)]
758
204
    pub fn set_text(&mut self, text: AzString) {
759
204
        self.text_input_state.inner.text = text
760
204
            .as_str()
761
204
            .chars()
762
100717
            .map(|c| c as u32)
763
204
            .collect::<Vec<_>>()
764
204
            .into();
765
204
    }
766

            
767
70
    pub fn set_placeholder(&mut self, placeholder: AzString) {
768
70
        self.text_input_state.inner.placeholder = Some(placeholder).into();
769
70
    }
770

            
771
47
    #[must_use] pub fn with_placeholder(mut self, placeholder: AzString) -> Self {
772
47
        self.set_placeholder(placeholder);
773
47
        self
774
47
    }
775

            
776
68
    pub fn set_on_text_input<C: Into<TextInputOnTextInputCallback>>(
777
68
        &mut self,
778
68
        refany: RefAny,
779
68
        callback: C,
780
68
    ) {
781
68
        self.text_input_state.on_text_input = Some(TextInputOnTextInput {
782
68
            callback: callback.into(),
783
68
            refany,
784
68
        })
785
68
        .into();
786
68
    }
787

            
788
    #[must_use]
789
5
    pub fn with_on_text_input<C: Into<TextInputOnTextInputCallback>>(
790
5
        mut self,
791
5
        refany: RefAny,
792
5
        callback: C,
793
5
    ) -> Self {
794
5
        self.set_on_text_input(refany, callback);
795
5
        self
796
5
    }
797

            
798
13
    pub fn set_on_virtual_key_down<C: Into<TextInputOnVirtualKeyDownCallback>>(
799
13
        &mut self,
800
13
        refany: RefAny,
801
13
        callback: C,
802
13
    ) {
803
13
        self.text_input_state.on_virtual_key_down = Some(TextInputOnVirtualKeyDown {
804
13
            callback: callback.into(),
805
13
            refany,
806
13
        })
807
13
        .into();
808
13
    }
809

            
810
    #[must_use]
811
8
    pub fn with_on_virtual_key_down<C: Into<TextInputOnVirtualKeyDownCallback>>(
812
8
        mut self,
813
8
        refany: RefAny,
814
8
        callback: C,
815
8
    ) -> Self {
816
8
        self.set_on_virtual_key_down(refany, callback);
817
8
        self
818
8
    }
819

            
820
64
    pub fn set_on_focus_lost<C: Into<TextInputOnFocusLostCallback>>(
821
64
        &mut self,
822
64
        refany: RefAny,
823
64
        callback: C,
824
64
    ) {
825
64
        self.text_input_state.on_focus_lost = Some(TextInputOnFocusLost {
826
64
            callback: callback.into(),
827
64
            refany,
828
64
        })
829
64
        .into();
830
64
    }
831

            
832
    #[must_use]
833
6
    pub fn with_on_focus_lost<C: Into<TextInputOnFocusLostCallback>>(
834
6
        mut self,
835
6
        refany: RefAny,
836
6
        callback: C,
837
6
    ) -> Self {
838
6
        self.set_on_focus_lost(refany, callback);
839
6
        self
840
6
    }
841

            
842
6
    pub fn set_placeholder_style(&mut self, style: CssPropertyWithConditionsVec) {
843
6
        self.placeholder_style = style;
844
6
    }
845

            
846
4
    #[must_use] pub fn with_placeholder_style(mut self, style: CssPropertyWithConditionsVec) -> Self {
847
4
        self.set_placeholder_style(style);
848
4
        self
849
4
    }
850

            
851
7
    pub fn set_container_style(&mut self, style: CssPropertyWithConditionsVec) {
852
7
        self.container_style = style;
853
7
    }
854

            
855
3
    #[must_use] pub fn with_container_style(mut self, style: CssPropertyWithConditionsVec) -> Self {
856
3
        self.set_container_style(style);
857
3
        self
858
3
    }
859

            
860
5
    pub fn set_label_style(&mut self, style: CssPropertyWithConditionsVec) {
861
5
        self.label_style = style;
862
5
    }
863

            
864
3
    #[must_use] pub fn with_label_style(mut self, style: CssPropertyWithConditionsVec) -> Self {
865
3
        self.set_label_style(style);
866
3
        self
867
3
    }
868

            
869
    #[must_use]
870
4
    pub fn swap_with_default(&mut self) -> Self {
871
4
        let mut s = Self::default();
872
4
        core::mem::swap(&mut s, self);
873
4
        s
874
4
    }
875

            
876
    /// Renders the widget.
877
    ///
878
    /// The container is the `contenteditable` host — the flag, the tab index and
879
    /// the focus callbacks all sit on it, because focus events do not bubble and
880
    /// the engine records an edit against the *focused* node. Its two children
881
    /// are `<p>` blocks wrapping a bare text node each; nothing else is emitted,
882
    /// in particular no caret node (the engine paints the caret and the
883
    /// selection from its display list).
884
166
    #[must_use] pub fn dom(mut self) -> Dom {
885
        use azul_core::{
886
            callbacks::CoreCallbackData,
887
            dom::{
888
                AttributeType, DomVec, EventFilter, FocusEventFilter, HoverEventFilter,
889
                IdOrClass::Class, TabIndex,
890
            },
891
        };
892

            
893
166
        self.text_input_state.inner.cursor_pos = self.text_input_state.inner.text.len();
894

            
895
166
        let label_text: String = self
896
166
            .text_input_state
897
166
            .inner
898
166
            .text
899
166
            .iter()
900
50835
            .filter_map(|s| core::char::from_u32(*s))
901
166
            .collect();
902

            
903
166
        let placeholder = self
904
166
            .text_input_state
905
166
            .inner
906
166
            .placeholder
907
166
            .as_ref()
908
166
            .map(|s| s.as_str().to_string())
909
166
            .unwrap_or_default();
910

            
911
166
        let mut placeholder_style = self.placeholder_style;
912
166
        if !self.text_input_state.inner.text.is_empty() {
913
124
            placeholder_style = hidden_placeholder_style(&placeholder_style);
914
124
        }
915

            
916
166
        let state_ref = RefAny::new(self.text_input_state);
917

            
918
166
        Dom::create_div()
919
166
            .with_ids_and_classes(vec![Class("__azul-native-text-input-container".into())].into())
920
166
            .with_css_props(self.container_style)
921
166
            .with_tab_index(TabIndex::Auto)
922
166
            .with_contenteditable(true)
923
166
            .with_dataset(Some(state_ref.clone()).into())
924
166
            .with_callbacks(
925
166
                vec![
926
166
                    CoreCallbackData {
927
166
                        event: EventFilter::Focus(FocusEventFilter::FocusReceived),
928
166
                        refany: state_ref.clone(),
929
166
                        callback: CoreCallback {
930
166
                            cb: default_on_focus_received as usize,
931
166
                            ctx: azul_core::refany::OptionRefAny::None,
932
166
                        },
933
166
                    },
934
166
                    CoreCallbackData {
935
166
                        event: EventFilter::Focus(FocusEventFilter::FocusLost),
936
166
                        refany: state_ref.clone(),
937
166
                        callback: CoreCallback {
938
166
                            cb: default_on_focus_lost as usize,
939
166
                            ctx: azul_core::refany::OptionRefAny::None,
940
166
                        },
941
166
                    },
942
166
                    CoreCallbackData {
943
166
                        event: EventFilter::Focus(FocusEventFilter::TextInput),
944
166
                        refany: state_ref.clone(),
945
166
                        callback: CoreCallback {
946
166
                            cb: default_on_text_input as usize,
947
166
                            ctx: azul_core::refany::OptionRefAny::None,
948
166
                        },
949
166
                    },
950
166
                    CoreCallbackData {
951
166
                        event: EventFilter::Focus(FocusEventFilter::VirtualKeyDown),
952
166
                        refany: state_ref.clone(),
953
166
                        callback: CoreCallback {
954
166
                            cb: default_on_virtual_key_down as usize,
955
166
                            ctx: azul_core::refany::OptionRefAny::None,
956
166
                        },
957
166
                    },
958
166
                    CoreCallbackData {
959
166
                        event: EventFilter::Hover(HoverEventFilter::MouseOver),
960
166
                        refany: state_ref,
961
166
                        callback: CoreCallback {
962
166
                            cb: default_on_mouse_hover as usize,
963
166
                            ctx: azul_core::refany::OptionRefAny::None,
964
166
                        },
965
166
                    },
966
                ]
967
166
                .into(),
968
            )
969
166
            .with_children(
970
166
                vec![
971
166
                    Dom::create_p()
972
166
                        .with_ids_and_classes(
973
166
                            vec![Class("__azul-native-text-input-placeholder".into())].into(),
974
                        )
975
166
                        .with_css_props(placeholder_style)
976
                        // appended, never `with_attributes`: that one replaces the
977
                        // whole vector, classes included
978
166
                        .with_attribute(AttributeType::ContentEditable(false))
979
166
                        .with_children(DomVec::from_vec(vec![Dom::create_text_do_not_use_without_block_level_wrapper(placeholder)])),
980
166
                    Dom::create_p()
981
166
                        .with_ids_and_classes(
982
166
                            vec![Class("__azul-native-text-input-label".into())].into(),
983
                        )
984
166
                        .with_css_props(self.label_style)
985
166
                        .with_children(DomVec::from_vec(vec![Dom::create_text_do_not_use_without_block_level_wrapper(label_text)])),
986
                ]
987
166
                .into(),
988
            )
989
166
    }
990
}
991

            
992
/// `style` with the placeholder taken out of the flow: `display: none` on top
993
/// of `opacity: 0`, so a hidden prompt owns neither pixels nor an inline layout.
994
124
fn hidden_placeholder_style(
995
124
    style: &CssPropertyWithConditionsVec,
996
124
) -> CssPropertyWithConditionsVec {
997
124
    let mut props = style.as_ref().to_vec();
998
124
    props.push(CssPropertyWithConditions::simple(CssProperty::const_display(
999
124
        LayoutDisplay::None,
    )));
124
    props.push(CssPropertyWithConditions::simple(CssProperty::const_opacity(
124
        StyleOpacity::const_new(0),
    )));
124
    CssPropertyWithConditionsVec::from_vec(props)
124
}
/// The placeholder `<p>` and the value `<p>`, in that order.
///
/// Both handlers and tests resolve them through the same hierarchy hops the
/// container's own layout guarantees; a subtree of any other shape yields
/// `None` and every handler bails out.
29
fn label_nodes(info: &CallbackInfo) -> Option<(DomNodeId, DomNodeId)> {
29
    let placeholder = info.get_first_child(info.get_hit_node())?;
27
    let label = info.get_next_sibling(placeholder)?;
26
    Some((placeholder, label))
29
}
/// Shows or hides the placeholder prompt.
13
fn set_placeholder_visible(info: &mut CallbackInfo, placeholder: DomNodeId, visible: bool) {
13
    let (display, opacity) = if visible {
1
        (LayoutDisplay::Block, StyleOpacity::const_new(100))
    } else {
12
        (LayoutDisplay::None, StyleOpacity::const_new(0))
    };
13
    info.set_css_property(placeholder, CssProperty::const_opacity(opacity));
13
    info.set_css_property(placeholder, CssProperty::const_display(display));
13
}
/// Adopts the engine's text for `node` into the widget's mirror.
///
/// The engine owns the buffer, so its answer wins — except that an empty answer
/// is ambiguous: `get_text_before_textinput` also yields nothing for a node
/// whose text sits under a block wrapper it does not descend into. An empty
/// read therefore never clears a non-empty mirror.
33
fn adopt_engine_text(state: &mut TextInputState, info: &CallbackInfo, node: DomNodeId) {
33
    let Some(text) = info.get_node_text_content(node) else {
        return;
    };
33
    if text.is_empty() && !state.text.is_empty() {
6
        return;
27
    }
49
    state.text = text.chars().map(|c| c as u32).collect::<Vec<_>>().into();
33
}
/// The engine's selection, in the widget's public shape.
///
/// Offsets are byte offsets into the value, matching the cursor positions the
/// engine reports; a range that spans the whole buffer collapses to
/// [`TextInputSelection::All`].
47
fn engine_selection(
47
    info: &CallbackInfo,
47
    node: DomNodeId,
47
    len: usize,
47
) -> Option<TextInputSelection> {
47
    let ranges = info.get_node_selection_ranges(node);
47
    let range = *ranges.as_ref().first()?;
    let dir_from = range.start.cluster_id.start_byte_in_run as usize;
    let dir_to = range.end.cluster_id.start_byte_in_run as usize;
    if len != 0 && dir_from == 0 && dir_to >= len {
        return Some(TextInputSelection::All);
    }
    Some(TextInputSelection::FromTo(TextInputSelectionRange {
        dir_from,
        dir_to,
    }))
47
}
/// Mirrors the insertion the engine is about to apply.
///
/// The engine inserts at the caret, so the mirror does too whenever the caret
/// is readable and lands on a character boundary; otherwise it appends, which
/// is where the caret sits for every append-only path. `cursor_pos` stays a
/// byte offset, as it has always been.
23
fn mirror_insertion(state: &mut TextInputState, inserted: &str, caret: Option<usize>) {
23
    let text = state.get_text();
23
    let at = caret
23
        .filter(|at| *at <= text.len() && text.is_char_boundary(*at))
23
        .unwrap_or(text.len());
23
    let mut next = String::with_capacity(text.len() + inserted.len());
23
    next.push_str(&text[..at]);
23
    next.push_str(inserted);
23
    next.push_str(&text[at..]);
217
    state.text = next.chars().map(|c| c as u32).collect::<Vec<_>>().into();
23
    state.cursor_pos = at.saturating_add(inserted.len());
23
}
/// The caret's byte offset inside the edited node, if the engine has one.
15
fn engine_caret(info: &CallbackInfo, node: DomNodeId) -> Option<usize> {
15
    info.get_node_cursor_position(node)
15
        .map(|c| c.cluster_id.start_byte_in_run as usize)
15
}
6
extern "C" fn default_on_focus_received(mut text_input: RefAny, mut info: CallbackInfo) -> Update {
6
    let Some(mut text_input) = text_input.downcast_mut::<TextInputStateWrapper>() else {
1
        return Update::DoNothing;
    };
5
    let text_input = &mut *text_input;
5
    let Some(placeholder_text_node_id) = info.get_first_child(info.get_hit_node()) else {
2
        return Update::DoNothing;
    };
3
    let container = info.get_hit_node();
3
    adopt_engine_text(&mut text_input.inner, &info, container);
    // hide the placeholder text
3
    if text_input.inner.text.is_empty() {
1
        set_placeholder_visible(&mut info, placeholder_text_node_id, false);
2
    }
    // The engine seeds the caret at the end of the value when focus lands on a
    // contenteditable host; the mirror follows it.
3
    let end_of_text = text_input.inner.text.len();
3
    text_input.inner.cursor_pos = engine_caret(&info, container).unwrap_or(end_of_text);
3
    Update::DoNothing
6
}
6
extern "C" fn default_on_focus_lost(mut text_input: RefAny, mut info: CallbackInfo) -> Update {
6
    let Some(mut text_input) = text_input.downcast_mut::<TextInputStateWrapper>() else {
1
        return Update::DoNothing;
    };
5
    let text_input = &mut *text_input;
5
    let Some(placeholder_text_node_id) = info.get_first_child(info.get_hit_node()) else {
1
        return Update::DoNothing;
    };
4
    let container = info.get_hit_node();
4
    adopt_engine_text(&mut text_input.inner, &info, container);
    // show the placeholder text
4
    if text_input.inner.text.is_empty() {
1
        set_placeholder_visible(&mut info, placeholder_text_node_id, true);
3
    }
    // rustc doesn't understand the borrowing lifetime here
4
    let text_input = &mut *text_input;
4
    let onfocuslost = &mut text_input.on_focus_lost;
4
    let inner = text_input.inner.clone();
4
    match onfocuslost.as_mut() {
1
        Some(TextInputOnFocusLost { callback, refany }) => {
1
            (callback.cb)(refany.clone(), info, inner)
        }
3
        None => Update::DoNothing,
    }
6
}
15
extern "C" fn default_on_text_input(text_input: RefAny, info: CallbackInfo) -> Update {
15
    default_on_text_input_inner(text_input, info).unwrap_or(Update::DoNothing)
15
}
17
fn default_on_text_input_inner(mut text_input: RefAny, mut info: CallbackInfo) -> Option<Update> {
17
    let mut text_input = text_input.downcast_mut::<TextInputStateWrapper>()?;
    // The engine records the edit before the callbacks run and applies it after
    // them; this handler only observes it and mirrors it into the widget state.
    // An `Input` WITHOUT a pending record is a post-edit NOTIFICATION: an edit
    // committed outside the record pipeline (deletion, programmatic edit) that
    // is already applied — adopt it and inform the user hook; `valid` cannot
    // veto what already happened.
16
    let inserted_text = info
16
        .get_text_changeset()
16
        .map(|c| c.inserted_text.as_str().to_string())
16
        .unwrap_or_default();
16
    let (placeholder_node_id, _label_node_id) = label_nodes(&info)?;
14
    let container = info.get_hit_node();
14
    if inserted_text.is_empty() {
        // Idempotent: a notification that changed nothing observable (a
        // spurious Input, an edit already mirrored) stays a strict no-op, so
        // the no-changeset pins keep holding.
2
        let before = text_input.inner.get_text();
2
        adopt_engine_text(&mut text_input.inner, &info, container);
2
        if text_input.inner.get_text() == before {
2
            return None;
        }
        let len = text_input.inner.get_text().len();
        text_input.inner.selection = engine_selection(&info, container, len).into();
        // A field deleted to empty shows its placeholder again.
        set_placeholder_visible(&mut info, placeholder_node_id, len == 0);
        let result = {
            let text_input = &mut *text_input;
            let inner_clone = text_input.inner.clone();
            match text_input.on_text_input.as_mut() {
                Some(TextInputOnTextInput { callback, refany }) => {
                    (callback.cb)(refany.clone(), info, inner_clone)
                }
                None => OnTextInputReturn {
                    update: Update::DoNothing,
                    valid: TextInputValid::Yes,
                },
            }
        };
        return Some(result.update);
12
    }
    // A single-line field never accepts a line separator: veto insertions
    // carrying one (paste with newlines; the engine's Enter line break is
    // already vetoed in the key handler).
12
    if inserted_text.contains('\n') {
        info.prevent_default();
        return Some(Update::DoNothing);
12
    }
12
    let caret = engine_caret(&info, container);
12
    adopt_engine_text(&mut text_input.inner, &info, container);
12
    let result = {
        // rustc doesn't understand the borrowing lifetime here
12
        let text_input = &mut *text_input;
12
        let ontextinput = &mut text_input.on_text_input;
        // inner_clone has the new text
12
        let mut inner_clone = text_input.inner.clone();
12
        mirror_insertion(&mut inner_clone, &inserted_text, caret);
12
        let len = inner_clone.get_text().len();
12
        inner_clone.selection = engine_selection(&info, container, len).into();
12
        match ontextinput.as_mut() {
2
            Some(TextInputOnTextInput { callback, refany }) => {
2
                (callback.cb)(refany.clone(), info, inner_clone)
            }
10
            None => OnTextInputReturn {
10
                update: Update::DoNothing,
10
                valid: TextInputValid::Yes,
10
            },
        }
    };
12
    if result.valid == TextInputValid::Yes {
11
        // hide the placeholder text
11
        set_placeholder_visible(&mut info, placeholder_node_id, false);
11

            
11
        mirror_insertion(&mut text_input.inner, &inserted_text, caret);
11
        let len = text_input.inner.get_text().len();
11
        text_input.inner.selection = engine_selection(&info, container, len).into();
11
    } else {
1
        // The engine applies the recorded changeset once the callbacks return,
1
        // unless one of them vetoes it.
1
        info.prevent_default();
1
    }
12
    Some(result.update)
17
}
14
extern "C" fn default_on_virtual_key_down(text_input: RefAny, info: CallbackInfo) -> Update {
14
    default_on_virtual_key_down_inner(text_input, info).unwrap_or(Update::DoNothing)
14
}
15
fn default_on_virtual_key_down_inner(
15
    mut text_input: RefAny,
15
    mut info: CallbackInfo,
15
) -> Option<Update> {
15
    let mut text_input = text_input.downcast_mut::<TextInputStateWrapper>()?;
14
    let keyboard_state = info.get_current_keyboard_state();
14
    let keycode = keyboard_state.current_virtual_keycode.into_option()?;
13
    let (_placeholder_node_id, _label_node_id) = label_nodes(&info)?;
12
    let container = info.get_hit_node();
12
    adopt_engine_text(&mut text_input.inner, &info, container);
    // Editing keys (Backspace, Delete, the arrows, Enter) are the engine's
    // default actions; this handler only forwards the key to the user's hook
    // and lets a rejection stop the default from running.
12
    let result = {
        // rustc doesn't understand the borrowing lifetime here
12
        let text_input = &mut *text_input;
12
        let mut inner_clone = text_input.inner.clone();
12
        let len = inner_clone.get_text().len();
12
        inner_clone.selection = engine_selection(&info, container, len).into();
12
        match text_input.on_virtual_key_down.as_mut() {
5
            Some(TextInputOnVirtualKeyDown { callback, refany }) => {
5
                (callback.cb)(refany.clone(), info, inner_clone)
            }
7
            None => OnTextInputReturn {
7
                update: Update::DoNothing,
7
                valid: TextInputValid::Yes,
7
            },
        }
    };
12
    let len = text_input.inner.get_text().len();
12
    text_input.inner.selection = engine_selection(&info, container, len).into();
12
    if result.valid == TextInputValid::No {
1
        info.prevent_default();
11
    }
    // Single-line field: Enter must never edit the value. The engine-side
    // default in this host (white-space:pre) is a literal "\n" insert, so it
    // is vetoed here; activation semantics stay with the user's hook above.
11
    if matches!(
12
        keycode,
        azul_core::window::VirtualKeyCode::Return | azul_core::window::VirtualKeyCode::NumpadEnter
1
    ) {
1
        info.prevent_default();
11
    }
12
    Some(result.update)
15
}
2
extern "C" fn default_on_mouse_hover(mut text_input: RefAny, _info: CallbackInfo) -> Update {
2
    let Some(_text_input) = text_input.downcast_mut::<TextInputStateWrapper>() else {
1
        return Update::DoNothing;
    };
1
    Update::DoNothing
2
}
#[cfg(all(test, feature = "std"))]
#[allow(clippy::too_many_lines, clippy::float_cmp)]
mod autotest_generated {
    use std::{
        collections::{BTreeMap, HashMap},
        sync::{Arc, Mutex},
    };
    use azul_core::{
        dom::{
            AttributeType, DomId, DomNodeId, EventFilter, FocusEventFilter, HoverEventFilter,
            IdOrClass, NodeId, NodeType, TabIndex,
        },
        geom::{LogicalRect, OptionLogicalPosition},
        gl::OptionGlContextPtr,
        hit_test::ScrollPosition,
        refany::OptionRefAny,
        resources::RendererResources,
        styled_dom::{NodeHierarchyItemId, StyledDom},
        window::{MonitorVec, RawWindowHandle, VirtualKeyCode},
    };
    use rust_fontconfig::FcFontCache;
    use super::*;
    #[cfg(feature = "icu")]
    use crate::icu::IcuLocalizerHandle;
    use crate::{
        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
        managers::text_input::PendingTextEdit,
        solver3::{display_list::DisplayList, layout_tree::LayoutTree},
        window::{DomLayoutResult, LayoutWindow},
        window_state::FullWindowState,
    };
    // ==================================================================
    // Sample data
    // ==================================================================
    /// Strings the buffer has to survive a `set_text` -> `get_text` round-trip on.
    /// Deliberately loaded with cases where "length" is ambiguous: the buffer counts
    /// *scalars*, `str::len()` counts *bytes*, and a human counts *graphemes* — three
    /// numbers that only agree for pure ASCII.
    const HOSTILE: [&str; 20] = [
        "",
        " ",
        "a",
        "hello world",
        "\0",           // a lone NUL is a perfectly good scalar
        "a\0b",         // ... and it survives in the middle of a string, too
        "\u{7f}",       // largest 1-byte scalar
        "\u{80}",       // smallest 2-byte scalar
        "\u{7ff}",      // largest 2-byte scalar
        "\u{800}",      // smallest 3-byte scalar
        "\u{ffff}",     // largest 3-byte scalar (and a non-character)
        "\u{10000}",    // smallest 4-byte scalar
        "\u{10ffff}",   // the largest scalar that exists at all
        "é",
        "e\u{301}",     // e + COMBINING ACUTE: 2 scalars, 1 grapheme
        "👨‍👩‍👧‍👦", // ZWJ family: 7 scalars, 1 grapheme, 25 bytes
        "日本語",
        "مرحبا",        // RTL
        "\u{200b}",     // zero-width space: invisible, but still one scalar
        "\r\n\t",
    ];
    /// `u32` code units that are **not** Unicode scalars, so `char::from_u32` rejects
    /// every one of them. The buffer is a `U32Vec`, so nothing stops them from being
    /// in there — `get_text` is the only thing standing between them and a `String`.
    const NON_SCALAR_UNITS: [u32; 6] = [
        0xD800,      // leading surrogate
        0xDC00,      // trailing surrogate
        0xDFFF,      // last surrogate
        0x0011_0000, // one past the last scalar
        0xFFFF_FFFE,
        u32::MAX,
    ];
    // ==================================================================
    // Widget fixtures
    // ==================================================================
    /// A `TextInput` whose buffer holds *raw* code units — the only way to build a
    /// state that `set_text` could never produce (it goes through `char`).
    fn input_with_units(units: &[u32]) -> TextInput {
        let mut input = TextInput::create();
        input.text_input_state.inner.text = units.to_vec().into();
        input
    }
    /// Renders `input` and hands back both the flattened DOM *and* the very `RefAny`
    /// the widget registered on its own handlers. Driving the handlers with these two
    /// is the real wiring: nothing is rebuilt by hand, so a mismatch between what
    /// `dom()` stores and what the handlers expect cannot hide behind the fixture.
    fn rendered(input: TextInput) -> (StyledDom, RefAny) {
        let dom = input.dom();
        let state = dom.root.callbacks.as_ref()[0].refany.clone();
        (StyledDom::create_from_dom(dom), state)
    }
    /// The `TextInputState` currently sitting behind a widget-state payload.
    fn state_of(state: &RefAny) -> TextInputState {
        let mut state = state.clone();
        let wrapper = state
            .downcast_ref::<TextInputStateWrapper>()
            .expect("the widget state must still be a TextInputStateWrapper");
        wrapper.inner.clone()
    }
    /// Reaches into the live widget state — used to plant a cursor position that
    /// `dom()` would otherwise have already normalised away.
    fn poke(state: &RefAny, f: impl FnOnce(&mut TextInputStateWrapper)) {
        let mut state = state.clone();
        let mut wrapper = state
            .downcast_mut::<TextInputStateWrapper>()
            .expect("the widget state must still be a TextInputStateWrapper");
        f(&mut wrapper);
    }
    // ==================================================================
    // Recording hooks
    // ==================================================================
    /// A user payload that records every state it is handed and answers with a
    /// canned verdict. It arrives as the `refany` argument — a *shared* clone of
    /// what the test still holds — so the test reads back exactly what the widget
    /// passed, with no global state involved.
    struct Recorder {
        seen: Vec<TextInputState>,
        update: Update,
        valid: TextInputValid,
    }
    fn recorder(update: Update, valid: TextInputValid) -> RefAny {
        RefAny::new(Recorder {
            seen: Vec::new(),
            update,
            valid,
        })
    }
    fn recorded(probe: &RefAny) -> Vec<TextInputState> {
        let mut probe = probe.clone();
        let log = probe
            .downcast_ref::<Recorder>()
            .expect("the user payload must still be a Recorder");
        log.seen.clone()
    }
    extern "C" fn record_text_input(
        mut data: RefAny,
        _: CallbackInfo,
        state: TextInputState,
    ) -> OnTextInputReturn {
        let Some(mut log) = data.downcast_mut::<Recorder>() else {
            return OnTextInputReturn {
                update: Update::DoNothing,
                valid: TextInputValid::Yes,
            };
        };
        log.seen.push(state);
        OnTextInputReturn {
            update: log.update,
            valid: log.valid,
        }
    }
    // Deliberately *not* the same body as `record_text_input`: two hooks with
    // byte-identical bodies can be folded onto a single symbol by the linker, and
    // the two slots have to stay distinguishable by function pointer.
    extern "C" fn record_virtual_key(
        mut data: RefAny,
        _: CallbackInfo,
        state: TextInputState,
    ) -> OnTextInputReturn {
        match data.downcast_mut::<Recorder>() {
            Some(mut log) => {
                let answer = OnTextInputReturn {
                    update: log.update,
                    valid: log.valid,
                };
                log.seen.push(state);
                answer
            }
            None => OnTextInputReturn {
                update: Update::RefreshDom,
                valid: TextInputValid::No,
            },
        }
    }
    extern "C" fn record_focus_lost(
        mut data: RefAny,
        _: CallbackInfo,
        state: TextInputState,
    ) -> Update {
        data.downcast_mut::<Recorder>().map_or(Update::RefreshDom, |mut log| {
            log.seen.push(state);
            log.update
        })
    }
    /// A `Callback`-shaped (2-argument) function — the shape FFI bindings hand in,
    /// which the `From<Callback>` arm *transmutes* into the 3-argument widget slot.
    /// Never invoked; only its address is ever compared.
    extern "C" fn generic_shaped(_: RefAny, _: CallbackInfo) -> Update {
        Update::DoNothing
    }
    // ==================================================================
    // CallbackInfo harness
    // ==================================================================
    /// The container is always the flattened root of `TextInput::dom()`.
    const CONTAINER: usize = 0;
    fn dom_node(idx: usize) -> DomNodeId {
        DomNodeId {
            dom: DomId::ROOT_ID,
            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(idx))),
        }
    }
    /// A `DomNodeId` whose node component is `None` — the "nothing concrete was hit"
    /// case. `CallbackInfo::set_css_property` *panics* on such an id, so every
    /// handler has to bail out before it ever gets there.
    fn node_none() -> DomNodeId {
        DomNodeId {
            dom: DomId::ROOT_ID,
            node: NodeHierarchyItemId::NONE,
        }
    }
    fn inner_id(node: DomNodeId) -> NodeId {
        node.node
            .into_crate_internal()
            .expect("expected a concrete node id")
    }
    /// A `DomLayoutResult` carrying only a `styled_dom`: the text-input handlers
    /// reach only `get_hit_node` / `get_first_child` / `get_next_sibling`, all of
    /// which read the node hierarchy alone — no real layout (and no font) needed.
    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
        DomLayoutResult {
            styled_dom,
            layout_tree: LayoutTree {
                nodes: Vec::new(),
                warm: Vec::new(),
                cold: Vec::new(),
                root: 0,
                dom_to_layout: BTreeMap::new(),
                children_arena: Vec::new(),
                children_offsets: Vec::new(),
                subtree_needs_intrinsic: Vec::new(),
            },
            calculated_positions: Vec::new(),
            viewport: LogicalRect::zero(),
            display_list: Arc::new(DisplayList::default()),
            scroll_ids: HashMap::new(),
            scroll_id_to_node_id: HashMap::new(),
        }
    }
    /// Everything a default handler can read out of the window.
    struct Env {
        styled_dom: StyledDom,
        hit: DomNodeId,
        keycode: Option<VirtualKeyCode>,
        changeset: Option<PendingTextEdit>,
    }
    impl Env {
        fn new(styled_dom: StyledDom) -> Self {
            Self {
                styled_dom,
                hit: dom_node(CONTAINER),
                keycode: None,
                changeset: None,
            }
        }
        fn hit(mut self, hit: DomNodeId) -> Self {
            self.hit = hit;
            self
        }
        fn key(mut self, keycode: VirtualKeyCode) -> Self {
            self.keycode = Some(keycode);
            self
        }
        fn insert(mut self, text: &str) -> Self {
            self.changeset = Some(PendingTextEdit {
                node: dom_node(CONTAINER),
                inserted_text: text.into(),
                old_text: AzString::from(""),
            });
            self
        }
    }
    /// The container's two `<p>` children plus the value's bare text leaf,
    /// resolved through the *same* API the handlers use — so no test has to
    /// hard-code a flattened index.
    #[derive(Copy, Clone, Debug, PartialEq, Eq)]
    struct Nodes {
        placeholder: Option<DomNodeId>,
        label: Option<DomNodeId>,
        label_text: Option<DomNodeId>,
    }
    /// Runs `f` with a real `CallbackInfo` over a window holding `env.styled_dom` as
    /// the root DOM. Returns `f`'s value, every change the handler pushed onto the
    /// transaction log, and the resolved child node ids.
    fn run<R>(env: Env, f: impl FnOnce(CallbackInfo) -> R) -> (R, Vec<CallbackChange>, Nodes) {
        let mut layout_window =
            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
        layout_window
            .layout_results
            .insert(DomId::ROOT_ID, layout_result(env.styled_dom));
        if let Some(changeset) = env.changeset {
            layout_window.text_input_manager.set_changeset(changeset);
        }
        let layout_window = layout_window;
        let renderer_resources = RendererResources::default();
        let previous_window_state: Option<FullWindowState> = None;
        let mut current_window_state = FullWindowState::default();
        current_window_state.keyboard_state.current_virtual_keycode = env.keycode.into();
        let gl_context = OptionGlContextPtr::None;
        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
            BTreeMap::new();
        let window_handle = RawWindowHandle::Unsupported;
        let system_callbacks = ExternalSystemCallbacks::rust_internal();
        let ref_data = CallbackInfoRefData {
            layout_window: &layout_window,
            renderer_resources: &renderer_resources,
            previous_window_state: &previous_window_state,
            current_window_state: &current_window_state,
            gl_context: &gl_context,
            current_scroll_manager: &scroll_states,
            current_window_handle: &window_handle,
            system_callbacks: &system_callbacks,
            system_style: Arc::new(system::SystemStyle::default()),
            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
            #[cfg(feature = "icu")]
            icu_localizer: IcuLocalizerHandle::default(),
            ctx: OptionRefAny::None,
        };
        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
        let probe = CallbackInfo::new(
            &ref_data,
            &changes,
            dom_node(CONTAINER),
            OptionLogicalPosition::None,
            OptionLogicalPosition::None,
        );
        let placeholder = probe.get_first_child(dom_node(CONTAINER));
        let label = placeholder.and_then(|p| probe.get_next_sibling(p));
        let label_text = label.and_then(|l| probe.get_first_child(l));
        let nodes = Nodes {
            placeholder,
            label,
            label_text,
        };
        let info = CallbackInfo::new(
            &ref_data,
            &changes,
            env.hit,
            OptionLogicalPosition::None,
            OptionLogicalPosition::None,
        );
        let r = f(info);
        let pushed = info.take_changes();
        (r, pushed, nodes)
    }
    /// Every `(node, opacity)` pair pushed onto the transaction log, in push order.
    fn pushed_opacities(changes: &[CallbackChange]) -> Vec<(NodeId, f32)> {
        changes
            .iter()
            .filter_map(|c| match c {
                CallbackChange::ChangeNodeCssProperties {
                    node_id, properties, ..
                } => {
                    let o = properties.as_ref().iter().find_map(|p| match p {
                        CssProperty::Opacity(o) => o.get_property().map(|o| o.inner.normalized()),
                        _ => None,
                    })?;
                    Some((*node_id, o))
                }
                _ => None,
            })
            .collect()
    }
    /// Every `(node, text)` repaint pushed onto the transaction log, in push order.
    fn pushed_texts(changes: &[CallbackChange]) -> Vec<(DomNodeId, String)> {
        changes
            .iter()
            .filter_map(|c| match c {
                CallbackChange::ChangeNodeText { node_id, text } => {
                    Some((*node_id, text.as_str().to_string()))
                }
                _ => None,
            })
            .collect()
    }
    // ==================================================================
    // DOM probes
    // ==================================================================
    /// Flattened child indices of `TextInput::dom()`.
    const PLACEHOLDER_CHILD: usize = 0;
    const LABEL_CHILD: usize = 1;
    fn classes(node: &Dom) -> Vec<String> {
        node.root
            .get_ids_and_classes()
            .as_ref()
            .iter()
            .filter_map(|c| match c {
                IdOrClass::Class(s) => Some(s.as_str().to_string()),
                IdOrClass::Id(_) => None,
            })
            .collect()
    }
    /// The text a `<p>` label wraps, looking through the block wrapper the
    /// widget convention mandates (`p > text`).
    fn text_of(node: &Dom) -> String {
        assert!(
            matches!(node.root.get_node_type(), NodeType::P),
            "widget text must be wrapped in a <p> block"
        );
        match node.children.as_ref() {
            [only] => only
                .root
                .get_node_type()
                .format()
                .expect("expected a bare text leaf"),
            other => panic!("a label <p> wraps exactly one text node, found {}", other.len()),
        }
    }
    /// Every `NodeType::Text` node in `node`'s subtree that is not a bare leaf
    /// under a `<p>`: css props, callbacks, a tab index, a dataset or children
    /// on a text node are all inert, because a text node owns no rect.
    fn text_nodes_carrying_state(node: &Dom, parent_is_p: bool, bad: &mut Vec<String>) {
        if let NodeType::Text(t) = node.root.get_node_type() {
            let carries = !node.root.get_style().is_empty()
                || !node.root.get_callbacks().as_ref().is_empty()
                || node.root.get_tab_index().is_some()
                || node.root.get_dataset().is_some()
                || !node.children.as_ref().is_empty()
                || !parent_is_p;
            if carries {
                bad.push(t.as_ref().as_str().to_string());
            }
        }
        let is_p = matches!(node.root.get_node_type(), NodeType::P);
        for c in node.children.as_ref() {
            text_nodes_carrying_state(c, is_p, bad);
        }
    }
    fn dataset_state(dom: &Dom) -> TextInputState {
        let mut dataset = dom
            .root
            .get_dataset()
            .cloned()
            .expect("TextInput::dom must attach its state as the container's dataset");
        let wrapper = dataset
            .downcast_ref::<TextInputStateWrapper>()
            .expect("the dataset must be a TextInputStateWrapper");
        wrapper.inner.clone()
    }
    /// `n` properties lifted off the default container style — an easy way to mint
    /// pairwise-distinct style vectors without hard-coding any CSS.
    fn style(n: usize) -> CssPropertyWithConditionsVec {
        let all: Vec<CssPropertyWithConditions> =
            TextInput::default().container_style.as_ref().to_vec();
        assert!(n <= all.len(), "not enough default properties to slice");
        CssPropertyWithConditionsVec::from_vec(all.into_iter().take(n).collect())
    }
    // ==================================================================
    // TextInputState::get_text
    // ==================================================================
    #[test]
    fn get_text_on_a_default_state_is_the_empty_string() {
        let state = TextInputState::default();
        assert_eq!(state.get_text(), "");
        assert!(state.text.is_empty());
        assert_eq!(state.cursor_pos, 0);
        assert!(state.placeholder.is_none());
        assert!(state.selection.is_none());
    }
    #[test]
    fn get_text_round_trips_every_hostile_string() {
        // The buffer stores one `u32` per scalar, so the round-trip must be exact for
        // anything `chars()` can produce — combining marks, ZWJ sequences, embedded
        // NULs and the very last scalar included.
        for s in HOSTILE {
            let input = TextInput::create().with_text(s.into());
            assert_eq!(
                input.text_input_state.inner.get_text(),
                s,
                "the buffer did not round-trip {s:?}",
            );
        }
    }
    #[test]
    fn get_text_silently_drops_code_units_that_are_not_unicode_scalars() {
        // `get_text` is a `filter_map(char::from_u32)`: junk in the buffer is skipped,
        // not escaped and not panicked on. Pin that, because "skip" is the difference
        // between a lossy read and a crash on FFI-provided buffers.
        for unit in NON_SCALAR_UNITS {
            let state = TextInputState {
                text: vec![u32::from('A'), unit, u32::from('B')].into(),
                ..TextInputState::default()
            };
            assert_eq!(
                state.get_text(),
                "AB",
                "code unit {unit:#x} was not dropped from the rendered text",
            );
            assert_eq!(
                state.text.len(),
                3,
                "get_text must not mutate the buffer it reads",
            );
        }
    }
    #[test]
    fn get_text_on_a_buffer_of_nothing_but_junk_is_empty_and_does_not_panic() {
        let state = TextInputState {
            text: NON_SCALAR_UNITS.to_vec().into(),
            ..TextInputState::default()
        };
        assert_eq!(state.get_text(), "");
        assert_eq!(state.text.len(), NON_SCALAR_UNITS.len());
    }
    #[test]
    fn get_text_never_yields_more_chars_than_the_buffer_holds() {
        // The one invariant that holds for *any* buffer contents: filtering can only
        // ever shrink. A `get_text` that grew would mean the buffer and the rendered
        // label disagree about how far the cursor can travel.
        let mut units: Vec<u32> = Vec::new();
        for (i, unit) in NON_SCALAR_UNITS.iter().enumerate() {
            units.push(u32::from('x'));
            units.push(*unit);
            units.push(0x1F600 + i as u32);
        }
        let state = TextInputState {
            text: units.clone().into(),
            ..TextInputState::default()
        };
        let rendered = state.get_text();
        assert!(
            rendered.chars().count() <= state.text.len(),
            "get_text produced {} chars from a {}-unit buffer",
            rendered.chars().count(),
            state.text.len(),
        );
        assert_eq!(rendered.chars().count(), units.len() - NON_SCALAR_UNITS.len());
    }
    #[test]
    fn get_text_is_pure() {
        let state = TextInputState {
            text: vec![u32::from('a'), 0xD800, u32::from('b')].into(),
            ..TextInputState::default()
        };
        let before = state.clone();
        assert_eq!(state.get_text(), state.get_text());
        assert_eq!(state, before, "get_text mutated the state it was given");
    }
    #[test]
    fn get_text_on_a_very_large_buffer_does_not_panic() {
        let n = 50_000;
        let state = TextInputState {
            text: core::iter::repeat_n(u32::from('ß'), n).collect::<Vec<_>>().into(),
            ..TextInputState::default()
        };
        let text = state.get_text();
        assert_eq!(text.chars().count(), n);
        // 'ß' is two bytes: byte length and scalar count are *not* the same number.
        assert_eq!(text.len(), n * 2);
    }
    // ==================================================================
    // TextInput::set_text / with_text
    // ==================================================================
    #[test]
    fn with_text_is_exactly_set_text() {
        for s in HOSTILE {
            let mut a = TextInput::create();
            a.set_text(s.into());
            let b = TextInput::create().with_text(s.into());
            assert_eq!(a, b, "with_text and set_text disagree on {s:?}");
        }
    }
    #[test]
    fn set_text_stores_one_code_unit_per_scalar_not_per_byte() {
        // The classic off-by-UTF-8 bug: storing `s.len()` units for a string whose
        // scalar count is smaller. Every non-ASCII entry in the table has a byte
        // length strictly greater than its scalar count.
        for s in HOSTILE {
            let input = TextInput::create().with_text(s.into());
            assert_eq!(
                input.text_input_state.inner.text.len(),
                s.chars().count(),
                "the buffer length for {s:?} is not the scalar count",
            );
        }
        let family = "👨‍👩‍👧‍👦";
        let input = TextInput::create().with_text(family.into());
        assert_eq!(input.text_input_state.inner.text.len(), 7);
        assert_eq!(family.len(), 25, "the ZWJ family is 25 bytes, not 7");
    }
    #[test]
    fn set_text_stores_the_scalar_values_verbatim() {
        let input = TextInput::create().with_text("aé\u{10ffff}".into());
        assert_eq!(
            input.text_input_state.inner.text.as_slice(),
            &[0x61, 0xE9, 0x0010_FFFF],
        );
    }
    #[test]
    fn set_text_replaces_rather_than_appends() {
        let mut input = TextInput::create();
        input.set_text("first".into());
        input.set_text("second".into());
        assert_eq!(input.text_input_state.inner.get_text(), "second");
        assert_eq!(input.text_input_state.inner.text.len(), 6);
    }
    #[test]
    fn set_text_with_an_empty_string_clears_the_buffer() {
        let mut input = TextInput::create().with_text("something".into());
        input.set_text("".into());
        assert!(input.text_input_state.inner.text.is_empty());
        assert_eq!(input.text_input_state.inner.get_text(), "");
        assert_eq!(input, TextInput::create(), "clearing did not restore a fresh widget");
    }
    #[test]
    fn set_text_does_not_enforce_max_len() {
        // KNOWN GAP: `max_len` defaults to 50 and is never read anywhere in this
        // module — not by `set_text`, not by the text-input handler. A 200-char
        // assignment is stored whole. Pinned so that adding enforcement later shows
        // up as a deliberate change rather than a silent one.
        let long: String = "x".repeat(200);
        let input = TextInput::create().with_text(long.clone().into());
        assert_eq!(input.text_input_state.inner.max_len, 50);
        assert_eq!(input.text_input_state.inner.text.len(), 200);
        assert_eq!(input.text_input_state.inner.get_text(), long);
    }
    #[test]
    fn set_text_leaves_the_cursor_where_it_was() {
        // `set_text` writes the buffer and nothing else: the cursor is only
        // reconciled by `dom()` / the focus handler. A widget built with text but
        // never rendered therefore reports a cursor of 0 over a non-empty buffer.
        let input = TextInput::create().with_text("hello".into());
        assert_eq!(input.text_input_state.inner.cursor_pos, 0);
        assert_eq!(input.text_input_state.inner.text.len(), 5);
    }
    #[test]
    fn set_text_touches_nothing_but_the_buffer() {
        let mut input = TextInput::create()
            .with_placeholder("type here".into())
            .with_placeholder_style(style(3));
        let before = input.clone();
        input.set_text("abc".into());
        assert_eq!(
            input.text_input_state.inner.placeholder.as_ref().map(|s| s.as_str().to_string()),
            Some("type here".to_string()),
        );
        assert_eq!(input.placeholder_style, before.placeholder_style);
        assert_eq!(input.container_style, before.container_style);
        assert_eq!(input.label_style, before.label_style);
        assert_eq!(input.text_input_state.inner.max_len, before.text_input_state.inner.max_len);
        assert!(input.text_input_state.inner.selection.is_none());
    }
    #[test]
    fn with_text_on_a_very_large_string_does_not_panic() {
        let n = 50_000;
        let long: String = "a".repeat(n);
        let input = TextInput::create().with_text(long.into());
        assert_eq!(input.text_input_state.inner.text.len(), n);
    }
    #[test]
    fn set_text_is_idempotent() {
        for s in HOSTILE {
            let mut input = TextInput::create();
            input.set_text(s.into());
            let once = input.clone();
            input.set_text(s.into());
            assert_eq!(input, once, "re-assigning {s:?} changed the widget");
        }
    }
    // ==================================================================
    // TextInput::set_placeholder / with_placeholder
    // ==================================================================
    #[test]
    fn placeholder_is_absent_on_a_fresh_widget() {
        assert!(TextInput::create().text_input_state.inner.placeholder.is_none());
    }
    #[test]
    fn with_placeholder_is_exactly_set_placeholder_and_stores_the_string_verbatim() {
        for s in HOSTILE {
            let mut a = TextInput::create();
            a.set_placeholder(s.into());
            let b = TextInput::create().with_placeholder(s.into());
            assert_eq!(a, b, "with_placeholder and set_placeholder disagree on {s:?}");
            assert_eq!(
                a.text_input_state.inner.placeholder.as_ref().map(|p| p.as_str()),
                Some(s),
                "the placeholder {s:?} was not stored byte-for-byte",
            );
        }
    }
    #[test]
    fn set_placeholder_overwrites_a_previous_placeholder_and_never_clears_it() {
        let mut input = TextInput::create();
        input.set_placeholder("first".into());
        input.set_placeholder("".into());
        // An empty placeholder is still *a* placeholder, not the absence of one.
        assert_eq!(
            input.text_input_state.inner.placeholder.as_ref().map(|p| p.as_str()),
            Some(""),
        );
    }
    #[test]
    fn set_placeholder_does_not_touch_the_text_buffer() {
        let mut input = TextInput::create().with_text("abc".into());
        input.set_placeholder("hint".into());
        assert_eq!(input.text_input_state.inner.get_text(), "abc");
    }
    // ==================================================================
    // Style setters
    // ==================================================================
    #[test]
    fn each_style_setter_writes_exactly_one_slot() {
        let marker = style(1);
        let mut a = TextInput::create();
        a.set_placeholder_style(marker.clone());
        assert_eq!(a.placeholder_style, marker);
        assert_eq!(a.container_style, TextInput::create().container_style);
        assert_eq!(a.label_style, TextInput::create().label_style);
        let mut b = TextInput::create();
        b.set_container_style(marker.clone());
        assert_eq!(b.container_style, marker);
        assert_eq!(b.placeholder_style, TextInput::create().placeholder_style);
        assert_eq!(b.label_style, TextInput::create().label_style);
        let mut c = TextInput::create();
        c.set_label_style(marker.clone());
        assert_eq!(c.label_style, marker);
        assert_eq!(c.placeholder_style, TextInput::create().placeholder_style);
        assert_eq!(c.container_style, TextInput::create().container_style);
    }
    #[test]
    fn the_with_style_builders_are_exactly_their_setters() {
        let s = style(2);
        let mut a = TextInput::create();
        a.set_placeholder_style(s.clone());
        assert_eq!(a, TextInput::create().with_placeholder_style(s.clone()));
        let mut b = TextInput::create();
        b.set_container_style(s.clone());
        assert_eq!(b, TextInput::create().with_container_style(s.clone()));
        let mut c = TextInput::create();
        c.set_label_style(s.clone());
        assert_eq!(c, TextInput::create().with_label_style(s));
    }
    #[test]
    fn style_setters_accept_an_empty_vector_and_survive_rendering() {
        let empty = CssPropertyWithConditionsVec::from_vec(Vec::new());
        let input = TextInput::create()
            .with_placeholder_style(empty.clone())
            .with_container_style(empty.clone())
            .with_label_style(empty.clone());
        assert!(input.container_style.is_empty());
        // Stripping every declared property must not stop the widget from rendering.
        let dom = input.dom();
        assert_eq!(dom.children.as_ref().len(), 2);
    }
    #[test]
    fn style_setters_overwrite_rather_than_merge() {
        let mut input = TextInput::create();
        input.set_container_style(style(4));
        input.set_container_style(style(1));
        assert_eq!(input.container_style.len(), 1);
    }
    // ==================================================================
    // Callback setters
    // ==================================================================
    #[test]
    fn set_on_text_input_stores_the_fn_pointer_and_the_payload_verbatim() {
        let mut input = TextInput::create();
        input.set_on_text_input(
            RefAny::new(0xDEAD_BEEF_u32),
            record_text_input as TextInputOnTextInputCallbackType,
        );
        let slot = input
            .text_input_state
            .on_text_input
            .as_ref()
            .expect("set_on_text_input stored nothing");
        assert_eq!(
            slot.callback.cb as *const () as usize,
            record_text_input as TextInputOnTextInputCallbackType as *const () as usize,
            "the fn pointer was mangled on the way in",
        );
        let mut payload = slot.refany.clone();
        assert_eq!(
            *payload.downcast_ref::<u32>().expect("the payload changed type"),
            0xDEAD_BEEF,
        );
        assert!(
            payload.downcast_ref::<u64>().is_none(),
            "the payload must not be readable as a differently-typed value",
        );
    }
    #[test]
    fn set_on_virtual_key_down_and_set_on_focus_lost_store_their_own_slots() {
        let mut input = TextInput::create();
        input.set_on_virtual_key_down(
            RefAny::new(1_u8),
            record_virtual_key as TextInputOnVirtualKeyDownCallbackType,
        );
        input.set_on_focus_lost(
            RefAny::new(2_u8),
            record_focus_lost as TextInputOnFocusLostCallbackType,
        );
        assert!(
            input.text_input_state.on_text_input.as_ref().is_none(),
            "the text-input slot was filled in by an unrelated setter",
        );
        assert_eq!(
            input
                .text_input_state
                .on_virtual_key_down
                .as_ref()
                .expect("the virtual-key slot is empty")
                .callback
                .cb as *const () as usize,
            record_virtual_key as TextInputOnVirtualKeyDownCallbackType as *const () as usize,
        );
        assert_eq!(
            input
                .text_input_state
                .on_focus_lost
                .as_ref()
                .expect("the focus-lost slot is empty")
                .callback
                .cb as *const () as usize,
            record_focus_lost as TextInputOnFocusLostCallbackType as *const () as usize,
        );
    }
    #[test]
    fn the_with_callback_builders_are_exactly_their_setters() {
        let payload = RefAny::new(7_u16);
        let mut a = TextInput::create();
        a.set_on_text_input(payload.clone(), record_text_input as TextInputOnTextInputCallbackType);
        assert_eq!(
            a,
            TextInput::create().with_on_text_input(
                payload.clone(),
                record_text_input as TextInputOnTextInputCallbackType,
            ),
        );
        let mut b = TextInput::create();
        b.set_on_virtual_key_down(
            payload.clone(),
            record_virtual_key as TextInputOnVirtualKeyDownCallbackType,
        );
        assert_eq!(
            b,
            TextInput::create().with_on_virtual_key_down(
                payload.clone(),
                record_virtual_key as TextInputOnVirtualKeyDownCallbackType,
            ),
        );
        let mut c = TextInput::create();
        c.set_on_focus_lost(payload.clone(), record_focus_lost as TextInputOnFocusLostCallbackType);
        assert_eq!(
            c,
            TextInput::create()
                .with_on_focus_lost(payload, record_focus_lost as TextInputOnFocusLostCallbackType),
        );
    }
    #[test]
    fn setting_a_callback_twice_replaces_it_rather_than_stacking() {
        let mut input = TextInput::create();
        input.set_on_text_input(
            RefAny::new(1_u8),
            record_text_input as TextInputOnTextInputCallbackType,
        );
        input.set_on_text_input(
            RefAny::new(2_u8),
            record_virtual_key as TextInputOnTextInputCallbackType,
        );
        let slot = input.text_input_state.on_text_input.as_ref().expect("slot is empty");
        assert_eq!(
            slot.callback.cb as *const () as usize,
            record_virtual_key as TextInputOnTextInputCallbackType as *const () as usize,
            "the second assignment did not win",
        );
        let mut payload = slot.refany.clone();
        assert_eq!(*payload.downcast_ref::<u8>().expect("wrong payload type"), 2);
    }
    #[test]
    fn a_generic_two_argument_callback_is_accepted_through_the_ffi_conversion() {
        // FFI bindings hand in a `Callback` (2 args) which the `From<Callback>` arm
        // transmutes into the 3-argument widget slot. It must survive being stored
        // and read back — only the *address* is meaningful, so that is all we check.
        let generic = Callback {
            cb: generic_shaped,
            ctx: OptionRefAny::None,
        };
        let input = TextInput::create().with_on_text_input(RefAny::new(0_u8), generic);
        assert_eq!(
            input
                .text_input_state
                .on_text_input
                .as_ref()
                .expect("the transmuted callback was dropped")
                .callback
                .cb as *const () as usize,
            generic_shaped as *const () as usize,
        );
    }
    // ==================================================================
    // TextInput::create / swap_with_default
    // ==================================================================
    #[test]
    fn create_is_default_and_is_pure() {
        assert_eq!(TextInput::create(), TextInput::default());
        assert_eq!(TextInput::create(), TextInput::create());
    }
    #[test]
    fn create_starts_empty_with_no_hooks_and_no_running_animation() {
        let input = TextInput::create();
        assert!(input.text_input_state.inner.text.is_empty());
        assert!(input.text_input_state.inner.placeholder.is_none());
        assert!(input.text_input_state.inner.selection.is_none());
        assert_eq!(input.text_input_state.inner.cursor_pos, 0);
        assert_eq!(input.text_input_state.inner.max_len, 50);
        assert!(input.text_input_state.on_text_input.as_ref().is_none());
        assert!(input.text_input_state.on_virtual_key_down.as_ref().is_none());
        assert!(input.text_input_state.on_focus_lost.as_ref().is_none());
        assert!(input.text_input_state.cursor_animation.is_none());
        assert!(input.text_input_state.update_text_input_before_calling_focus_lost_fn);
        assert!(input.text_input_state.update_text_input_before_calling_vk_down_fn);
        assert!(!input.container_style.is_empty());
    }
    #[test]
    fn swap_with_default_returns_the_old_widget_and_leaves_a_fresh_one_behind() {
        let mut input = TextInput::create()
            .with_text("typed".into())
            .with_placeholder("hint".into());
        let old = input.swap_with_default();
        assert_eq!(old.text_input_state.inner.get_text(), "typed");
        assert_eq!(input, TextInput::create(), "what was left behind is not a fresh widget");
    }
    #[test]
    fn swapping_twice_round_trips_the_original_widget() {
        let mut a = TextInput::create().with_text("abc".into());
        let mut b = a.swap_with_default(); // a = default, b = "abc"
        let c = b.swap_with_default(); // b = default, c = "abc"
        assert_eq!(c, TextInput::create().with_text("abc".into()));
        assert_eq!(a, TextInput::create());
        assert_eq!(b, TextInput::create());
    }
    #[test]
    fn swap_with_default_moves_the_hooks_out_rather_than_copying_them() {
        let probe = recorder(Update::DoNothing, TextInputValid::Yes);
        let mut input = TextInput::create().with_on_text_input(
            probe.clone(),
            record_text_input as TextInputOnTextInputCallbackType,
        );
        let old = input.swap_with_default();
        assert!(
            old.text_input_state.on_text_input.as_ref().is_some(),
            "the hook vanished during the swap",
        );
        // A duplicated hook would fire twice, and a duplicated RefAny would
        // double-free its payload.
        assert!(
            input.text_input_state.on_text_input.as_ref().is_none(),
            "the hook was copied instead of moved",
        );
        // The payload is still alive and still typed after the move (a double-freed
        // RefAny would not survive the downcast inside `recorded`).
        assert!(recorded(&probe).is_empty(), "the hook fired during a swap");
    }
    // ==================================================================
    // TextInput::dom
    // ==================================================================
    #[test]
    fn dom_builds_a_container_with_a_placeholder_block_and_a_value_block() {
        let dom = TextInput::create().dom();
        assert_eq!(classes(&dom), vec!["__azul-native-text-input-container"]);
        assert_eq!(dom.children.as_ref().len(), 2);
        let placeholder = &dom.children.as_ref()[PLACEHOLDER_CHILD];
        let label = &dom.children.as_ref()[LABEL_CHILD];
        assert_eq!(classes(placeholder), vec!["__azul-native-text-input-placeholder"]);
        assert_eq!(classes(label), vec!["__azul-native-text-input-label"]);
        for block in [placeholder, label] {
            assert!(matches!(block.root.get_node_type(), NodeType::P));
            assert_eq!(block.children.as_ref().len(), 1);
            assert!(matches!(
                block.children.as_ref()[0].root.get_node_type(),
                NodeType::Text(_),
            ));
        }
    }
    #[test]
    fn dom_emits_no_cursor_node() {
        // The caret and the selection are display-list items driven by the
        // engine's TextEditManager; a widget-owned cursor div resolved against
        // the container and never tracked the caret.
        fn walk(node: &Dom, out: &mut Vec<String>) {
            out.extend(classes(node));
            for c in node.children.as_ref() {
                walk(c, out);
            }
        }
        let mut all = Vec::new();
        walk(&TextInput::create().with_text("typed".into()).dom(), &mut all);
        assert!(
            !all.iter().any(|c| c.contains("cursor")),
            "the widget still emits a cursor node: {all:?}",
        );
    }
    #[test]
    fn dom_carries_no_state_on_any_text_node() {
        // A NodeType::Text node is unconditionally inline-level and owns no
        // rect, so css props / callbacks / a tab index / a dataset / children on
        // one are all inert. Every text node must be a bare leaf under a <p>.
        for input in [
            TextInput::create(),
            TextInput::create().with_text("typed".into()).with_placeholder("hint".into()),
        ] {
            let mut bad = Vec::new();
            text_nodes_carrying_state(&input.dom(), false, &mut bad);
            assert!(bad.is_empty(), "text nodes carrying inert state: {bad:?}");
        }
    }
    #[test]
    fn dom_marks_the_container_as_keyboard_focusable_and_editable() {
        // Focus events do not bubble and the engine records an edit against the
        // FOCUSED node, so the tab index and the contenteditable flag have to
        // sit on the same node the handlers are attached to.
        let dom = TextInput::create().dom();
        assert_eq!(dom.root.get_tab_index(), Some(TabIndex::Auto));
        assert!(dom.root.is_contenteditable());
    }
    #[test]
    fn dom_keeps_the_placeholder_out_of_the_editable_content() {
        // Everything inside a contenteditable host is editable content unless a
        // node blocks the inheritance walk; the prompt must never be typed into.
        let dom = TextInput::create().with_placeholder("hint".into()).dom();
        let placeholder = &dom.children.as_ref()[PLACEHOLDER_CHILD];
        assert!(
            placeholder
                .root
                .attributes()
                .as_ref()
                .iter()
                .any(|a| matches!(a, AttributeType::ContentEditable(false))),
            "the placeholder is inside the editable host and does not opt out",
        );
        assert!(!dom.children.as_ref()[LABEL_CHILD]
            .root
            .attributes()
            .as_ref()
            .iter()
            .any(|a| matches!(a, AttributeType::ContentEditable(_))));
    }
    #[test]
    fn dom_hides_the_placeholder_when_the_buffer_is_not_empty() {
        let hidden = TextInput::create().with_text("typed".into()).dom();
        let shown = TextInput::create().dom();
        let display = |node: &Dom| -> Option<CssProperty> {
            node.root
                .style
                .iter_inline_properties()
                .map(|(p, _)| p.clone())
                .filter(|p| matches!(p, CssProperty::Display(_)))
                .last()
        };
        assert_eq!(
            display(&hidden.children.as_ref()[PLACEHOLDER_CHILD]),
            Some(CssProperty::const_display(LayoutDisplay::None)),
        );
        assert_ne!(
            display(&shown.children.as_ref()[PLACEHOLDER_CHILD]),
            Some(CssProperty::const_display(LayoutDisplay::None)),
        );
    }
    #[test]
    fn dom_registers_exactly_the_five_default_handlers_over_one_shared_state() {
        let dom = TextInput::create().dom();
        let callbacks = dom.root.callbacks.as_ref();
        let events: Vec<EventFilter> = callbacks.iter().map(|c| c.event).collect();
        assert_eq!(
            events,
            vec![
                EventFilter::Focus(FocusEventFilter::FocusReceived),
                EventFilter::Focus(FocusEventFilter::FocusLost),
                EventFilter::Focus(FocusEventFilter::TextInput),
                EventFilter::Focus(FocusEventFilter::VirtualKeyDown),
                EventFilter::Hover(HoverEventFilter::MouseOver),
            ],
        );
        let targets: Vec<usize> = callbacks.iter().map(|c| c.callback.cb).collect();
        assert_eq!(
            targets,
            vec![
                default_on_focus_received as usize,
                default_on_focus_lost as usize,
                default_on_text_input as usize,
                default_on_virtual_key_down as usize,
                default_on_mouse_hover as usize,
            ],
            "the handlers are wired to the wrong events",
        );
        // All five handlers plus the dataset must share ONE state; separate copies
        // would let the focus handler and the text handler drift apart.
        for c in callbacks {
            assert_eq!(c.refany, callbacks[0].refany, "a handler got its own state copy");
        }
        assert_eq!(
            dom.root.get_dataset().expect("no dataset attached"),
            &callbacks[0].refany,
        );
    }
    #[test]
    fn dom_renders_the_buffer_into_the_label_and_the_placeholder_into_its_own_node() {
        let dom = TextInput::create()
            .with_text("typed".into())
            .with_placeholder("hint".into())
            .dom();
        assert_eq!(text_of(&dom.children.as_ref()[PLACEHOLDER_CHILD]), "hint");
        assert_eq!(text_of(&dom.children.as_ref()[LABEL_CHILD]), "typed");
    }
    #[test]
    fn dom_without_a_placeholder_still_renders_an_empty_placeholder_node() {
        // The handlers navigate `container -> first child -> next sibling`; dropping
        // the placeholder node when unset would make the label unreachable.
        let dom = TextInput::create().with_text("typed".into()).dom();
        assert_eq!(dom.children.as_ref().len(), 2);
        assert_eq!(text_of(&dom.children.as_ref()[PLACEHOLDER_CHILD]), "");
    }
    #[test]
    fn dom_passes_hostile_text_and_placeholder_through_unchanged() {
        for s in HOSTILE {
            let dom = TextInput::create()
                .with_text(s.into())
                .with_placeholder(s.into())
                .dom();
            assert_eq!(text_of(&dom.children.as_ref()[LABEL_CHILD]), s, "label mangled {s:?}");
            assert_eq!(
                text_of(&dom.children.as_ref()[PLACEHOLDER_CHILD]),
                s,
                "placeholder mangled {s:?}",
            );
        }
    }
    #[test]
    fn dom_syncs_the_cursor_to_the_end_of_the_buffer() {
        for s in HOSTILE {
            let dom = TextInput::create().with_text(s.into()).dom();
            assert_eq!(
                dataset_state(&dom).cursor_pos,
                s.chars().count(),
                "the cursor was not parked at the end of {s:?}",
            );
        }
    }
    #[test]
    fn dom_measures_the_cursor_in_code_units_which_can_outrun_the_rendered_text() {
        // KNOWN GAP: `dom()` sets `cursor_pos = text.len()` (code units), while the
        // label only renders the units that are valid scalars. A buffer holding junk
        // therefore ends up with a cursor past the end of what is on screen.
        let dom = input_with_units(&[u32::from('a'), 0xD800, u32::from('b')]).dom();
        assert_eq!(dataset_state(&dom).cursor_pos, 3);
        assert_eq!(text_of(&dom.children.as_ref()[LABEL_CHILD]), "ab");
    }
    #[test]
    fn dom_on_a_very_large_buffer_does_not_panic() {
        let n = 50_000;
        let long: String = "x".repeat(n);
        let dom = TextInput::create().with_text(long.into()).dom();
        assert_eq!(text_of(&dom.children.as_ref()[LABEL_CHILD]).len(), n);
        assert_eq!(dataset_state(&dom).cursor_pos, n);
    }
    #[test]
    fn dom_keeps_the_configured_styles_on_the_nodes_they_were_set_for() {
        let placeholder_style = style(1);
        let label_style = style(2);
        let container_style = style(3);
        let dom = TextInput::create()
            .with_placeholder_style(placeholder_style.clone())
            .with_label_style(label_style.clone())
            .with_container_style(container_style.clone())
            .dom();
        let inline = |node: &Dom| -> Vec<CssProperty> {
            node.root.style.iter_inline_properties().map(|(p, _)| p.clone()).collect()
        };
        let declared = |v: &CssPropertyWithConditionsVec| -> Vec<CssProperty> {
            v.as_ref().iter().map(|p| p.property.clone()).collect()
        };
        assert_eq!(inline(&dom), declared(&container_style));
        assert_eq!(
            inline(&dom.children.as_ref()[PLACEHOLDER_CHILD]),
            declared(&placeholder_style),
        );
        assert_eq!(inline(&dom.children.as_ref()[LABEL_CHILD]), declared(&label_style));
    }
    #[test]
    fn the_rendered_tree_flattens_to_the_shape_the_handlers_navigate() {
        let (styled_dom, _) = rendered(TextInput::create());
        let ((), _, nodes) = run(Env::new(styled_dom), |_| ());
        let placeholder = nodes.placeholder.expect("the container has no first child");
        let label = nodes.label.expect("the placeholder has no next sibling");
        let label_text = nodes.label_text.expect("the value block has no text leaf");
        assert_ne!(placeholder, label);
        assert_ne!(label, label_text);
        assert_ne!(placeholder, label_text);
        assert_ne!(placeholder, dom_node(CONTAINER));
    }
    // ==================================================================
    // default_on_focus_received
    // ==================================================================
    #[test]
    fn focus_received_with_a_foreign_payload_is_an_inert_no_op() {
        let (styled_dom, _) = rendered(TextInput::create());
        let foreign = RefAny::new(0xDEAD_BEEF_u32);
        let (update, changes, _) = run(Env::new(styled_dom), |info| {
            default_on_focus_received(foreign.clone(), info)
        });
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty(), "a foreign payload still produced {changes:?}");
    }
    #[test]
    fn focus_received_on_a_node_with_no_children_bails_out_before_touching_css() {
        // `set_css_property` panics on a `None` node id, and the widget only has a
        // placeholder to hide if the hit node actually has children. Both escapes have
        // to happen before the css write.
        let (styled_dom, state) = rendered(TextInput::create());
        let (update, changes, nodes) = run(Env::new(styled_dom).hit(node_none()), |info| {
            default_on_focus_received(state.clone(), info)
        });
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        assert!(nodes.placeholder.is_some(), "the fixture itself is malformed");
    }
    #[test]
    fn focus_received_on_the_value_text_leaf_is_a_no_op() {
        // The bare text leaf is the one node in the tree with no children.
        let (probe_dom, _) = rendered(TextInput::create());
        let (_, _, nodes) = run(Env::new(probe_dom), |_| ());
        let leaf = nodes.label_text.expect("no value text leaf");
        let (styled_dom, state) = rendered(TextInput::create());
        let (update, changes, _) = run(Env::new(styled_dom).hit(leaf), |info| {
            default_on_focus_received(state.clone(), info)
        });
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty(), "a childless hit node still pushed {changes:?}");
    }
    #[test]
    fn focus_received_hides_the_placeholder_only_while_the_buffer_is_empty() {
        let (styled_dom, state) = rendered(TextInput::create());
        let (update, changes, nodes) = run(Env::new(styled_dom), |info| {
            default_on_focus_received(state.clone(), info)
        });
        assert_eq!(update, Update::DoNothing);
        assert_eq!(
            pushed_opacities(&changes),
            vec![(inner_id(nodes.placeholder.expect("no placeholder")), 0.0)],
            "focusing an empty input did not hide its placeholder",
        );
        let (styled_dom, state) = rendered(TextInput::create().with_text("typed".into()));
        let (update, changes, _) = run(Env::new(styled_dom), |info| {
            default_on_focus_received(state.clone(), info)
        });
        assert_eq!(update, Update::DoNothing);
        assert!(
            changes.is_empty(),
            "focusing a non-empty input touched the placeholder anyway: {changes:?}",
        );
    }
    #[test]
    fn focus_received_reparks_the_cursor_at_the_end_of_the_buffer() {
        let (styled_dom, state) = rendered(TextInput::create().with_text("hello".into()));
        // Plant a cursor that is both stale and out of range.
        poke(&state, |w| w.inner.cursor_pos = usize::MAX);
        let (_, _, _) = run(Env::new(styled_dom), |info| {
            default_on_focus_received(state.clone(), info)
        });
        assert_eq!(state_of(&state).cursor_pos, 5);
    }
    // ==================================================================
    // default_on_focus_lost
    // ==================================================================
    #[test]
    fn focus_lost_shows_the_placeholder_only_while_the_buffer_is_empty() {
        let (styled_dom, state) = rendered(TextInput::create());
        let (update, changes, nodes) =
            run(Env::new(styled_dom), |info| default_on_focus_lost(state.clone(), info));
        assert_eq!(update, Update::DoNothing);
        assert_eq!(
            pushed_opacities(&changes),
            vec![(inner_id(nodes.placeholder.expect("no placeholder")), 1.0)],
            "blurring an empty input did not bring its placeholder back",
        );
        let (styled_dom, state) = rendered(TextInput::create().with_text("typed".into()));
        let (_, changes, _) =
            run(Env::new(styled_dom), |info| default_on_focus_lost(state.clone(), info));
        assert!(
            changes.is_empty(),
            "blurring a non-empty input revealed the placeholder over the text: {changes:?}",
        );
    }
    #[test]
    fn focus_lost_hands_the_hook_the_live_state_and_returns_its_verdict() {
        let probe = recorder(Update::RefreshDomAllWindows, TextInputValid::Yes);
        let (styled_dom, state) = rendered(
            TextInput::create()
                .with_text("typed".into())
                .with_on_focus_lost(probe.clone(), record_focus_lost as TextInputOnFocusLostCallbackType),
        );
        let (update, _, _) =
            run(Env::new(styled_dom), |info| default_on_focus_lost(state.clone(), info));
        assert_eq!(update, Update::RefreshDomAllWindows, "the hook's Update was swallowed");
        let seen = recorded(&probe);
        assert_eq!(seen.len(), 1, "the hook fired {} times, expected once", seen.len());
        assert_eq!(seen[0].get_text(), "typed");
        assert_eq!(seen[0].cursor_pos, 5, "the hook saw a cursor that dom() should have synced");
    }
    #[test]
    fn focus_lost_without_a_hook_reports_no_work_to_do() {
        let (styled_dom, state) = rendered(TextInput::create().with_text("typed".into()));
        let (update, _, _) =
            run(Env::new(styled_dom), |info| default_on_focus_lost(state.clone(), info));
        assert_eq!(update, Update::DoNothing);
    }
    #[test]
    fn focus_lost_on_a_none_hit_node_skips_the_hook_entirely() {
        // The early return sits *above* the hook dispatch, so a blur that cannot find
        // its placeholder never reaches user code. Worth pinning: a user hook that
        // commits a form would otherwise fire on a malformed hit.
        let probe = recorder(Update::RefreshDom, TextInputValid::Yes);
        let (styled_dom, state) = rendered(TextInput::create().with_on_focus_lost(
            probe.clone(),
            record_focus_lost as TextInputOnFocusLostCallbackType,
        ));
        let (update, changes, _) = run(Env::new(styled_dom).hit(node_none()), |info| {
            default_on_focus_lost(state.clone(), info)
        });
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        assert!(recorded(&probe).is_empty(), "the hook fired on a hit node that does not exist");
    }
    #[test]
    fn focus_lost_with_a_foreign_payload_is_an_inert_no_op() {
        let (styled_dom, _) = rendered(TextInput::create());
        let foreign = RefAny::new("not a text input".to_string());
        let (update, changes, _) = run(Env::new(styled_dom), |info| {
            default_on_focus_lost(foreign.clone(), info)
        });
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
    }
    // ==================================================================
    // default_on_text_input
    // ==================================================================
    #[test]
    fn text_input_without_a_pending_changeset_does_nothing() {
        let (styled_dom, state) = rendered(TextInput::create());
        let (update, changes, _) =
            run(Env::new(styled_dom), |info| default_on_text_input(state.clone(), info));
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        assert_eq!(state_of(&state).get_text(), "");
    }
    #[test]
    fn text_input_with_an_empty_insertion_does_nothing() {
        let (styled_dom, state) = rendered(TextInput::create().with_text("abc".into()));
        let (update, changes, _) = run(Env::new(styled_dom).insert(""), |info| {
            default_on_text_input(state.clone(), info)
        });
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty(), "an empty insertion still repainted: {changes:?}");
        assert_eq!(state_of(&state).get_text(), "abc");
        assert_eq!(state_of(&state).cursor_pos, 3);
    }
    #[test]
    fn text_input_mirrors_the_insertion_and_hides_the_placeholder() {
        let (styled_dom, state) = rendered(TextInput::create().with_placeholder("hint".into()));
        let (update, changes, nodes) = run(Env::new(styled_dom).insert("hi"), |info| {
            default_on_text_input(state.clone(), info)
        });
        assert_eq!(update, Update::DoNothing, "no hook is installed, so nothing needs redrawing");
        assert_eq!(state_of(&state).get_text(), "hi");
        assert_eq!(state_of(&state).cursor_pos, 2);
        assert_eq!(
            pushed_opacities(&changes),
            vec![(inner_id(nodes.placeholder.expect("no placeholder")), 0.0)],
        );
        assert!(
            pushed_texts(&changes).is_empty(),
            "the widget repainted the value itself; the engine owns the buffer: {changes:?}",
        );
    }
    #[test]
    fn text_input_appends_to_an_existing_buffer_rather_than_replacing_it() {
        let (styled_dom, state) = rendered(TextInput::create().with_text("ab".into()));
        let (_, changes, _) = run(Env::new(styled_dom).insert("cd"), |info| {
            default_on_text_input(state.clone(), info)
        });
        assert_eq!(state_of(&state).get_text(), "abcd");
        assert!(pushed_texts(&changes).is_empty());
    }
    #[test]
    fn text_input_hands_the_hook_a_preview_that_already_contains_the_insertion() {
        // The hook is a *validator*: it has to see the would-be result, not the state
        // before the edit, or it can never reject an edit for what it produces.
        let probe = recorder(Update::RefreshDom, TextInputValid::Yes);
        let (styled_dom, state) = rendered(
            TextInput::create()
                .with_text("ab".into())
                .with_on_text_input(probe.clone(), record_text_input as TextInputOnTextInputCallbackType),
        );
        let (update, _, _) = run(Env::new(styled_dom).insert("c"), |info| {
            default_on_text_input(state.clone(), info)
        });
        assert_eq!(update, Update::RefreshDom, "the hook's Update was swallowed");
        let seen = recorded(&probe);
        assert_eq!(seen.len(), 1);
        assert_eq!(seen[0].get_text(), "abc", "the hook was shown the pre-edit buffer");
        assert_eq!(seen[0].cursor_pos, 3);
    }
    #[test]
    fn text_input_rejected_by_the_hook_leaves_the_buffer_and_the_screen_untouched() {
        let probe = recorder(Update::RefreshDomAllWindows, TextInputValid::No);
        let (styled_dom, state) = rendered(
            TextInput::create()
                .with_text("ab".into())
                .with_placeholder("hint".into())
                .with_on_text_input(probe.clone(), record_text_input as TextInputOnTextInputCallbackType),
        );
        let (update, changes, _) = run(Env::new(styled_dom).insert("c"), |info| {
            default_on_text_input(state.clone(), info)
        });
        assert_eq!(update, Update::RefreshDomAllWindows, "a rejected edit still reports its Update");
        assert_eq!(state_of(&state).get_text(), "ab", "a rejected edit was applied anyway");
        assert_eq!(state_of(&state).cursor_pos, 2, "a rejected edit still moved the cursor");
        assert!(
            changes
                .iter()
                .all(|c| matches!(c, CallbackChange::PreventDefault)),
            "a rejected edit still repainted the widget: {changes:?}",
        );
        assert!(
            changes
                .iter()
                .any(|c| matches!(c, CallbackChange::PreventDefault)),
            "a rejected edit did not stop the engine from applying the changeset",
        );
    }
    #[test]
    fn text_input_advances_the_cursor_by_utf8_byte_length_not_by_scalar_count() {
        // KNOWN GAP: the cursor is advanced by `inserted_text.len()` — the *byte*
        // length of the insertion — while the buffer grows by one unit per scalar.
        // For anything outside ASCII the two disagree, and the cursor ends up past
        // the end of the buffer it indexes into.
        let (styled_dom, state) = rendered(TextInput::create());
        let (_, _, _) = run(Env::new(styled_dom).insert("é"), |info| {
            default_on_text_input(state.clone(), info)
        });
        let after = state_of(&state);
        assert_eq!(after.get_text(), "é");
        assert_eq!(after.text.len(), 1, "the buffer holds one scalar");
        assert_eq!(after.cursor_pos, 2, "but the cursor moved by the two UTF-8 bytes");
        assert!(
            after.cursor_pos > after.text.len(),
            "the cursor is expected to overshoot here; see the KNOWN GAP above",
        );
    }
    #[test]
    fn text_input_recomputes_the_cursor_rather_than_accumulating_a_stale_one() {
        // The cursor is derived from the insertion point every time, so a stale
        // value planted by a host cannot survive — nor overflow.
        let (styled_dom, state) = rendered(TextInput::create());
        poke(&state, |w| w.inner.cursor_pos = usize::MAX);
        let (update, _, _) = run(Env::new(styled_dom).insert("abc"), |info| {
            default_on_text_input(state.clone(), info)
        });
        assert_eq!(update, Update::DoNothing);
        assert_eq!(state_of(&state).cursor_pos, 3);
        assert_eq!(state_of(&state).get_text(), "abc");
    }
    #[test]
    fn text_input_accepts_astral_combining_and_multi_scalar_insertions() {
        for s in ["\u{10ffff}", "e\u{301}", "👨‍👩‍👧‍👦", "日本語", "\0"] {
            let (styled_dom, state) = rendered(TextInput::create());
            let (_, changes, _) = run(Env::new(styled_dom).insert(s), |info| {
                default_on_text_input(state.clone(), info)
            });
            assert_eq!(state_of(&state).get_text(), s, "the buffer mangled {s:?}");
            assert_eq!(state_of(&state).text.len(), s.chars().count());
            assert!(pushed_texts(&changes).is_empty());
        }
    }
    #[test]
    fn text_input_on_a_wrong_shaped_subtree_changes_nothing() {
        // Hitting the label means `first child -> next sibling` walks off the end of
        // the tree. The handler has to give up *before* mutating the buffer, or the
        // model and the screen would silently diverge.
        let (probe_dom, _) = rendered(TextInput::create());
        let (_, _, nodes) = run(Env::new(probe_dom), |_| ());
        let label = nodes.label.expect("no label");
        let (styled_dom, state) = rendered(TextInput::create().with_text("ab".into()));
        let (result, changes, _) = run(Env::new(styled_dom).hit(label).insert("c"), |info| {
            default_on_text_input_inner(state.clone(), info)
        });
        assert_eq!(result, None, "the handler claimed to have handled a malformed tree");
        assert!(changes.is_empty());
        assert_eq!(state_of(&state).get_text(), "ab", "the buffer changed anyway");
    }
    #[test]
    fn text_input_on_a_none_hit_node_changes_nothing() {
        let (styled_dom, state) = rendered(TextInput::create().with_text("ab".into()));
        let (update, changes, _) = run(Env::new(styled_dom).hit(node_none()).insert("c"), |info| {
            default_on_text_input(state.clone(), info)
        });
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        assert_eq!(state_of(&state).get_text(), "ab");
    }
    #[test]
    fn text_input_with_a_foreign_payload_is_an_inert_no_op() {
        let (styled_dom, _) = rendered(TextInput::create());
        let foreign = RefAny::new(0_u8);
        let (result, changes, _) = run(Env::new(styled_dom).insert("a"), |info| {
            default_on_text_input_inner(foreign.clone(), info)
        });
        assert_eq!(result, None);
        assert!(changes.is_empty());
    }
    #[test]
    fn text_input_ignores_max_len() {
        // KNOWN GAP (same root cause as `set_text_does_not_enforce_max_len`): typing
        // past `max_len` is accepted, one changeset at a time.
        let (styled_dom, state) = rendered(TextInput::create());
        let filler: String = "x".repeat(80);
        let (_, _, _) = run(Env::new(styled_dom).insert(&filler), |info| {
            default_on_text_input(state.clone(), info)
        });
        let after = state_of(&state);
        assert_eq!(after.max_len, 50);
        assert_eq!(after.text.len(), 80);
    }
    // ==================================================================
    // default_on_virtual_key_down
    // ==================================================================
    #[test]
    fn virtual_key_down_without_a_pressed_key_does_nothing() {
        let probe = recorder(Update::RefreshDom, TextInputValid::Yes);
        let (styled_dom, state) = rendered(
            TextInput::create().with_text("ab".into()).with_on_virtual_key_down(
                probe.clone(),
                record_virtual_key as TextInputOnVirtualKeyDownCallbackType,
            ),
        );
        let (update, changes, _) = run(Env::new(styled_dom), |info| {
            default_on_virtual_key_down(state.clone(), info)
        });
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        assert!(recorded(&probe).is_empty(), "the hook fired without a key being down");
        assert_eq!(state_of(&state).get_text(), "ab");
    }
    #[test]
    fn backspace_is_the_engines_default_action_not_the_widgets() {
        // Deletion is `SystemChange::ApplySelectionOp` on the engine side; a
        // widget that also popped its own buffer would double-delete.
        let (styled_dom, state) = rendered(TextInput::create().with_text("abc".into()));
        let (update, changes, _) =
            run(Env::new(styled_dom).key(VirtualKeyCode::Back), |info| {
                default_on_virtual_key_down(state.clone(), info)
            });
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty(), "backspace still mutated the DOM: {changes:?}");
        assert_eq!(state_of(&state).get_text(), "abc", "the widget deleted behind the engine");
    }
    #[test]
    fn every_key_reaches_the_hook_and_leaves_the_buffer_alone() {
        for key in [VirtualKeyCode::A, VirtualKeyCode::Back, VirtualKeyCode::Return] {
            let probe = recorder(Update::RefreshDom, TextInputValid::Yes);
            let (styled_dom, state) = rendered(
                TextInput::create().with_text("ab".into()).with_on_virtual_key_down(
                    probe.clone(),
                    record_virtual_key as TextInputOnVirtualKeyDownCallbackType,
                ),
            );
            let (update, changes, _) = run(Env::new(styled_dom).key(key), |info| {
                default_on_virtual_key_down(state.clone(), info)
            });
            assert_eq!(update, Update::RefreshDom, "{key:?} swallowed the hook's Update");
            if matches!(key, VirtualKeyCode::Return) {
                // Single-line field: Enter must never edit the value, so the
                // handler vetoes the engine's "\n" default — and nothing else.
                assert_eq!(changes.len(), 1, "Return must veto exactly once: {changes:?}");
                assert!(
                    matches!(changes[0], CallbackChange::PreventDefault),
                    "Return must veto the engine line break, and only that: {changes:?}"
                );
            } else {
                assert!(changes.is_empty(), "{key:?} repainted the value: {changes:?}");
            }
            assert_eq!(state_of(&state).get_text(), "ab");
            assert_eq!(recorded(&probe).len(), 1, "the hook must see {key:?} too");
        }
    }
    #[test]
    fn a_rejecting_hook_vetoes_the_engines_default_and_keeps_its_update() {
        let probe = recorder(Update::RefreshDomAllWindows, TextInputValid::No);
        let (styled_dom, state) = rendered(
            TextInput::create().with_text("abc".into()).with_on_virtual_key_down(
                probe.clone(),
                record_virtual_key as TextInputOnVirtualKeyDownCallbackType,
            ),
        );
        let (update, changes, _) = run(Env::new(styled_dom).key(VirtualKeyCode::Back), |info| {
            default_on_virtual_key_down(state.clone(), info)
        });
        assert_eq!(update, Update::RefreshDomAllWindows);
        assert_eq!(
            changes.len(),
            1,
            "a veto must push nothing but the preventDefault: {changes:?}",
        );
        assert!(matches!(changes[0], CallbackChange::PreventDefault));
        assert_eq!(state_of(&state).get_text(), "abc");
        assert_eq!(state_of(&state).cursor_pos, 3);
    }
    #[test]
    fn the_virtual_key_hook_is_shown_the_state_from_before_the_key_is_handled() {
        // The hook runs first so that it can veto; that means it necessarily sees
        // the pre-edit buffer. Pinned because "which side of the edit does the
        // hook see" is exactly the thing a refactor gets wrong.
        let probe = recorder(Update::DoNothing, TextInputValid::Yes);
        let (styled_dom, state) = rendered(
            TextInput::create().with_text("abc".into()).with_on_virtual_key_down(
                probe.clone(),
                record_virtual_key as TextInputOnVirtualKeyDownCallbackType,
            ),
        );
        let (_, _, _) = run(Env::new(styled_dom).key(VirtualKeyCode::Back), |info| {
            default_on_virtual_key_down(state.clone(), info)
        });
        let seen = recorded(&probe);
        assert_eq!(seen.len(), 1);
        assert_eq!(seen[0].get_text(), "abc");
        assert_eq!(seen[0].cursor_pos, 3);
    }
    #[test]
    fn virtual_key_down_on_a_none_hit_node_skips_the_hook_entirely() {
        let probe = recorder(Update::RefreshDom, TextInputValid::Yes);
        let (styled_dom, state) = rendered(
            TextInput::create().with_text("abc".into()).with_on_virtual_key_down(
                probe.clone(),
                record_virtual_key as TextInputOnVirtualKeyDownCallbackType,
            ),
        );
        let (result, changes, _) = run(
            Env::new(styled_dom).hit(node_none()).key(VirtualKeyCode::Back),
            |info| default_on_virtual_key_down_inner(state.clone(), info),
        );
        assert_eq!(result, None);
        assert!(changes.is_empty());
        assert!(recorded(&probe).is_empty(), "the hook fired on a hit node that does not exist");
        assert_eq!(state_of(&state).get_text(), "abc");
    }
    #[test]
    fn virtual_key_down_with_a_foreign_payload_is_an_inert_no_op() {
        let (styled_dom, _) = rendered(TextInput::create());
        let foreign = RefAny::new(vec![1_u32, 2, 3]);
        let (update, changes, _) = run(Env::new(styled_dom).key(VirtualKeyCode::Back), |info| {
            default_on_virtual_key_down(foreign.clone(), info)
        });
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
    }
    #[test]
    fn repeated_key_presses_are_idempotent_on_the_widget_state() {
        // Six backspaces over a three-scalar buffer: the widget must not move a
        // single unit — every one of them belongs to the engine.
        let (_, state) = rendered(TextInput::create().with_text("abc".into()));
        for i in 0..6 {
            // The tree shape does not depend on what the buffer holds, so a fresh
            // container is enough to navigate; the live state is `state`.
            let (styled_dom, _) = rendered(TextInput::create());
            let (update, _, _) = run(Env::new(styled_dom).key(VirtualKeyCode::Back), |info| {
                default_on_virtual_key_down(state.clone(), info)
            });
            assert_eq!(update, Update::DoNothing, "backspace #{i} reported work to do");
        }
        let after = state_of(&state);
        assert_eq!(after.get_text(), "abc");
        assert_eq!(after.cursor_pos, 3);
    }
    // ==================================================================
    // default_on_mouse_hover
    // ==================================================================
    #[test]
    fn mouse_hover_is_inert_for_every_payload_and_every_hit_node() {
        let (styled_dom, state) = rendered(TextInput::create().with_text("abc".into()));
        let (update, changes, _) =
            run(Env::new(styled_dom), |info| default_on_mouse_hover(state.clone(), info));
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        assert_eq!(state_of(&state).get_text(), "abc", "hovering edited the buffer");
        let (styled_dom, _) = rendered(TextInput::create());
        let foreign = RefAny::new(0_u8);
        let (update, changes, _) = run(Env::new(styled_dom).hit(node_none()), |info| {
            default_on_mouse_hover(foreign.clone(), info)
        });
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
    }
}