1
//! Multi-line text input (text area) widget.
2
//!
3
//! A multi-line sibling of [`crate::widgets::text_input::TextInput`], built on
4
//! the same flow: the container is a `contenteditable` host carrying the tab
5
//! index and the focus callbacks, so the engine's `TextEditManager` owns the
6
//! caret, the selection and the buffer, and edits run through
7
//! `record_text_input` / `apply_text_changeset`. Caret and selection are
8
//! display-list items driven by that manager; the widget emits no cursor node.
9
//!
10
//! Value and placeholder are `<p>` blocks wrapping a bare text node — a
11
//! [`NodeType::Text`](azul_core::dom::NodeType::Text) node is always
12
//! inline-level and owns no rect, so box-model properties on one are inert.
13
//! Line wrapping relies on the text layout honouring `white-space: pre-wrap`.
14
//!
15
//! [`TextAreaState`] is a *mirror* of the engine's state, refreshed from its
16
//! changesets so the public callbacks keep the shape existing hosts bind
17
//! against. The widget reuses [`TextInput`]'s [`OnTextInputReturn`] /
18
//! [`TextInputValid`] return types for its `on_text_input` callback so existing
19
//! host bindings and validation logic apply unchanged; a `TextInputValid::No`
20
//! answer turns into `CallbackInfo::prevent_default`, which stops the engine
21
//! from applying the edit it recorded.
22
//!
23
//! KNOWN GAP: caret-relative *deletion* (Backspace/Delete) and Enter are engine
24
//! default actions and the mirror cannot see their result, because the engine
25
//! exposes no post-apply text for a node whose value sits under a block
26
//! wrapper. Enter in a contenteditable host records a structural block split
27
//! rather than inserting a `'\n'` into the buffer, so a multi-paragraph value
28
//! no longer round-trips through [`TextAreaState::get_text`] as newlines.
29
//!
30
//! Key types: [`TextArea`], [`TextAreaState`], [`TextAreaOnTextInput`],
31
//! [`TextAreaOnVirtualKeyDown`], [`TextAreaOnFocusLost`].
32

            
33
use alloc::{string::String, vec::Vec};
34

            
35
use azul_core::{
36
    callbacks::{CoreCallback, CoreCallbackData, Update},
37
    dom::{Dom, DomNodeId},
38
    refany::RefAny,
39
};
40
use azul_css::{
41
    dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec},
42
    props::{basic::{ColorU, StyleFontFamily, StyleFontFamilyVec, StyleFontSize}, layout::{LayoutPosition, LayoutBoxSizing, LayoutFlexGrow, LayoutMinHeight, LayoutPaddingLeft, LayoutPaddingRight, LayoutPaddingTop, LayoutPaddingBottom, LayoutOverflow, LayoutDisplay, LayoutTop, LayoutLeft}, property::{CssProperty, StyleWhiteSpaceValue}, style::{StyleBackgroundContent, StyleBackgroundContentVec, StyleOpacity, StyleCursor, StyleTextColor, LayoutBorderTopWidth, LayoutBorderBottomWidth, LayoutBorderLeftWidth, LayoutBorderRightWidth, StyleBorderTopStyle, BorderStyle, StyleBorderBottomStyle, StyleBorderLeftStyle, StyleBorderRightStyle, StyleBorderTopColor, StyleBorderBottomColor, StyleBorderLeftColor, StyleBorderRightColor, StyleTextAlign, StyleWhiteSpace}},
43
    impl_option_inner, AzString, U32Vec, OptionString,
44
};
45

            
46
use crate::callbacks::{Callback, CallbackInfo};
47
use crate::widgets::text_input::{OnTextInputReturn, TextInputValid};
48

            
49
// ---- colours ----
50
const BACKGROUND_COLOR: ColorU = ColorU {
51
    r: 255,
52
    g: 255,
53
    b: 255,
54
    a: 255,
55
}; // white
56
const COLOR_9B9B9B: ColorU = ColorU {
57
    r: 155,
58
    g: 155,
59
    b: 155,
60
    a: 255,
61
}; // #9b9b9b border
62
const COLOR_4286F4: ColorU = ColorU {
63
    r: 66,
64
    g: 134,
65
    b: 244,
66
    a: 255,
67
}; // #4286f4 focus/hover
68
const COLOR_4C4C4C: ColorU = ColorU {
69
    r: 76,
70
    g: 76,
71
    b: 76,
72
    a: 255,
73
}; // #4C4C4C text
74

            
75
const BACKGROUND_THEME_LIGHT: &[StyleBackgroundContent] =
76
    &[StyleBackgroundContent::Color(BACKGROUND_COLOR)];
77
const BACKGROUND_COLOR_LIGHT: StyleBackgroundContentVec =
78
    StyleBackgroundContentVec::from_const_slice(BACKGROUND_THEME_LIGHT);
79

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

            
86
/// Minimum height of the editable area (~4 lines).
87
const MIN_HEIGHT_PX: isize = 64;
88

            
89
// -- container style (cross-platform single style) --
90
static TEXT_AREA_CONTAINER_PROPS: &[CssPropertyWithConditions] = &[
91
    CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Relative)),
92
    CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Text)),
93
    CssPropertyWithConditions::simple(CssProperty::const_box_sizing(LayoutBoxSizing::BorderBox)),
94
    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1))),
95
    CssPropertyWithConditions::simple(CssProperty::const_min_height(LayoutMinHeight::const_px(
96
        MIN_HEIGHT_PX,
97
    ))),
98
    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(13))),
99
    CssPropertyWithConditions::simple(CssProperty::const_background_content(BACKGROUND_COLOR_LIGHT)),
100
    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
101
        inner: COLOR_4C4C4C,
102
    })),
103
    CssPropertyWithConditions::simple(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(
104
        4,
105
    ))),
106
    CssPropertyWithConditions::simple(CssProperty::const_padding_right(
107
        LayoutPaddingRight::const_px(4),
108
    )),
109
    CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(4))),
110
    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
111
        LayoutPaddingBottom::const_px(4),
112
    )),
113
    // border: 1px inset #9b9b9b
114
    CssPropertyWithConditions::simple(CssProperty::const_border_top_width(
115
        LayoutBorderTopWidth::const_px(1),
116
    )),
117
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(
118
        LayoutBorderBottomWidth::const_px(1),
119
    )),
120
    CssPropertyWithConditions::simple(CssProperty::const_border_left_width(
121
        LayoutBorderLeftWidth::const_px(1),
122
    )),
123
    CssPropertyWithConditions::simple(CssProperty::const_border_right_width(
124
        LayoutBorderRightWidth::const_px(1),
125
    )),
126
    CssPropertyWithConditions::simple(CssProperty::const_border_top_style(StyleBorderTopStyle {
127
        inner: BorderStyle::Inset,
128
    })),
129
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(
130
        StyleBorderBottomStyle {
131
            inner: BorderStyle::Inset,
132
        },
133
    )),
134
    CssPropertyWithConditions::simple(CssProperty::const_border_left_style(StyleBorderLeftStyle {
135
        inner: BorderStyle::Inset,
136
    })),
137
    CssPropertyWithConditions::simple(CssProperty::const_border_right_style(
138
        StyleBorderRightStyle {
139
            inner: BorderStyle::Inset,
140
        },
141
    )),
142
    CssPropertyWithConditions::simple(CssProperty::const_border_top_color(StyleBorderTopColor {
143
        inner: COLOR_9B9B9B,
144
    })),
145
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(
146
        StyleBorderBottomColor {
147
            inner: COLOR_9B9B9B,
148
        },
149
    )),
150
    CssPropertyWithConditions::simple(CssProperty::const_border_left_color(StyleBorderLeftColor {
151
        inner: COLOR_9B9B9B,
152
    })),
153
    CssPropertyWithConditions::simple(CssProperty::const_border_right_color(
154
        StyleBorderRightColor {
155
            inner: COLOR_9B9B9B,
156
        },
157
    )),
158
    CssPropertyWithConditions::simple(CssProperty::const_overflow_x(LayoutOverflow::Hidden)),
159
    CssPropertyWithConditions::simple(CssProperty::const_overflow_y(LayoutOverflow::Scroll)),
160
    CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Left)),
161
    CssPropertyWithConditions::simple(CssProperty::const_font_family(SANS_SERIF_FAMILY)),
162
    // Preserve newlines + wrap long lines.
163
    CssPropertyWithConditions::simple(CssProperty::WhiteSpace(StyleWhiteSpaceValue::Exact(
164
        StyleWhiteSpace::PreWrap,
165
    ))),
166
    // Hover / focus border highlight.
167
    CssPropertyWithConditions::on_hover(CssProperty::const_border_top_color(StyleBorderTopColor {
168
        inner: COLOR_4286F4,
169
    })),
170
    CssPropertyWithConditions::on_hover(CssProperty::const_border_bottom_color(
171
        StyleBorderBottomColor {
172
            inner: COLOR_4286F4,
173
        },
174
    )),
175
    CssPropertyWithConditions::on_hover(CssProperty::const_border_left_color(StyleBorderLeftColor {
176
        inner: COLOR_4286F4,
177
    })),
178
    CssPropertyWithConditions::on_hover(CssProperty::const_border_right_color(
179
        StyleBorderRightColor {
180
            inner: COLOR_4286F4,
181
        },
182
    )),
183
    CssPropertyWithConditions::on_focus(CssProperty::const_border_top_color(StyleBorderTopColor {
184
        inner: COLOR_4286F4,
185
    })),
186
    CssPropertyWithConditions::on_focus(CssProperty::const_border_bottom_color(
187
        StyleBorderBottomColor {
188
            inner: COLOR_4286F4,
189
        },
190
    )),
191
    CssPropertyWithConditions::on_focus(CssProperty::const_border_left_color(StyleBorderLeftColor {
192
        inner: COLOR_4286F4,
193
    })),
194
    CssPropertyWithConditions::on_focus(CssProperty::const_border_right_color(
195
        StyleBorderRightColor {
196
            inner: COLOR_4286F4,
197
        },
198
    )),
199
];
200

            
201
// -- label style (the `<p>` block wrapping the multi-line value) --
202
static TEXT_AREA_LABEL_PROPS: &[CssPropertyWithConditions] = &[
203
    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Block)),
204
    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
205
    CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Relative)),
206
    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(13))),
207
    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
208
        inner: COLOR_4C4C4C,
209
    })),
210
    CssPropertyWithConditions::simple(CssProperty::const_font_family(SANS_SERIF_FAMILY)),
211
    CssPropertyWithConditions::simple(CssProperty::WhiteSpace(StyleWhiteSpaceValue::Exact(
212
        StyleWhiteSpace::PreWrap,
213
    ))),
214
];
215

            
216
// -- placeholder style --
217
//
218
// An absolutely-positioned `<p>` overlay inside the editable container. It is
219
// marked `contenteditable="false"` so the engine's inheritance walk stops at it
220
// and the prompt never becomes part of the buffer, and it is toggled with
221
// `display` as well as `opacity`: a hidden-but-laid-out overlay would still own
222
// the container's first inline layout, which is what
223
// `LayoutWindow::reshape_text_node` picks up when it looks for the IFC to write
224
// an edit into.
225
static TEXT_AREA_PLACEHOLDER_PROPS: &[CssPropertyWithConditions] = &[
226
    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Block)),
227
    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
228
    CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Absolute)),
229
    CssPropertyWithConditions::simple(CssProperty::const_top(LayoutTop::const_px(4))),
230
    CssPropertyWithConditions::simple(CssProperty::const_left(LayoutLeft::const_px(4))),
231
    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(13))),
232
    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
233
        inner: COLOR_9B9B9B,
234
    })),
235
    CssPropertyWithConditions::simple(CssProperty::const_font_family(SANS_SERIF_FAMILY)),
236
    CssPropertyWithConditions::simple(CssProperty::const_opacity(StyleOpacity::const_new(100))),
237
];
238

            
239
/// Multi-line text input widget.
240
#[derive(Debug, Clone, PartialEq, Eq)]
241
#[repr(C)]
242
pub struct TextArea {
243
    pub text_area_state: TextAreaStateWrapper,
244
    pub placeholder_style: CssPropertyWithConditionsVec,
245
    pub container_style: CssPropertyWithConditionsVec,
246
    pub label_style: CssPropertyWithConditionsVec,
247
}
248

            
249
/// Editable state of a text area (text buffer + cursor position).
250
#[derive(Debug, Clone, PartialEq, Eq)]
251
#[repr(C)]
252
pub struct TextAreaState {
253
    /// The text buffer as `Vec<char>` (newlines included).
254
    pub text: U32Vec,
255
    pub placeholder: OptionString,
256
    pub max_len: usize,
257
    pub cursor_pos: usize,
258
}
259

            
260
/// [`TextAreaState`] together with optional user callbacks.
261
#[derive(Debug, Clone, PartialEq, Eq)]
262
#[repr(C)]
263
pub struct TextAreaStateWrapper {
264
    pub inner: TextAreaState,
265
    pub on_text_input: OptionTextAreaOnTextInput,
266
    pub on_focus_lost: OptionTextAreaOnFocusLost,
267
    pub update_text_area_before_calling_focus_lost_fn: bool,
268
    // appended at the END of the repr(C) struct for ABI stability
269
    pub on_virtual_key_down: OptionTextAreaOnVirtualKeyDown,
270
}
271

            
272
// -- callbacks --
273

            
274
/// Invoked on each text edit. Returns whether the edit is valid (reusing
275
/// [`TextInput`](crate::widgets::text_input::TextInput)'s [`OnTextInputReturn`]).
276
pub type TextAreaOnTextInputCallbackType =
277
    extern "C" fn(RefAny, CallbackInfo, TextAreaState) -> OnTextInputReturn;
278
impl_widget_callback!(
279
    TextAreaOnTextInput,
280
    OptionTextAreaOnTextInput,
281
    TextAreaOnTextInputCallback,
282
    TextAreaOnTextInputCallbackType
283
);
284

            
285
azul_core::impl_managed_callback! {
286
    wrapper:        TextAreaOnTextInputCallback,
287
    info_ty:        CallbackInfo,
288
    return_ty:      OnTextInputReturn,
289
    default_ret:    OnTextInputReturn { update: Update::DoNothing, valid: TextInputValid::Yes },
290
    invoker_static: TEXT_AREA_ON_TEXT_INPUT_INVOKER,
291
    invoker_ty:     AzTextAreaOnTextInputCallbackInvoker,
292
    thunk_fn:       az_text_area_on_text_input_callback_thunk,
293
    setter_fn:      AzApp_setTextAreaOnTextInputCallbackInvoker,
294
    from_handle_fn: AzTextAreaOnTextInputCallback_createFromHostHandle,
295
    extra_args:     [ state: TextAreaState ],
296
}
297

            
298
/// Invoked on every virtual-key press while the text area is focused (reusing
299
/// [`TextInput`](crate::widgets::text_input::TextInput)'s [`OnTextInputReturn`]).
300
pub type TextAreaOnVirtualKeyDownCallbackType =
301
    extern "C" fn(RefAny, CallbackInfo, TextAreaState) -> OnTextInputReturn;
302
impl_widget_callback!(
303
    TextAreaOnVirtualKeyDown,
304
    OptionTextAreaOnVirtualKeyDown,
305
    TextAreaOnVirtualKeyDownCallback,
306
    TextAreaOnVirtualKeyDownCallbackType
307
);
308

            
309
azul_core::impl_managed_callback! {
310
    wrapper:        TextAreaOnVirtualKeyDownCallback,
311
    info_ty:        CallbackInfo,
312
    return_ty:      OnTextInputReturn,
313
    default_ret:    OnTextInputReturn { update: Update::DoNothing, valid: TextInputValid::Yes },
314
    invoker_static: TEXT_AREA_ON_VIRTUAL_KEY_DOWN_INVOKER,
315
    invoker_ty:     AzTextAreaOnVirtualKeyDownCallbackInvoker,
316
    thunk_fn:       az_text_area_on_virtual_key_down_callback_thunk,
317
    setter_fn:      AzApp_setTextAreaOnVirtualKeyDownCallbackInvoker,
318
    from_handle_fn: AzTextAreaOnVirtualKeyDownCallback_createFromHostHandle,
319
    extra_args:     [ state: TextAreaState ],
320
}
321

            
322
/// Invoked when the text area loses focus.
323
pub type TextAreaOnFocusLostCallbackType =
324
    extern "C" fn(RefAny, CallbackInfo, TextAreaState) -> Update;
325
impl_widget_callback!(
326
    TextAreaOnFocusLost,
327
    OptionTextAreaOnFocusLost,
328
    TextAreaOnFocusLostCallback,
329
    TextAreaOnFocusLostCallbackType
330
);
331

            
332
azul_core::impl_managed_callback! {
333
    wrapper:        TextAreaOnFocusLostCallback,
334
    info_ty:        CallbackInfo,
335
    return_ty:      Update,
336
    default_ret:    Update::DoNothing,
337
    invoker_static: TEXT_AREA_ON_FOCUS_LOST_INVOKER,
338
    invoker_ty:     AzTextAreaOnFocusLostCallbackInvoker,
339
    thunk_fn:       az_text_area_on_focus_lost_callback_thunk,
340
    setter_fn:      AzApp_setTextAreaOnFocusLostCallbackInvoker,
341
    from_handle_fn: AzTextAreaOnFocusLostCallback_createFromHostHandle,
342
    extra_args:     [ state: TextAreaState ],
343
}
344

            
345
impl Default for TextAreaState {
346
464
    fn default() -> Self {
347
464
        Self {
348
464
            text: Vec::new().into(),
349
464
            placeholder: None.into(),
350
464
            max_len: 1000,
351
464
            cursor_pos: 0,
352
464
        }
353
464
    }
354
}
355

            
356
impl TextAreaState {
357
    /// Reconstructs the (multi-line) string, including `'\n'` characters.
358
167
    #[must_use] pub fn get_text(&self) -> String {
359
167
        self.text
360
167
            .iter()
361
380487
            .filter_map(|c| core::char::from_u32(*c))
362
167
            .collect()
363
167
    }
364
}
365

            
366
impl Default for TextAreaStateWrapper {
367
364
    fn default() -> Self {
368
364
        Self {
369
364
            inner: TextAreaState::default(),
370
364
            on_text_input: None.into(),
371
364
            on_focus_lost: None.into(),
372
364
            update_text_area_before_calling_focus_lost_fn: true,
373
364
            on_virtual_key_down: None.into(),
374
364
        }
375
364
    }
376
}
377

            
378
impl Default for TextArea {
379
273
    fn default() -> Self {
380
273
        Self {
381
273
            text_area_state: TextAreaStateWrapper::default(),
382
273
            placeholder_style: CssPropertyWithConditionsVec::from_const_slice(
383
273
                TEXT_AREA_PLACEHOLDER_PROPS,
384
273
            ),
385
273
            container_style: CssPropertyWithConditionsVec::from_const_slice(
386
273
                TEXT_AREA_CONTAINER_PROPS,
387
273
            ),
388
273
            label_style: CssPropertyWithConditionsVec::from_const_slice(TEXT_AREA_LABEL_PROPS),
389
273
        }
390
273
    }
391
}
392

            
393
impl TextArea {
394
262
    #[must_use] pub fn create() -> Self {
395
262
        Self::default()
396
262
    }
397

            
398
    /// Sets the (multi-line) text. Newlines in `text` are preserved.
399
    #[allow(clippy::needless_pass_by_value)] // public by-value setter; builder with_text moves the arg in
400
113
    pub fn set_text(&mut self, text: AzString) {
401
113
        self.text_area_state.inner.text = text
402
113
            .as_str()
403
113
            .chars()
404
540489
            .map(|c| c as u32)
405
113
            .collect::<Vec<_>>()
406
113
            .into();
407
113
    }
408

            
409
84
    #[must_use] pub fn with_text(mut self, text: AzString) -> Self {
410
84
        self.set_text(text);
411
84
        self
412
84
    }
413

            
414
33
    pub fn set_placeholder(&mut self, placeholder: AzString) {
415
33
        self.text_area_state.inner.placeholder = Some(placeholder).into();
416
33
    }
417

            
418
30
    #[must_use] pub fn with_placeholder(mut self, placeholder: AzString) -> Self {
419
30
        self.set_placeholder(placeholder);
420
30
        self
421
30
    }
422

            
423
6
    pub fn set_on_text_input<C: Into<TextAreaOnTextInputCallback>>(
424
6
        &mut self,
425
6
        refany: RefAny,
426
6
        callback: C,
427
6
    ) {
428
6
        self.text_area_state.on_text_input = Some(TextAreaOnTextInput {
429
6
            callback: callback.into(),
430
6
            refany,
431
6
        })
432
6
        .into();
433
6
    }
434

            
435
4
    #[must_use] pub fn with_on_text_input<C: Into<TextAreaOnTextInputCallback>>(
436
4
        mut self,
437
4
        refany: RefAny,
438
4
        callback: C,
439
4
    ) -> Self {
440
4
        self.set_on_text_input(refany, callback);
441
4
        self
442
4
    }
443

            
444
2
    pub fn set_on_virtual_key_down<C: Into<TextAreaOnVirtualKeyDownCallback>>(
445
2
        &mut self,
446
2
        refany: RefAny,
447
2
        callback: C,
448
2
    ) {
449
2
        self.text_area_state.on_virtual_key_down = Some(TextAreaOnVirtualKeyDown {
450
2
            callback: callback.into(),
451
2
            refany,
452
2
        })
453
2
        .into();
454
2
    }
455

            
456
2
    #[must_use] pub fn with_on_virtual_key_down<C: Into<TextAreaOnVirtualKeyDownCallback>>(
457
2
        mut self,
458
2
        refany: RefAny,
459
2
        callback: C,
460
2
    ) -> Self {
461
2
        self.set_on_virtual_key_down(refany, callback);
462
2
        self
463
2
    }
464

            
465
5
    pub fn set_on_focus_lost<C: Into<TextAreaOnFocusLostCallback>>(
466
5
        &mut self,
467
5
        refany: RefAny,
468
5
        callback: C,
469
5
    ) {
470
5
        self.text_area_state.on_focus_lost = Some(TextAreaOnFocusLost {
471
5
            callback: callback.into(),
472
5
            refany,
473
5
        })
474
5
        .into();
475
5
    }
476

            
477
4
    #[must_use] pub fn with_on_focus_lost<C: Into<TextAreaOnFocusLostCallback>>(
478
4
        mut self,
479
4
        refany: RefAny,
480
4
        callback: C,
481
4
    ) -> Self {
482
4
        self.set_on_focus_lost(refany, callback);
483
4
        self
484
4
    }
485

            
486
4
    pub fn set_container_style(&mut self, style: CssPropertyWithConditionsVec) {
487
4
        self.container_style = style;
488
4
    }
489

            
490
3
    #[must_use] pub fn with_container_style(mut self, style: CssPropertyWithConditionsVec) -> Self {
491
3
        self.set_container_style(style);
492
3
        self
493
3
    }
494

            
495
4
    #[must_use] pub fn swap_with_default(&mut self) -> Self {
496
4
        let mut s = Self::default();
497
4
        core::mem::swap(&mut s, self);
498
4
        s
499
4
    }
500

            
501
    /// Renders the widget.
502
    ///
503
    /// The container is the `contenteditable` host — the flag, the tab index and
504
    /// the focus callbacks all sit on it, because focus events do not bubble and
505
    /// the engine records an edit against the *focused* node. Its two children
506
    /// are `<p>` blocks wrapping a bare text node each; nothing else is emitted,
507
    /// in particular no caret node.
508
142
    #[must_use] pub fn dom(mut self) -> Dom {
509
        use azul_core::dom::{AttributeType, DomVec, EventFilter, FocusEventFilter, IdOrClass::Class, TabIndex};
510

            
511
142
        self.text_area_state.inner.cursor_pos = self.text_area_state.inner.text.len();
512

            
513
142
        let label_text: String = self
514
142
            .text_area_state
515
142
            .inner
516
142
            .text
517
142
            .iter()
518
240126
            .filter_map(|s| core::char::from_u32(*s))
519
142
            .collect();
520

            
521
142
        let placeholder = self
522
142
            .text_area_state
523
142
            .inner
524
142
            .placeholder
525
142
            .as_ref()
526
142
            .map(|s| s.as_str().to_string())
527
142
            .unwrap_or_default();
528

            
529
142
        let mut placeholder_style = self.placeholder_style;
530
142
        if !self.text_area_state.inner.text.is_empty() {
531
30
            placeholder_style = hidden_placeholder_style(&placeholder_style);
532
112
        }
533

            
534
142
        let state_ref = RefAny::new(self.text_area_state);
535

            
536
142
        Dom::create_div()
537
142
            .with_ids_and_classes(vec![Class("__azul-native-text-area-container".into())].into())
538
142
            .with_css_props(self.container_style)
539
142
            .with_tab_index(TabIndex::Auto)
540
142
            .with_contenteditable(true)
541
142
            .with_dataset(Some(state_ref.clone()).into())
542
142
            .with_callbacks(
543
142
                vec![
544
142
                    CoreCallbackData {
545
142
                        event: EventFilter::Focus(FocusEventFilter::FocusReceived),
546
142
                        refany: state_ref.clone(),
547
142
                        callback: CoreCallback {
548
142
                            cb: default_on_focus_received as usize,
549
142
                            ctx: azul_core::refany::OptionRefAny::None,
550
142
                        },
551
142
                    },
552
142
                    CoreCallbackData {
553
142
                        event: EventFilter::Focus(FocusEventFilter::FocusLost),
554
142
                        refany: state_ref.clone(),
555
142
                        callback: CoreCallback {
556
142
                            cb: default_on_focus_lost as usize,
557
142
                            ctx: azul_core::refany::OptionRefAny::None,
558
142
                        },
559
142
                    },
560
142
                    CoreCallbackData {
561
142
                        event: EventFilter::Focus(FocusEventFilter::TextInput),
562
142
                        refany: state_ref.clone(),
563
142
                        callback: CoreCallback {
564
142
                            cb: default_on_text_input as usize,
565
142
                            ctx: azul_core::refany::OptionRefAny::None,
566
142
                        },
567
142
                    },
568
142
                    CoreCallbackData {
569
142
                        event: EventFilter::Focus(FocusEventFilter::VirtualKeyDown),
570
142
                        refany: state_ref,
571
142
                        callback: CoreCallback {
572
142
                            cb: default_on_virtual_key_down as usize,
573
142
                            ctx: azul_core::refany::OptionRefAny::None,
574
142
                        },
575
142
                    },
576
                ]
577
142
                .into(),
578
            )
579
142
            .with_children(
580
142
                vec![
581
142
                    Dom::create_p()
582
142
                        .with_ids_and_classes(
583
142
                            vec![Class("__azul-native-text-area-placeholder".into())].into(),
584
                        )
585
142
                        .with_css_props(placeholder_style)
586
                        // appended, never `with_attributes`: that one replaces the
587
                        // whole vector, classes included
588
142
                        .with_attribute(AttributeType::ContentEditable(false))
589
142
                        .with_children(DomVec::from_vec(vec![Dom::create_text_do_not_use_without_block_level_wrapper(placeholder)])),
590
142
                    Dom::create_p()
591
142
                        .with_ids_and_classes(
592
142
                            vec![Class("__azul-native-text-area-label".into())].into(),
593
                        )
594
142
                        .with_css_props(self.label_style)
595
142
                        .with_children(DomVec::from_vec(vec![Dom::create_text_do_not_use_without_block_level_wrapper(label_text)])),
596
                ]
597
142
                .into(),
598
            )
599
142
    }
600
}
601

            
602
/// `style` with the placeholder taken out of the flow: `display: none` on top
603
/// of `opacity: 0`, so a hidden prompt owns neither pixels nor an inline layout.
604
30
fn hidden_placeholder_style(
605
30
    style: &CssPropertyWithConditionsVec,
606
30
) -> CssPropertyWithConditionsVec {
607
30
    let mut props = style.as_ref().to_vec();
608
30
    props.push(CssPropertyWithConditions::simple(CssProperty::const_display(
609
30
        LayoutDisplay::None,
610
    )));
611
30
    props.push(CssPropertyWithConditions::simple(CssProperty::const_opacity(
612
30
        StyleOpacity::const_new(0),
613
    )));
614
30
    CssPropertyWithConditionsVec::from_vec(props)
615
30
}
616

            
617
/// The placeholder `<p>` and the value `<p>`, in that order.
618
///
619
/// Both handlers and tests resolve them through the same hierarchy hops the
620
/// container's own layout guarantees; a subtree of any other shape yields
621
/// `None` and every handler bails out.
622
76
fn label_nodes(info: &CallbackInfo) -> Option<(DomNodeId, DomNodeId)> {
623
76
    let placeholder = info.get_first_child(info.get_hit_node())?;
624
71
    let label = info.get_next_sibling(placeholder)?;
625
70
    Some((placeholder, label))
626
76
}
627

            
628
/// Shows or hides the placeholder prompt.
629
38
fn set_placeholder_visible(info: &mut CallbackInfo, placeholder: DomNodeId, visible: bool) {
630
38
    let (display, opacity) = if visible {
631
1
        (LayoutDisplay::Block, StyleOpacity::const_new(100))
632
    } else {
633
37
        (LayoutDisplay::None, StyleOpacity::const_new(0))
634
    };
635
38
    info.set_css_property(placeholder, CssProperty::const_opacity(opacity));
636
38
    info.set_css_property(placeholder, CssProperty::const_display(display));
637
38
}
638

            
639
/// Adopts the engine's text for `node` into the widget's mirror.
640
///
641
/// The engine owns the buffer, so its answer wins — except that an empty answer
642
/// is ambiguous: `get_text_before_textinput` also yields nothing for a node
643
/// whose text sits under a block wrapper it does not descend into. An empty
644
/// read therefore never clears a non-empty mirror.
645
82
fn adopt_engine_text(state: &mut TextAreaState, info: &CallbackInfo, node: DomNodeId) {
646
82
    let Some(text) = info.get_node_text_content(node) else {
647
        return;
648
    };
649
82
    if text.is_empty() && !state.text.is_empty() {
650
44
        return;
651
38
    }
652
38
    state.text = text.chars().map(|c| c as u32).collect::<Vec<_>>().into();
653
82
}
654

            
655
/// Mirrors the insertion the engine is about to apply.
656
///
657
/// The engine inserts at the caret, so the mirror does too whenever the caret
658
/// is readable and lands on a character boundary; otherwise it appends, which
659
/// is where the caret sits for every append-only path. `cursor_pos` stays a
660
/// byte offset, as it has always been.
661
71
fn mirror_insertion(state: &mut TextAreaState, inserted: &str, caret: Option<usize>) {
662
71
    let text = state.get_text();
663
71
    let at = caret
664
71
        .filter(|at| *at <= text.len() && text.is_char_boundary(*at))
665
71
        .unwrap_or(text.len());
666

            
667
71
    let mut next = String::with_capacity(text.len() + inserted.len());
668
71
    next.push_str(&text[..at]);
669
71
    next.push_str(inserted);
670
71
    next.push_str(&text[at..]);
671

            
672
160332
    state.text = next.chars().map(|c| c as u32).collect::<Vec<_>>().into();
673
71
    state.cursor_pos = at.saturating_add(inserted.len());
674
71
}
675

            
676
/// The caret's byte offset inside the edited node, if the engine has one.
677
42
fn engine_caret(info: &CallbackInfo, node: DomNodeId) -> Option<usize> {
678
42
    info.get_node_cursor_position(node)
679
42
        .map(|c| c.cluster_id.start_byte_in_run as usize)
680
42
}
681

            
682
10
extern "C" fn default_on_focus_received(mut text_area: RefAny, mut info: CallbackInfo) -> Update {
683
10
    let Some(mut text_area) = text_area.downcast_mut::<TextAreaStateWrapper>() else {
684
1
        return Update::DoNothing;
685
    };
686

            
687
9
    let text_area = &mut *text_area;
688

            
689
9
    let Some(placeholder_text_node_id) = info.get_first_child(info.get_hit_node()) else {
690
3
        return Update::DoNothing;
691
    };
692

            
693
6
    let container = info.get_hit_node();
694
6
    adopt_engine_text(&mut text_area.inner, &info, container);
695

            
696
    // hide the placeholder text
697
6
    if text_area.inner.text.is_empty() {
698
2
        set_placeholder_visible(&mut info, placeholder_text_node_id, false);
699
4
    }
700

            
701
    // The engine seeds the caret at the end of the value when focus lands on a
702
    // contenteditable host; the mirror follows it.
703
6
    let end_of_text = text_area.inner.text.len();
704
6
    text_area.inner.cursor_pos = engine_caret(&info, container).unwrap_or(end_of_text);
705

            
706
6
    Update::DoNothing
707
10
}
708

            
709
9
extern "C" fn default_on_focus_lost(mut text_area: RefAny, mut info: CallbackInfo) -> Update {
710
9
    let Some(mut text_area) = text_area.downcast_mut::<TextAreaStateWrapper>() else {
711
1
        return Update::DoNothing;
712
    };
713

            
714
8
    let text_area = &mut *text_area;
715

            
716
8
    let Some(placeholder_text_node_id) = info.get_first_child(info.get_hit_node()) else {
717
2
        return Update::DoNothing;
718
    };
719

            
720
6
    let container = info.get_hit_node();
721
6
    adopt_engine_text(&mut text_area.inner, &info, container);
722

            
723
    // show the placeholder text
724
6
    if text_area.inner.text.is_empty() {
725
1
        set_placeholder_visible(&mut info, placeholder_text_node_id, true);
726
5
    }
727

            
728
6
    let text_area = &mut *text_area;
729
6
    let onfocuslost = &mut text_area.on_focus_lost;
730
6
    let inner = text_area.inner.clone();
731

            
732
6
    match onfocuslost.as_mut() {
733
2
        Some(TextAreaOnFocusLost { callback, refany }) => (callback.cb)(refany.clone(), info, inner),
734
4
        None => Update::DoNothing,
735
    }
736
9
}
737

            
738
1
extern "C" fn default_on_text_input(text_area: RefAny, info: CallbackInfo) -> Update {
739
1
    default_on_text_input_inner(text_area, info).unwrap_or(Update::DoNothing)
740
1
}
741

            
742
43
fn default_on_text_input_inner(mut text_area: RefAny, mut info: CallbackInfo) -> Option<Update> {
743
43
    let mut text_area = text_area.downcast_mut::<TextAreaStateWrapper>()?;
744

            
745
    // The engine records the edit before the callbacks run and applies it after
746
    // them; this handler only observes it and mirrors it into the widget state.
747
    // An `Input` WITHOUT a pending record is a post-edit NOTIFICATION: an edit
748
    // committed outside the record pipeline (deletion, the Enter line break,
749
    // programmatic edit) that is already applied — adopt it and inform the
750
    // user hook; `valid` cannot veto what already happened.
751
42
    let inserted_text = info
752
42
        .get_text_changeset()
753
42
        .map(|c| c.inserted_text.as_str().to_string())
754
42
        .unwrap_or_default();
755

            
756
42
    let (placeholder_node_id, _label_node_id) = label_nodes(&info)?;
757
39
    let container = info.get_hit_node();
758

            
759
39
    if inserted_text.is_empty() {
760
        // Idempotent: a notification that changed nothing observable stays a
761
        // strict no-op, so the no-changeset pins keep holding.
762
3
        let before = text_area.inner.get_text();
763
3
        adopt_engine_text(&mut text_area.inner, &info, container);
764
3
        if text_area.inner.get_text() == before {
765
3
            return None;
766
        }
767
        let empty = text_area.inner.get_text().is_empty();
768
        set_placeholder_visible(&mut info, placeholder_node_id, empty);
769
        let result = {
770
            let text_area = &mut *text_area;
771
            let inner_clone = text_area.inner.clone();
772
            match text_area.on_text_input.as_mut() {
773
                Some(TextAreaOnTextInput { callback, refany }) => {
774
                    (callback.cb)(refany.clone(), info, inner_clone)
775
                }
776
                None => OnTextInputReturn {
777
                    update: Update::DoNothing,
778
                    valid: TextInputValid::Yes,
779
                },
780
            }
781
        };
782
        return Some(result.update);
783
36
    }
784

            
785
36
    let caret = engine_caret(&info, container);
786
36
    adopt_engine_text(&mut text_area.inner, &info, container);
787

            
788
36
    let result = {
789
36
        let text_area = &mut *text_area;
790
36
        let ontextinput = &mut text_area.on_text_input;
791

            
792
        // inner_clone has the new (would-be) text
793
36
        let mut inner_clone = text_area.inner.clone();
794
36
        mirror_insertion(&mut inner_clone, &inserted_text, caret);
795

            
796
36
        match ontextinput.as_mut() {
797
2
            Some(TextAreaOnTextInput { callback, refany }) => {
798
2
                (callback.cb)(refany.clone(), info, inner_clone)
799
            }
800
34
            None => OnTextInputReturn {
801
34
                update: Update::DoNothing,
802
34
                valid: TextInputValid::Yes,
803
34
            },
804
        }
805
    };
806

            
807
36
    if result.valid == TextInputValid::Yes {
808
35
        // hide the placeholder text
809
35
        set_placeholder_visible(&mut info, placeholder_node_id, false);
810
35

            
811
35
        mirror_insertion(&mut text_area.inner, &inserted_text, caret);
812
35
    } else {
813
1
        // The engine applies the recorded changeset once the callbacks return,
814
1
        // unless one of them vetoes it.
815
1
        info.prevent_default();
816
1
    }
817

            
818
36
    Some(result.update)
819
43
}
820

            
821
1
extern "C" fn default_on_virtual_key_down(text_area: RefAny, info: CallbackInfo) -> Update {
822
1
    default_on_virtual_key_down_inner(text_area, info).unwrap_or(Update::DoNothing)
823
1
}
824

            
825
37
fn default_on_virtual_key_down_inner(
826
37
    mut text_area: RefAny,
827
37
    mut info: CallbackInfo,
828
37
) -> Option<Update> {
829
37
    let mut text_area = text_area.downcast_mut::<TextAreaStateWrapper>()?;
830
36
    let keyboard_state = info.get_current_keyboard_state();
831

            
832
36
    let _keycode = keyboard_state.current_virtual_keycode.into_option()?;
833
34
    let (_placeholder_node_id, _label_node_id) = label_nodes(&info)?;
834

            
835
31
    let container = info.get_hit_node();
836
31
    adopt_engine_text(&mut text_area.inner, &info, container);
837

            
838
    // Editing keys (Backspace, Delete, the arrows, Enter) are the engine's
839
    // default actions; this handler only forwards the key to the user's hook
840
    // and lets a rejection stop the default from running.
841
31
    let result = {
842
        // rustc doesn't understand the borrowing lifetime here
843
31
        let text_area = &mut *text_area;
844
31
        let inner_clone = text_area.inner.clone();
845
31
        match text_area.on_virtual_key_down.as_mut() {
846
4
            Some(TextAreaOnVirtualKeyDown { callback, refany }) => {
847
4
                (callback.cb)(refany.clone(), info, inner_clone)
848
            }
849
27
            None => OnTextInputReturn {
850
27
                update: Update::DoNothing,
851
27
                valid: TextInputValid::Yes,
852
27
            },
853
        }
854
    };
855

            
856
31
    if result.valid == TextInputValid::No {
857
2
        info.prevent_default();
858
29
    }
859

            
860
31
    Some(result.update)
861
37
}
862

            
863
impl From<TextArea> for Dom {
864
    fn from(t: TextArea) -> Self {
865
        t.dom()
866
    }
867
}
868

            
869
#[cfg(test)]
870
// `redundant_closure`: NOT redundant here. `run()` takes
871
// `impl FnOnce(RefAny, CallbackInfo) -> R`; `CallbackInfo` carries an elided
872
// lifetime, so the bound is higher-ranked (`for<'a> FnOnce(_, CallbackInfo<'a>)`).
873
// The handlers are `extern "C" fn` items, which do NOT satisfy a higher-ranked
874
// `FnOnce` bound — passing one bare fails to compile with E0277. The `|r, ci| f(r, ci)`
875
// wrapper is what makes the coercion happen and must stay.
876
#[allow(clippy::redundant_closure)]
877
mod autotest_generated {
878
    use std::{
879
        collections::{BTreeMap, HashMap},
880
        sync::{Arc, Mutex},
881
    };
882

            
883
    use azul_core::{
884
        dom::{
885
            AttributeType, DomId, DomNodeId, EventFilter, FocusEventFilter, NodeId, NodeType,
886
            TabIndex,
887
        },
888
        geom::{LogicalRect, OptionLogicalPosition},
889
        gl::OptionGlContextPtr,
890
        hit_test::ScrollPosition,
891
        refany::OptionRefAny,
892
        resources::RendererResources,
893
        styled_dom::{NodeHierarchyItemId, StyledDom},
894
        window::{MonitorVec, RawWindowHandle, VirtualKeyCode},
895
    };
896
    use rust_fontconfig::FcFontCache;
897

            
898
    use super::*;
899
    #[cfg(feature = "icu")]
900
    use crate::icu::IcuLocalizerHandle;
901
    use crate::{
902
        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
903
        managers::text_input::PendingTextEdit,
904
        solver3::{display_list::DisplayList, layout_tree::LayoutTree},
905
        window::{DomLayoutResult, LayoutWindow},
906
        window_state::FullWindowState,
907
    };
908

            
909
    // ==================================================================
910
    // Sample data
911
    // ==================================================================
912

            
913
    /// Strings the buffer must round-trip verbatim through `set_text` ->
914
    /// `get_text`. Every entry is a valid Rust `str`, so none of them can be
915
    /// lost to the `char::from_u32` filter in `get_text` — anything that does
916
    /// not come back is real damage, not an encoding limit.
917
    const ROUND_TRIP: [&str; 22] = [
918
        "",
919
        " ",
920
        "a",
921
        "hello",
922
        "\n",
923
        "\n\n\n",
924
        "a\nb",
925
        "a\r\nb",           // CRLF: *both* units have to survive
926
        "trailing\n",
927
        "\nleading",
928
        "\t\ttabbed",
929
        "\0",               // NUL is a perfectly good `char`
930
        "line1\nline2\nline3",
931
        "ünïcödé",
932
        "e\u{301}",         // combining acute: 2 chars, 1 grapheme
933
        "😀",               // astral plane: 1 char, 4 bytes
934
        "👩‍👩‍👧‍👦",     // ZWJ family: 7 chars, 25 bytes
935
        "🇩🇪",              // regional-indicator pair
936
        "مرحبا",            // RTL
937
        "日本語",
938
        "a\u{200b}b",       // zero-width space wedged between two letters
939
        "\u{10FFFF}",       // the largest scalar value there is
940
    ];
941

            
942
    /// `u32` values that are *not* Unicode scalar values. The buffer is a
943
    /// `U32Vec`, not a `String`, so it can hold them — `get_text` has to drop
944
    /// them rather than panic.
945
    const NON_SCALAR: [u32; 6] = [
946
        0xD800,      // lone high surrogate
947
        0xDBFF,
948
        0xDC00,      // lone low surrogate
949
        0xDFFF,
950
        0x0011_0000, // one past the last scalar value
951
        u32::MAX,
952
    ];
953

            
954
    // ==================================================================
955
    // Fixtures
956
    // ==================================================================
957

            
958
    /// A state buffer built from a `&str` exactly the way `set_text` builds it.
959
    fn buffer(text: &str) -> U32Vec {
960
        text.chars().map(|c| c as u32).collect::<Vec<_>>().into()
961
    }
962

            
963
    /// A `TextAreaStateWrapper` with no user hooks, holding `text`.
964
    fn wrapper(text: &str) -> TextAreaStateWrapper {
965
        TextAreaStateWrapper {
966
            inner: TextAreaState {
967
                text: buffer(text),
968
                ..TextAreaState::default()
969
            },
970
            ..TextAreaStateWrapper::default()
971
        }
972
    }
973

            
974
    /// The state currently stored behind a `TextAreaStateWrapper` payload.
975
    fn read(state: &RefAny) -> TextAreaState {
976
        let mut handle = state.clone();
977
        let w = handle
978
            .downcast_ref::<TextAreaStateWrapper>()
979
            .expect("the payload must still be a TextAreaStateWrapper");
980
        w.inner.clone()
981
    }
982

            
983
    /// Mutates the shared state behind a payload (the borrow is released before
984
    /// this returns, so a handler may be invoked right afterwards).
985
    fn poke(state: &RefAny, f: impl FnOnce(&mut TextAreaStateWrapper)) {
986
        let mut handle = state.clone();
987
        let mut w = handle
988
            .downcast_mut::<TextAreaStateWrapper>()
989
            .expect("the payload must still be a TextAreaStateWrapper");
990
        f(&mut w);
991
    }
992

            
993
    /// `n` properties lifted off the default container style — an easy way to
994
    /// mint pairwise-distinct style vectors without hard-coding CSS.
995
    fn style(n: usize) -> CssPropertyWithConditionsVec {
996
        let all: Vec<CssPropertyWithConditions> =
997
            TextArea::default().container_style.as_ref().to_vec();
998
        assert!(n <= all.len(), "not enough default properties to slice");
999
        CssPropertyWithConditionsVec::from_vec(all.into_iter().take(n).collect())
    }
    /// The text a node carries, looking through the `<p>` block wrapper the
    /// widget convention mandates (`p > text`).
    fn text_of(node: &Dom) -> Option<&str> {
        match node.root.get_node_type() {
            NodeType::Text(s) => Some(s.as_ref().as_str()),
            NodeType::P => match node.children.as_ref() {
                [only] => match only.root.get_node_type() {
                    NodeType::Text(s) => Some(s.as_ref().as_str()),
                    _ => None,
                },
                _ => None,
            },
            _ => None,
        }
    }
    // ---- recording hooks -------------------------------------------------
    //
    // NOTE: each hook below has a deliberately *different* body. Identical
    // function bodies can be folded onto a single symbol by the linker, and
    // these callbacks are compared by function-pointer identity.
    /// Records every `TextAreaState` an `on_text_input` / `on_virtual_key_down`
    /// hook is handed, and answers with a fixed verdict.
    struct EditLog {
        seen: Vec<TextAreaState>,
        ret: OnTextInputReturn,
    }
    /// Records every `TextAreaState` an `on_focus_lost` hook is handed.
    struct FocusLog {
        seen: Vec<TextAreaState>,
        ret: Update,
    }
    extern "C" fn record_text_input(
        mut data: RefAny,
        _: CallbackInfo,
        state: TextAreaState,
    ) -> OnTextInputReturn {
        let Some(mut log) = data.downcast_mut::<EditLog>() else {
            return OnTextInputReturn {
                update: Update::DoNothing,
                valid: TextInputValid::Yes,
            };
        };
        log.seen.push(state);
        log.ret
    }
    extern "C" fn record_virtual_key(
        mut data: RefAny,
        _: CallbackInfo,
        state: TextAreaState,
    ) -> OnTextInputReturn {
        match data.downcast_mut::<EditLog>() {
            Some(mut log) => {
                log.seen.push(state.clone());
                log.ret
            }
            None => OnTextInputReturn {
                update: Update::RefreshDom,
                valid: TextInputValid::Yes,
            },
        }
    }
    extern "C" fn record_focus_lost(
        mut data: RefAny,
        _: CallbackInfo,
        state: TextAreaState,
    ) -> Update {
        let mut update = Update::DoNothing;
        if let Some(mut log) = data.downcast_mut::<FocusLog>() {
            log.seen.push(state);
            update = log.ret;
        }
        update
    }
    fn edit_log(ret: OnTextInputReturn) -> RefAny {
        RefAny::new(EditLog {
            seen: Vec::new(),
            ret,
        })
    }
    fn focus_log(ret: Update) -> RefAny {
        RefAny::new(FocusLog {
            seen: Vec::new(),
            ret,
        })
    }
    fn edits_seen(log: &RefAny) -> Vec<TextAreaState> {
        let mut handle = log.clone();
        let l = handle
            .downcast_ref::<EditLog>()
            .expect("the payload must still be an EditLog");
        l.seen.clone()
    }
    fn focus_seen(log: &RefAny) -> Vec<TextAreaState> {
        let mut handle = log.clone();
        let l = handle
            .downcast_ref::<FocusLog>()
            .expect("the payload must still be a FocusLog");
        l.seen.clone()
    }
    const ACCEPT: OnTextInputReturn = OnTextInputReturn {
        update: Update::RefreshDom,
        valid: TextInputValid::Yes,
    };
    const REJECT: OnTextInputReturn = OnTextInputReturn {
        update: Update::RefreshDomAllWindows,
        valid: TextInputValid::No,
    };
    // ==================================================================
    // CallbackInfo harness
    // ==================================================================
    /// Flattened node indices of a `TextArea::dom()`.
    #[derive(Copy, Clone, Debug)]
    struct Nodes {
        container: usize,
        placeholder: usize,
        label: usize,
        label_text: usize,
    }
    /// Which node the event hit.
    #[derive(Copy, Clone, Debug)]
    enum Hit {
        /// `NodeHierarchyItemId::NONE` — no node was hit at all.
        Nothing,
        Container,
        Placeholder,
        /// The value's bare text leaf: it has no children, so every handler
        /// must bail out.
        TextLeaf,
    }
    /// Flattened indices of every node carrying `class`, in tree order.
    fn nodes_with_class(styled: &StyledDom, class: &str) -> Vec<usize> {
        styled
            .node_data
            .as_ref()
            .iter()
            .enumerate()
            .filter(|(_, nd)| nd.has_class(class))
            .map(|(i, _)| i)
            .collect()
    }
    /// A styled, but never laid out, `TextArea::dom()` — the handlers only walk
    /// `styled_dom.node_hierarchy`, so no real layout (and no font) is needed.
    /// The DOM here is a pure *navigation skeleton*: the state a handler edits
    /// is always the `RefAny` passed to it, never this DOM's own dataset.
    fn skeleton() -> (StyledDom, Nodes) {
        let styled = StyledDom::create_from_dom(TextArea::create().dom());
        fn one(styled: &StyledDom, class: &str) -> usize {
            let found = nodes_with_class(styled, class);
            assert_eq!(found.len(), 1, "expected exactly one `{class}` node");
            found[0]
        }
        let label = one(&styled, "__azul-native-text-area-label");
        let nodes = Nodes {
            container: one(&styled, "__azul-native-text-area-container"),
            placeholder: one(&styled, "__azul-native-text-area-placeholder"),
            label,
            label_text: first_child(&styled, label),
        };
        (styled, nodes)
    }
    /// The flattened index of `node`'s first child.
    fn first_child(styled: &StyledDom, node: usize) -> usize {
        styled
            .node_hierarchy
            .as_ref()
            .get(node)
            .and_then(|item| item.first_child_id(NodeId::new(node)))
            .expect("expected a child node")
            .index()
    }
    fn dom_node(idx: usize) -> DomNodeId {
        DomNodeId {
            dom: DomId::ROOT_ID,
            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(idx))),
        }
    }
    /// A `DomLayoutResult` with an empty layout tree and no display list.
    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
        DomLayoutResult {
            styled_dom,
            layout_tree: LayoutTree {
                nodes: Vec::new(),
                warm: Vec::new(),
                cold: Vec::new(),
                root: 0,
                dom_to_layout: BTreeMap::new(),
                children_arena: Vec::new(),
                children_offsets: Vec::new(),
                subtree_needs_intrinsic: Vec::new(),
            },
            calculated_positions: Vec::new(),
            viewport: LogicalRect::zero(),
            display_list: Arc::new(DisplayList::default()),
            scroll_ids: HashMap::new(),
            scroll_id_to_node_id: HashMap::new(),
        }
    }
    /// Everything the handlers read out of the window.
    struct Env {
        /// `false` installs a `LayoutWindow` with no layout result at all — the
        /// "callback fired before the first layout" case.
        with_dom: bool,
        changeset: Option<PendingTextEdit>,
        keycode: Option<VirtualKeyCode>,
        hit: Hit,
    }
    impl Default for Env {
        fn default() -> Self {
            Self {
                with_dom: true,
                changeset: None,
                keycode: None,
                hit: Hit::Container,
            }
        }
    }
    impl Env {
        fn typed(text: &str) -> Self {
            Self {
                changeset: Some(PendingTextEdit {
                    node: dom_node(0),
                    inserted_text: AzString::from(text),
                    old_text: AzString::from(""),
                }),
                ..Self::default()
            }
        }
        fn key(code: VirtualKeyCode) -> Self {
            Self {
                keycode: Some(code),
                ..Self::default()
            }
        }
        fn hitting(mut self, hit: Hit) -> Self {
            self.hit = hit;
            self
        }
    }
    /// Invokes `call` against a `LayoutWindow` built from `env`. Returns the
    /// handler's value, every recorded `CallbackChange`, and the node indices.
    fn run<R>(
        env: Env,
        data: &RefAny,
        call: impl FnOnce(RefAny, CallbackInfo) -> R,
    ) -> (R, Vec<CallbackChange>, Nodes) {
        let (styled, nodes) = skeleton();
        let mut layout_window =
            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
        if env.with_dom {
            layout_window
                .layout_results
                .insert(DomId::ROOT_ID, layout_result(styled));
        }
        if let Some(changeset) = env.changeset {
            layout_window.text_input_manager.set_changeset(changeset);
        }
        let renderer_resources = RendererResources::default();
        let previous_window_state: Option<FullWindowState> = None;
        let mut current_window_state = FullWindowState::default();
        current_window_state.keyboard_state.current_virtual_keycode = env.keycode.into();
        let gl_context = OptionGlContextPtr::None;
        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
            BTreeMap::new();
        let window_handle = RawWindowHandle::Unsupported;
        let system_callbacks = ExternalSystemCallbacks::rust_internal();
        let ref_data = CallbackInfoRefData {
            layout_window: &layout_window,
            renderer_resources: &renderer_resources,
            previous_window_state: &previous_window_state,
            current_window_state: &current_window_state,
            gl_context: &gl_context,
            current_scroll_manager: &scroll_states,
            current_window_handle: &window_handle,
            system_callbacks: &system_callbacks,
            system_style: Arc::new(azul_css::system::SystemStyle::default()),
            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
            #[cfg(feature = "icu")]
            icu_localizer: IcuLocalizerHandle::default(),
            ctx: OptionRefAny::None,
        };
        let hit = match env.hit {
            Hit::Nothing => DomNodeId {
                dom: DomId::ROOT_ID,
                node: NodeHierarchyItemId::NONE,
            },
            Hit::Container => dom_node(nodes.container),
            Hit::Placeholder => dom_node(nodes.placeholder),
            Hit::TextLeaf => dom_node(nodes.label_text),
        };
        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
        let info = CallbackInfo::new(
            &ref_data,
            &changes,
            hit,
            OptionLogicalPosition::None,
            OptionLogicalPosition::None,
        );
        let out = call(data.clone(), info);
        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
        (out, recorded, nodes)
    }
    /// Every opacity write in the change log, as `(node index, normalized opacity)`.
    fn opacity_writes(changes: &[CallbackChange]) -> Vec<(usize, f32)> {
        let mut out = Vec::new();
        for change in changes {
            if let CallbackChange::ChangeNodeCssProperties {
                node_id, properties, ..
            } = change
            {
                for p in properties.as_ref() {
                    if let CssProperty::Opacity(v) = p {
                        if let Some(o) = v.get_property() {
                            out.push((node_id.index(), o.inner.normalized()));
                        }
                    }
                }
            }
        }
        out
    }
    /// Every text write in the change log, as `(node index, new text)`.
    fn text_writes(changes: &[CallbackChange]) -> Vec<(usize, String)> {
        changes
            .iter()
            .filter_map(|change| match change {
                CallbackChange::ChangeNodeText { node_id, text } => Some((
                    node_id
                        .node
                        .into_crate_internal()
                        .expect("a text write always targets a real node")
                        .index(),
                    text.as_str().to_string(),
                )),
                _ => None,
            })
            .collect()
    }
    // ==================================================================
    // TextAreaState::get_text
    // ==================================================================
    #[test]
    fn get_text_on_a_default_state_is_empty() {
        let state = TextAreaState::default();
        assert_eq!(state.get_text(), "");
        assert!(state.text.is_empty());
        assert_eq!(state.cursor_pos, 0);
        assert_eq!(state.max_len, 1000);
        assert!(state.placeholder.is_none());
    }
    #[test]
    fn get_text_round_trips_every_sample_string() {
        for s in ROUND_TRIP {
            let area = TextArea::create().with_text(AzString::from(s));
            assert_eq!(
                area.text_area_state.inner.get_text(),
                s,
                "set_text -> get_text must be lossless for {s:?}"
            );
            assert_eq!(
                area.text_area_state.inner.text.len(),
                s.chars().count(),
                "the buffer counts chars, not bytes, for {s:?}"
            );
        }
    }
    #[test]
    fn get_text_drops_code_units_that_are_not_scalar_values() {
        // A `U32Vec` is not a `String`: it can hold surrogates and out-of-range
        // values. `get_text` must silently drop them, never panic.
        for unit in NON_SCALAR {
            let state = TextAreaState {
                text: vec![unit].into(),
                ..TextAreaState::default()
            };
            assert_eq!(
                state.get_text(),
                "",
                "0x{unit:X} is not a scalar value and must not reach the string"
            );
            assert_eq!(state.text.len(), 1, "the raw buffer keeps the unit");
        }
    }
    #[test]
    fn get_text_keeps_the_scalars_around_dropped_units() {
        let mut units = vec!['a' as u32];
        units.extend(NON_SCALAR);
        units.push('b' as u32);
        let state = TextAreaState {
            text: units.into(),
            ..TextAreaState::default()
        };
        assert_eq!(state.get_text(), "ab", "only the non-scalars may be dropped");
        assert_eq!(state.text.len(), NON_SCALAR.len() + 2);
    }
    #[test]
    fn get_text_accepts_the_boundary_scalars() {
        // The exact edges of the two legal ranges: 0, the last code point below
        // the surrogate block, the first above it, and the very last scalar.
        let units = vec![0x0000, 0xD7FF, 0xE000, 0x0010_FFFF];
        let state = TextAreaState {
            text: units.clone().into(),
            ..TextAreaState::default()
        };
        assert_eq!(
            state.get_text().chars().count(),
            units.len(),
            "every boundary scalar must survive"
        );
    }
    #[test]
    fn get_text_handles_a_very_large_buffer() {
        let big: String = "line 😀 ünicode\n".repeat(20_000);
        let area = TextArea::create().with_text(AzString::from(big.as_str()));
        assert_eq!(area.text_area_state.inner.text.len(), big.chars().count());
        assert_eq!(area.text_area_state.inner.get_text(), big);
    }
    // ==================================================================
    // TextArea::create
    // ==================================================================
    #[test]
    fn create_equals_default() {
        assert_eq!(TextArea::create(), TextArea::default());
    }
    #[test]
    fn create_starts_empty_with_no_hooks() {
        let area = TextArea::create();
        let s = &area.text_area_state;
        assert!(s.inner.text.is_empty());
        assert!(s.inner.placeholder.is_none());
        assert_eq!(s.inner.max_len, 1000);
        assert_eq!(s.inner.cursor_pos, 0);
        assert!(s.on_text_input.is_none());
        assert!(s.on_virtual_key_down.is_none());
        assert!(s.on_focus_lost.is_none());
        assert!(s.update_text_area_before_calling_focus_lost_fn);
    }
    #[test]
    fn create_ships_all_three_style_vectors_non_empty() {
        let area = TextArea::create();
        assert!(!area.container_style.as_ref().is_empty());
        assert!(!area.label_style.as_ref().is_empty());
        assert!(!area.placeholder_style.as_ref().is_empty());
    }
    #[test]
    fn create_is_repeatable_and_unshared() {
        // Two areas must be equal but must not alias: editing one may not be
        // visible in the other.
        let mut a = TextArea::create();
        let b = TextArea::create();
        a.set_text(AzString::from("mutated"));
        assert_ne!(a, b);
        assert_eq!(b.text_area_state.inner.get_text(), "");
    }
    // ==================================================================
    // TextArea::set_text / with_text
    // ==================================================================
    #[test]
    fn set_text_preserves_newlines() {
        let mut area = TextArea::create();
        area.set_text(AzString::from("a\nb\n\nc\n"));
        assert_eq!(area.text_area_state.inner.get_text(), "a\nb\n\nc\n");
        assert_eq!(
            area.text_area_state
                .inner
                .text
                .iter()
                .filter(|c| **c == '\n' as u32)
                .count(),
            4,
            "all four newlines have to be stored"
        );
    }
    #[test]
    fn set_text_replaces_rather_than_appends() {
        let mut area = TextArea::create();
        area.set_text(AzString::from("first"));
        area.set_text(AzString::from("second"));
        assert_eq!(area.text_area_state.inner.get_text(), "second");
        assert_eq!(area.text_area_state.inner.text.len(), 6);
    }
    #[test]
    fn set_text_with_an_empty_string_clears_the_buffer() {
        let mut area = TextArea::create().with_text(AzString::from("something"));
        area.set_text(AzString::from(""));
        assert!(area.text_area_state.inner.text.is_empty());
        assert_eq!(area.text_area_state.inner.get_text(), "");
    }
    #[test]
    fn with_text_is_exactly_set_text() {
        for s in ROUND_TRIP {
            let mut a = TextArea::create();
            a.set_text(AzString::from(s));
            let b = TextArea::create().with_text(AzString::from(s));
            assert_eq!(a, b, "the builder and the setter must agree for {s:?}");
        }
    }
    #[test]
    fn set_text_ignores_max_len() {
        // `max_len` is stored but never enforced anywhere in this widget.
        // Pinning that here so a future limit check is a deliberate change and
        // not a silent behaviour flip.
        let mut area = TextArea::create();
        area.text_area_state.inner.max_len = 3;
        area.set_text(AzString::from("far past the limit"));
        assert_eq!(area.text_area_state.inner.text.len(), 18);
        assert_eq!(area.text_area_state.inner.max_len, 3);
    }
    #[test]
    fn set_text_leaves_a_stale_cursor_behind() {
        // `set_text` does not touch `cursor_pos`, so shrinking the text can
        // leave the cursor pointing past the end. `dom()` is what repairs it.
        let mut area = TextArea::create().with_text(AzString::from("0123456789"));
        area.text_area_state.inner.cursor_pos = 10;
        area.set_text(AzString::from(""));
        assert_eq!(
            area.text_area_state.inner.cursor_pos, 10,
            "the setter deliberately leaves the cursor alone"
        );
        assert!(area.text_area_state.inner.text.is_empty());
        let dom = area.dom();
        let mut dataset = dom
            .root
            .get_dataset()
            .cloned()
            .expect("dom() must attach the state");
        let w = dataset
            .downcast_ref::<TextAreaStateWrapper>()
            .expect("the dataset must be a TextAreaStateWrapper");
        assert_eq!(w.inner.cursor_pos, 0, "dom() must repair the stale cursor");
    }
    #[test]
    fn set_text_does_not_disturb_the_other_fields() {
        let area = TextArea::create()
            .with_placeholder(AzString::from("type here"))
            .with_container_style(style(3))
            .with_text(AzString::from("body"));
        assert_eq!(
            area.text_area_state.inner.placeholder.as_ref().map(AzString::as_str),
            Some("type here")
        );
        assert_eq!(area.container_style.len(), 3);
        assert_eq!(area.text_area_state.inner.get_text(), "body");
    }
    // ==================================================================
    // TextArea::set_placeholder / with_placeholder
    // ==================================================================
    #[test]
    fn placeholder_round_trips_every_sample_string() {
        for s in ROUND_TRIP {
            let area = TextArea::create().with_placeholder(AzString::from(s));
            assert_eq!(
                area.text_area_state.inner.placeholder.as_ref().map(AzString::as_str),
                Some(s)
            );
        }
    }
    #[test]
    fn an_empty_placeholder_is_some_not_none() {
        // `Some("")` and `None` are different states: only the former means
        // "the user explicitly asked for no placeholder text".
        let area = TextArea::create().with_placeholder(AzString::from(""));
        assert!(area.text_area_state.inner.placeholder.is_some());
        assert_eq!(
            area.text_area_state.inner.placeholder.as_ref().map(AzString::as_str),
            Some("")
        );
    }
    #[test]
    fn set_placeholder_overwrites_the_previous_one() {
        let mut area = TextArea::create();
        area.set_placeholder(AzString::from("one"));
        area.set_placeholder(AzString::from("two"));
        assert_eq!(
            area.text_area_state.inner.placeholder.as_ref().map(AzString::as_str),
            Some("two")
        );
    }
    #[test]
    fn with_placeholder_is_exactly_set_placeholder() {
        let mut a = TextArea::create();
        a.set_placeholder(AzString::from("hint"));
        let b = TextArea::create().with_placeholder(AzString::from("hint"));
        assert_eq!(a, b);
    }
    #[test]
    fn set_placeholder_does_not_touch_the_text() {
        let area = TextArea::create()
            .with_text(AzString::from("body\ntext"))
            .with_placeholder(AzString::from("hint"));
        assert_eq!(area.text_area_state.inner.get_text(), "body\ntext");
        assert_eq!(area.text_area_state.inner.cursor_pos, 0);
    }
    // ==================================================================
    // TextArea::set_on_* / with_on_*
    // ==================================================================
    #[test]
    fn each_hook_setter_touches_only_its_own_slot() {
        let text_in = TextArea::create()
            .with_on_text_input(RefAny::new(1u32), record_text_input as TextAreaOnTextInputCallbackType);
        assert!(text_in.text_area_state.on_text_input.is_some());
        assert!(text_in.text_area_state.on_virtual_key_down.is_none());
        assert!(text_in.text_area_state.on_focus_lost.is_none());
        let key_down = TextArea::create().with_on_virtual_key_down(
            RefAny::new(2u32),
            record_virtual_key as TextAreaOnVirtualKeyDownCallbackType,
        );
        assert!(key_down.text_area_state.on_text_input.is_none());
        assert!(key_down.text_area_state.on_virtual_key_down.is_some());
        assert!(key_down.text_area_state.on_focus_lost.is_none());
        let focus = TextArea::create()
            .with_on_focus_lost(RefAny::new(3u32), record_focus_lost as TextAreaOnFocusLostCallbackType);
        assert!(focus.text_area_state.on_text_input.is_none());
        assert!(focus.text_area_state.on_virtual_key_down.is_none());
        assert!(focus.text_area_state.on_focus_lost.is_some());
    }
    #[test]
    fn hook_setters_keep_the_user_payload_reachable() {
        let payload = RefAny::new(0xDEAD_BEEF_u32);
        let area = TextArea::create()
            .with_on_text_input(payload.clone(), record_text_input as TextAreaOnTextInputCallbackType);
        let mut stored = area
            .text_area_state
            .on_text_input
            .as_ref()
            .expect("the hook must be stored")
            .refany
            .clone();
        assert_eq!(
            *stored.downcast_ref::<u32>().expect("payload type must survive"),
            0xDEAD_BEEF_u32
        );
    }
    #[test]
    fn setting_a_hook_twice_replaces_it_and_keeps_the_old_payload_alive() {
        let first = RefAny::new(11u32);
        let second = RefAny::new(22u32);
        let mut area = TextArea::create();
        area.set_on_text_input(first.clone(), record_text_input as TextAreaOnTextInputCallbackType);
        area.set_on_text_input(second, record_text_input as TextAreaOnTextInputCallbackType);
        let mut stored = area
            .text_area_state
            .on_text_input
            .as_ref()
            .expect("the hook must be stored")
            .refany
            .clone();
        assert_eq!(*stored.downcast_ref::<u32>().expect("payload"), 22);
        // The replaced handle must not have been freed out from under us.
        let mut first = first;
        assert_eq!(*first.downcast_ref::<u32>().expect("payload"), 11);
    }
    #[test]
    fn with_on_hooks_are_exactly_their_setters() {
        let payload = RefAny::new(7u32);
        let mut a = TextArea::create();
        a.set_on_focus_lost(payload.clone(), record_focus_lost as TextAreaOnFocusLostCallbackType);
        let b = TextArea::create()
            .with_on_focus_lost(payload, record_focus_lost as TextAreaOnFocusLostCallbackType);
        assert_eq!(a, b);
    }
    #[test]
    fn all_three_hooks_can_coexist() {
        let area = TextArea::create()
            .with_on_text_input(RefAny::new(1u32), record_text_input as TextAreaOnTextInputCallbackType)
            .with_on_virtual_key_down(
                RefAny::new(2u32),
                record_virtual_key as TextAreaOnVirtualKeyDownCallbackType,
            )
            .with_on_focus_lost(RefAny::new(3u32), record_focus_lost as TextAreaOnFocusLostCallbackType)
            .with_text(AzString::from("still here"));
        assert!(area.text_area_state.on_text_input.is_some());
        assert!(area.text_area_state.on_virtual_key_down.is_some());
        assert!(area.text_area_state.on_focus_lost.is_some());
        assert_eq!(area.text_area_state.inner.get_text(), "still here");
    }
    #[test]
    fn hook_setters_leave_a_zero_sized_payload_usable() {
        // A `RefAny` over a ZST is the degenerate case for the refcount /
        // destructor plumbing.
        struct Zst;
        let area = TextArea::create()
            .with_on_text_input(RefAny::new(Zst), record_text_input as TextAreaOnTextInputCallbackType);
        let mut stored = area
            .text_area_state
            .on_text_input
            .as_ref()
            .expect("the hook must be stored")
            .refany
            .clone();
        assert!(stored.downcast_ref::<Zst>().is_some());
        assert!(stored.downcast_ref::<u32>().is_none(), "the type tag must still discriminate");
    }
    // ==================================================================
    // TextArea::set_container_style / with_container_style
    // ==================================================================
    #[test]
    fn set_container_style_replaces_the_whole_vector() {
        let mut area = TextArea::create();
        let before = area.container_style.len();
        area.set_container_style(style(2));
        assert_eq!(area.container_style.len(), 2);
        assert_ne!(before, 2, "the fixture has to actually change something");
    }
    #[test]
    fn an_empty_container_style_is_accepted() {
        let area = TextArea::create()
            .with_container_style(CssPropertyWithConditionsVec::from_vec(Vec::new()));
        assert!(area.container_style.as_ref().is_empty());
        // ...and still produces a DOM.
        let dom = area.dom();
        assert_eq!(dom.children.as_ref().len(), 2);
    }
    #[test]
    fn container_style_does_not_leak_into_the_other_style_slots() {
        let default_label = TextArea::create().label_style;
        let default_placeholder = TextArea::create().placeholder_style;
        let area = TextArea::create().with_container_style(style(1));
        assert_eq!(area.label_style, default_label);
        assert_eq!(area.placeholder_style, default_placeholder);
    }
    // ==================================================================
    // TextArea::swap_with_default
    // ==================================================================
    #[test]
    fn swap_with_default_hands_back_the_old_value_and_resets_self() {
        let mut area = TextArea::create()
            .with_text(AzString::from("keep\nme"))
            .with_placeholder(AzString::from("hint"));
        let old = area.swap_with_default();
        assert_eq!(old.text_area_state.inner.get_text(), "keep\nme");
        assert_eq!(
            old.text_area_state.inner.placeholder.as_ref().map(AzString::as_str),
            Some("hint")
        );
        assert_eq!(area, TextArea::default(), "self must be a fresh default");
    }
    #[test]
    fn swap_with_default_twice_yields_a_default_the_second_time() {
        let mut area = TextArea::create().with_text(AzString::from("x"));
        let first = area.swap_with_default();
        let second = area.swap_with_default();
        assert_eq!(first.text_area_state.inner.get_text(), "x");
        assert_eq!(second, TextArea::default());
        assert_eq!(area, TextArea::default());
    }
    #[test]
    fn swap_with_default_carries_the_hooks_out_with_it() {
        let payload = RefAny::new(99u32);
        let mut area = TextArea::create()
            .with_on_focus_lost(payload, record_focus_lost as TextAreaOnFocusLostCallbackType);
        let old = area.swap_with_default();
        assert!(old.text_area_state.on_focus_lost.is_some());
        assert!(area.text_area_state.on_focus_lost.is_none());
        let mut stored = old
            .text_area_state
            .on_focus_lost
            .as_ref()
            .expect("hook")
            .refany
            .clone();
        assert_eq!(*stored.downcast_ref::<u32>().expect("payload"), 99);
    }
    // ==================================================================
    // TextArea::dom
    // ==================================================================
    #[test]
    fn dom_has_the_shape_the_handlers_navigate() {
        let dom = TextArea::create().dom();
        let children = dom.children.as_ref();
        assert_eq!(children.len(), 2, "a text area is exactly [placeholder, label]");
        assert!(dom.root.has_class("__azul-native-text-area-container"));
        assert!(children[0].root.has_class("__azul-native-text-area-placeholder"));
        assert!(children[1].root.has_class("__azul-native-text-area-label"));
        for block in children {
            assert!(matches!(block.root.get_node_type(), NodeType::P));
            assert_eq!(block.children.as_ref().len(), 1, "a label wraps one text node");
            let leaf = &block.children.as_ref()[0];
            assert!(matches!(leaf.root.get_node_type(), NodeType::Text(_)));
            assert!(leaf.children.as_ref().is_empty(), "the text node is a leaf");
        }
    }
    #[test]
    fn dom_emits_no_cursor_node() {
        // The caret and the selection are display-list items driven by the
        // engine's TextEditManager; a widget-owned cursor div resolved against
        // the container and never tracked the caret.
        let styled = StyledDom::create_from_dom(TextArea::create().dom());
        assert!(nodes_with_class(&styled, "__azul-native-text-area-cursor").is_empty());
    }
    #[test]
    fn dom_carries_no_state_on_any_text_node() {
        // A NodeType::Text node is unconditionally inline-level and owns no
        // rect, so css props / callbacks / a tab index / a dataset / children on
        // one are all inert. Every text node must be a bare leaf under a <p>.
        fn walk(node: &Dom, parent_is_p: bool, bad: &mut Vec<String>) {
            if let NodeType::Text(t) = node.root.get_node_type() {
                let carries = !node.root.get_style().is_empty()
                    || !node.root.get_callbacks().as_ref().is_empty()
                    || node.root.get_tab_index().is_some()
                    || node.root.get_dataset().is_some()
                    || !node.children.as_ref().is_empty()
                    || !parent_is_p;
                if carries {
                    bad.push(t.as_ref().as_str().to_string());
                }
            }
            let is_p = matches!(node.root.get_node_type(), NodeType::P);
            for c in node.children.as_ref() {
                walk(c, is_p, bad);
            }
        }
        for area in [
            TextArea::create(),
            TextArea::create()
                .with_text(AzString::from("a\nb"))
                .with_placeholder(AzString::from("hint")),
        ] {
            let mut bad = Vec::new();
            walk(&area.dom(), false, &mut bad);
            assert!(bad.is_empty(), "text nodes carrying inert state: {bad:?}");
        }
    }
    #[test]
    fn dom_marks_the_container_as_keyboard_focusable_and_editable() {
        // Focus events do not bubble and the engine records an edit against the
        // FOCUSED node, so the tab index and the contenteditable flag have to
        // sit on the same node the handlers are attached to.
        let dom = TextArea::create().dom();
        assert_eq!(dom.root.get_tab_index(), Some(TabIndex::Auto));
        assert!(dom.root.is_contenteditable());
    }
    #[test]
    fn dom_keeps_the_placeholder_out_of_the_editable_content() {
        // Everything inside a contenteditable host is editable content unless a
        // node blocks the inheritance walk; the prompt must never be typed into.
        let dom = TextArea::create().with_placeholder(AzString::from("hint")).dom();
        let children = dom.children.as_ref();
        assert!(
            children[0]
                .root
                .attributes()
                .as_ref()
                .iter()
                .any(|a| matches!(a, AttributeType::ContentEditable(false))),
            "the placeholder is inside the editable host and does not opt out",
        );
        assert!(!children[1]
            .root
            .attributes()
            .as_ref()
            .iter()
            .any(|a| matches!(a, AttributeType::ContentEditable(_))));
    }
    #[test]
    fn dom_renders_the_text_into_the_label_and_the_placeholder_into_its_own_node() {
        let dom = TextArea::create()
            .with_text(AzString::from("body\nlines"))
            .with_placeholder(AzString::from("hint"))
            .dom();
        let children = dom.children.as_ref();
        assert_eq!(text_of(&children[0]), Some("hint"));
        assert_eq!(text_of(&children[1]), Some("body\nlines"));
    }
    #[test]
    fn dom_renders_an_empty_placeholder_node_when_none_was_set() {
        // The node must still exist: every handler navigates *through* it to
        // reach the label.
        let dom = TextArea::create().with_text(AzString::from("x")).dom();
        assert_eq!(text_of(&dom.children.as_ref()[0]), Some(""));
    }
    #[test]
    fn dom_round_trips_every_sample_string_into_the_label() {
        for s in ROUND_TRIP {
            let dom = TextArea::create().with_text(AzString::from(s)).dom();
            assert_eq!(
                text_of(&dom.children.as_ref()[1]),
                Some(s),
                "the label must render {s:?} verbatim"
            );
        }
    }
    #[test]
    fn dom_drops_non_scalar_units_from_the_label() {
        let mut area = TextArea::create().with_text(AzString::from("ab"));
        let mut units = area.text_area_state.inner.text.clone().into_library_owned_vec();
        units.extend(NON_SCALAR);
        area.text_area_state.inner.text = units.into();
        let dom = area.dom();
        assert_eq!(
            text_of(&dom.children.as_ref()[1]),
            Some("ab"),
            "the rendered label may only contain real scalars"
        );
    }
    #[test]
    fn dom_snaps_the_cursor_to_the_end_of_the_buffer() {
        for (text, expected) in [("", 0), ("abc", 3), ("😀😀", 2), ("a\nb", 3)] {
            let mut area = TextArea::create().with_text(AzString::from(text));
            area.text_area_state.inner.cursor_pos = usize::MAX;
            let dom = area.dom();
            let mut dataset = dom.root.get_dataset().cloned().expect("dataset");
            let w = dataset
                .downcast_ref::<TextAreaStateWrapper>()
                .expect("the dataset must be a TextAreaStateWrapper");
            assert_eq!(
                w.inner.cursor_pos, expected,
                "dom() must clamp the cursor to the buffer for {text:?}"
            );
        }
    }
    #[test]
    fn dom_wires_up_all_four_focus_callbacks() {
        let dom = TextArea::create().dom();
        let callbacks = dom.root.get_callbacks();
        assert_eq!(callbacks.len(), 4);
        let expected = [
            (
                EventFilter::Focus(FocusEventFilter::FocusReceived),
                default_on_focus_received as usize,
            ),
            (
                EventFilter::Focus(FocusEventFilter::FocusLost),
                default_on_focus_lost as usize,
            ),
            (
                EventFilter::Focus(FocusEventFilter::TextInput),
                default_on_text_input as usize,
            ),
            (
                EventFilter::Focus(FocusEventFilter::VirtualKeyDown),
                default_on_virtual_key_down as usize,
            ),
        ];
        for (cd, (event, cb)) in callbacks.as_ref().iter().zip(expected) {
            assert_eq!(cd.event, event);
            assert_eq!(cd.callback.cb, cb, "wrong handler wired to {event:?}");
        }
    }
    #[test]
    fn dom_shares_one_state_handle_between_the_dataset_and_every_callback() {
        let dom = TextArea::create().with_text(AzString::from("seed")).dom();
        let dataset = dom.root.get_dataset().cloned().expect("dataset");
        poke(&dataset, |w| w.inner.max_len = 7);
        for cd in dom.root.get_callbacks().as_ref() {
            let mut handle = cd.refany.clone();
            let w = handle
                .downcast_ref::<TextAreaStateWrapper>()
                .expect("every callback must carry the state wrapper");
            assert_eq!(
                w.inner.max_len, 7,
                "every callback must see the *same* state object as the dataset"
            );
        }
    }
    #[test]
    fn dom_survives_a_very_large_buffer() {
        let big: String = "wide 😀 line\n".repeat(20_000);
        let dom = TextArea::create().with_text(AzString::from(big.as_str())).dom();
        assert_eq!(text_of(&dom.children.as_ref()[1]), Some(big.as_str()));
    }
    #[test]
    fn styled_dom_navigation_matches_what_the_handlers_assume() {
        // Every handler walks container -> first child (placeholder) -> next
        // sibling (label). If that walk ever stops matching the DOM, all of
        // them silently no-op.
        let (styled, nodes) = skeleton();
        let hierarchy = styled.node_hierarchy.as_container();
        let placeholder = hierarchy[NodeId::new(nodes.container)]
            .first_child_id(NodeId::new(nodes.container))
            .expect("the container must have a first child");
        assert_eq!(placeholder.index(), nodes.placeholder);
        let label = hierarchy[placeholder]
            .next_sibling_id()
            .expect("the placeholder must have a next sibling");
        assert_eq!(label.index(), nodes.label);
        let leaf = hierarchy[label]
            .first_child_id(label)
            .expect("the value block must wrap a text node");
        assert_eq!(leaf.index(), nodes.label_text);
        assert!(hierarchy[leaf].first_child_id(leaf).is_none(), "the text node is a leaf");
    }
    // ==================================================================
    // default_on_focus_received
    // ==================================================================
    #[test]
    fn focus_received_ignores_a_foreign_payload() {
        let data = RefAny::new(0u8);
        let (update, changes, _) = run(Env::default(), &data, |r, ci| default_on_focus_received(r, ci));
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty(), "a foreign payload must not touch the DOM");
    }
    #[test]
    fn focus_received_bails_out_without_a_hit_node() {
        let data = RefAny::new(wrapper(""));
        poke(&data, |w| w.inner.cursor_pos = 42);
        let (update, changes, _) = run(
            Env::default().hitting(Hit::Nothing),
            &data,
            |r, ci| default_on_focus_received(r, ci),
        );
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        assert_eq!(
            read(&data).cursor_pos,
            42,
            "the early return happens *before* the cursor is repaired"
        );
    }
    #[test]
    fn focus_received_bails_out_on_a_childless_hit_node() {
        let data = RefAny::new(wrapper("text"));
        let (update, changes, _) = run(
            Env::default().hitting(Hit::TextLeaf),
            &data,
            |r, ci| default_on_focus_received(r, ci),
        );
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
    }
    #[test]
    fn focus_received_hides_the_placeholder_only_while_the_buffer_is_empty() {
        let empty = RefAny::new(wrapper(""));
        let (update, changes, nodes) = run(Env::default(), &empty, |r, ci| default_on_focus_received(r, ci));
        assert_eq!(update, Update::DoNothing);
        assert_eq!(
            opacity_writes(&changes),
            vec![(nodes.placeholder, 0.0)],
            "an empty area hides its placeholder on focus"
        );
        let filled = RefAny::new(wrapper("typed"));
        let (update, changes, _) = run(Env::default(), &filled, |r, ci| default_on_focus_received(r, ci));
        assert_eq!(update, Update::DoNothing);
        assert!(
            changes.is_empty(),
            "a non-empty area has nothing to hide — the placeholder is already gone"
        );
    }
    #[test]
    fn focus_received_repairs_a_stale_cursor() {
        for (text, expected) in [("", 0usize), ("abc", 3), ("😀 x", 3)] {
            let data = RefAny::new(wrapper(text));
            poke(&data, |w| w.inner.cursor_pos = usize::MAX);
            let (_, _, _) = run(Env::default(), &data, |r, ci| default_on_focus_received(r, ci));
            assert_eq!(
                read(&data).cursor_pos,
                expected,
                "focus must snap the cursor to the end for {text:?}"
            );
        }
    }
    #[test]
    fn focus_received_does_not_edit_the_buffer() {
        let data = RefAny::new(wrapper("untouched\ntext"));
        let (_, _, _) = run(Env::default(), &data, |r, ci| default_on_focus_received(r, ci));
        assert_eq!(read(&data).get_text(), "untouched\ntext");
    }
    // ==================================================================
    // default_on_focus_lost
    // ==================================================================
    #[test]
    fn focus_lost_ignores_a_foreign_payload() {
        let data = RefAny::new("not a text area".to_string());
        let (update, changes, _) = run(Env::default(), &data, |r, ci| default_on_focus_lost(r, ci));
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
    }
    #[test]
    fn focus_lost_shows_the_placeholder_only_while_the_buffer_is_empty() {
        let empty = RefAny::new(wrapper(""));
        let (update, changes, nodes) = run(Env::default(), &empty, |r, ci| default_on_focus_lost(r, ci));
        assert_eq!(update, Update::DoNothing);
        assert_eq!(opacity_writes(&changes), vec![(nodes.placeholder, 1.0)]);
        let filled = RefAny::new(wrapper("typed"));
        let (update, changes, _) = run(Env::default(), &filled, |r, ci| default_on_focus_lost(r, ci));
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
    }
    #[test]
    fn focus_lost_forwards_the_state_to_the_user_hook() {
        let log = focus_log(Update::RefreshDomAllWindows);
        let mut state = wrapper("saved\ntext");
        state.on_focus_lost = Some(TextAreaOnFocusLost {
            callback: (record_focus_lost as TextAreaOnFocusLostCallbackType).into(),
            refany: log.clone(),
        })
        .into();
        let data = RefAny::new(state);
        let (update, _, _) = run(Env::default(), &data, |r, ci| default_on_focus_lost(r, ci));
        assert_eq!(
            update,
            Update::RefreshDomAllWindows,
            "the hook's Update must be propagated verbatim"
        );
        let seen = focus_seen(&log);
        assert_eq!(seen.len(), 1);
        assert_eq!(seen[0].get_text(), "saved\ntext");
    }
    #[test]
    fn focus_lost_skips_the_user_hook_when_the_dom_has_no_children() {
        // The DOM walk happens *before* the hook is dispatched, so a text area
        // whose node has no children never notifies its owner. Pinned as the
        // current contract, not endorsed as ideal.
        let log = focus_log(Update::RefreshDom);
        let mut state = wrapper("x");
        state.on_focus_lost = Some(TextAreaOnFocusLost {
            callback: (record_focus_lost as TextAreaOnFocusLostCallbackType).into(),
            refany: log.clone(),
        })
        .into();
        let data = RefAny::new(state);
        let (update, changes, _) = run(
            Env::default().hitting(Hit::TextLeaf),
            &data,
            |r, ci| default_on_focus_lost(r, ci),
        );
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        assert!(focus_seen(&log).is_empty(), "the hook must not have run");
    }
    #[test]
    fn focus_lost_hands_the_hook_a_snapshot_it_cannot_write_back_through() {
        // The hook receives a *clone* of the inner state; mutating it (which the
        // signature allows, it is by value) must not reach the widget.
        let log = focus_log(Update::DoNothing);
        let mut state = wrapper("original");
        state.on_focus_lost = Some(TextAreaOnFocusLost {
            callback: (record_focus_lost as TextAreaOnFocusLostCallbackType).into(),
            refany: log.clone(),
        })
        .into();
        let data = RefAny::new(state);
        let (_, _, _) = run(Env::default(), &data, |r, ci| default_on_focus_lost(r, ci));
        let mut seen = focus_seen(&log);
        assert_eq!(seen.len(), 1);
        seen[0].text = buffer("clobbered");
        assert_eq!(read(&data).get_text(), "original");
    }
    #[test]
    fn focus_lost_without_a_hook_reports_do_nothing() {
        let data = RefAny::new(wrapper("text"));
        let (update, _, _) = run(Env::default(), &data, |r, ci| default_on_focus_lost(r, ci));
        assert_eq!(update, Update::DoNothing);
    }
    #[test]
    fn focus_lost_does_not_move_the_cursor() {
        let data = RefAny::new(wrapper("abcdef"));
        poke(&data, |w| w.inner.cursor_pos = 2);
        let (_, _, _) = run(Env::default(), &data, |r, ci| default_on_focus_lost(r, ci));
        assert_eq!(
            read(&data).cursor_pos,
            2,
            "only focus-received re-snaps the cursor"
        );
    }
    // ==================================================================
    // default_on_text_input / default_on_text_input_inner
    // ==================================================================
    #[test]
    fn text_input_without_a_changeset_does_nothing() {
        let data = RefAny::new(wrapper("abc"));
        let (out, changes, _) = run(Env::default(), &data, default_on_text_input_inner);
        assert_eq!(out, None);
        assert!(changes.is_empty());
        assert_eq!(read(&data).get_text(), "abc");
    }
    #[test]
    fn text_input_with_an_empty_insertion_does_nothing() {
        let data = RefAny::new(wrapper("abc"));
        let (out, changes, _) = run(Env::typed(""), &data, default_on_text_input_inner);
        assert_eq!(out, None, "an empty insertion is not an edit");
        assert!(changes.is_empty());
        assert_eq!(read(&data).get_text(), "abc");
    }
    #[test]
    fn text_input_ignores_a_foreign_payload() {
        let data = RefAny::new(1234u64);
        let (out, changes, _) = run(Env::typed("x"), &data, default_on_text_input_inner);
        assert_eq!(out, None);
        assert!(changes.is_empty());
    }
    #[test]
    fn text_input_bails_out_on_a_childless_hit_node() {
        let data = RefAny::new(wrapper("abc"));
        let (out, changes, _) = run(
            Env::typed("x").hitting(Hit::TextLeaf),
            &data,
            default_on_text_input_inner,
        );
        assert_eq!(out, None);
        assert!(changes.is_empty());
        assert_eq!(read(&data).get_text(), "abc", "no DOM, no edit");
    }
    #[test]
    fn text_input_bails_out_when_the_hit_node_has_no_sibling_chain() {
        // Hitting the placeholder: its own text leaf has no next sibling, so
        // the walk stops one step in.
        let data = RefAny::new(wrapper("abc"));
        let (out, changes, _) = run(
            Env::typed("x").hitting(Hit::Placeholder),
            &data,
            default_on_text_input_inner,
        );
        assert_eq!(out, None);
        assert!(changes.is_empty());
        assert_eq!(read(&data).get_text(), "abc");
    }
    #[test]
    fn text_input_mirrors_the_insertion_and_hides_the_placeholder() {
        let data = RefAny::new(wrapper("ab"));
        let (out, changes, nodes) = run(Env::typed("cd"), &data, default_on_text_input_inner);
        assert_eq!(out, Some(Update::DoNothing), "no hook means no refresh");
        assert_eq!(read(&data).get_text(), "abcd");
        assert_eq!(
            opacity_writes(&changes),
            vec![(nodes.placeholder, 0.0)],
            "typing hides the placeholder"
        );
        assert!(
            text_writes(&changes).is_empty(),
            "the widget repainted the value itself; the engine owns the buffer"
        );
    }
    #[test]
    fn text_input_preserves_embedded_newlines() {
        let data = RefAny::new(wrapper("first"));
        let (out, changes, _) = run(Env::typed("\nsecond\n"), &data, default_on_text_input_inner);
        assert_eq!(out, Some(Update::DoNothing));
        assert_eq!(read(&data).get_text(), "first\nsecond\n");
        assert!(text_writes(&changes).is_empty());
    }
    #[test]
    fn text_input_stores_pasted_unicode_by_char() {
        for s in ROUND_TRIP {
            if s.is_empty() {
                continue; // an empty insertion is a documented no-op
            }
            let data = RefAny::new(wrapper(""));
            let (out, _, _) = run(Env::typed(s), &data, default_on_text_input_inner);
            assert_eq!(out, Some(Update::DoNothing), "insertion of {s:?}");
            let state = read(&data);
            assert_eq!(state.get_text(), s, "insertion of {s:?} must be lossless");
            assert_eq!(state.text.len(), s.chars().count());
        }
    }
    #[test]
    fn text_input_advances_the_cursor_by_bytes_not_chars() {
        // KNOWN QUIRK: the buffer grows by `chars`, but `cursor_pos` is advanced
        // by `inserted_text.len()`, which is a *byte* count. For any non-ASCII
        // insertion the cursor therefore ends up past the end of the buffer.
        // `dom()` and `default_on_focus_received` both re-snap it, which is why
        // this is survivable — pinned here so the divergence is visible.
        let data = RefAny::new(wrapper(""));
        let (_, _, _) = run(Env::typed("😀"), &data, default_on_text_input_inner);
        let state = read(&data);
        assert_eq!(state.text.len(), 1, "one char went into the buffer");
        assert_eq!(state.cursor_pos, 4, "but the cursor moved by four bytes");
        assert!(
            state.cursor_pos > state.text.len(),
            "the cursor is left past the end of the buffer"
        );
        // ASCII is the case where the two counts happen to agree.
        let ascii = RefAny::new(wrapper(""));
        let (_, _, _) = run(Env::typed("abcd"), &ascii, default_on_text_input_inner);
        let ascii_state = read(&ascii);
        assert_eq!(ascii_state.cursor_pos, ascii_state.text.len());
    }
    #[test]
    fn text_input_does_not_enforce_max_len() {
        // KNOWN GAP: `max_len` is never consulted by the edit path. Typing past
        // it is accepted silently.
        let data = RefAny::new(wrapper("ab"));
        poke(&data, |w| w.inner.max_len = 2);
        let (out, _, _) = run(Env::typed("cdefgh"), &data, default_on_text_input_inner);
        assert_eq!(out, Some(Update::DoNothing));
        assert_eq!(read(&data).get_text(), "abcdefgh");
        assert_eq!(read(&data).max_len, 2, "the limit is stored, just not applied");
    }
    #[test]
    fn text_input_shows_the_hook_the_would_be_text_before_committing() {
        let log = edit_log(ACCEPT);
        let mut state = wrapper("old");
        state.on_text_input = Some(TextAreaOnTextInput {
            callback: (record_text_input as TextAreaOnTextInputCallbackType).into(),
            refany: log.clone(),
        })
        .into();
        let data = RefAny::new(state);
        let (out, _, _) = run(Env::typed("+new"), &data, default_on_text_input_inner);
        assert_eq!(out, Some(Update::RefreshDom), "the hook's Update wins");
        let seen = edits_seen(&log);
        assert_eq!(seen.len(), 1);
        assert_eq!(
            seen[0].get_text(),
            "old+new",
            "the hook is shown the text as it *would* be after the edit"
        );
        assert_eq!(read(&data).get_text(), "old+new");
    }
    #[test]
    fn text_input_rejected_by_the_hook_changes_nothing() {
        let log = edit_log(REJECT);
        let mut state = wrapper("locked");
        state.on_text_input = Some(TextAreaOnTextInput {
            callback: (record_text_input as TextAreaOnTextInputCallbackType).into(),
            refany: log.clone(),
        })
        .into();
        let data = RefAny::new(state);
        let (out, changes, _) = run(Env::typed("nope"), &data, default_on_text_input_inner);
        assert_eq!(
            out,
            Some(Update::RefreshDomAllWindows),
            "a rejected edit still returns the hook's Update"
        );
        assert_eq!(
            changes.len(),
            1,
            "a rejected edit must push nothing but the preventDefault: {changes:?}"
        );
        assert!(matches!(changes[0], CallbackChange::PreventDefault));
        let state = read(&data);
        assert_eq!(state.get_text(), "locked");
        assert_eq!(state.cursor_pos, 0, "and must not move the cursor");
        assert_eq!(edits_seen(&log).len(), 1, "the hook still ran exactly once");
    }
    #[test]
    fn text_input_accumulates_across_edits() {
        let data = RefAny::new(wrapper(""));
        for chunk in ["a", "b\n", "c"] {
            let (out, _, _) = run(Env::typed(chunk), &data, default_on_text_input_inner);
            assert_eq!(out, Some(Update::DoNothing));
        }
        let state = read(&data);
        assert_eq!(state.get_text(), "ab\nc");
        assert_eq!(state.cursor_pos, 4);
    }
    #[test]
    fn text_input_drops_non_scalar_units_the_engine_could_never_hold() {
        // The mirror is rebuilt from the *string* the engine works in, so
        // non-scalar units planted directly into the buffer do not survive an
        // edit. Nothing can render them either, so there is nothing to lose.
        let data = RefAny::new(wrapper("a"));
        poke(&data, |w| {
            let mut units = w.inner.text.clone().into_library_owned_vec();
            units.extend(NON_SCALAR);
            w.inner.text = units.into();
        });
        let (out, changes, _) = run(Env::typed("b"), &data, default_on_text_input_inner);
        assert_eq!(out, Some(Update::DoNothing));
        assert!(text_writes(&changes).is_empty());
        assert_eq!(read(&data).get_text(), "ab");
        assert_eq!(read(&data).text.len(), 2);
    }
    #[test]
    fn text_input_extern_wrapper_maps_none_onto_do_nothing() {
        let data = RefAny::new(wrapper("abc"));
        let (update, changes, _) = run(Env::default(), &data, |r, ci| default_on_text_input(r, ci));
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
    }
    #[test]
    fn text_input_survives_a_very_large_insertion() {
        let big: String = "chunk 😀\n".repeat(10_000);
        let data = RefAny::new(wrapper(""));
        let (out, changes, _) = run(Env::typed(&big), &data, default_on_text_input_inner);
        assert_eq!(out, Some(Update::DoNothing));
        assert_eq!(read(&data).text.len(), big.chars().count());
        assert_eq!(read(&data).get_text(), big);
        assert!(text_writes(&changes).is_empty());
    }
    // ==================================================================
    // default_on_virtual_key_down / default_on_virtual_key_down_inner
    // ==================================================================
    #[test]
    fn virtual_key_down_without_a_keycode_does_nothing() {
        let data = RefAny::new(wrapper("abc"));
        let (out, changes, _) = run(Env::default(), &data, default_on_virtual_key_down_inner);
        assert_eq!(out, None);
        assert!(changes.is_empty());
        assert_eq!(read(&data).get_text(), "abc");
    }
    #[test]
    fn virtual_key_down_ignores_a_foreign_payload() {
        let data = RefAny::new(0i64);
        let (out, changes, _) = run(
            Env::key(VirtualKeyCode::Back),
            &data,
            default_on_virtual_key_down_inner,
        );
        assert_eq!(out, None);
        assert!(changes.is_empty());
    }
    #[test]
    fn virtual_key_down_bails_out_on_a_childless_hit_node() {
        let log = edit_log(ACCEPT);
        let mut state = wrapper("abc");
        state.on_virtual_key_down = Some(TextAreaOnVirtualKeyDown {
            callback: (record_virtual_key as TextAreaOnVirtualKeyDownCallbackType).into(),
            refany: log.clone(),
        })
        .into();
        let data = RefAny::new(state);
        let (out, changes, _) = run(
            Env::key(VirtualKeyCode::Back).hitting(Hit::TextLeaf),
            &data,
            default_on_virtual_key_down_inner,
        );
        assert_eq!(out, None);
        assert!(changes.is_empty());
        assert_eq!(read(&data).get_text(), "abc");
        assert!(
            edits_seen(&log).is_empty(),
            "the DOM walk precedes the hook, so it never ran"
        );
    }
    #[test]
    fn no_key_edits_the_buffer_behind_the_engine() {
        // Backspace, Delete and the arrows are `SystemChange::ApplySelectionOp`
        // and Enter records a structural block split — all of them engine
        // default actions. A widget that also edited its own buffer would
        // double-apply every one of them.
        for key in [
            VirtualKeyCode::Back,
            VirtualKeyCode::Delete,
            VirtualKeyCode::Return,
            VirtualKeyCode::NumpadEnter,
            VirtualKeyCode::Left,
            VirtualKeyCode::A,
            VirtualKeyCode::Space,
            VirtualKeyCode::Tab,
            VirtualKeyCode::Escape,
        ] {
            for text in ["", "abc", "a\nb"] {
                let data = RefAny::new(wrapper(text));
                let before = read(&data);
                let (out, changes, _) =
                    run(Env::key(key), &data, default_on_virtual_key_down_inner);
                assert_eq!(out, Some(Update::DoNothing), "{key:?} on {text:?}");
                assert!(changes.is_empty(), "{key:?} on {text:?} mutated the DOM: {changes:?}");
                assert_eq!(read(&data), before, "{key:?} on {text:?} edited the mirror");
            }
        }
    }
    #[test]
    fn the_hook_runs_even_for_keys_that_do_not_edit() {
        let log = edit_log(ACCEPT);
        let mut state = wrapper("abc");
        state.on_virtual_key_down = Some(TextAreaOnVirtualKeyDown {
            callback: (record_virtual_key as TextAreaOnVirtualKeyDownCallbackType).into(),
            refany: log.clone(),
        })
        .into();
        let data = RefAny::new(state);
        let (out, changes, _) = run(
            Env::key(VirtualKeyCode::F1),
            &data,
            default_on_virtual_key_down_inner,
        );
        assert_eq!(out, Some(Update::RefreshDom), "the hook's Update is returned");
        assert!(changes.is_empty());
        let seen = edits_seen(&log);
        assert_eq!(seen.len(), 1);
        assert_eq!(seen[0].get_text(), "abc", "the hook sees the pre-edit state");
    }
    #[test]
    fn a_rejecting_hook_vetoes_the_engines_default_action() {
        for key in [VirtualKeyCode::Back, VirtualKeyCode::Return] {
            let log = edit_log(REJECT);
            let mut state = wrapper("frozen");
            state.on_virtual_key_down = Some(TextAreaOnVirtualKeyDown {
                callback: (record_virtual_key as TextAreaOnVirtualKeyDownCallbackType).into(),
                refany: log.clone(),
            })
            .into();
            let data = RefAny::new(state);
            let (out, changes, _) = run(Env::key(key), &data, default_on_virtual_key_down_inner);
            assert_eq!(out, Some(Update::RefreshDomAllWindows), "{key:?}");
            assert_eq!(changes.len(), 1, "{key:?} pushed more than the veto: {changes:?}");
            assert!(matches!(changes[0], CallbackChange::PreventDefault), "{key:?}");
            assert_eq!(read(&data).get_text(), "frozen", "{key:?} must not edit");
            assert_eq!(edits_seen(&log).len(), 1);
        }
    }
    #[test]
    fn an_accepting_hook_leaves_the_default_action_alone_and_still_sets_the_update() {
        let log = edit_log(ACCEPT);
        let mut state = wrapper("ab");
        state.on_virtual_key_down = Some(TextAreaOnVirtualKeyDown {
            callback: (record_virtual_key as TextAreaOnVirtualKeyDownCallbackType).into(),
            refany: log.clone(),
        })
        .into();
        let data = RefAny::new(state);
        let (out, changes, _) = run(
            Env::key(VirtualKeyCode::Back),
            &data,
            default_on_virtual_key_down_inner,
        );
        assert_eq!(out, Some(Update::RefreshDom));
        assert!(changes.is_empty(), "an accepted key must push nothing: {changes:?}");
        assert_eq!(read(&data).get_text(), "ab", "the engine owns the deletion");
    }
    #[test]
    fn virtual_key_down_extern_wrapper_maps_none_onto_do_nothing() {
        let data = RefAny::new(wrapper("abc"));
        let (update, changes, _) = run(Env::default(), &data, |r, ci| default_on_virtual_key_down(r, ci));
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
    }
    #[test]
    fn successive_insertions_accumulate_in_the_mirror_without_touching_the_dom() {
        // The engine repaints the value; the widget only tracks what was
        // inserted so its callbacks can hand the host a current state.
        let data = RefAny::new(wrapper(""));
        for (chunk, expected) in [
            ("hello", "hello"),
            ("\n", "hello\n"),
            ("world", "hello\nworld"),
        ] {
            let (_, changes, _) = run(Env::typed(chunk), &data, default_on_text_input_inner);
            assert!(
                text_writes(&changes).is_empty(),
                "the widget repainted the value for {chunk:?}"
            );
            assert_eq!(read(&data).get_text(), expected);
        }
        assert_eq!(read(&data).cursor_pos, "hello\nworld".len());
    }
    // ==================================================================
    // Every handler, fired before the first layout
    // ==================================================================
    /// An `Env` whose `LayoutWindow` holds no layout result at all — the state a
    /// callback sees if it is dispatched before the DOM has ever been laid out.
    fn before_first_layout() -> Env {
        Env {
            with_dom: false,
            ..Env::default()
        }
    }
    #[test]
    fn focus_received_is_inert_before_the_first_layout() {
        let data = RefAny::new(wrapper(""));
        poke(&data, |w| w.inner.cursor_pos = 5);
        let (update, changes, _) = run(before_first_layout(), &data, |r, ci| default_on_focus_received(r, ci));
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        assert_eq!(read(&data).cursor_pos, 5, "the cursor is not repaired either");
    }
    #[test]
    fn focus_lost_is_inert_before_the_first_layout() {
        let log = focus_log(Update::RefreshDom);
        let mut state = wrapper("");
        state.on_focus_lost = Some(TextAreaOnFocusLost {
            callback: (record_focus_lost as TextAreaOnFocusLostCallbackType).into(),
            refany: log.clone(),
        })
        .into();
        let data = RefAny::new(state);
        let (update, changes, _) = run(before_first_layout(), &data, |r, ci| default_on_focus_lost(r, ci));
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        assert!(focus_seen(&log).is_empty());
    }
    #[test]
    fn text_input_is_inert_before_the_first_layout() {
        let data = RefAny::new(wrapper("abc"));
        let env = Env {
            with_dom: false,
            ..Env::typed("xyz")
        };
        let (out, changes, _) = run(env, &data, default_on_text_input_inner);
        assert_eq!(out, None);
        assert!(changes.is_empty());
        assert_eq!(read(&data).get_text(), "abc", "no DOM to walk, no edit");
    }
    #[test]
    fn virtual_key_down_is_inert_before_the_first_layout() {
        for key in [VirtualKeyCode::Back, VirtualKeyCode::Return] {
            let data = RefAny::new(wrapper("abc"));
            let env = Env {
                with_dom: false,
                ..Env::key(key)
            };
            let (out, changes, _) = run(env, &data, default_on_virtual_key_down_inner);
            assert_eq!(out, None, "{key:?}");
            assert!(changes.is_empty(), "{key:?}");
            assert_eq!(read(&data).get_text(), "abc", "{key:?}");
        }
    }
}