Skip to main content

azul_layout/widgets/
text_input.rs

1//! Single-line text input widget with cursor, 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//! For higher-level text-input management (IME, clipboard, undo) see
8//! `layout/src/managers/text_input.rs`.
9
10use alloc::{string::String, vec::Vec};
11
12use azul_core::{
13    callbacks::{CoreCallback, CoreCallbackData, Update},
14    dom::Dom,
15    refany::RefAny,
16    task::OptionTimerId,
17    window::VirtualKeyCode,
18};
19#[allow(clippy::wildcard_imports)] // widget/render module pulls in the css property/value types it builds with
20use azul_css::{
21    dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec},
22    props::{
23        basic::*,
24        layout::*,
25        property::{CssProperty, *},
26        style::*,
27    },
28    *,
29};
30use azul_css::css::BoxOrStatic;
31
32use crate::callbacks::{Callback, CallbackInfo};
33
34const BACKGROUND_COLOR: ColorU = ColorU {
35    r: 255,
36    g: 255,
37    b: 255,
38    a: 255,
39}; // white
40const BLACK: ColorU = ColorU {
41    r: 0,
42    g: 0,
43    b: 0,
44    a: 255,
45};
46const TEXT_COLOR: StyleTextColor = StyleTextColor { inner: BLACK }; // black
47const COLOR_9B9B9B: ColorU = ColorU {
48    r: 155,
49    g: 155,
50    b: 155,
51    a: 255,
52}; // #9b9b9b
53const COLOR_4286F4: ColorU = ColorU {
54    r: 66,
55    g: 134,
56    b: 244,
57    a: 255,
58}; // #4286f4
59const COLOR_4C4C4C: ColorU = ColorU {
60    r: 76,
61    g: 76,
62    b: 76,
63    a: 255,
64}; // #4C4C4C
65
66const CURSOR_COLOR_BLACK: &[StyleBackgroundContent] = &[StyleBackgroundContent::Color(BLACK)];
67const CURSOR_COLOR: StyleBackgroundContentVec =
68    StyleBackgroundContentVec::from_const_slice(CURSOR_COLOR_BLACK);
69
70const BACKGROUND_THEME_LIGHT: &[StyleBackgroundContent] =
71    &[StyleBackgroundContent::Color(BACKGROUND_COLOR)];
72const BACKGROUND_COLOR_LIGHT: StyleBackgroundContentVec =
73    StyleBackgroundContentVec::from_const_slice(BACKGROUND_THEME_LIGHT);
74
75const SANS_SERIF_STR: &str = "system:ui";
76const SANS_SERIF: AzString = AzString::from_const_str(SANS_SERIF_STR);
77const SANS_SERIF_FAMILIES: &[StyleFontFamily] = &[StyleFontFamily::System(SANS_SERIF)];
78const SANS_SERIF_FAMILY: StyleFontFamilyVec =
79    StyleFontFamilyVec::from_const_slice(SANS_SERIF_FAMILIES);
80
81// -- cursor style
82
83const TEXT_CURSOR_TRANSFORM: &[StyleTransform] =
84    &[StyleTransform::Translate(StyleTransformTranslate2D {
85        x: PixelValue::const_px(0),
86        y: PixelValue::const_px(2),
87    })];
88
89static TEXT_CURSOR_PROPS: &[CssPropertyWithConditions] = &[
90    CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Absolute)),
91    CssPropertyWithConditions::simple(CssProperty::const_width(LayoutWidth::const_px(1))),
92    CssPropertyWithConditions::simple(CssProperty::const_height(LayoutHeight::const_px(11))),
93    CssPropertyWithConditions::simple(CssProperty::const_background_content(CURSOR_COLOR)),
94    CssPropertyWithConditions::simple(CssProperty::const_opacity(StyleOpacity::const_new(0))),
95    CssPropertyWithConditions::simple(CssProperty::const_transform(
96        StyleTransformVec::from_const_slice(TEXT_CURSOR_TRANSFORM),
97    )),
98];
99
100// -- container style
101
102#[cfg(target_os = "windows")]
103static TEXT_INPUT_CONTAINER_PROPS: &[CssPropertyWithConditions] = &[
104    CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Relative)),
105    CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Text)),
106    CssPropertyWithConditions::simple(CssProperty::const_box_sizing(LayoutBoxSizing::BorderBox)),
107    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1))),
108    CssPropertyWithConditions::simple(CssProperty::const_background_content(
109        BACKGROUND_COLOR_LIGHT,
110    )),
111    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
112        inner: COLOR_4C4C4C,
113    })),
114    CssPropertyWithConditions::simple(CssProperty::const_padding_left(
115        LayoutPaddingLeft::const_px(2),
116    )),
117    CssPropertyWithConditions::simple(CssProperty::const_padding_right(
118        LayoutPaddingRight::const_px(2),
119    )),
120    CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(
121        1,
122    ))),
123    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
124        LayoutPaddingBottom::const_px(1),
125    )),
126    // border: 1px solid #484c52;
127    CssPropertyWithConditions::simple(CssProperty::const_border_top_width(
128        LayoutBorderTopWidth::const_px(1),
129    )),
130    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(
131        LayoutBorderBottomWidth::const_px(1),
132    )),
133    CssPropertyWithConditions::simple(CssProperty::const_border_left_width(
134        LayoutBorderLeftWidth::const_px(1),
135    )),
136    CssPropertyWithConditions::simple(CssProperty::const_border_right_width(
137        LayoutBorderRightWidth::const_px(1),
138    )),
139    CssPropertyWithConditions::simple(CssProperty::const_border_top_style(StyleBorderTopStyle {
140        inner: BorderStyle::Inset,
141    })),
142    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(
143        StyleBorderBottomStyle {
144            inner: BorderStyle::Inset,
145        },
146    )),
147    CssPropertyWithConditions::simple(CssProperty::const_border_left_style(StyleBorderLeftStyle {
148        inner: BorderStyle::Inset,
149    })),
150    CssPropertyWithConditions::simple(CssProperty::const_border_right_style(
151        StyleBorderRightStyle {
152            inner: BorderStyle::Inset,
153        },
154    )),
155    CssPropertyWithConditions::simple(CssProperty::const_border_top_color(StyleBorderTopColor {
156        inner: COLOR_9B9B9B,
157    })),
158    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(
159        StyleBorderBottomColor {
160            inner: COLOR_9B9B9B,
161        },
162    )),
163    CssPropertyWithConditions::simple(CssProperty::const_border_left_color(StyleBorderLeftColor {
164        inner: COLOR_9B9B9B,
165    })),
166    CssPropertyWithConditions::simple(CssProperty::const_border_right_color(
167        StyleBorderRightColor {
168            inner: COLOR_9B9B9B,
169        },
170    )),
171    CssPropertyWithConditions::simple(CssProperty::const_overflow_x(LayoutOverflow::Hidden)),
172    CssPropertyWithConditions::simple(CssProperty::const_overflow_y(LayoutOverflow::Hidden)),
173    CssPropertyWithConditions::simple(CssProperty::const_justify_content(
174        LayoutJustifyContent::Center,
175    )),
176    // Hover(border-color: #4c4c4c;)
177    CssPropertyWithConditions::on_hover(CssProperty::const_border_top_color(StyleBorderTopColor {
178        inner: COLOR_4C4C4C,
179    })),
180    CssPropertyWithConditions::on_hover(CssProperty::const_border_bottom_color(
181        StyleBorderBottomColor {
182            inner: COLOR_4C4C4C,
183        },
184    )),
185    CssPropertyWithConditions::on_hover(CssProperty::const_border_left_color(
186        StyleBorderLeftColor {
187            inner: COLOR_4C4C4C,
188        },
189    )),
190    CssPropertyWithConditions::on_hover(CssProperty::const_border_right_color(
191        StyleBorderRightColor {
192            inner: COLOR_4C4C4C,
193        },
194    )),
195    // Focus(border-color: #4286f4;)
196    CssPropertyWithConditions::on_focus(CssProperty::const_border_top_color(StyleBorderTopColor {
197        inner: COLOR_4286F4,
198    })),
199    CssPropertyWithConditions::on_focus(CssProperty::const_border_bottom_color(
200        StyleBorderBottomColor {
201            inner: COLOR_4286F4,
202        },
203    )),
204    CssPropertyWithConditions::on_focus(CssProperty::const_border_left_color(
205        StyleBorderLeftColor {
206            inner: COLOR_4286F4,
207        },
208    )),
209    CssPropertyWithConditions::on_focus(CssProperty::const_border_right_color(
210        StyleBorderRightColor {
211            inner: COLOR_4286F4,
212        },
213    )),
214];
215
216#[cfg(target_os = "linux")]
217static TEXT_INPUT_CONTAINER_PROPS: &[CssPropertyWithConditions] = &[
218    CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Relative)),
219    CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Text)),
220    CssPropertyWithConditions::simple(CssProperty::const_box_sizing(LayoutBoxSizing::BorderBox)),
221    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(11))),
222    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1))),
223    CssPropertyWithConditions::simple(CssProperty::const_background_content(
224        BACKGROUND_COLOR_LIGHT,
225    )),
226    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
227        inner: COLOR_4C4C4C,
228    })),
229    CssPropertyWithConditions::simple(CssProperty::const_padding_left(
230        LayoutPaddingLeft::const_px(2),
231    )),
232    CssPropertyWithConditions::simple(CssProperty::const_padding_right(
233        LayoutPaddingRight::const_px(2),
234    )),
235    CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(
236        1,
237    ))),
238    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
239        LayoutPaddingBottom::const_px(1),
240    )),
241    // border: 1px solid #484c52;
242    CssPropertyWithConditions::simple(CssProperty::const_border_top_width(
243        LayoutBorderTopWidth::const_px(1),
244    )),
245    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(
246        LayoutBorderBottomWidth::const_px(1),
247    )),
248    CssPropertyWithConditions::simple(CssProperty::const_border_left_width(
249        LayoutBorderLeftWidth::const_px(1),
250    )),
251    CssPropertyWithConditions::simple(CssProperty::const_border_right_width(
252        LayoutBorderRightWidth::const_px(1),
253    )),
254    CssPropertyWithConditions::simple(CssProperty::const_border_top_style(StyleBorderTopStyle {
255        inner: BorderStyle::Inset,
256    })),
257    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(
258        StyleBorderBottomStyle {
259            inner: BorderStyle::Inset,
260        },
261    )),
262    CssPropertyWithConditions::simple(CssProperty::const_border_left_style(StyleBorderLeftStyle {
263        inner: BorderStyle::Inset,
264    })),
265    CssPropertyWithConditions::simple(CssProperty::const_border_right_style(
266        StyleBorderRightStyle {
267            inner: BorderStyle::Inset,
268        },
269    )),
270    CssPropertyWithConditions::simple(CssProperty::const_border_top_color(StyleBorderTopColor {
271        inner: COLOR_9B9B9B,
272    })),
273    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(
274        StyleBorderBottomColor {
275            inner: COLOR_9B9B9B,
276        },
277    )),
278    CssPropertyWithConditions::simple(CssProperty::const_border_left_color(StyleBorderLeftColor {
279        inner: COLOR_9B9B9B,
280    })),
281    CssPropertyWithConditions::simple(CssProperty::const_border_right_color(
282        StyleBorderRightColor {
283            inner: COLOR_9B9B9B,
284        },
285    )),
286    CssPropertyWithConditions::simple(CssProperty::const_overflow_x(LayoutOverflow::Hidden)),
287    CssPropertyWithConditions::simple(CssProperty::const_overflow_y(LayoutOverflow::Hidden)),
288    CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Left)),
289    CssPropertyWithConditions::simple(CssProperty::const_justify_content(
290        LayoutJustifyContent::Center,
291    )),
292    CssPropertyWithConditions::simple(CssProperty::const_font_family(SANS_SERIF_FAMILY)),
293    // Hover(border-color: #4286f4;)
294    CssPropertyWithConditions::on_hover(CssProperty::const_border_top_color(StyleBorderTopColor {
295        inner: COLOR_4286F4,
296    })),
297    CssPropertyWithConditions::on_hover(CssProperty::const_border_bottom_color(
298        StyleBorderBottomColor {
299            inner: COLOR_4286F4,
300        },
301    )),
302    CssPropertyWithConditions::on_hover(CssProperty::const_border_left_color(
303        StyleBorderLeftColor {
304            inner: COLOR_4286F4,
305        },
306    )),
307    CssPropertyWithConditions::on_hover(CssProperty::const_border_right_color(
308        StyleBorderRightColor {
309            inner: COLOR_4286F4,
310        },
311    )),
312    // Focus(border-color: #4286f4;)
313    CssPropertyWithConditions::on_focus(CssProperty::const_border_top_color(StyleBorderTopColor {
314        inner: COLOR_4286F4,
315    })),
316    CssPropertyWithConditions::on_focus(CssProperty::const_border_bottom_color(
317        StyleBorderBottomColor {
318            inner: COLOR_4286F4,
319        },
320    )),
321    CssPropertyWithConditions::on_focus(CssProperty::const_border_left_color(
322        StyleBorderLeftColor {
323            inner: COLOR_4286F4,
324        },
325    )),
326    CssPropertyWithConditions::on_focus(CssProperty::const_border_right_color(
327        StyleBorderRightColor {
328            inner: COLOR_4286F4,
329        },
330    )),
331];
332
333// Mobile (Android / iOS) inherit the macOS-style container — same flex
334// box-sizing and background; touch-target padding is the user's concern.
335#[cfg(not(any(target_os = "windows", target_os = "linux")))]
336static TEXT_INPUT_CONTAINER_PROPS: &[CssPropertyWithConditions] = &[
337    CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Relative)),
338    CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Text)),
339    CssPropertyWithConditions::simple(CssProperty::const_box_sizing(LayoutBoxSizing::BorderBox)),
340    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1))),
341    CssPropertyWithConditions::simple(CssProperty::const_background_content(
342        BACKGROUND_COLOR_LIGHT,
343    )),
344    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
345        inner: COLOR_4C4C4C,
346    })),
347    CssPropertyWithConditions::simple(CssProperty::const_padding_left(
348        LayoutPaddingLeft::const_px(2),
349    )),
350    CssPropertyWithConditions::simple(CssProperty::const_padding_right(
351        LayoutPaddingRight::const_px(2),
352    )),
353    CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(
354        1,
355    ))),
356    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
357        LayoutPaddingBottom::const_px(1),
358    )),
359    // border: 1px solid #484c52;
360    CssPropertyWithConditions::simple(CssProperty::const_border_top_width(
361        LayoutBorderTopWidth::const_px(1),
362    )),
363    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(
364        LayoutBorderBottomWidth::const_px(1),
365    )),
366    CssPropertyWithConditions::simple(CssProperty::const_border_left_width(
367        LayoutBorderLeftWidth::const_px(1),
368    )),
369    CssPropertyWithConditions::simple(CssProperty::const_border_right_width(
370        LayoutBorderRightWidth::const_px(1),
371    )),
372    CssPropertyWithConditions::simple(CssProperty::const_border_top_style(StyleBorderTopStyle {
373        inner: BorderStyle::Inset,
374    })),
375    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(
376        StyleBorderBottomStyle {
377            inner: BorderStyle::Inset,
378        },
379    )),
380    CssPropertyWithConditions::simple(CssProperty::const_border_left_style(StyleBorderLeftStyle {
381        inner: BorderStyle::Inset,
382    })),
383    CssPropertyWithConditions::simple(CssProperty::const_border_right_style(
384        StyleBorderRightStyle {
385            inner: BorderStyle::Inset,
386        },
387    )),
388    CssPropertyWithConditions::simple(CssProperty::const_border_top_color(StyleBorderTopColor {
389        inner: COLOR_9B9B9B,
390    })),
391    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(
392        StyleBorderBottomColor {
393            inner: COLOR_9B9B9B,
394        },
395    )),
396    CssPropertyWithConditions::simple(CssProperty::const_border_left_color(StyleBorderLeftColor {
397        inner: COLOR_9B9B9B,
398    })),
399    CssPropertyWithConditions::simple(CssProperty::const_border_right_color(
400        StyleBorderRightColor {
401            inner: COLOR_9B9B9B,
402        },
403    )),
404    CssPropertyWithConditions::simple(CssProperty::const_overflow_x(LayoutOverflow::Hidden)),
405    CssPropertyWithConditions::simple(CssProperty::const_overflow_y(LayoutOverflow::Hidden)),
406    CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Left)),
407    CssPropertyWithConditions::simple(CssProperty::const_justify_content(
408        LayoutJustifyContent::Center,
409    )),
410    // Hover(border-color: #4286f4;)
411    CssPropertyWithConditions::on_hover(CssProperty::const_border_top_color(StyleBorderTopColor {
412        inner: COLOR_4286F4,
413    })),
414    CssPropertyWithConditions::on_hover(CssProperty::const_border_bottom_color(
415        StyleBorderBottomColor {
416            inner: COLOR_4286F4,
417        },
418    )),
419    CssPropertyWithConditions::on_hover(CssProperty::const_border_left_color(
420        StyleBorderLeftColor {
421            inner: COLOR_4286F4,
422        },
423    )),
424    CssPropertyWithConditions::on_hover(CssProperty::const_border_right_color(
425        StyleBorderRightColor {
426            inner: COLOR_4286F4,
427        },
428    )),
429    // Focus(border-color: #4286f4;)
430    CssPropertyWithConditions::on_focus(CssProperty::const_border_top_color(StyleBorderTopColor {
431        inner: COLOR_4286F4,
432    })),
433    CssPropertyWithConditions::on_focus(CssProperty::const_border_bottom_color(
434        StyleBorderBottomColor {
435            inner: COLOR_4286F4,
436        },
437    )),
438    CssPropertyWithConditions::on_focus(CssProperty::const_border_left_color(
439        StyleBorderLeftColor {
440            inner: COLOR_4286F4,
441        },
442    )),
443    CssPropertyWithConditions::on_focus(CssProperty::const_border_right_color(
444        StyleBorderRightColor {
445            inner: COLOR_4286F4,
446        },
447    )),
448];
449
450// -- label style
451
452#[cfg(target_os = "windows")]
453static TEXT_INPUT_LABEL_PROPS: &[CssPropertyWithConditions] = &[
454    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::InlineBlock)),
455    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
456    CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Relative)),
457    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(11))),
458    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
459        inner: COLOR_4C4C4C,
460    })),
461    CssPropertyWithConditions::simple(CssProperty::const_font_family(SANS_SERIF_FAMILY)),
462];
463
464#[cfg(target_os = "linux")]
465static TEXT_INPUT_LABEL_PROPS: &[CssPropertyWithConditions] = &[
466    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::InlineBlock)),
467    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
468    CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Relative)),
469    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(11))),
470    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
471        inner: COLOR_4C4C4C,
472    })),
473    CssPropertyWithConditions::simple(CssProperty::const_font_family(SANS_SERIF_FAMILY)),
474];
475
476#[cfg(not(any(target_os = "windows", target_os = "linux")))]
477static TEXT_INPUT_LABEL_PROPS: &[CssPropertyWithConditions] = &[
478    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::InlineBlock)),
479    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
480    CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Relative)),
481    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(11))),
482    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
483        inner: COLOR_4C4C4C,
484    })),
485    CssPropertyWithConditions::simple(CssProperty::const_font_family(SANS_SERIF_FAMILY)),
486];
487
488// --- placeholder
489
490#[cfg(target_os = "windows")]
491static TEXT_INPUT_PLACEHOLDER_PROPS: &[CssPropertyWithConditions] = &[
492    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Block)),
493    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
494    CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Absolute)),
495    CssPropertyWithConditions::simple(CssProperty::const_top(LayoutTop::const_px(2))),
496    CssPropertyWithConditions::simple(CssProperty::const_left(LayoutLeft::const_px(2))),
497    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(11))),
498    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
499        inner: COLOR_4C4C4C,
500    })),
501    CssPropertyWithConditions::simple(CssProperty::const_font_family(SANS_SERIF_FAMILY)),
502    CssPropertyWithConditions::simple(CssProperty::const_opacity(StyleOpacity::const_new(100))),
503];
504
505#[cfg(target_os = "linux")]
506static TEXT_INPUT_PLACEHOLDER_PROPS: &[CssPropertyWithConditions] = &[
507    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Block)),
508    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
509    CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Absolute)),
510    CssPropertyWithConditions::simple(CssProperty::const_top(LayoutTop::const_px(2))),
511    CssPropertyWithConditions::simple(CssProperty::const_left(LayoutLeft::const_px(2))),
512    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(11))),
513    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
514        inner: COLOR_4C4C4C,
515    })),
516    CssPropertyWithConditions::simple(CssProperty::const_font_family(SANS_SERIF_FAMILY)),
517    CssPropertyWithConditions::simple(CssProperty::const_opacity(StyleOpacity::const_new(100))),
518];
519
520#[cfg(not(any(target_os = "windows", target_os = "linux")))]
521static TEXT_INPUT_PLACEHOLDER_PROPS: &[CssPropertyWithConditions] = &[
522    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Block)),
523    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
524    CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Absolute)),
525    CssPropertyWithConditions::simple(CssProperty::const_top(LayoutTop::const_px(2))),
526    CssPropertyWithConditions::simple(CssProperty::const_left(LayoutLeft::const_px(2))),
527    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(11))),
528    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
529        inner: COLOR_4C4C4C,
530    })),
531    CssPropertyWithConditions::simple(CssProperty::const_font_family(SANS_SERIF_FAMILY)),
532    CssPropertyWithConditions::simple(CssProperty::const_opacity(StyleOpacity::const_new(100))),
533];
534
535/// Single-line text input widget with platform-native styling.
536///
537/// Use [`TextInput::create()`] to build an instance, configure it with the
538/// `with_*` / `set_*` builder methods, and call [`TextInput::dom()`] to
539/// obtain a renderable DOM tree.
540#[derive(Debug, Clone, PartialEq, Eq)]
541#[repr(C)]
542pub struct TextInput {
543    pub text_input_state: TextInputStateWrapper,
544    pub placeholder_style: CssPropertyWithConditionsVec,
545    pub container_style: CssPropertyWithConditionsVec,
546    pub label_style: CssPropertyWithConditionsVec,
547}
548
549/// Editable state of a text input (text buffer, cursor position, selection).
550#[derive(Debug, Clone, PartialEq, Eq)]
551#[repr(C)]
552pub struct TextInputState {
553    pub text: U32Vec, // Vec<char>
554    pub placeholder: OptionString,
555    pub max_len: usize,
556    pub selection: OptionTextInputSelection,
557    pub cursor_pos: usize,
558}
559
560/// [`TextInputState`] together with optional user callbacks and cursor animation state.
561#[derive(Debug, Clone, PartialEq, Eq)]
562#[repr(C)]
563pub struct TextInputStateWrapper {
564    pub inner: TextInputState,
565    pub on_text_input: OptionTextInputOnTextInput,
566    pub on_virtual_key_down: OptionTextInputOnVirtualKeyDown,
567    pub on_focus_lost: OptionTextInputOnFocusLost,
568    pub update_text_input_before_calling_focus_lost_fn: bool,
569    pub update_text_input_before_calling_vk_down_fn: bool,
570    pub cursor_animation: OptionTimerId,
571}
572
573/// Return value from a text-input callback indicating whether the framework
574/// should update and whether the input was valid.
575#[derive(Debug, Copy, Clone, PartialEq, Eq)]
576#[repr(C)]
577pub struct OnTextInputReturn {
578    pub update: Update,
579    pub valid: TextInputValid,
580}
581
582/// Whether the text input accepted or rejected the most recent edit.
583#[derive(Debug, Copy, Clone, PartialEq, Eq)]
584#[repr(C)]
585pub enum TextInputValid {
586    Yes,
587    No,
588}
589
590// The text input field has a special return which specifies
591// whether the text input should handle the character
592pub type TextInputOnTextInputCallbackType =
593    extern "C" fn(RefAny, CallbackInfo, TextInputState) -> OnTextInputReturn;
594impl_widget_callback!(
595    TextInputOnTextInput,
596    OptionTextInputOnTextInput,
597    TextInputOnTextInputCallback,
598    TextInputOnTextInputCallbackType
599);
600
601azul_core::impl_managed_callback! {
602    wrapper:        TextInputOnTextInputCallback,
603    info_ty:        CallbackInfo,
604    return_ty:      OnTextInputReturn,
605    default_ret:    OnTextInputReturn { update: Update::DoNothing, valid: TextInputValid::Yes },
606    invoker_static: TEXT_INPUT_ON_TEXT_INPUT_INVOKER,
607    invoker_ty:     AzTextInputOnTextInputCallbackInvoker,
608    thunk_fn:       az_text_input_on_text_input_callback_thunk,
609    setter_fn:      AzApp_setTextInputOnTextInputCallbackInvoker,
610    from_handle_fn: AzTextInputOnTextInputCallback_createFromHostHandle,
611    extra_args:     [ state: TextInputState ],
612}
613
614pub type TextInputOnVirtualKeyDownCallbackType =
615    extern "C" fn(RefAny, CallbackInfo, TextInputState) -> OnTextInputReturn;
616impl_widget_callback!(
617    TextInputOnVirtualKeyDown,
618    OptionTextInputOnVirtualKeyDown,
619    TextInputOnVirtualKeyDownCallback,
620    TextInputOnVirtualKeyDownCallbackType
621);
622
623azul_core::impl_managed_callback! {
624    wrapper:        TextInputOnVirtualKeyDownCallback,
625    info_ty:        CallbackInfo,
626    return_ty:      OnTextInputReturn,
627    default_ret:    OnTextInputReturn { update: Update::DoNothing, valid: TextInputValid::Yes },
628    invoker_static: TEXT_INPUT_ON_VIRTUAL_KEY_DOWN_INVOKER,
629    invoker_ty:     AzTextInputOnVirtualKeyDownCallbackInvoker,
630    thunk_fn:       az_text_input_on_virtual_key_down_callback_thunk,
631    setter_fn:      AzApp_setTextInputOnVirtualKeyDownCallbackInvoker,
632    from_handle_fn: AzTextInputOnVirtualKeyDownCallback_createFromHostHandle,
633    extra_args:     [ state: TextInputState ],
634}
635
636pub type TextInputOnFocusLostCallbackType =
637    extern "C" fn(RefAny, CallbackInfo, TextInputState) -> Update;
638impl_widget_callback!(
639    TextInputOnFocusLost,
640    OptionTextInputOnFocusLost,
641    TextInputOnFocusLostCallback,
642    TextInputOnFocusLostCallbackType
643);
644
645azul_core::impl_managed_callback! {
646    wrapper:        TextInputOnFocusLostCallback,
647    info_ty:        CallbackInfo,
648    return_ty:      Update,
649    default_ret:    Update::DoNothing,
650    invoker_static: TEXT_INPUT_ON_FOCUS_LOST_INVOKER,
651    invoker_ty:     AzTextInputOnFocusLostCallbackInvoker,
652    thunk_fn:       az_text_input_on_focus_lost_callback_thunk,
653    setter_fn:      AzApp_setTextInputOnFocusLostCallbackInvoker,
654    from_handle_fn: AzTextInputOnFocusLostCallback_createFromHostHandle,
655    extra_args:     [ state: TextInputState ],
656}
657#[allow(variant_size_differences)] // repr(C,u8) FFI enum: boxing the large variant would change the C ABI (api.json bindings); size disparity accepted
658#[derive(Copy, Debug, Clone, Hash, PartialEq, Eq)]
659#[repr(C, u8)]
660pub enum TextInputSelection {
661    All,
662    FromTo(TextInputSelectionRange),
663}
664
665azul_css::impl_option!(
666    TextInputSelection,
667    OptionTextInputSelection,
668    copy = false,
669    [Debug, Clone, Hash, PartialEq, Eq]
670);
671
672#[derive(Copy, Debug, Clone, Hash, PartialEq, Eq)]
673#[repr(C)]
674pub struct TextInputSelectionRange {
675    pub dir_from: usize,
676    pub dir_to: usize,
677}
678
679impl Default for TextInput {
680    fn default() -> Self {
681        Self {
682            text_input_state: TextInputStateWrapper::default(),
683            placeholder_style: CssPropertyWithConditionsVec::from_const_slice(
684                TEXT_INPUT_PLACEHOLDER_PROPS,
685            ),
686            container_style: CssPropertyWithConditionsVec::from_const_slice(
687                TEXT_INPUT_CONTAINER_PROPS,
688            ),
689            label_style: CssPropertyWithConditionsVec::from_const_slice(TEXT_INPUT_LABEL_PROPS),
690        }
691    }
692}
693
694impl Default for TextInputState {
695    fn default() -> Self {
696        Self {
697            text: Vec::new().into(),
698            placeholder: None.into(),
699            max_len: 50,
700            selection: None.into(),
701            cursor_pos: 0,
702        }
703    }
704}
705
706impl TextInputState {
707    #[must_use] pub fn get_text(&self) -> String {
708        self.text
709            .iter()
710            .filter_map(|c| core::char::from_u32(*c))
711            .collect()
712    }
713}
714
715impl Default for TextInputStateWrapper {
716    fn default() -> Self {
717        Self {
718            inner: TextInputState::default(),
719            on_text_input: None.into(),
720            on_virtual_key_down: None.into(),
721            on_focus_lost: None.into(),
722            update_text_input_before_calling_focus_lost_fn: true,
723            update_text_input_before_calling_vk_down_fn: true,
724            cursor_animation: None.into(),
725        }
726    }
727}
728
729impl TextInput {
730    #[must_use] pub fn create() -> Self {
731        Self::default()
732    }
733
734    #[must_use] pub fn with_text(mut self, text: AzString) -> Self {
735        self.set_text(text);
736        self
737    }
738
739    // owned AzString passed by value per the azul FFI / api.json setter convention.
740    #[allow(clippy::needless_pass_by_value)]
741    pub fn set_text(&mut self, text: AzString) {
742        self.text_input_state.inner.text = text
743            .as_str()
744            .chars()
745            .map(|c| c as u32)
746            .collect::<Vec<_>>()
747            .into();
748    }
749
750    pub fn set_placeholder(&mut self, placeholder: AzString) {
751        self.text_input_state.inner.placeholder = Some(placeholder).into();
752    }
753
754    #[must_use] pub fn with_placeholder(mut self, placeholder: AzString) -> Self {
755        self.set_placeholder(placeholder);
756        self
757    }
758
759    pub fn set_on_text_input<C: Into<TextInputOnTextInputCallback>>(
760        &mut self,
761        refany: RefAny,
762        callback: C,
763    ) {
764        self.text_input_state.on_text_input = Some(TextInputOnTextInput {
765            callback: callback.into(),
766            refany,
767        })
768        .into();
769    }
770
771    #[must_use]
772    pub fn with_on_text_input<C: Into<TextInputOnTextInputCallback>>(
773        mut self,
774        refany: RefAny,
775        callback: C,
776    ) -> Self {
777        self.set_on_text_input(refany, callback);
778        self
779    }
780
781    pub fn set_on_virtual_key_down<C: Into<TextInputOnVirtualKeyDownCallback>>(
782        &mut self,
783        refany: RefAny,
784        callback: C,
785    ) {
786        self.text_input_state.on_virtual_key_down = Some(TextInputOnVirtualKeyDown {
787            callback: callback.into(),
788            refany,
789        })
790        .into();
791    }
792
793    #[must_use]
794    pub fn with_on_virtual_key_down<C: Into<TextInputOnVirtualKeyDownCallback>>(
795        mut self,
796        refany: RefAny,
797        callback: C,
798    ) -> Self {
799        self.set_on_virtual_key_down(refany, callback);
800        self
801    }
802
803    pub fn set_on_focus_lost<C: Into<TextInputOnFocusLostCallback>>(
804        &mut self,
805        refany: RefAny,
806        callback: C,
807    ) {
808        self.text_input_state.on_focus_lost = Some(TextInputOnFocusLost {
809            callback: callback.into(),
810            refany,
811        })
812        .into();
813    }
814
815    #[must_use]
816    pub fn with_on_focus_lost<C: Into<TextInputOnFocusLostCallback>>(
817        mut self,
818        refany: RefAny,
819        callback: C,
820    ) -> Self {
821        self.set_on_focus_lost(refany, callback);
822        self
823    }
824
825    pub fn set_placeholder_style(&mut self, style: CssPropertyWithConditionsVec) {
826        self.placeholder_style = style;
827    }
828
829    #[must_use] pub fn with_placeholder_style(mut self, style: CssPropertyWithConditionsVec) -> Self {
830        self.set_placeholder_style(style);
831        self
832    }
833
834    pub fn set_container_style(&mut self, style: CssPropertyWithConditionsVec) {
835        self.container_style = style;
836    }
837
838    #[must_use] pub fn with_container_style(mut self, style: CssPropertyWithConditionsVec) -> Self {
839        self.set_container_style(style);
840        self
841    }
842
843    pub fn set_label_style(&mut self, style: CssPropertyWithConditionsVec) {
844        self.label_style = style;
845    }
846
847    #[must_use] pub fn with_label_style(mut self, style: CssPropertyWithConditionsVec) -> Self {
848        self.set_label_style(style);
849        self
850    }
851
852    #[must_use]
853    pub fn swap_with_default(&mut self) -> Self {
854        let mut s = Self::default();
855        core::mem::swap(&mut s, self);
856        s
857    }
858
859    #[must_use] pub fn dom(mut self) -> Dom {
860        use azul_core::{
861            callbacks::CoreCallbackData,
862            dom::{EventFilter, FocusEventFilter, HoverEventFilter, IdOrClass::Class, TabIndex},
863        };
864
865        self.text_input_state.inner.cursor_pos = self.text_input_state.inner.text.len();
866
867        let label_text: String = self
868            .text_input_state
869            .inner
870            .text
871            .iter()
872            .filter_map(|s| core::char::from_u32(*s))
873            .collect();
874
875        let placeholder = self
876            .text_input_state
877            .inner
878            .placeholder
879            .as_ref()
880            .map(|s| s.as_str().to_string())
881            .unwrap_or_default();
882
883        let state_ref = RefAny::new(self.text_input_state);
884
885        Dom::create_div()
886            .with_ids_and_classes(vec![Class("__azul-native-text-input-container".into())].into())
887            .with_css_props(self.container_style)
888            .with_tab_index(TabIndex::Auto)
889            .with_dataset(Some(state_ref.clone()).into())
890            .with_callbacks(
891                vec![
892                    CoreCallbackData {
893                        event: EventFilter::Focus(FocusEventFilter::FocusReceived),
894                        refany: state_ref.clone(),
895                        callback: CoreCallback {
896                            cb: default_on_focus_received as usize,
897                            ctx: azul_core::refany::OptionRefAny::None,
898                        },
899                    },
900                    CoreCallbackData {
901                        event: EventFilter::Focus(FocusEventFilter::FocusLost),
902                        refany: state_ref.clone(),
903                        callback: CoreCallback {
904                            cb: default_on_focus_lost as usize,
905                            ctx: azul_core::refany::OptionRefAny::None,
906                        },
907                    },
908                    CoreCallbackData {
909                        event: EventFilter::Focus(FocusEventFilter::TextInput),
910                        refany: state_ref.clone(),
911                        callback: CoreCallback {
912                            cb: default_on_text_input as usize,
913                            ctx: azul_core::refany::OptionRefAny::None,
914                        },
915                    },
916                    CoreCallbackData {
917                        event: EventFilter::Focus(FocusEventFilter::VirtualKeyDown),
918                        refany: state_ref.clone(),
919                        callback: CoreCallback {
920                            cb: default_on_virtual_key_down as usize,
921                            ctx: azul_core::refany::OptionRefAny::None,
922                        },
923                    },
924                    CoreCallbackData {
925                        event: EventFilter::Hover(HoverEventFilter::MouseOver),
926                        refany: state_ref,
927                        callback: CoreCallback {
928                            cb: default_on_mouse_hover as usize,
929                            ctx: azul_core::refany::OptionRefAny::None,
930                        },
931                    },
932                ]
933                .into(),
934            )
935            .with_children(
936                vec![
937                    Dom::create_text(placeholder)
938                        .with_ids_and_classes(
939                            vec![Class("__azul-native-text-input-placeholder".into())].into(),
940                        )
941                        .with_css_props(self.placeholder_style),
942                    Dom::create_text(label_text)
943                        .with_ids_and_classes(
944                            vec![Class("__azul-native-text-input-label".into())].into(),
945                        )
946                        .with_css_props(self.label_style)
947                        .with_children(
948                            vec![Dom::create_div()
949                                .with_ids_and_classes(
950                                    vec![Class("__azul-native-text-input-cursor".into())].into(),
951                                )
952                                .with_css_props(CssPropertyWithConditionsVec::from_const_slice(
953                                    TEXT_CURSOR_PROPS,
954                                ))]
955                            .into(),
956                        ),
957                ]
958                .into(),
959            )
960    }
961}
962
963extern "C" fn default_on_focus_received(mut text_input: RefAny, mut info: CallbackInfo) -> Update {
964    let Some(mut text_input) = text_input.downcast_mut::<TextInputStateWrapper>() else {
965        return Update::DoNothing;
966    };
967
968    let text_input = &mut *text_input;
969
970    let Some(placeholder_text_node_id) = info.get_first_child(info.get_hit_node()) else {
971        return Update::DoNothing;
972    };
973
974    // hide the placeholder text
975    if text_input.inner.text.is_empty() {
976        info.set_css_property(
977            placeholder_text_node_id,
978            CssProperty::const_opacity(StyleOpacity::const_new(0)),
979        );
980    }
981
982    text_input.inner.cursor_pos = text_input.inner.text.len();
983
984    Update::DoNothing
985}
986
987extern "C" fn default_on_focus_lost(mut text_input: RefAny, mut info: CallbackInfo) -> Update {
988    let Some(mut text_input) = text_input.downcast_mut::<TextInputStateWrapper>() else {
989        return Update::DoNothing;
990    };
991
992    let text_input = &mut *text_input;
993
994    let Some(placeholder_text_node_id) = info.get_first_child(info.get_hit_node()) else {
995        return Update::DoNothing;
996    };
997
998    // show the placeholder text
999    if text_input.inner.text.is_empty() {
1000        info.set_css_property(
1001            placeholder_text_node_id,
1002            CssProperty::const_opacity(StyleOpacity::const_new(100)),
1003        );
1004    }
1005
1006    // rustc doesn't understand the borrowing lifetime here
1007    let text_input = &mut *text_input;
1008    let onfocuslost = &mut text_input.on_focus_lost;
1009    let inner = text_input.inner.clone();
1010
1011    match onfocuslost.as_mut() {
1012        Some(TextInputOnFocusLost { callback, refany }) => {
1013            (callback.cb)(refany.clone(), info, inner)
1014        }
1015        None => Update::DoNothing,
1016    }
1017}
1018
1019extern "C" fn default_on_text_input(text_input: RefAny, info: CallbackInfo) -> Update {
1020    default_on_text_input_inner(text_input, info).unwrap_or(Update::DoNothing)
1021}
1022
1023fn default_on_text_input_inner(mut text_input: RefAny, mut info: CallbackInfo) -> Option<Update> {
1024    let mut text_input = text_input.downcast_mut::<TextInputStateWrapper>()?;
1025
1026    // Get the text changeset (replaces old keyboard_state.current_char API)
1027    let changeset = info.get_text_changeset()?;
1028    let inserted_text = changeset.inserted_text.as_str().to_string();
1029
1030    // Early return if no text to insert
1031    if inserted_text.is_empty() {
1032        return None;
1033    }
1034
1035    let placeholder_node_id = info.get_first_child(info.get_hit_node())?;
1036    let label_node_id = info.get_next_sibling(placeholder_node_id)?;
1037    let _cursor_node_id = info.get_first_child(label_node_id)?;
1038
1039    let result = {
1040        // rustc doesn't understand the borrowing lifetime here
1041        let text_input = &mut *text_input;
1042        let ontextinput = &mut text_input.on_text_input;
1043
1044        // inner_clone has the new text
1045        let mut inner_clone = text_input.inner.clone();
1046        inner_clone.cursor_pos = inner_clone.cursor_pos.saturating_add(inserted_text.len());
1047        inner_clone.text = {
1048            let mut internal = inner_clone.text.clone().into_library_owned_vec();
1049            internal.extend(inserted_text.chars().map(|c| c as u32));
1050            internal.into()
1051        };
1052
1053        match ontextinput.as_mut() {
1054            Some(TextInputOnTextInput { callback, refany }) => {
1055                (callback.cb)(refany.clone(), info, inner_clone)
1056            }
1057            None => OnTextInputReturn {
1058                update: Update::DoNothing,
1059                valid: TextInputValid::Yes,
1060            },
1061        }
1062    };
1063
1064    if result.valid == TextInputValid::Yes {
1065        // hide the placeholder text
1066        info.set_css_property(
1067            placeholder_node_id,
1068            CssProperty::const_opacity(StyleOpacity::const_new(0)),
1069        );
1070
1071        // append to the text
1072        text_input.inner.text = {
1073            let mut internal = text_input.inner.text.clone().into_library_owned_vec();
1074            internal.extend(inserted_text.chars().map(|c| c as u32));
1075            internal.into()
1076        };
1077        text_input.inner.cursor_pos = text_input
1078            .inner
1079            .cursor_pos
1080            .saturating_add(inserted_text.len());
1081
1082        info.change_node_text(label_node_id, text_input.inner.get_text().into());
1083    }
1084
1085    Some(result.update)
1086}
1087
1088extern "C" fn default_on_virtual_key_down(text_input: RefAny, info: CallbackInfo) -> Update {
1089    default_on_virtual_key_down_inner(text_input, info).unwrap_or(Update::DoNothing)
1090}
1091
1092fn default_on_virtual_key_down_inner(
1093    mut text_input: RefAny,
1094    mut info: CallbackInfo,
1095) -> Option<Update> {
1096    let mut text_input = text_input.downcast_mut::<TextInputStateWrapper>()?;
1097    let keyboard_state = info.get_current_keyboard_state();
1098
1099    let c = keyboard_state.current_virtual_keycode.into_option()?;
1100    let placeholder_node_id = info.get_first_child(info.get_hit_node())?;
1101    let label_node_id = info.get_next_sibling(placeholder_node_id)?;
1102    let _cursor_node_id = info.get_first_child(label_node_id)?;
1103
1104    // Dispatch to the user's on_virtual_key_down callback first; a
1105    // TextInputValid::No return suppresses the built-in editing behavior.
1106    let result = {
1107        // rustc doesn't understand the borrowing lifetime here
1108        let text_input = &mut *text_input;
1109        let inner_clone = text_input.inner.clone();
1110        match text_input.on_virtual_key_down.as_mut() {
1111            Some(TextInputOnVirtualKeyDown { callback, refany }) => {
1112                (callback.cb)(refany.clone(), info, inner_clone)
1113            }
1114            None => OnTextInputReturn {
1115                update: Update::DoNothing,
1116                valid: TextInputValid::Yes,
1117            },
1118        }
1119    };
1120
1121    if result.valid == TextInputValid::Yes && c == VirtualKeyCode::Back {
1122        text_input.inner.text = {
1123            let mut internal = text_input.inner.text.clone().into_library_owned_vec();
1124            internal.pop();
1125            internal.into()
1126        };
1127        text_input.inner.cursor_pos = text_input.inner.cursor_pos.saturating_sub(1);
1128
1129        info.change_node_text(label_node_id, text_input.inner.get_text().into());
1130    }
1131
1132    Some(result.update)
1133}
1134
1135extern "C" fn default_on_mouse_hover(mut text_input: RefAny, _info: CallbackInfo) -> Update {
1136    let Some(_text_input) = text_input.downcast_mut::<TextInputStateWrapper>() else {
1137        return Update::DoNothing;
1138    };
1139
1140    Update::DoNothing
1141}
1142
1143#[cfg(all(test, feature = "std"))]
1144#[allow(clippy::too_many_lines, clippy::float_cmp)]
1145mod autotest_generated {
1146    use std::{
1147        collections::{BTreeMap, HashMap},
1148        sync::{Arc, Mutex},
1149    };
1150
1151    use azul_core::{
1152        dom::{
1153            DomId, DomNodeId, EventFilter, FocusEventFilter, HoverEventFilter, IdOrClass, NodeId,
1154            TabIndex,
1155        },
1156        geom::{LogicalRect, OptionLogicalPosition},
1157        gl::OptionGlContextPtr,
1158        hit_test::ScrollPosition,
1159        refany::OptionRefAny,
1160        resources::RendererResources,
1161        styled_dom::{NodeHierarchyItemId, StyledDom},
1162        window::{MonitorVec, RawWindowHandle},
1163    };
1164    use rust_fontconfig::FcFontCache;
1165
1166    use super::*;
1167    #[cfg(feature = "icu")]
1168    use crate::icu::IcuLocalizerHandle;
1169    use crate::{
1170        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
1171        managers::text_input::PendingTextEdit,
1172        solver3::{display_list::DisplayList, layout_tree::LayoutTree},
1173        window::{DomLayoutResult, LayoutWindow},
1174        window_state::FullWindowState,
1175    };
1176
1177    // ==================================================================
1178    // Sample data
1179    // ==================================================================
1180
1181    /// Strings the buffer has to survive a `set_text` -> `get_text` round-trip on.
1182    /// Deliberately loaded with cases where "length" is ambiguous: the buffer counts
1183    /// *scalars*, `str::len()` counts *bytes*, and a human counts *graphemes* — three
1184    /// numbers that only agree for pure ASCII.
1185    const HOSTILE: [&str; 20] = [
1186        "",
1187        " ",
1188        "a",
1189        "hello world",
1190        "\0",           // a lone NUL is a perfectly good scalar
1191        "a\0b",         // ... and it survives in the middle of a string, too
1192        "\u{7f}",       // largest 1-byte scalar
1193        "\u{80}",       // smallest 2-byte scalar
1194        "\u{7ff}",      // largest 2-byte scalar
1195        "\u{800}",      // smallest 3-byte scalar
1196        "\u{ffff}",     // largest 3-byte scalar (and a non-character)
1197        "\u{10000}",    // smallest 4-byte scalar
1198        "\u{10ffff}",   // the largest scalar that exists at all
1199        "é",
1200        "e\u{301}",     // e + COMBINING ACUTE: 2 scalars, 1 grapheme
1201        "👨‍👩‍👧‍👦", // ZWJ family: 7 scalars, 1 grapheme, 25 bytes
1202        "日本語",
1203        "مرحبا",        // RTL
1204        "\u{200b}",     // zero-width space: invisible, but still one scalar
1205        "\r\n\t",
1206    ];
1207
1208    /// `u32` code units that are **not** Unicode scalars, so `char::from_u32` rejects
1209    /// every one of them. The buffer is a `U32Vec`, so nothing stops them from being
1210    /// in there — `get_text` is the only thing standing between them and a `String`.
1211    const NON_SCALAR_UNITS: [u32; 6] = [
1212        0xD800,      // leading surrogate
1213        0xDC00,      // trailing surrogate
1214        0xDFFF,      // last surrogate
1215        0x0011_0000, // one past the last scalar
1216        0xFFFF_FFFE,
1217        u32::MAX,
1218    ];
1219
1220    // ==================================================================
1221    // Widget fixtures
1222    // ==================================================================
1223
1224    /// A `TextInput` whose buffer holds *raw* code units — the only way to build a
1225    /// state that `set_text` could never produce (it goes through `char`).
1226    fn input_with_units(units: &[u32]) -> TextInput {
1227        let mut input = TextInput::create();
1228        input.text_input_state.inner.text = units.to_vec().into();
1229        input
1230    }
1231
1232    /// Renders `input` and hands back both the flattened DOM *and* the very `RefAny`
1233    /// the widget registered on its own handlers. Driving the handlers with these two
1234    /// is the real wiring: nothing is rebuilt by hand, so a mismatch between what
1235    /// `dom()` stores and what the handlers expect cannot hide behind the fixture.
1236    fn rendered(input: TextInput) -> (StyledDom, RefAny) {
1237        let dom = input.dom();
1238        let state = dom.root.callbacks.as_ref()[0].refany.clone();
1239        (StyledDom::create_from_dom(dom), state)
1240    }
1241
1242    /// The `TextInputState` currently sitting behind a widget-state payload.
1243    fn state_of(state: &RefAny) -> TextInputState {
1244        let mut state = state.clone();
1245        let wrapper = state
1246            .downcast_ref::<TextInputStateWrapper>()
1247            .expect("the widget state must still be a TextInputStateWrapper");
1248        wrapper.inner.clone()
1249    }
1250
1251    /// Reaches into the live widget state — used to plant a cursor position that
1252    /// `dom()` would otherwise have already normalised away.
1253    fn poke(state: &RefAny, f: impl FnOnce(&mut TextInputStateWrapper)) {
1254        let mut state = state.clone();
1255        let mut wrapper = state
1256            .downcast_mut::<TextInputStateWrapper>()
1257            .expect("the widget state must still be a TextInputStateWrapper");
1258        f(&mut wrapper);
1259    }
1260
1261    // ==================================================================
1262    // Recording hooks
1263    // ==================================================================
1264
1265    /// A user payload that records every state it is handed and answers with a
1266    /// canned verdict. It arrives as the `refany` argument — a *shared* clone of
1267    /// what the test still holds — so the test reads back exactly what the widget
1268    /// passed, with no global state involved.
1269    struct Recorder {
1270        seen: Vec<TextInputState>,
1271        update: Update,
1272        valid: TextInputValid,
1273    }
1274
1275    fn recorder(update: Update, valid: TextInputValid) -> RefAny {
1276        RefAny::new(Recorder {
1277            seen: Vec::new(),
1278            update,
1279            valid,
1280        })
1281    }
1282
1283    fn recorded(probe: &RefAny) -> Vec<TextInputState> {
1284        let mut probe = probe.clone();
1285        let log = probe
1286            .downcast_ref::<Recorder>()
1287            .expect("the user payload must still be a Recorder");
1288        log.seen.clone()
1289    }
1290
1291    extern "C" fn record_text_input(
1292        mut data: RefAny,
1293        _: CallbackInfo,
1294        state: TextInputState,
1295    ) -> OnTextInputReturn {
1296        let Some(mut log) = data.downcast_mut::<Recorder>() else {
1297            return OnTextInputReturn {
1298                update: Update::DoNothing,
1299                valid: TextInputValid::Yes,
1300            };
1301        };
1302        log.seen.push(state);
1303        OnTextInputReturn {
1304            update: log.update,
1305            valid: log.valid,
1306        }
1307    }
1308
1309    // Deliberately *not* the same body as `record_text_input`: two hooks with
1310    // byte-identical bodies can be folded onto a single symbol by the linker, and
1311    // the two slots have to stay distinguishable by function pointer.
1312    extern "C" fn record_virtual_key(
1313        mut data: RefAny,
1314        _: CallbackInfo,
1315        state: TextInputState,
1316    ) -> OnTextInputReturn {
1317        match data.downcast_mut::<Recorder>() {
1318            Some(mut log) => {
1319                let answer = OnTextInputReturn {
1320                    update: log.update,
1321                    valid: log.valid,
1322                };
1323                log.seen.push(state);
1324                answer
1325            }
1326            None => OnTextInputReturn {
1327                update: Update::RefreshDom,
1328                valid: TextInputValid::No,
1329            },
1330        }
1331    }
1332
1333    extern "C" fn record_focus_lost(
1334        mut data: RefAny,
1335        _: CallbackInfo,
1336        state: TextInputState,
1337    ) -> Update {
1338        data.downcast_mut::<Recorder>().map_or(Update::RefreshDom, |mut log| {
1339            log.seen.push(state);
1340            log.update
1341        })
1342    }
1343
1344    /// A `Callback`-shaped (2-argument) function — the shape FFI bindings hand in,
1345    /// which the `From<Callback>` arm *transmutes* into the 3-argument widget slot.
1346    /// Never invoked; only its address is ever compared.
1347    extern "C" fn generic_shaped(_: RefAny, _: CallbackInfo) -> Update {
1348        Update::DoNothing
1349    }
1350
1351    // ==================================================================
1352    // CallbackInfo harness
1353    // ==================================================================
1354
1355    /// The container is always the flattened root of `TextInput::dom()`.
1356    const CONTAINER: usize = 0;
1357
1358    fn dom_node(idx: usize) -> DomNodeId {
1359        DomNodeId {
1360            dom: DomId::ROOT_ID,
1361            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(idx))),
1362        }
1363    }
1364
1365    /// A `DomNodeId` whose node component is `None` — the "nothing concrete was hit"
1366    /// case. `CallbackInfo::set_css_property` *panics* on such an id, so every
1367    /// handler has to bail out before it ever gets there.
1368    fn node_none() -> DomNodeId {
1369        DomNodeId {
1370            dom: DomId::ROOT_ID,
1371            node: NodeHierarchyItemId::NONE,
1372        }
1373    }
1374
1375    fn inner_id(node: DomNodeId) -> NodeId {
1376        node.node
1377            .into_crate_internal()
1378            .expect("expected a concrete node id")
1379    }
1380
1381    /// A `DomLayoutResult` carrying only a `styled_dom`: the text-input handlers
1382    /// reach only `get_hit_node` / `get_first_child` / `get_next_sibling`, all of
1383    /// which read the node hierarchy alone — no real layout (and no font) needed.
1384    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
1385        DomLayoutResult {
1386            styled_dom,
1387            layout_tree: LayoutTree {
1388                nodes: Vec::new(),
1389                warm: Vec::new(),
1390                cold: Vec::new(),
1391                root: 0,
1392                dom_to_layout: BTreeMap::new(),
1393                children_arena: Vec::new(),
1394                children_offsets: Vec::new(),
1395                subtree_needs_intrinsic: Vec::new(),
1396            },
1397            calculated_positions: Vec::new(),
1398            viewport: LogicalRect::zero(),
1399            display_list: DisplayList::default(),
1400            scroll_ids: HashMap::new(),
1401            scroll_id_to_node_id: HashMap::new(),
1402        }
1403    }
1404
1405    /// Everything a default handler can read out of the window.
1406    struct Env {
1407        styled_dom: StyledDom,
1408        hit: DomNodeId,
1409        keycode: Option<VirtualKeyCode>,
1410        changeset: Option<PendingTextEdit>,
1411    }
1412
1413    impl Env {
1414        fn new(styled_dom: StyledDom) -> Self {
1415            Self {
1416                styled_dom,
1417                hit: dom_node(CONTAINER),
1418                keycode: None,
1419                changeset: None,
1420            }
1421        }
1422
1423        fn hit(mut self, hit: DomNodeId) -> Self {
1424            self.hit = hit;
1425            self
1426        }
1427
1428        fn key(mut self, keycode: VirtualKeyCode) -> Self {
1429            self.keycode = Some(keycode);
1430            self
1431        }
1432
1433        fn insert(mut self, text: &str) -> Self {
1434            self.changeset = Some(PendingTextEdit {
1435                node: dom_node(CONTAINER),
1436                inserted_text: text.into(),
1437                old_text: AzString::from(""),
1438            });
1439            self
1440        }
1441    }
1442
1443    /// The three child nodes of the container, resolved through the *same* API the
1444    /// handlers use — so no test has to hard-code a flattened index.
1445    #[derive(Copy, Clone, Debug, PartialEq, Eq)]
1446    struct Nodes {
1447        placeholder: Option<DomNodeId>,
1448        label: Option<DomNodeId>,
1449        cursor: Option<DomNodeId>,
1450    }
1451
1452    /// Runs `f` with a real `CallbackInfo` over a window holding `env.styled_dom` as
1453    /// the root DOM. Returns `f`'s value, every change the handler pushed onto the
1454    /// transaction log, and the resolved child node ids.
1455    fn run<R>(env: Env, f: impl FnOnce(CallbackInfo) -> R) -> (R, Vec<CallbackChange>, Nodes) {
1456        let mut layout_window =
1457            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
1458        layout_window
1459            .layout_results
1460            .insert(DomId::ROOT_ID, layout_result(env.styled_dom));
1461        layout_window.text_input_manager.pending_changeset = env.changeset;
1462        let layout_window = layout_window;
1463
1464        let renderer_resources = RendererResources::default();
1465        let previous_window_state: Option<FullWindowState> = None;
1466        let mut current_window_state = FullWindowState::default();
1467        current_window_state.keyboard_state.current_virtual_keycode = env.keycode.into();
1468        let gl_context = OptionGlContextPtr::None;
1469        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
1470            BTreeMap::new();
1471        let window_handle = RawWindowHandle::Unsupported;
1472        let system_callbacks = ExternalSystemCallbacks::rust_internal();
1473
1474        let ref_data = CallbackInfoRefData {
1475            layout_window: &layout_window,
1476            renderer_resources: &renderer_resources,
1477            previous_window_state: &previous_window_state,
1478            current_window_state: &current_window_state,
1479            gl_context: &gl_context,
1480            current_scroll_manager: &scroll_states,
1481            current_window_handle: &window_handle,
1482            system_callbacks: &system_callbacks,
1483            system_style: Arc::new(azul_css::system::SystemStyle::default()),
1484            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
1485            #[cfg(feature = "icu")]
1486            icu_localizer: IcuLocalizerHandle::default(),
1487            ctx: OptionRefAny::None,
1488        };
1489
1490        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
1491
1492        let probe = CallbackInfo::new(
1493            &ref_data,
1494            &changes,
1495            dom_node(CONTAINER),
1496            OptionLogicalPosition::None,
1497            OptionLogicalPosition::None,
1498        );
1499        let placeholder = probe.get_first_child(dom_node(CONTAINER));
1500        let label = placeholder.and_then(|p| probe.get_next_sibling(p));
1501        let cursor = label.and_then(|l| probe.get_first_child(l));
1502        let nodes = Nodes {
1503            placeholder,
1504            label,
1505            cursor,
1506        };
1507
1508        let info = CallbackInfo::new(
1509            &ref_data,
1510            &changes,
1511            env.hit,
1512            OptionLogicalPosition::None,
1513            OptionLogicalPosition::None,
1514        );
1515
1516        let r = f(info);
1517        let pushed = info.take_changes();
1518        (r, pushed, nodes)
1519    }
1520
1521    /// Every `(node, opacity)` pair pushed onto the transaction log, in push order.
1522    fn pushed_opacities(changes: &[CallbackChange]) -> Vec<(NodeId, f32)> {
1523        changes
1524            .iter()
1525            .filter_map(|c| match c {
1526                CallbackChange::ChangeNodeCssProperties {
1527                    node_id, properties, ..
1528                } => {
1529                    let o = properties.as_ref().iter().find_map(|p| match p {
1530                        CssProperty::Opacity(o) => o.get_property().map(|o| o.inner.normalized()),
1531                        _ => None,
1532                    })?;
1533                    Some((*node_id, o))
1534                }
1535                _ => None,
1536            })
1537            .collect()
1538    }
1539
1540    /// Every `(node, text)` repaint pushed onto the transaction log, in push order.
1541    fn pushed_texts(changes: &[CallbackChange]) -> Vec<(DomNodeId, String)> {
1542        changes
1543            .iter()
1544            .filter_map(|c| match c {
1545                CallbackChange::ChangeNodeText { node_id, text } => {
1546                    Some((*node_id, text.as_str().to_string()))
1547                }
1548                _ => None,
1549            })
1550            .collect()
1551    }
1552
1553    // ==================================================================
1554    // DOM probes
1555    // ==================================================================
1556
1557    /// Flattened child indices of `TextInput::dom()`.
1558    const PLACEHOLDER_CHILD: usize = 0;
1559    const LABEL_CHILD: usize = 1;
1560
1561    fn classes(node: &Dom) -> Vec<String> {
1562        node.root
1563            .get_ids_and_classes()
1564            .as_ref()
1565            .iter()
1566            .filter_map(|c| match c {
1567                IdOrClass::Class(s) => Some(s.as_str().to_string()),
1568                IdOrClass::Id(_) => None,
1569            })
1570            .collect()
1571    }
1572
1573    fn text_of(node: &Dom) -> String {
1574        node.root
1575            .get_node_type()
1576            .format()
1577            .expect("expected a text node")
1578    }
1579
1580    fn dataset_state(dom: &Dom) -> TextInputState {
1581        let mut dataset = dom
1582            .root
1583            .get_dataset()
1584            .cloned()
1585            .expect("TextInput::dom must attach its state as the container's dataset");
1586        let wrapper = dataset
1587            .downcast_ref::<TextInputStateWrapper>()
1588            .expect("the dataset must be a TextInputStateWrapper");
1589        wrapper.inner.clone()
1590    }
1591
1592    /// `n` properties lifted off the default container style — an easy way to mint
1593    /// pairwise-distinct style vectors without hard-coding any CSS.
1594    fn style(n: usize) -> CssPropertyWithConditionsVec {
1595        let all: Vec<CssPropertyWithConditions> =
1596            TextInput::default().container_style.as_ref().to_vec();
1597        assert!(n <= all.len(), "not enough default properties to slice");
1598        CssPropertyWithConditionsVec::from_vec(all.into_iter().take(n).collect())
1599    }
1600
1601    // ==================================================================
1602    // TextInputState::get_text
1603    // ==================================================================
1604
1605    #[test]
1606    fn get_text_on_a_default_state_is_the_empty_string() {
1607        let state = TextInputState::default();
1608        assert_eq!(state.get_text(), "");
1609        assert!(state.text.is_empty());
1610        assert_eq!(state.cursor_pos, 0);
1611        assert!(state.placeholder.is_none());
1612        assert!(state.selection.is_none());
1613    }
1614
1615    #[test]
1616    fn get_text_round_trips_every_hostile_string() {
1617        // The buffer stores one `u32` per scalar, so the round-trip must be exact for
1618        // anything `chars()` can produce — combining marks, ZWJ sequences, embedded
1619        // NULs and the very last scalar included.
1620        for s in HOSTILE {
1621            let input = TextInput::create().with_text(s.into());
1622            assert_eq!(
1623                input.text_input_state.inner.get_text(),
1624                s,
1625                "the buffer did not round-trip {s:?}",
1626            );
1627        }
1628    }
1629
1630    #[test]
1631    fn get_text_silently_drops_code_units_that_are_not_unicode_scalars() {
1632        // `get_text` is a `filter_map(char::from_u32)`: junk in the buffer is skipped,
1633        // not escaped and not panicked on. Pin that, because "skip" is the difference
1634        // between a lossy read and a crash on FFI-provided buffers.
1635        for unit in NON_SCALAR_UNITS {
1636            let state = TextInputState {
1637                text: vec![u32::from('A'), unit, u32::from('B')].into(),
1638                ..TextInputState::default()
1639            };
1640            assert_eq!(
1641                state.get_text(),
1642                "AB",
1643                "code unit {unit:#x} was not dropped from the rendered text",
1644            );
1645            assert_eq!(
1646                state.text.len(),
1647                3,
1648                "get_text must not mutate the buffer it reads",
1649            );
1650        }
1651    }
1652
1653    #[test]
1654    fn get_text_on_a_buffer_of_nothing_but_junk_is_empty_and_does_not_panic() {
1655        let state = TextInputState {
1656            text: NON_SCALAR_UNITS.to_vec().into(),
1657            ..TextInputState::default()
1658        };
1659        assert_eq!(state.get_text(), "");
1660        assert_eq!(state.text.len(), NON_SCALAR_UNITS.len());
1661    }
1662
1663    #[test]
1664    fn get_text_never_yields_more_chars_than_the_buffer_holds() {
1665        // The one invariant that holds for *any* buffer contents: filtering can only
1666        // ever shrink. A `get_text` that grew would mean the buffer and the rendered
1667        // label disagree about how far the cursor can travel.
1668        let mut units: Vec<u32> = Vec::new();
1669        for (i, unit) in NON_SCALAR_UNITS.iter().enumerate() {
1670            units.push(u32::from('x'));
1671            units.push(*unit);
1672            units.push(0x1F600 + i as u32);
1673        }
1674        let state = TextInputState {
1675            text: units.clone().into(),
1676            ..TextInputState::default()
1677        };
1678        let rendered = state.get_text();
1679        assert!(
1680            rendered.chars().count() <= state.text.len(),
1681            "get_text produced {} chars from a {}-unit buffer",
1682            rendered.chars().count(),
1683            state.text.len(),
1684        );
1685        assert_eq!(rendered.chars().count(), units.len() - NON_SCALAR_UNITS.len());
1686    }
1687
1688    #[test]
1689    fn get_text_is_pure() {
1690        let state = TextInputState {
1691            text: vec![u32::from('a'), 0xD800, u32::from('b')].into(),
1692            ..TextInputState::default()
1693        };
1694        let before = state.clone();
1695        assert_eq!(state.get_text(), state.get_text());
1696        assert_eq!(state, before, "get_text mutated the state it was given");
1697    }
1698
1699    #[test]
1700    fn get_text_on_a_very_large_buffer_does_not_panic() {
1701        let n = 50_000;
1702        let state = TextInputState {
1703            text: core::iter::repeat_n(u32::from('ß'), n).collect::<Vec<_>>().into(),
1704            ..TextInputState::default()
1705        };
1706        let text = state.get_text();
1707        assert_eq!(text.chars().count(), n);
1708        // 'ß' is two bytes: byte length and scalar count are *not* the same number.
1709        assert_eq!(text.len(), n * 2);
1710    }
1711
1712    // ==================================================================
1713    // TextInput::set_text / with_text
1714    // ==================================================================
1715
1716    #[test]
1717    fn with_text_is_exactly_set_text() {
1718        for s in HOSTILE {
1719            let mut a = TextInput::create();
1720            a.set_text(s.into());
1721            let b = TextInput::create().with_text(s.into());
1722            assert_eq!(a, b, "with_text and set_text disagree on {s:?}");
1723        }
1724    }
1725
1726    #[test]
1727    fn set_text_stores_one_code_unit_per_scalar_not_per_byte() {
1728        // The classic off-by-UTF-8 bug: storing `s.len()` units for a string whose
1729        // scalar count is smaller. Every non-ASCII entry in the table has a byte
1730        // length strictly greater than its scalar count.
1731        for s in HOSTILE {
1732            let input = TextInput::create().with_text(s.into());
1733            assert_eq!(
1734                input.text_input_state.inner.text.len(),
1735                s.chars().count(),
1736                "the buffer length for {s:?} is not the scalar count",
1737            );
1738        }
1739
1740        let family = "👨‍👩‍👧‍👦";
1741        let input = TextInput::create().with_text(family.into());
1742        assert_eq!(input.text_input_state.inner.text.len(), 7);
1743        assert_eq!(family.len(), 25, "the ZWJ family is 25 bytes, not 7");
1744    }
1745
1746    #[test]
1747    fn set_text_stores_the_scalar_values_verbatim() {
1748        let input = TextInput::create().with_text("aé\u{10ffff}".into());
1749        assert_eq!(
1750            input.text_input_state.inner.text.as_slice(),
1751            &[0x61, 0xE9, 0x0010_FFFF],
1752        );
1753    }
1754
1755    #[test]
1756    fn set_text_replaces_rather_than_appends() {
1757        let mut input = TextInput::create();
1758        input.set_text("first".into());
1759        input.set_text("second".into());
1760        assert_eq!(input.text_input_state.inner.get_text(), "second");
1761        assert_eq!(input.text_input_state.inner.text.len(), 6);
1762    }
1763
1764    #[test]
1765    fn set_text_with_an_empty_string_clears_the_buffer() {
1766        let mut input = TextInput::create().with_text("something".into());
1767        input.set_text("".into());
1768        assert!(input.text_input_state.inner.text.is_empty());
1769        assert_eq!(input.text_input_state.inner.get_text(), "");
1770        assert_eq!(input, TextInput::create(), "clearing did not restore a fresh widget");
1771    }
1772
1773    #[test]
1774    fn set_text_does_not_enforce_max_len() {
1775        // KNOWN GAP: `max_len` defaults to 50 and is never read anywhere in this
1776        // module — not by `set_text`, not by the text-input handler. A 200-char
1777        // assignment is stored whole. Pinned so that adding enforcement later shows
1778        // up as a deliberate change rather than a silent one.
1779        let long: String = "x".repeat(200);
1780        let input = TextInput::create().with_text(long.clone().into());
1781        assert_eq!(input.text_input_state.inner.max_len, 50);
1782        assert_eq!(input.text_input_state.inner.text.len(), 200);
1783        assert_eq!(input.text_input_state.inner.get_text(), long);
1784    }
1785
1786    #[test]
1787    fn set_text_leaves_the_cursor_where_it_was() {
1788        // `set_text` writes the buffer and nothing else: the cursor is only
1789        // reconciled by `dom()` / the focus handler. A widget built with text but
1790        // never rendered therefore reports a cursor of 0 over a non-empty buffer.
1791        let input = TextInput::create().with_text("hello".into());
1792        assert_eq!(input.text_input_state.inner.cursor_pos, 0);
1793        assert_eq!(input.text_input_state.inner.text.len(), 5);
1794    }
1795
1796    #[test]
1797    fn set_text_touches_nothing_but_the_buffer() {
1798        let mut input = TextInput::create()
1799            .with_placeholder("type here".into())
1800            .with_placeholder_style(style(3));
1801        let before = input.clone();
1802        input.set_text("abc".into());
1803
1804        assert_eq!(
1805            input.text_input_state.inner.placeholder.as_ref().map(|s| s.as_str().to_string()),
1806            Some("type here".to_string()),
1807        );
1808        assert_eq!(input.placeholder_style, before.placeholder_style);
1809        assert_eq!(input.container_style, before.container_style);
1810        assert_eq!(input.label_style, before.label_style);
1811        assert_eq!(input.text_input_state.inner.max_len, before.text_input_state.inner.max_len);
1812        assert!(input.text_input_state.inner.selection.is_none());
1813    }
1814
1815    #[test]
1816    fn with_text_on_a_very_large_string_does_not_panic() {
1817        let n = 50_000;
1818        let long: String = "a".repeat(n);
1819        let input = TextInput::create().with_text(long.into());
1820        assert_eq!(input.text_input_state.inner.text.len(), n);
1821    }
1822
1823    #[test]
1824    fn set_text_is_idempotent() {
1825        for s in HOSTILE {
1826            let mut input = TextInput::create();
1827            input.set_text(s.into());
1828            let once = input.clone();
1829            input.set_text(s.into());
1830            assert_eq!(input, once, "re-assigning {s:?} changed the widget");
1831        }
1832    }
1833
1834    // ==================================================================
1835    // TextInput::set_placeholder / with_placeholder
1836    // ==================================================================
1837
1838    #[test]
1839    fn placeholder_is_absent_on_a_fresh_widget() {
1840        assert!(TextInput::create().text_input_state.inner.placeholder.is_none());
1841    }
1842
1843    #[test]
1844    fn with_placeholder_is_exactly_set_placeholder_and_stores_the_string_verbatim() {
1845        for s in HOSTILE {
1846            let mut a = TextInput::create();
1847            a.set_placeholder(s.into());
1848            let b = TextInput::create().with_placeholder(s.into());
1849            assert_eq!(a, b, "with_placeholder and set_placeholder disagree on {s:?}");
1850
1851            assert_eq!(
1852                a.text_input_state.inner.placeholder.as_ref().map(|p| p.as_str()),
1853                Some(s),
1854                "the placeholder {s:?} was not stored byte-for-byte",
1855            );
1856        }
1857    }
1858
1859    #[test]
1860    fn set_placeholder_overwrites_a_previous_placeholder_and_never_clears_it() {
1861        let mut input = TextInput::create();
1862        input.set_placeholder("first".into());
1863        input.set_placeholder("".into());
1864        // An empty placeholder is still *a* placeholder, not the absence of one.
1865        assert_eq!(
1866            input.text_input_state.inner.placeholder.as_ref().map(|p| p.as_str()),
1867            Some(""),
1868        );
1869    }
1870
1871    #[test]
1872    fn set_placeholder_does_not_touch_the_text_buffer() {
1873        let mut input = TextInput::create().with_text("abc".into());
1874        input.set_placeholder("hint".into());
1875        assert_eq!(input.text_input_state.inner.get_text(), "abc");
1876    }
1877
1878    // ==================================================================
1879    // Style setters
1880    // ==================================================================
1881
1882    #[test]
1883    fn each_style_setter_writes_exactly_one_slot() {
1884        let marker = style(1);
1885
1886        let mut a = TextInput::create();
1887        a.set_placeholder_style(marker.clone());
1888        assert_eq!(a.placeholder_style, marker);
1889        assert_eq!(a.container_style, TextInput::create().container_style);
1890        assert_eq!(a.label_style, TextInput::create().label_style);
1891
1892        let mut b = TextInput::create();
1893        b.set_container_style(marker.clone());
1894        assert_eq!(b.container_style, marker);
1895        assert_eq!(b.placeholder_style, TextInput::create().placeholder_style);
1896        assert_eq!(b.label_style, TextInput::create().label_style);
1897
1898        let mut c = TextInput::create();
1899        c.set_label_style(marker.clone());
1900        assert_eq!(c.label_style, marker);
1901        assert_eq!(c.placeholder_style, TextInput::create().placeholder_style);
1902        assert_eq!(c.container_style, TextInput::create().container_style);
1903    }
1904
1905    #[test]
1906    fn the_with_style_builders_are_exactly_their_setters() {
1907        let s = style(2);
1908
1909        let mut a = TextInput::create();
1910        a.set_placeholder_style(s.clone());
1911        assert_eq!(a, TextInput::create().with_placeholder_style(s.clone()));
1912
1913        let mut b = TextInput::create();
1914        b.set_container_style(s.clone());
1915        assert_eq!(b, TextInput::create().with_container_style(s.clone()));
1916
1917        let mut c = TextInput::create();
1918        c.set_label_style(s.clone());
1919        assert_eq!(c, TextInput::create().with_label_style(s));
1920    }
1921
1922    #[test]
1923    fn style_setters_accept_an_empty_vector_and_survive_rendering() {
1924        let empty = CssPropertyWithConditionsVec::from_vec(Vec::new());
1925        let input = TextInput::create()
1926            .with_placeholder_style(empty.clone())
1927            .with_container_style(empty.clone())
1928            .with_label_style(empty.clone());
1929        assert!(input.container_style.is_empty());
1930
1931        // Stripping every declared property must not stop the widget from rendering.
1932        let dom = input.dom();
1933        assert_eq!(dom.children.as_ref().len(), 2);
1934    }
1935
1936    #[test]
1937    fn style_setters_overwrite_rather_than_merge() {
1938        let mut input = TextInput::create();
1939        input.set_container_style(style(4));
1940        input.set_container_style(style(1));
1941        assert_eq!(input.container_style.len(), 1);
1942    }
1943
1944    // ==================================================================
1945    // Callback setters
1946    // ==================================================================
1947
1948    #[test]
1949    fn set_on_text_input_stores_the_fn_pointer_and_the_payload_verbatim() {
1950        let mut input = TextInput::create();
1951        input.set_on_text_input(
1952            RefAny::new(0xDEAD_BEEF_u32),
1953            record_text_input as TextInputOnTextInputCallbackType,
1954        );
1955
1956        let slot = input
1957            .text_input_state
1958            .on_text_input
1959            .as_ref()
1960            .expect("set_on_text_input stored nothing");
1961        assert_eq!(
1962            slot.callback.cb as *const () as usize,
1963            record_text_input as TextInputOnTextInputCallbackType as *const () as usize,
1964            "the fn pointer was mangled on the way in",
1965        );
1966
1967        let mut payload = slot.refany.clone();
1968        assert_eq!(
1969            *payload.downcast_ref::<u32>().expect("the payload changed type"),
1970            0xDEAD_BEEF,
1971        );
1972        assert!(
1973            payload.downcast_ref::<u64>().is_none(),
1974            "the payload must not be readable as a differently-typed value",
1975        );
1976    }
1977
1978    #[test]
1979    fn set_on_virtual_key_down_and_set_on_focus_lost_store_their_own_slots() {
1980        let mut input = TextInput::create();
1981        input.set_on_virtual_key_down(
1982            RefAny::new(1_u8),
1983            record_virtual_key as TextInputOnVirtualKeyDownCallbackType,
1984        );
1985        input.set_on_focus_lost(
1986            RefAny::new(2_u8),
1987            record_focus_lost as TextInputOnFocusLostCallbackType,
1988        );
1989
1990        assert!(
1991            input.text_input_state.on_text_input.as_ref().is_none(),
1992            "the text-input slot was filled in by an unrelated setter",
1993        );
1994        assert_eq!(
1995            input
1996                .text_input_state
1997                .on_virtual_key_down
1998                .as_ref()
1999                .expect("the virtual-key slot is empty")
2000                .callback
2001                .cb as *const () as usize,
2002            record_virtual_key as TextInputOnVirtualKeyDownCallbackType as *const () as usize,
2003        );
2004        assert_eq!(
2005            input
2006                .text_input_state
2007                .on_focus_lost
2008                .as_ref()
2009                .expect("the focus-lost slot is empty")
2010                .callback
2011                .cb as *const () as usize,
2012            record_focus_lost as TextInputOnFocusLostCallbackType as *const () as usize,
2013        );
2014    }
2015
2016    #[test]
2017    fn the_with_callback_builders_are_exactly_their_setters() {
2018        let payload = RefAny::new(7_u16);
2019
2020        let mut a = TextInput::create();
2021        a.set_on_text_input(payload.clone(), record_text_input as TextInputOnTextInputCallbackType);
2022        assert_eq!(
2023            a,
2024            TextInput::create().with_on_text_input(
2025                payload.clone(),
2026                record_text_input as TextInputOnTextInputCallbackType,
2027            ),
2028        );
2029
2030        let mut b = TextInput::create();
2031        b.set_on_virtual_key_down(
2032            payload.clone(),
2033            record_virtual_key as TextInputOnVirtualKeyDownCallbackType,
2034        );
2035        assert_eq!(
2036            b,
2037            TextInput::create().with_on_virtual_key_down(
2038                payload.clone(),
2039                record_virtual_key as TextInputOnVirtualKeyDownCallbackType,
2040            ),
2041        );
2042
2043        let mut c = TextInput::create();
2044        c.set_on_focus_lost(payload.clone(), record_focus_lost as TextInputOnFocusLostCallbackType);
2045        assert_eq!(
2046            c,
2047            TextInput::create()
2048                .with_on_focus_lost(payload, record_focus_lost as TextInputOnFocusLostCallbackType),
2049        );
2050    }
2051
2052    #[test]
2053    fn setting_a_callback_twice_replaces_it_rather_than_stacking() {
2054        let mut input = TextInput::create();
2055        input.set_on_text_input(
2056            RefAny::new(1_u8),
2057            record_text_input as TextInputOnTextInputCallbackType,
2058        );
2059        input.set_on_text_input(
2060            RefAny::new(2_u8),
2061            record_virtual_key as TextInputOnTextInputCallbackType,
2062        );
2063
2064        let slot = input.text_input_state.on_text_input.as_ref().expect("slot is empty");
2065        assert_eq!(
2066            slot.callback.cb as *const () as usize,
2067            record_virtual_key as TextInputOnTextInputCallbackType as *const () as usize,
2068            "the second assignment did not win",
2069        );
2070        let mut payload = slot.refany.clone();
2071        assert_eq!(*payload.downcast_ref::<u8>().expect("wrong payload type"), 2);
2072    }
2073
2074    #[test]
2075    fn a_generic_two_argument_callback_is_accepted_through_the_ffi_conversion() {
2076        // FFI bindings hand in a `Callback` (2 args) which the `From<Callback>` arm
2077        // transmutes into the 3-argument widget slot. It must survive being stored
2078        // and read back — only the *address* is meaningful, so that is all we check.
2079        let generic = Callback {
2080            cb: generic_shaped,
2081            ctx: OptionRefAny::None,
2082        };
2083        let input = TextInput::create().with_on_text_input(RefAny::new(0_u8), generic);
2084        assert_eq!(
2085            input
2086                .text_input_state
2087                .on_text_input
2088                .as_ref()
2089                .expect("the transmuted callback was dropped")
2090                .callback
2091                .cb as *const () as usize,
2092            generic_shaped as *const () as usize,
2093        );
2094    }
2095
2096    // ==================================================================
2097    // TextInput::create / swap_with_default
2098    // ==================================================================
2099
2100    #[test]
2101    fn create_is_default_and_is_pure() {
2102        assert_eq!(TextInput::create(), TextInput::default());
2103        assert_eq!(TextInput::create(), TextInput::create());
2104    }
2105
2106    #[test]
2107    fn create_starts_empty_with_no_hooks_and_no_running_animation() {
2108        let input = TextInput::create();
2109        assert!(input.text_input_state.inner.text.is_empty());
2110        assert!(input.text_input_state.inner.placeholder.is_none());
2111        assert!(input.text_input_state.inner.selection.is_none());
2112        assert_eq!(input.text_input_state.inner.cursor_pos, 0);
2113        assert_eq!(input.text_input_state.inner.max_len, 50);
2114        assert!(input.text_input_state.on_text_input.as_ref().is_none());
2115        assert!(input.text_input_state.on_virtual_key_down.as_ref().is_none());
2116        assert!(input.text_input_state.on_focus_lost.as_ref().is_none());
2117        assert!(input.text_input_state.cursor_animation.is_none());
2118        assert!(input.text_input_state.update_text_input_before_calling_focus_lost_fn);
2119        assert!(input.text_input_state.update_text_input_before_calling_vk_down_fn);
2120        assert!(!input.container_style.is_empty());
2121    }
2122
2123    #[test]
2124    fn swap_with_default_returns_the_old_widget_and_leaves_a_fresh_one_behind() {
2125        let mut input = TextInput::create()
2126            .with_text("typed".into())
2127            .with_placeholder("hint".into());
2128        let old = input.swap_with_default();
2129
2130        assert_eq!(old.text_input_state.inner.get_text(), "typed");
2131        assert_eq!(input, TextInput::create(), "what was left behind is not a fresh widget");
2132    }
2133
2134    #[test]
2135    fn swapping_twice_round_trips_the_original_widget() {
2136        let mut a = TextInput::create().with_text("abc".into());
2137        let mut b = a.swap_with_default(); // a = default, b = "abc"
2138        let c = b.swap_with_default(); // b = default, c = "abc"
2139
2140        assert_eq!(c, TextInput::create().with_text("abc".into()));
2141        assert_eq!(a, TextInput::create());
2142        assert_eq!(b, TextInput::create());
2143    }
2144
2145    #[test]
2146    fn swap_with_default_moves_the_hooks_out_rather_than_copying_them() {
2147        let probe = recorder(Update::DoNothing, TextInputValid::Yes);
2148        let mut input = TextInput::create().with_on_text_input(
2149            probe.clone(),
2150            record_text_input as TextInputOnTextInputCallbackType,
2151        );
2152
2153        let old = input.swap_with_default();
2154
2155        assert!(
2156            old.text_input_state.on_text_input.as_ref().is_some(),
2157            "the hook vanished during the swap",
2158        );
2159        // A duplicated hook would fire twice, and a duplicated RefAny would
2160        // double-free its payload.
2161        assert!(
2162            input.text_input_state.on_text_input.as_ref().is_none(),
2163            "the hook was copied instead of moved",
2164        );
2165        // The payload is still alive and still typed after the move (a double-freed
2166        // RefAny would not survive the downcast inside `recorded`).
2167        assert!(recorded(&probe).is_empty(), "the hook fired during a swap");
2168    }
2169
2170    // ==================================================================
2171    // TextInput::dom
2172    // ==================================================================
2173
2174    #[test]
2175    fn dom_builds_a_container_with_a_placeholder_and_a_label_carrying_a_cursor() {
2176        let dom = TextInput::create().dom();
2177
2178        assert_eq!(classes(&dom), vec!["__azul-native-text-input-container"]);
2179        assert_eq!(dom.children.as_ref().len(), 2);
2180
2181        let placeholder = &dom.children.as_ref()[PLACEHOLDER_CHILD];
2182        let label = &dom.children.as_ref()[LABEL_CHILD];
2183        assert_eq!(classes(placeholder), vec!["__azul-native-text-input-placeholder"]);
2184        assert_eq!(classes(label), vec!["__azul-native-text-input-label"]);
2185
2186        assert!(
2187            placeholder.children.as_ref().is_empty(),
2188            "the placeholder must be a leaf: the handlers reach the label through its sibling",
2189        );
2190        assert_eq!(label.children.as_ref().len(), 1);
2191        assert_eq!(
2192            classes(&label.children.as_ref()[0]),
2193            vec!["__azul-native-text-input-cursor"],
2194        );
2195    }
2196
2197    #[test]
2198    fn dom_marks_the_container_as_keyboard_focusable() {
2199        // Without a tab index the widget can never receive the focus events that all
2200        // four of its editing handlers are filtered on.
2201        assert_eq!(TextInput::create().dom().root.get_tab_index(), Some(TabIndex::Auto));
2202    }
2203
2204    #[test]
2205    fn dom_registers_exactly_the_five_default_handlers_over_one_shared_state() {
2206        let dom = TextInput::create().dom();
2207        let callbacks = dom.root.callbacks.as_ref();
2208
2209        let events: Vec<EventFilter> = callbacks.iter().map(|c| c.event).collect();
2210        assert_eq!(
2211            events,
2212            vec![
2213                EventFilter::Focus(FocusEventFilter::FocusReceived),
2214                EventFilter::Focus(FocusEventFilter::FocusLost),
2215                EventFilter::Focus(FocusEventFilter::TextInput),
2216                EventFilter::Focus(FocusEventFilter::VirtualKeyDown),
2217                EventFilter::Hover(HoverEventFilter::MouseOver),
2218            ],
2219        );
2220
2221        let targets: Vec<usize> = callbacks.iter().map(|c| c.callback.cb).collect();
2222        assert_eq!(
2223            targets,
2224            vec![
2225                default_on_focus_received as usize,
2226                default_on_focus_lost as usize,
2227                default_on_text_input as usize,
2228                default_on_virtual_key_down as usize,
2229                default_on_mouse_hover as usize,
2230            ],
2231            "the handlers are wired to the wrong events",
2232        );
2233
2234        // All five handlers plus the dataset must share ONE state; separate copies
2235        // would let the focus handler and the text handler drift apart.
2236        for c in callbacks {
2237            assert_eq!(c.refany, callbacks[0].refany, "a handler got its own state copy");
2238        }
2239        assert_eq!(
2240            dom.root.get_dataset().expect("no dataset attached"),
2241            &callbacks[0].refany,
2242        );
2243    }
2244
2245    #[test]
2246    fn dom_renders_the_buffer_into_the_label_and_the_placeholder_into_its_own_node() {
2247        let dom = TextInput::create()
2248            .with_text("typed".into())
2249            .with_placeholder("hint".into())
2250            .dom();
2251
2252        assert_eq!(text_of(&dom.children.as_ref()[PLACEHOLDER_CHILD]), "hint");
2253        assert_eq!(text_of(&dom.children.as_ref()[LABEL_CHILD]), "typed");
2254    }
2255
2256    #[test]
2257    fn dom_without_a_placeholder_still_renders_an_empty_placeholder_node() {
2258        // The handlers navigate `container -> first child -> next sibling`; dropping
2259        // the placeholder node when unset would make the label unreachable.
2260        let dom = TextInput::create().with_text("typed".into()).dom();
2261        assert_eq!(dom.children.as_ref().len(), 2);
2262        assert_eq!(text_of(&dom.children.as_ref()[PLACEHOLDER_CHILD]), "");
2263    }
2264
2265    #[test]
2266    fn dom_passes_hostile_text_and_placeholder_through_unchanged() {
2267        for s in HOSTILE {
2268            let dom = TextInput::create()
2269                .with_text(s.into())
2270                .with_placeholder(s.into())
2271                .dom();
2272            assert_eq!(text_of(&dom.children.as_ref()[LABEL_CHILD]), s, "label mangled {s:?}");
2273            assert_eq!(
2274                text_of(&dom.children.as_ref()[PLACEHOLDER_CHILD]),
2275                s,
2276                "placeholder mangled {s:?}",
2277            );
2278        }
2279    }
2280
2281    #[test]
2282    fn dom_syncs_the_cursor_to_the_end_of_the_buffer() {
2283        for s in HOSTILE {
2284            let dom = TextInput::create().with_text(s.into()).dom();
2285            assert_eq!(
2286                dataset_state(&dom).cursor_pos,
2287                s.chars().count(),
2288                "the cursor was not parked at the end of {s:?}",
2289            );
2290        }
2291    }
2292
2293    #[test]
2294    fn dom_measures_the_cursor_in_code_units_which_can_outrun_the_rendered_text() {
2295        // KNOWN GAP: `dom()` sets `cursor_pos = text.len()` (code units), while the
2296        // label only renders the units that are valid scalars. A buffer holding junk
2297        // therefore ends up with a cursor past the end of what is on screen.
2298        let dom = input_with_units(&[u32::from('a'), 0xD800, u32::from('b')]).dom();
2299        assert_eq!(dataset_state(&dom).cursor_pos, 3);
2300        assert_eq!(text_of(&dom.children.as_ref()[LABEL_CHILD]), "ab");
2301    }
2302
2303    #[test]
2304    fn dom_on_a_very_large_buffer_does_not_panic() {
2305        let n = 50_000;
2306        let long: String = "x".repeat(n);
2307        let dom = TextInput::create().with_text(long.into()).dom();
2308        assert_eq!(text_of(&dom.children.as_ref()[LABEL_CHILD]).len(), n);
2309        assert_eq!(dataset_state(&dom).cursor_pos, n);
2310    }
2311
2312    #[test]
2313    fn dom_keeps_the_configured_styles_on_the_nodes_they_were_set_for() {
2314        let placeholder_style = style(1);
2315        let label_style = style(2);
2316        let container_style = style(3);
2317        let dom = TextInput::create()
2318            .with_placeholder_style(placeholder_style.clone())
2319            .with_label_style(label_style.clone())
2320            .with_container_style(container_style.clone())
2321            .dom();
2322
2323        let inline = |node: &Dom| -> Vec<CssProperty> {
2324            node.root.style.iter_inline_properties().map(|(p, _)| p.clone()).collect()
2325        };
2326        let declared = |v: &CssPropertyWithConditionsVec| -> Vec<CssProperty> {
2327            v.as_ref().iter().map(|p| p.property.clone()).collect()
2328        };
2329
2330        assert_eq!(inline(&dom), declared(&container_style));
2331        assert_eq!(
2332            inline(&dom.children.as_ref()[PLACEHOLDER_CHILD]),
2333            declared(&placeholder_style),
2334        );
2335        assert_eq!(inline(&dom.children.as_ref()[LABEL_CHILD]), declared(&label_style));
2336    }
2337
2338    #[test]
2339    fn the_rendered_tree_flattens_to_three_distinct_reachable_children() {
2340        let (styled_dom, _) = rendered(TextInput::create());
2341        let ((), _, nodes) = run(Env::new(styled_dom), |_| ());
2342
2343        let placeholder = nodes.placeholder.expect("the container has no first child");
2344        let label = nodes.label.expect("the placeholder has no next sibling");
2345        let cursor = nodes.cursor.expect("the label has no first child");
2346
2347        assert_ne!(placeholder, label);
2348        assert_ne!(label, cursor);
2349        assert_ne!(placeholder, cursor);
2350        assert_ne!(placeholder, dom_node(CONTAINER));
2351    }
2352
2353    // ==================================================================
2354    // default_on_focus_received
2355    // ==================================================================
2356
2357    #[test]
2358    fn focus_received_with_a_foreign_payload_is_an_inert_no_op() {
2359        let (styled_dom, _) = rendered(TextInput::create());
2360        let foreign = RefAny::new(0xDEAD_BEEF_u32);
2361        let (update, changes, _) = run(Env::new(styled_dom), |info| {
2362            default_on_focus_received(foreign.clone(), info)
2363        });
2364        assert_eq!(update, Update::DoNothing);
2365        assert!(changes.is_empty(), "a foreign payload still produced {changes:?}");
2366    }
2367
2368    #[test]
2369    fn focus_received_on_a_node_with_no_children_bails_out_before_touching_css() {
2370        // `set_css_property` panics on a `None` node id, and the widget only has a
2371        // placeholder to hide if the hit node actually has children. Both escapes have
2372        // to happen before the css write.
2373        let (styled_dom, state) = rendered(TextInput::create());
2374        let (update, changes, nodes) = run(Env::new(styled_dom).hit(node_none()), |info| {
2375            default_on_focus_received(state.clone(), info)
2376        });
2377        assert_eq!(update, Update::DoNothing);
2378        assert!(changes.is_empty());
2379        assert!(nodes.placeholder.is_some(), "the fixture itself is malformed");
2380    }
2381
2382    #[test]
2383    fn focus_received_on_the_cursor_leaf_is_a_no_op() {
2384        // The cursor is the one node in the tree with no children of its own.
2385        let (probe_dom, _) = rendered(TextInput::create());
2386        let (_, _, nodes) = run(Env::new(probe_dom), |_| ());
2387        let cursor = nodes.cursor.expect("no cursor node");
2388
2389        let (styled_dom, state) = rendered(TextInput::create());
2390        let (update, changes, _) = run(Env::new(styled_dom).hit(cursor), |info| {
2391            default_on_focus_received(state.clone(), info)
2392        });
2393        assert_eq!(update, Update::DoNothing);
2394        assert!(changes.is_empty(), "a childless hit node still pushed {changes:?}");
2395    }
2396
2397    #[test]
2398    fn focus_received_hides_the_placeholder_only_while_the_buffer_is_empty() {
2399        let (styled_dom, state) = rendered(TextInput::create());
2400        let (update, changes, nodes) = run(Env::new(styled_dom), |info| {
2401            default_on_focus_received(state.clone(), info)
2402        });
2403        assert_eq!(update, Update::DoNothing);
2404        assert_eq!(
2405            pushed_opacities(&changes),
2406            vec![(inner_id(nodes.placeholder.expect("no placeholder")), 0.0)],
2407            "focusing an empty input did not hide its placeholder",
2408        );
2409
2410        let (styled_dom, state) = rendered(TextInput::create().with_text("typed".into()));
2411        let (update, changes, _) = run(Env::new(styled_dom), |info| {
2412            default_on_focus_received(state.clone(), info)
2413        });
2414        assert_eq!(update, Update::DoNothing);
2415        assert!(
2416            changes.is_empty(),
2417            "focusing a non-empty input touched the placeholder anyway: {changes:?}",
2418        );
2419    }
2420
2421    #[test]
2422    fn focus_received_reparks_the_cursor_at_the_end_of_the_buffer() {
2423        let (styled_dom, state) = rendered(TextInput::create().with_text("hello".into()));
2424        // Plant a cursor that is both stale and out of range.
2425        poke(&state, |w| w.inner.cursor_pos = usize::MAX);
2426
2427        let (_, _, _) = run(Env::new(styled_dom), |info| {
2428            default_on_focus_received(state.clone(), info)
2429        });
2430        assert_eq!(state_of(&state).cursor_pos, 5);
2431    }
2432
2433    // ==================================================================
2434    // default_on_focus_lost
2435    // ==================================================================
2436
2437    #[test]
2438    fn focus_lost_shows_the_placeholder_only_while_the_buffer_is_empty() {
2439        let (styled_dom, state) = rendered(TextInput::create());
2440        let (update, changes, nodes) =
2441            run(Env::new(styled_dom), |info| default_on_focus_lost(state.clone(), info));
2442        assert_eq!(update, Update::DoNothing);
2443        assert_eq!(
2444            pushed_opacities(&changes),
2445            vec![(inner_id(nodes.placeholder.expect("no placeholder")), 1.0)],
2446            "blurring an empty input did not bring its placeholder back",
2447        );
2448
2449        let (styled_dom, state) = rendered(TextInput::create().with_text("typed".into()));
2450        let (_, changes, _) =
2451            run(Env::new(styled_dom), |info| default_on_focus_lost(state.clone(), info));
2452        assert!(
2453            changes.is_empty(),
2454            "blurring a non-empty input revealed the placeholder over the text: {changes:?}",
2455        );
2456    }
2457
2458    #[test]
2459    fn focus_lost_hands_the_hook_the_live_state_and_returns_its_verdict() {
2460        let probe = recorder(Update::RefreshDomAllWindows, TextInputValid::Yes);
2461        let (styled_dom, state) = rendered(
2462            TextInput::create()
2463                .with_text("typed".into())
2464                .with_on_focus_lost(probe.clone(), record_focus_lost as TextInputOnFocusLostCallbackType),
2465        );
2466
2467        let (update, _, _) =
2468            run(Env::new(styled_dom), |info| default_on_focus_lost(state.clone(), info));
2469
2470        assert_eq!(update, Update::RefreshDomAllWindows, "the hook's Update was swallowed");
2471        let seen = recorded(&probe);
2472        assert_eq!(seen.len(), 1, "the hook fired {} times, expected once", seen.len());
2473        assert_eq!(seen[0].get_text(), "typed");
2474        assert_eq!(seen[0].cursor_pos, 5, "the hook saw a cursor that dom() should have synced");
2475    }
2476
2477    #[test]
2478    fn focus_lost_without_a_hook_reports_no_work_to_do() {
2479        let (styled_dom, state) = rendered(TextInput::create().with_text("typed".into()));
2480        let (update, _, _) =
2481            run(Env::new(styled_dom), |info| default_on_focus_lost(state.clone(), info));
2482        assert_eq!(update, Update::DoNothing);
2483    }
2484
2485    #[test]
2486    fn focus_lost_on_a_none_hit_node_skips_the_hook_entirely() {
2487        // The early return sits *above* the hook dispatch, so a blur that cannot find
2488        // its placeholder never reaches user code. Worth pinning: a user hook that
2489        // commits a form would otherwise fire on a malformed hit.
2490        let probe = recorder(Update::RefreshDom, TextInputValid::Yes);
2491        let (styled_dom, state) = rendered(TextInput::create().with_on_focus_lost(
2492            probe.clone(),
2493            record_focus_lost as TextInputOnFocusLostCallbackType,
2494        ));
2495
2496        let (update, changes, _) = run(Env::new(styled_dom).hit(node_none()), |info| {
2497            default_on_focus_lost(state.clone(), info)
2498        });
2499
2500        assert_eq!(update, Update::DoNothing);
2501        assert!(changes.is_empty());
2502        assert!(recorded(&probe).is_empty(), "the hook fired on a hit node that does not exist");
2503    }
2504
2505    #[test]
2506    fn focus_lost_with_a_foreign_payload_is_an_inert_no_op() {
2507        let (styled_dom, _) = rendered(TextInput::create());
2508        let foreign = RefAny::new("not a text input".to_string());
2509        let (update, changes, _) = run(Env::new(styled_dom), |info| {
2510            default_on_focus_lost(foreign.clone(), info)
2511        });
2512        assert_eq!(update, Update::DoNothing);
2513        assert!(changes.is_empty());
2514    }
2515
2516    // ==================================================================
2517    // default_on_text_input
2518    // ==================================================================
2519
2520    #[test]
2521    fn text_input_without_a_pending_changeset_does_nothing() {
2522        let (styled_dom, state) = rendered(TextInput::create());
2523        let (update, changes, _) =
2524            run(Env::new(styled_dom), |info| default_on_text_input(state.clone(), info));
2525        assert_eq!(update, Update::DoNothing);
2526        assert!(changes.is_empty());
2527        assert_eq!(state_of(&state).get_text(), "");
2528    }
2529
2530    #[test]
2531    fn text_input_with_an_empty_insertion_does_nothing() {
2532        let (styled_dom, state) = rendered(TextInput::create().with_text("abc".into()));
2533        let (update, changes, _) = run(Env::new(styled_dom).insert(""), |info| {
2534            default_on_text_input(state.clone(), info)
2535        });
2536        assert_eq!(update, Update::DoNothing);
2537        assert!(changes.is_empty(), "an empty insertion still repainted: {changes:?}");
2538        assert_eq!(state_of(&state).get_text(), "abc");
2539        assert_eq!(state_of(&state).cursor_pos, 3);
2540    }
2541
2542    #[test]
2543    fn text_input_appends_the_insertion_hides_the_placeholder_and_repaints_the_label() {
2544        let (styled_dom, state) = rendered(TextInput::create().with_placeholder("hint".into()));
2545        let (update, changes, nodes) = run(Env::new(styled_dom).insert("hi"), |info| {
2546            default_on_text_input(state.clone(), info)
2547        });
2548
2549        assert_eq!(update, Update::DoNothing, "no hook is installed, so nothing needs redrawing");
2550        assert_eq!(state_of(&state).get_text(), "hi");
2551        assert_eq!(state_of(&state).cursor_pos, 2);
2552
2553        assert_eq!(
2554            pushed_opacities(&changes),
2555            vec![(inner_id(nodes.placeholder.expect("no placeholder")), 0.0)],
2556        );
2557        assert_eq!(
2558            pushed_texts(&changes),
2559            vec![(nodes.label.expect("no label"), "hi".to_string())],
2560            "the label was not repainted with the new buffer",
2561        );
2562    }
2563
2564    #[test]
2565    fn text_input_appends_to_an_existing_buffer_rather_than_replacing_it() {
2566        let (styled_dom, state) = rendered(TextInput::create().with_text("ab".into()));
2567        let (_, changes, nodes) = run(Env::new(styled_dom).insert("cd"), |info| {
2568            default_on_text_input(state.clone(), info)
2569        });
2570        assert_eq!(state_of(&state).get_text(), "abcd");
2571        assert_eq!(
2572            pushed_texts(&changes),
2573            vec![(nodes.label.expect("no label"), "abcd".to_string())],
2574        );
2575    }
2576
2577    #[test]
2578    fn text_input_hands_the_hook_a_preview_that_already_contains_the_insertion() {
2579        // The hook is a *validator*: it has to see the would-be result, not the state
2580        // before the edit, or it can never reject an edit for what it produces.
2581        let probe = recorder(Update::RefreshDom, TextInputValid::Yes);
2582        let (styled_dom, state) = rendered(
2583            TextInput::create()
2584                .with_text("ab".into())
2585                .with_on_text_input(probe.clone(), record_text_input as TextInputOnTextInputCallbackType),
2586        );
2587
2588        let (update, _, _) = run(Env::new(styled_dom).insert("c"), |info| {
2589            default_on_text_input(state.clone(), info)
2590        });
2591
2592        assert_eq!(update, Update::RefreshDom, "the hook's Update was swallowed");
2593        let seen = recorded(&probe);
2594        assert_eq!(seen.len(), 1);
2595        assert_eq!(seen[0].get_text(), "abc", "the hook was shown the pre-edit buffer");
2596        assert_eq!(seen[0].cursor_pos, 3);
2597    }
2598
2599    #[test]
2600    fn text_input_rejected_by_the_hook_leaves_the_buffer_and_the_screen_untouched() {
2601        let probe = recorder(Update::RefreshDomAllWindows, TextInputValid::No);
2602        let (styled_dom, state) = rendered(
2603            TextInput::create()
2604                .with_text("ab".into())
2605                .with_placeholder("hint".into())
2606                .with_on_text_input(probe.clone(), record_text_input as TextInputOnTextInputCallbackType),
2607        );
2608
2609        let (update, changes, _) = run(Env::new(styled_dom).insert("c"), |info| {
2610            default_on_text_input(state.clone(), info)
2611        });
2612
2613        assert_eq!(update, Update::RefreshDomAllWindows, "a rejected edit still reports its Update");
2614        assert_eq!(state_of(&state).get_text(), "ab", "a rejected edit was applied anyway");
2615        assert_eq!(state_of(&state).cursor_pos, 2, "a rejected edit still moved the cursor");
2616        assert!(
2617            changes.is_empty(),
2618            "a rejected edit still repainted the widget: {changes:?}",
2619        );
2620    }
2621
2622    #[test]
2623    fn text_input_advances_the_cursor_by_utf8_byte_length_not_by_scalar_count() {
2624        // KNOWN GAP: the cursor is advanced by `inserted_text.len()` — the *byte*
2625        // length of the insertion — while the buffer grows by one unit per scalar.
2626        // For anything outside ASCII the two disagree, and the cursor ends up past
2627        // the end of the buffer it indexes into.
2628        let (styled_dom, state) = rendered(TextInput::create());
2629        let (_, _, _) = run(Env::new(styled_dom).insert("é"), |info| {
2630            default_on_text_input(state.clone(), info)
2631        });
2632
2633        let after = state_of(&state);
2634        assert_eq!(after.get_text(), "é");
2635        assert_eq!(after.text.len(), 1, "the buffer holds one scalar");
2636        assert_eq!(after.cursor_pos, 2, "but the cursor moved by the two UTF-8 bytes");
2637        assert!(
2638            after.cursor_pos > after.text.len(),
2639            "the cursor is expected to overshoot here; see the KNOWN GAP above",
2640        );
2641    }
2642
2643    #[test]
2644    fn text_input_saturates_the_cursor_instead_of_overflowing_it() {
2645        let (styled_dom, state) = rendered(TextInput::create());
2646        poke(&state, |w| w.inner.cursor_pos = usize::MAX);
2647
2648        let (update, _, _) = run(Env::new(styled_dom).insert("abc"), |info| {
2649            default_on_text_input(state.clone(), info)
2650        });
2651
2652        assert_eq!(update, Update::DoNothing);
2653        assert_eq!(
2654            state_of(&state).cursor_pos,
2655            usize::MAX,
2656            "the cursor wrapped around instead of saturating",
2657        );
2658        assert_eq!(state_of(&state).get_text(), "abc");
2659    }
2660
2661    #[test]
2662    fn text_input_accepts_astral_combining_and_multi_scalar_insertions() {
2663        for s in ["\u{10ffff}", "e\u{301}", "👨‍👩‍👧‍👦", "日本語", "\0"] {
2664            let (styled_dom, state) = rendered(TextInput::create());
2665            let (_, changes, nodes) = run(Env::new(styled_dom).insert(s), |info| {
2666                default_on_text_input(state.clone(), info)
2667            });
2668            assert_eq!(state_of(&state).get_text(), s, "the buffer mangled {s:?}");
2669            assert_eq!(state_of(&state).text.len(), s.chars().count());
2670            assert_eq!(
2671                pushed_texts(&changes),
2672                vec![(nodes.label.expect("no label"), s.to_string())],
2673            );
2674        }
2675    }
2676
2677    #[test]
2678    fn text_input_on_a_wrong_shaped_subtree_changes_nothing() {
2679        // Hitting the label means `first child -> next sibling` walks off the end of
2680        // the tree. The handler has to give up *before* mutating the buffer, or the
2681        // model and the screen would silently diverge.
2682        let (probe_dom, _) = rendered(TextInput::create());
2683        let (_, _, nodes) = run(Env::new(probe_dom), |_| ());
2684        let label = nodes.label.expect("no label");
2685
2686        let (styled_dom, state) = rendered(TextInput::create().with_text("ab".into()));
2687        let (result, changes, _) = run(Env::new(styled_dom).hit(label).insert("c"), |info| {
2688            default_on_text_input_inner(state.clone(), info)
2689        });
2690
2691        assert_eq!(result, None, "the handler claimed to have handled a malformed tree");
2692        assert!(changes.is_empty());
2693        assert_eq!(state_of(&state).get_text(), "ab", "the buffer changed anyway");
2694    }
2695
2696    #[test]
2697    fn text_input_on_a_none_hit_node_changes_nothing() {
2698        let (styled_dom, state) = rendered(TextInput::create().with_text("ab".into()));
2699        let (update, changes, _) = run(Env::new(styled_dom).hit(node_none()).insert("c"), |info| {
2700            default_on_text_input(state.clone(), info)
2701        });
2702        assert_eq!(update, Update::DoNothing);
2703        assert!(changes.is_empty());
2704        assert_eq!(state_of(&state).get_text(), "ab");
2705    }
2706
2707    #[test]
2708    fn text_input_with_a_foreign_payload_is_an_inert_no_op() {
2709        let (styled_dom, _) = rendered(TextInput::create());
2710        let foreign = RefAny::new(0_u8);
2711        let (result, changes, _) = run(Env::new(styled_dom).insert("a"), |info| {
2712            default_on_text_input_inner(foreign.clone(), info)
2713        });
2714        assert_eq!(result, None);
2715        assert!(changes.is_empty());
2716    }
2717
2718    #[test]
2719    fn text_input_ignores_max_len() {
2720        // KNOWN GAP (same root cause as `set_text_does_not_enforce_max_len`): typing
2721        // past `max_len` is accepted, one changeset at a time.
2722        let (styled_dom, state) = rendered(TextInput::create());
2723        let filler: String = "x".repeat(80);
2724        let (_, _, _) = run(Env::new(styled_dom).insert(&filler), |info| {
2725            default_on_text_input(state.clone(), info)
2726        });
2727        let after = state_of(&state);
2728        assert_eq!(after.max_len, 50);
2729        assert_eq!(after.text.len(), 80);
2730    }
2731
2732    // ==================================================================
2733    // default_on_virtual_key_down
2734    // ==================================================================
2735
2736    #[test]
2737    fn virtual_key_down_without_a_pressed_key_does_nothing() {
2738        let probe = recorder(Update::RefreshDom, TextInputValid::Yes);
2739        let (styled_dom, state) = rendered(
2740            TextInput::create().with_text("ab".into()).with_on_virtual_key_down(
2741                probe.clone(),
2742                record_virtual_key as TextInputOnVirtualKeyDownCallbackType,
2743            ),
2744        );
2745
2746        let (update, changes, _) = run(Env::new(styled_dom), |info| {
2747            default_on_virtual_key_down(state.clone(), info)
2748        });
2749
2750        assert_eq!(update, Update::DoNothing);
2751        assert!(changes.is_empty());
2752        assert!(recorded(&probe).is_empty(), "the hook fired without a key being down");
2753        assert_eq!(state_of(&state).get_text(), "ab");
2754    }
2755
2756    #[test]
2757    fn backspace_pops_one_code_unit_and_walks_the_cursor_back() {
2758        let (styled_dom, state) = rendered(TextInput::create().with_text("abc".into()));
2759        let (update, changes, nodes) =
2760            run(Env::new(styled_dom).key(VirtualKeyCode::Back), |info| {
2761                default_on_virtual_key_down(state.clone(), info)
2762            });
2763
2764        assert_eq!(update, Update::DoNothing);
2765        assert_eq!(state_of(&state).get_text(), "ab");
2766        assert_eq!(state_of(&state).cursor_pos, 2);
2767        assert_eq!(
2768            pushed_texts(&changes),
2769            vec![(nodes.label.expect("no label"), "ab".to_string())],
2770        );
2771        assert!(pushed_opacities(&changes).is_empty(), "backspace must not touch the placeholder");
2772    }
2773
2774    #[test]
2775    fn backspace_on_an_empty_buffer_saturates_at_zero_instead_of_underflowing() {
2776        let (styled_dom, state) = rendered(TextInput::create());
2777        let (update, changes, nodes) =
2778            run(Env::new(styled_dom).key(VirtualKeyCode::Back), |info| {
2779                default_on_virtual_key_down(state.clone(), info)
2780            });
2781
2782        assert_eq!(update, Update::DoNothing);
2783        assert_eq!(state_of(&state).cursor_pos, 0, "the cursor underflowed past zero");
2784        assert!(state_of(&state).text.is_empty());
2785        // The repaint is still pushed — with the (unchanged) empty text.
2786        assert_eq!(
2787            pushed_texts(&changes),
2788            vec![(nodes.label.expect("no label"), String::new())],
2789        );
2790    }
2791
2792    #[test]
2793    fn backspace_deletes_one_scalar_not_one_grapheme() {
2794        // "e" + COMBINING ACUTE renders as a single glyph but is two scalars, so one
2795        // backspace leaves a bare "e" behind rather than clearing the cell.
2796        let (styled_dom, state) = rendered(TextInput::create().with_text("e\u{301}".into()));
2797        let (_, _, _) = run(Env::new(styled_dom).key(VirtualKeyCode::Back), |info| {
2798            default_on_virtual_key_down(state.clone(), info)
2799        });
2800        assert_eq!(state_of(&state).get_text(), "e");
2801        assert_eq!(state_of(&state).cursor_pos, 1);
2802    }
2803
2804    #[test]
2805    fn backspace_deletes_a_whole_astral_scalar_at_once() {
2806        // The buffer is UTF-32, so a 4-byte scalar is one unit: no half-a-character
2807        // can survive the pop.
2808        let (styled_dom, state) = rendered(TextInput::create().with_text("a\u{10ffff}".into()));
2809        let (_, _, _) = run(Env::new(styled_dom).key(VirtualKeyCode::Back), |info| {
2810            default_on_virtual_key_down(state.clone(), info)
2811        });
2812        assert_eq!(state_of(&state).get_text(), "a");
2813        assert_eq!(state_of(&state).text.as_slice(), &[0x61]);
2814    }
2815
2816    #[test]
2817    fn a_non_backspace_key_still_reaches_the_hook_but_leaves_the_buffer_alone() {
2818        let probe = recorder(Update::RefreshDom, TextInputValid::Yes);
2819        let (styled_dom, state) = rendered(
2820            TextInput::create().with_text("ab".into()).with_on_virtual_key_down(
2821                probe.clone(),
2822                record_virtual_key as TextInputOnVirtualKeyDownCallbackType,
2823            ),
2824        );
2825
2826        let (update, changes, _) = run(Env::new(styled_dom).key(VirtualKeyCode::A), |info| {
2827            default_on_virtual_key_down(state.clone(), info)
2828        });
2829
2830        assert_eq!(update, Update::RefreshDom);
2831        assert!(changes.is_empty(), "a plain key press repainted the label: {changes:?}");
2832        assert_eq!(state_of(&state).get_text(), "ab");
2833        assert_eq!(recorded(&probe).len(), 1, "the hook must see every key, not just backspace");
2834    }
2835
2836    #[test]
2837    fn a_rejecting_hook_suppresses_the_deletion_but_keeps_its_update() {
2838        let probe = recorder(Update::RefreshDomAllWindows, TextInputValid::No);
2839        let (styled_dom, state) = rendered(
2840            TextInput::create().with_text("abc".into()).with_on_virtual_key_down(
2841                probe.clone(),
2842                record_virtual_key as TextInputOnVirtualKeyDownCallbackType,
2843            ),
2844        );
2845
2846        let (update, changes, _) = run(Env::new(styled_dom).key(VirtualKeyCode::Back), |info| {
2847            default_on_virtual_key_down(state.clone(), info)
2848        });
2849
2850        assert_eq!(update, Update::RefreshDomAllWindows);
2851        assert!(changes.is_empty(), "a vetoed backspace still repainted: {changes:?}");
2852        assert_eq!(state_of(&state).get_text(), "abc", "a vetoed backspace deleted anyway");
2853        assert_eq!(state_of(&state).cursor_pos, 3);
2854    }
2855
2856    #[test]
2857    fn the_virtual_key_hook_is_shown_the_state_from_before_the_deletion() {
2858        // The hook runs first so that it can veto; that means it necessarily sees the
2859        // pre-delete buffer. Pinned because "which side of the edit does the hook
2860        // see" is exactly the thing a refactor gets wrong.
2861        let probe = recorder(Update::DoNothing, TextInputValid::Yes);
2862        let (styled_dom, state) = rendered(
2863            TextInput::create().with_text("abc".into()).with_on_virtual_key_down(
2864                probe.clone(),
2865                record_virtual_key as TextInputOnVirtualKeyDownCallbackType,
2866            ),
2867        );
2868
2869        let (_, _, _) = run(Env::new(styled_dom).key(VirtualKeyCode::Back), |info| {
2870            default_on_virtual_key_down(state.clone(), info)
2871        });
2872
2873        let seen = recorded(&probe);
2874        assert_eq!(seen.len(), 1);
2875        assert_eq!(seen[0].get_text(), "abc");
2876        assert_eq!(seen[0].cursor_pos, 3);
2877        assert_eq!(state_of(&state).get_text(), "ab", "... and the deletion still happened");
2878    }
2879
2880    #[test]
2881    fn virtual_key_down_on_a_none_hit_node_skips_the_hook_entirely() {
2882        let probe = recorder(Update::RefreshDom, TextInputValid::Yes);
2883        let (styled_dom, state) = rendered(
2884            TextInput::create().with_text("abc".into()).with_on_virtual_key_down(
2885                probe.clone(),
2886                record_virtual_key as TextInputOnVirtualKeyDownCallbackType,
2887            ),
2888        );
2889
2890        let (result, changes, _) = run(
2891            Env::new(styled_dom).hit(node_none()).key(VirtualKeyCode::Back),
2892            |info| default_on_virtual_key_down_inner(state.clone(), info),
2893        );
2894
2895        assert_eq!(result, None);
2896        assert!(changes.is_empty());
2897        assert!(recorded(&probe).is_empty(), "the hook fired on a hit node that does not exist");
2898        assert_eq!(state_of(&state).get_text(), "abc");
2899    }
2900
2901    #[test]
2902    fn virtual_key_down_with_a_foreign_payload_is_an_inert_no_op() {
2903        let (styled_dom, _) = rendered(TextInput::create());
2904        let foreign = RefAny::new(vec![1_u32, 2, 3]);
2905        let (update, changes, _) = run(Env::new(styled_dom).key(VirtualKeyCode::Back), |info| {
2906            default_on_virtual_key_down(foreign.clone(), info)
2907        });
2908        assert_eq!(update, Update::DoNothing);
2909        assert!(changes.is_empty());
2910    }
2911
2912    #[test]
2913    fn repeated_backspaces_drain_the_buffer_and_then_stop() {
2914        // Six backspaces over a three-scalar buffer: the last three must be harmless.
2915        let (_, state) = rendered(TextInput::create().with_text("abc".into()));
2916        for i in 0..6 {
2917            // The tree shape does not depend on what the buffer holds, so a fresh
2918            // container is enough to navigate; the live state is `state`.
2919            let (styled_dom, _) = rendered(TextInput::create());
2920            let (update, _, _) = run(Env::new(styled_dom).key(VirtualKeyCode::Back), |info| {
2921                default_on_virtual_key_down(state.clone(), info)
2922            });
2923            assert_eq!(update, Update::DoNothing, "backspace #{i} reported work to do");
2924        }
2925        let after = state_of(&state);
2926        assert!(after.text.is_empty());
2927        assert_eq!(after.cursor_pos, 0);
2928    }
2929
2930    // ==================================================================
2931    // default_on_mouse_hover
2932    // ==================================================================
2933
2934    #[test]
2935    fn mouse_hover_is_inert_for_every_payload_and_every_hit_node() {
2936        let (styled_dom, state) = rendered(TextInput::create().with_text("abc".into()));
2937        let (update, changes, _) =
2938            run(Env::new(styled_dom), |info| default_on_mouse_hover(state.clone(), info));
2939        assert_eq!(update, Update::DoNothing);
2940        assert!(changes.is_empty());
2941        assert_eq!(state_of(&state).get_text(), "abc", "hovering edited the buffer");
2942
2943        let (styled_dom, _) = rendered(TextInput::create());
2944        let foreign = RefAny::new(0_u8);
2945        let (update, changes, _) = run(Env::new(styled_dom).hit(node_none()), |info| {
2946            default_on_mouse_hover(foreign.clone(), info)
2947        });
2948        assert_eq!(update, Update::DoNothing);
2949        assert!(changes.is_empty());
2950    }
2951}