Skip to main content

azul_layout/widgets/
text_area.rs

1//! Multi-line text input (text area) widget.
2//!
3//! A multi-line sibling of [`crate::widgets::text_input::TextInput`]: it reuses
4//! the same editable-state / cursor / focus-callback machinery but stores and
5//! renders multiple lines. Pressing Return/Enter inserts a newline; Backspace
6//! deletes the last character; typed/pasted text (including embedded newlines)
7//! is appended. The buffer is a `Vec<char>` (as `U32Vec`) exactly like
8//! `TextInput`, so the `'\n'` characters round-trip through
9//! [`TextAreaState::get_text`].
10//!
11//! The widget reuses [`TextInput`]'s [`OnTextInputReturn`] / [`TextInputValid`]
12//! return types for its `on_text_input` callback so existing host bindings and
13//! validation logic apply unchanged.
14//!
15//! TODO2: this implements the *core* of multi-line editing — multi-line value,
16//! newline insertion, append/backspace, `on_text_input` (a.k.a. on_change) and
17//! `on_focus_lost`. Advanced editing is intentionally NOT implemented and is
18//! not verifiable without a live window: the blinking cursor is a static child
19//! and does not track the caret across lines, there is no selection/range
20//! editing, no mid-buffer insertion (edits append/truncate at the end), and no
21//! vertical (up/down) caret navigation. Line wrapping relies on the text
22//! layout honouring `white-space: pre-wrap`.
23//!
24//! Key types: [`TextArea`], [`TextAreaState`], [`TextAreaOnTextInput`],
25//! [`TextAreaOnVirtualKeyDown`], [`TextAreaOnFocusLost`].
26
27use alloc::{string::String, vec::Vec};
28
29use azul_core::{
30    callbacks::{CoreCallback, CoreCallbackData, Update},
31    dom::Dom,
32    refany::RefAny,
33    window::VirtualKeyCode,
34};
35use azul_css::{
36    dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec},
37    props::{basic::{ColorU, StyleFontFamily, StyleFontFamilyVec, StyleFontSize}, layout::{LayoutPosition, LayoutWidth, LayoutHeight, 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}},
38    impl_option_inner, AzString, U32Vec, OptionString,
39};
40
41use crate::callbacks::{Callback, CallbackInfo};
42use crate::widgets::text_input::{OnTextInputReturn, TextInputValid};
43
44// ---- colours ----
45const BACKGROUND_COLOR: ColorU = ColorU {
46    r: 255,
47    g: 255,
48    b: 255,
49    a: 255,
50}; // white
51const BLACK: ColorU = ColorU { r: 0, g: 0, b: 0, a: 255 };
52const COLOR_9B9B9B: ColorU = ColorU {
53    r: 155,
54    g: 155,
55    b: 155,
56    a: 255,
57}; // #9b9b9b border
58const COLOR_4286F4: ColorU = ColorU {
59    r: 66,
60    g: 134,
61    b: 244,
62    a: 255,
63}; // #4286f4 focus/hover
64const COLOR_4C4C4C: ColorU = ColorU {
65    r: 76,
66    g: 76,
67    b: 76,
68    a: 255,
69}; // #4C4C4C text
70
71const CURSOR_COLOR_BLACK: &[StyleBackgroundContent] = &[StyleBackgroundContent::Color(BLACK)];
72const CURSOR_COLOR: StyleBackgroundContentVec =
73    StyleBackgroundContentVec::from_const_slice(CURSOR_COLOR_BLACK);
74
75const BACKGROUND_THEME_LIGHT: &[StyleBackgroundContent] =
76    &[StyleBackgroundContent::Color(BACKGROUND_COLOR)];
77const BACKGROUND_COLOR_LIGHT: StyleBackgroundContentVec =
78    StyleBackgroundContentVec::from_const_slice(BACKGROUND_THEME_LIGHT);
79
80const SANS_SERIF_STR: &str = "system:ui";
81const SANS_SERIF: AzString = AzString::from_const_str(SANS_SERIF_STR);
82const SANS_SERIF_FAMILIES: &[StyleFontFamily] = &[StyleFontFamily::System(SANS_SERIF)];
83const SANS_SERIF_FAMILY: StyleFontFamilyVec =
84    StyleFontFamilyVec::from_const_slice(SANS_SERIF_FAMILIES);
85
86/// Minimum height of the editable area (~4 lines).
87const MIN_HEIGHT_PX: isize = 64;
88
89// -- cursor style (a static child; does not track the caret — see module TODO2) --
90static TEXT_CURSOR_PROPS: &[CssPropertyWithConditions] = &[
91    CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Absolute)),
92    CssPropertyWithConditions::simple(CssProperty::const_width(LayoutWidth::const_px(1))),
93    CssPropertyWithConditions::simple(CssProperty::const_height(LayoutHeight::const_px(11))),
94    CssPropertyWithConditions::simple(CssProperty::const_background_content(CURSOR_COLOR)),
95    CssPropertyWithConditions::simple(CssProperty::const_opacity(StyleOpacity::const_new(0))),
96];
97
98// -- container style (cross-platform single style) --
99static TEXT_AREA_CONTAINER_PROPS: &[CssPropertyWithConditions] = &[
100    CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Relative)),
101    CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Text)),
102    CssPropertyWithConditions::simple(CssProperty::const_box_sizing(LayoutBoxSizing::BorderBox)),
103    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1))),
104    CssPropertyWithConditions::simple(CssProperty::const_min_height(LayoutMinHeight::const_px(
105        MIN_HEIGHT_PX,
106    ))),
107    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(13))),
108    CssPropertyWithConditions::simple(CssProperty::const_background_content(BACKGROUND_COLOR_LIGHT)),
109    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
110        inner: COLOR_4C4C4C,
111    })),
112    CssPropertyWithConditions::simple(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(
113        4,
114    ))),
115    CssPropertyWithConditions::simple(CssProperty::const_padding_right(
116        LayoutPaddingRight::const_px(4),
117    )),
118    CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(4))),
119    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
120        LayoutPaddingBottom::const_px(4),
121    )),
122    // border: 1px inset #9b9b9b
123    CssPropertyWithConditions::simple(CssProperty::const_border_top_width(
124        LayoutBorderTopWidth::const_px(1),
125    )),
126    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(
127        LayoutBorderBottomWidth::const_px(1),
128    )),
129    CssPropertyWithConditions::simple(CssProperty::const_border_left_width(
130        LayoutBorderLeftWidth::const_px(1),
131    )),
132    CssPropertyWithConditions::simple(CssProperty::const_border_right_width(
133        LayoutBorderRightWidth::const_px(1),
134    )),
135    CssPropertyWithConditions::simple(CssProperty::const_border_top_style(StyleBorderTopStyle {
136        inner: BorderStyle::Inset,
137    })),
138    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(
139        StyleBorderBottomStyle {
140            inner: BorderStyle::Inset,
141        },
142    )),
143    CssPropertyWithConditions::simple(CssProperty::const_border_left_style(StyleBorderLeftStyle {
144        inner: BorderStyle::Inset,
145    })),
146    CssPropertyWithConditions::simple(CssProperty::const_border_right_style(
147        StyleBorderRightStyle {
148            inner: BorderStyle::Inset,
149        },
150    )),
151    CssPropertyWithConditions::simple(CssProperty::const_border_top_color(StyleBorderTopColor {
152        inner: COLOR_9B9B9B,
153    })),
154    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(
155        StyleBorderBottomColor {
156            inner: COLOR_9B9B9B,
157        },
158    )),
159    CssPropertyWithConditions::simple(CssProperty::const_border_left_color(StyleBorderLeftColor {
160        inner: COLOR_9B9B9B,
161    })),
162    CssPropertyWithConditions::simple(CssProperty::const_border_right_color(
163        StyleBorderRightColor {
164            inner: COLOR_9B9B9B,
165        },
166    )),
167    CssPropertyWithConditions::simple(CssProperty::const_overflow_x(LayoutOverflow::Hidden)),
168    CssPropertyWithConditions::simple(CssProperty::const_overflow_y(LayoutOverflow::Scroll)),
169    CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Left)),
170    CssPropertyWithConditions::simple(CssProperty::const_font_family(SANS_SERIF_FAMILY)),
171    // Preserve newlines + wrap long lines.
172    CssPropertyWithConditions::simple(CssProperty::WhiteSpace(StyleWhiteSpaceValue::Exact(
173        StyleWhiteSpace::PreWrap,
174    ))),
175    // Hover / focus border highlight.
176    CssPropertyWithConditions::on_hover(CssProperty::const_border_top_color(StyleBorderTopColor {
177        inner: COLOR_4286F4,
178    })),
179    CssPropertyWithConditions::on_hover(CssProperty::const_border_bottom_color(
180        StyleBorderBottomColor {
181            inner: COLOR_4286F4,
182        },
183    )),
184    CssPropertyWithConditions::on_hover(CssProperty::const_border_left_color(StyleBorderLeftColor {
185        inner: COLOR_4286F4,
186    })),
187    CssPropertyWithConditions::on_hover(CssProperty::const_border_right_color(
188        StyleBorderRightColor {
189            inner: COLOR_4286F4,
190        },
191    )),
192    CssPropertyWithConditions::on_focus(CssProperty::const_border_top_color(StyleBorderTopColor {
193        inner: COLOR_4286F4,
194    })),
195    CssPropertyWithConditions::on_focus(CssProperty::const_border_bottom_color(
196        StyleBorderBottomColor {
197            inner: COLOR_4286F4,
198        },
199    )),
200    CssPropertyWithConditions::on_focus(CssProperty::const_border_left_color(StyleBorderLeftColor {
201        inner: COLOR_4286F4,
202    })),
203    CssPropertyWithConditions::on_focus(CssProperty::const_border_right_color(
204        StyleBorderRightColor {
205            inner: COLOR_4286F4,
206        },
207    )),
208];
209
210// -- label style (the rendered multi-line text) --
211static TEXT_AREA_LABEL_PROPS: &[CssPropertyWithConditions] = &[
212    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Block)),
213    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
214    CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Relative)),
215    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(13))),
216    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
217        inner: COLOR_4C4C4C,
218    })),
219    CssPropertyWithConditions::simple(CssProperty::const_font_family(SANS_SERIF_FAMILY)),
220    CssPropertyWithConditions::simple(CssProperty::WhiteSpace(StyleWhiteSpaceValue::Exact(
221        StyleWhiteSpace::PreWrap,
222    ))),
223];
224
225// -- placeholder style --
226static TEXT_AREA_PLACEHOLDER_PROPS: &[CssPropertyWithConditions] = &[
227    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Block)),
228    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
229    CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Absolute)),
230    CssPropertyWithConditions::simple(CssProperty::const_top(LayoutTop::const_px(4))),
231    CssPropertyWithConditions::simple(CssProperty::const_left(LayoutLeft::const_px(4))),
232    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(13))),
233    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
234        inner: COLOR_9B9B9B,
235    })),
236    CssPropertyWithConditions::simple(CssProperty::const_font_family(SANS_SERIF_FAMILY)),
237    CssPropertyWithConditions::simple(CssProperty::const_opacity(StyleOpacity::const_new(100))),
238];
239
240/// Multi-line text input widget.
241#[derive(Debug, Clone, PartialEq, Eq)]
242#[repr(C)]
243pub struct TextArea {
244    pub text_area_state: TextAreaStateWrapper,
245    pub placeholder_style: CssPropertyWithConditionsVec,
246    pub container_style: CssPropertyWithConditionsVec,
247    pub label_style: CssPropertyWithConditionsVec,
248}
249
250/// Editable state of a text area (text buffer + cursor position).
251#[derive(Debug, Clone, PartialEq, Eq)]
252#[repr(C)]
253pub struct TextAreaState {
254    /// The text buffer as `Vec<char>` (newlines included).
255    pub text: U32Vec,
256    pub placeholder: OptionString,
257    pub max_len: usize,
258    pub cursor_pos: usize,
259}
260
261/// [`TextAreaState`] together with optional user callbacks.
262#[derive(Debug, Clone, PartialEq, Eq)]
263#[repr(C)]
264pub struct TextAreaStateWrapper {
265    pub inner: TextAreaState,
266    pub on_text_input: OptionTextAreaOnTextInput,
267    pub on_focus_lost: OptionTextAreaOnFocusLost,
268    pub update_text_area_before_calling_focus_lost_fn: bool,
269    // appended at the END of the repr(C) struct for ABI stability
270    pub on_virtual_key_down: OptionTextAreaOnVirtualKeyDown,
271}
272
273// -- callbacks --
274
275/// Invoked on each text edit. Returns whether the edit is valid (reusing
276/// [`TextInput`](crate::widgets::text_input::TextInput)'s [`OnTextInputReturn`]).
277pub type TextAreaOnTextInputCallbackType =
278    extern "C" fn(RefAny, CallbackInfo, TextAreaState) -> OnTextInputReturn;
279impl_widget_callback!(
280    TextAreaOnTextInput,
281    OptionTextAreaOnTextInput,
282    TextAreaOnTextInputCallback,
283    TextAreaOnTextInputCallbackType
284);
285
286azul_core::impl_managed_callback! {
287    wrapper:        TextAreaOnTextInputCallback,
288    info_ty:        CallbackInfo,
289    return_ty:      OnTextInputReturn,
290    default_ret:    OnTextInputReturn { update: Update::DoNothing, valid: TextInputValid::Yes },
291    invoker_static: TEXT_AREA_ON_TEXT_INPUT_INVOKER,
292    invoker_ty:     AzTextAreaOnTextInputCallbackInvoker,
293    thunk_fn:       az_text_area_on_text_input_callback_thunk,
294    setter_fn:      AzApp_setTextAreaOnTextInputCallbackInvoker,
295    from_handle_fn: AzTextAreaOnTextInputCallback_createFromHostHandle,
296    extra_args:     [ state: TextAreaState ],
297}
298
299/// Invoked on every virtual-key press while the text area is focused (reusing
300/// [`TextInput`](crate::widgets::text_input::TextInput)'s [`OnTextInputReturn`]).
301pub type TextAreaOnVirtualKeyDownCallbackType =
302    extern "C" fn(RefAny, CallbackInfo, TextAreaState) -> OnTextInputReturn;
303impl_widget_callback!(
304    TextAreaOnVirtualKeyDown,
305    OptionTextAreaOnVirtualKeyDown,
306    TextAreaOnVirtualKeyDownCallback,
307    TextAreaOnVirtualKeyDownCallbackType
308);
309
310azul_core::impl_managed_callback! {
311    wrapper:        TextAreaOnVirtualKeyDownCallback,
312    info_ty:        CallbackInfo,
313    return_ty:      OnTextInputReturn,
314    default_ret:    OnTextInputReturn { update: Update::DoNothing, valid: TextInputValid::Yes },
315    invoker_static: TEXT_AREA_ON_VIRTUAL_KEY_DOWN_INVOKER,
316    invoker_ty:     AzTextAreaOnVirtualKeyDownCallbackInvoker,
317    thunk_fn:       az_text_area_on_virtual_key_down_callback_thunk,
318    setter_fn:      AzApp_setTextAreaOnVirtualKeyDownCallbackInvoker,
319    from_handle_fn: AzTextAreaOnVirtualKeyDownCallback_createFromHostHandle,
320    extra_args:     [ state: TextAreaState ],
321}
322
323/// Invoked when the text area loses focus.
324pub type TextAreaOnFocusLostCallbackType =
325    extern "C" fn(RefAny, CallbackInfo, TextAreaState) -> Update;
326impl_widget_callback!(
327    TextAreaOnFocusLost,
328    OptionTextAreaOnFocusLost,
329    TextAreaOnFocusLostCallback,
330    TextAreaOnFocusLostCallbackType
331);
332
333azul_core::impl_managed_callback! {
334    wrapper:        TextAreaOnFocusLostCallback,
335    info_ty:        CallbackInfo,
336    return_ty:      Update,
337    default_ret:    Update::DoNothing,
338    invoker_static: TEXT_AREA_ON_FOCUS_LOST_INVOKER,
339    invoker_ty:     AzTextAreaOnFocusLostCallbackInvoker,
340    thunk_fn:       az_text_area_on_focus_lost_callback_thunk,
341    setter_fn:      AzApp_setTextAreaOnFocusLostCallbackInvoker,
342    from_handle_fn: AzTextAreaOnFocusLostCallback_createFromHostHandle,
343    extra_args:     [ state: TextAreaState ],
344}
345
346impl Default for TextAreaState {
347    fn default() -> Self {
348        Self {
349            text: Vec::new().into(),
350            placeholder: None.into(),
351            max_len: 1000,
352            cursor_pos: 0,
353        }
354    }
355}
356
357impl TextAreaState {
358    /// Reconstructs the (multi-line) string, including `'\n'` characters.
359    #[must_use] pub fn get_text(&self) -> String {
360        self.text
361            .iter()
362            .filter_map(|c| core::char::from_u32(*c))
363            .collect()
364    }
365}
366
367impl Default for TextAreaStateWrapper {
368    fn default() -> Self {
369        Self {
370            inner: TextAreaState::default(),
371            on_text_input: None.into(),
372            on_focus_lost: None.into(),
373            update_text_area_before_calling_focus_lost_fn: true,
374            on_virtual_key_down: None.into(),
375        }
376    }
377}
378
379impl Default for TextArea {
380    fn default() -> Self {
381        Self {
382            text_area_state: TextAreaStateWrapper::default(),
383            placeholder_style: CssPropertyWithConditionsVec::from_const_slice(
384                TEXT_AREA_PLACEHOLDER_PROPS,
385            ),
386            container_style: CssPropertyWithConditionsVec::from_const_slice(
387                TEXT_AREA_CONTAINER_PROPS,
388            ),
389            label_style: CssPropertyWithConditionsVec::from_const_slice(TEXT_AREA_LABEL_PROPS),
390        }
391    }
392}
393
394impl TextArea {
395    #[must_use] pub fn create() -> Self {
396        Self::default()
397    }
398
399    /// Sets the (multi-line) text. Newlines in `text` are preserved.
400    #[allow(clippy::needless_pass_by_value)] // public by-value setter; builder with_text moves the arg in
401    pub fn set_text(&mut self, text: AzString) {
402        self.text_area_state.inner.text = text
403            .as_str()
404            .chars()
405            .map(|c| c as u32)
406            .collect::<Vec<_>>()
407            .into();
408    }
409
410    #[must_use] pub fn with_text(mut self, text: AzString) -> Self {
411        self.set_text(text);
412        self
413    }
414
415    pub fn set_placeholder(&mut self, placeholder: AzString) {
416        self.text_area_state.inner.placeholder = Some(placeholder).into();
417    }
418
419    #[must_use] pub fn with_placeholder(mut self, placeholder: AzString) -> Self {
420        self.set_placeholder(placeholder);
421        self
422    }
423
424    pub fn set_on_text_input<C: Into<TextAreaOnTextInputCallback>>(
425        &mut self,
426        refany: RefAny,
427        callback: C,
428    ) {
429        self.text_area_state.on_text_input = Some(TextAreaOnTextInput {
430            callback: callback.into(),
431            refany,
432        })
433        .into();
434    }
435
436    #[must_use] pub fn with_on_text_input<C: Into<TextAreaOnTextInputCallback>>(
437        mut self,
438        refany: RefAny,
439        callback: C,
440    ) -> Self {
441        self.set_on_text_input(refany, callback);
442        self
443    }
444
445    pub fn set_on_virtual_key_down<C: Into<TextAreaOnVirtualKeyDownCallback>>(
446        &mut self,
447        refany: RefAny,
448        callback: C,
449    ) {
450        self.text_area_state.on_virtual_key_down = Some(TextAreaOnVirtualKeyDown {
451            callback: callback.into(),
452            refany,
453        })
454        .into();
455    }
456
457    #[must_use] pub fn with_on_virtual_key_down<C: Into<TextAreaOnVirtualKeyDownCallback>>(
458        mut self,
459        refany: RefAny,
460        callback: C,
461    ) -> Self {
462        self.set_on_virtual_key_down(refany, callback);
463        self
464    }
465
466    pub fn set_on_focus_lost<C: Into<TextAreaOnFocusLostCallback>>(
467        &mut self,
468        refany: RefAny,
469        callback: C,
470    ) {
471        self.text_area_state.on_focus_lost = Some(TextAreaOnFocusLost {
472            callback: callback.into(),
473            refany,
474        })
475        .into();
476    }
477
478    #[must_use] pub fn with_on_focus_lost<C: Into<TextAreaOnFocusLostCallback>>(
479        mut self,
480        refany: RefAny,
481        callback: C,
482    ) -> Self {
483        self.set_on_focus_lost(refany, callback);
484        self
485    }
486
487    pub fn set_container_style(&mut self, style: CssPropertyWithConditionsVec) {
488        self.container_style = style;
489    }
490
491    #[must_use] pub fn with_container_style(mut self, style: CssPropertyWithConditionsVec) -> Self {
492        self.set_container_style(style);
493        self
494    }
495
496    #[must_use] pub fn swap_with_default(&mut self) -> Self {
497        let mut s = Self::default();
498        core::mem::swap(&mut s, self);
499        s
500    }
501
502    #[must_use] pub fn dom(mut self) -> Dom {
503        use azul_core::dom::{EventFilter, FocusEventFilter, HoverEventFilter, IdOrClass::Class, TabIndex};
504
505        self.text_area_state.inner.cursor_pos = self.text_area_state.inner.text.len();
506
507        let label_text: String = self
508            .text_area_state
509            .inner
510            .text
511            .iter()
512            .filter_map(|s| core::char::from_u32(*s))
513            .collect();
514
515        let placeholder = self
516            .text_area_state
517            .inner
518            .placeholder
519            .as_ref()
520            .map(|s| s.as_str().to_string())
521            .unwrap_or_default();
522
523        let state_ref = RefAny::new(self.text_area_state);
524
525        Dom::create_div()
526            .with_ids_and_classes(vec![Class("__azul-native-text-area-container".into())].into())
527            .with_css_props(self.container_style)
528            .with_tab_index(TabIndex::Auto)
529            .with_dataset(Some(state_ref.clone()).into())
530            .with_callbacks(
531                vec![
532                    CoreCallbackData {
533                        event: EventFilter::Focus(FocusEventFilter::FocusReceived),
534                        refany: state_ref.clone(),
535                        callback: CoreCallback {
536                            cb: default_on_focus_received as usize,
537                            ctx: azul_core::refany::OptionRefAny::None,
538                        },
539                    },
540                    CoreCallbackData {
541                        event: EventFilter::Focus(FocusEventFilter::FocusLost),
542                        refany: state_ref.clone(),
543                        callback: CoreCallback {
544                            cb: default_on_focus_lost as usize,
545                            ctx: azul_core::refany::OptionRefAny::None,
546                        },
547                    },
548                    CoreCallbackData {
549                        event: EventFilter::Focus(FocusEventFilter::TextInput),
550                        refany: state_ref.clone(),
551                        callback: CoreCallback {
552                            cb: default_on_text_input as usize,
553                            ctx: azul_core::refany::OptionRefAny::None,
554                        },
555                    },
556                    CoreCallbackData {
557                        event: EventFilter::Focus(FocusEventFilter::VirtualKeyDown),
558                        refany: state_ref,
559                        callback: CoreCallback {
560                            cb: default_on_virtual_key_down as usize,
561                            ctx: azul_core::refany::OptionRefAny::None,
562                        },
563                    },
564                ]
565                .into(),
566            )
567            .with_children(
568                vec![
569                    Dom::create_text(placeholder)
570                        .with_ids_and_classes(
571                            vec![Class("__azul-native-text-area-placeholder".into())].into(),
572                        )
573                        .with_css_props(self.placeholder_style),
574                    Dom::create_text(label_text)
575                        .with_ids_and_classes(
576                            vec![Class("__azul-native-text-area-label".into())].into(),
577                        )
578                        .with_css_props(self.label_style)
579                        .with_children(
580                            vec![Dom::create_div()
581                                .with_ids_and_classes(
582                                    vec![Class("__azul-native-text-area-cursor".into())].into(),
583                                )
584                                .with_css_props(CssPropertyWithConditionsVec::from_const_slice(
585                                    TEXT_CURSOR_PROPS,
586                                ))]
587                            .into(),
588                        ),
589                ]
590                .into(),
591            )
592    }
593}
594
595extern "C" fn default_on_focus_received(mut text_area: RefAny, mut info: CallbackInfo) -> Update {
596    let Some(mut text_area) = text_area.downcast_mut::<TextAreaStateWrapper>() else {
597        return Update::DoNothing;
598    };
599
600    let text_area = &mut *text_area;
601
602    let Some(placeholder_text_node_id) = info.get_first_child(info.get_hit_node()) else {
603        return Update::DoNothing;
604    };
605
606    // hide the placeholder text
607    if text_area.inner.text.is_empty() {
608        info.set_css_property(
609            placeholder_text_node_id,
610            CssProperty::const_opacity(StyleOpacity::const_new(0)),
611        );
612    }
613
614    text_area.inner.cursor_pos = text_area.inner.text.len();
615
616    Update::DoNothing
617}
618
619extern "C" fn default_on_focus_lost(mut text_area: RefAny, mut info: CallbackInfo) -> Update {
620    let Some(mut text_area) = text_area.downcast_mut::<TextAreaStateWrapper>() else {
621        return Update::DoNothing;
622    };
623
624    let text_area = &mut *text_area;
625
626    let Some(placeholder_text_node_id) = info.get_first_child(info.get_hit_node()) else {
627        return Update::DoNothing;
628    };
629
630    // show the placeholder text
631    if text_area.inner.text.is_empty() {
632        info.set_css_property(
633            placeholder_text_node_id,
634            CssProperty::const_opacity(StyleOpacity::const_new(100)),
635        );
636    }
637
638    let text_area = &mut *text_area;
639    let onfocuslost = &mut text_area.on_focus_lost;
640    let inner = text_area.inner.clone();
641
642    match onfocuslost.as_mut() {
643        Some(TextAreaOnFocusLost { callback, refany }) => (callback.cb)(refany.clone(), info, inner),
644        None => Update::DoNothing,
645    }
646}
647
648extern "C" fn default_on_text_input(text_area: RefAny, info: CallbackInfo) -> Update {
649    default_on_text_input_inner(text_area, info).unwrap_or(Update::DoNothing)
650}
651
652fn default_on_text_input_inner(mut text_area: RefAny, mut info: CallbackInfo) -> Option<Update> {
653    let mut text_area = text_area.downcast_mut::<TextAreaStateWrapper>()?;
654
655    let changeset = info.get_text_changeset()?;
656    let inserted_text = changeset.inserted_text.as_str().to_string();
657
658    if inserted_text.is_empty() {
659        return None;
660    }
661
662    let placeholder_node_id = info.get_first_child(info.get_hit_node())?;
663    let label_node_id = info.get_next_sibling(placeholder_node_id)?;
664    let _cursor_node_id = info.get_first_child(label_node_id)?;
665
666    let result = {
667        let text_area = &mut *text_area;
668        let ontextinput = &mut text_area.on_text_input;
669
670        // inner_clone has the new (would-be) text
671        let mut inner_clone = text_area.inner.clone();
672        inner_clone.cursor_pos = inner_clone.cursor_pos.saturating_add(inserted_text.len());
673        inner_clone.text = {
674            let mut internal = inner_clone.text.clone().into_library_owned_vec();
675            internal.extend(inserted_text.chars().map(|c| c as u32));
676            internal.into()
677        };
678
679        match ontextinput.as_mut() {
680            Some(TextAreaOnTextInput { callback, refany }) => {
681                (callback.cb)(refany.clone(), info, inner_clone)
682            }
683            None => OnTextInputReturn {
684                update: Update::DoNothing,
685                valid: TextInputValid::Yes,
686            },
687        }
688    };
689
690    if result.valid == TextInputValid::Yes {
691        // hide the placeholder text
692        info.set_css_property(
693            placeholder_node_id,
694            CssProperty::const_opacity(StyleOpacity::const_new(0)),
695        );
696
697        // append to the text
698        text_area.inner.text = {
699            let mut internal = text_area.inner.text.clone().into_library_owned_vec();
700            internal.extend(inserted_text.chars().map(|c| c as u32));
701            internal.into()
702        };
703        text_area.inner.cursor_pos = text_area
704            .inner
705            .cursor_pos
706            .saturating_add(inserted_text.len());
707
708        info.change_node_text(label_node_id, text_area.inner.get_text().into());
709    }
710
711    Some(result.update)
712}
713
714extern "C" fn default_on_virtual_key_down(text_area: RefAny, info: CallbackInfo) -> Update {
715    default_on_virtual_key_down_inner(text_area, info).unwrap_or(Update::DoNothing)
716}
717
718fn default_on_virtual_key_down_inner(
719    mut text_area: RefAny,
720    mut info: CallbackInfo,
721) -> Option<Update> {
722    let mut text_area = text_area.downcast_mut::<TextAreaStateWrapper>()?;
723    let keyboard_state = info.get_current_keyboard_state();
724
725    let c = keyboard_state.current_virtual_keycode.into_option()?;
726    let placeholder_node_id = info.get_first_child(info.get_hit_node())?;
727    let label_node_id = info.get_next_sibling(placeholder_node_id)?;
728    let _cursor_node_id = info.get_first_child(label_node_id)?;
729
730    // Dispatch to the user's on_virtual_key_down callback first; a
731    // TextInputValid::No return suppresses the built-in editing behavior.
732    let result = {
733        // rustc doesn't understand the borrowing lifetime here
734        let text_area = &mut *text_area;
735        let inner_clone = text_area.inner.clone();
736        match text_area.on_virtual_key_down.as_mut() {
737            Some(TextAreaOnVirtualKeyDown { callback, refany }) => {
738                (callback.cb)(refany.clone(), info, inner_clone)
739            }
740            None => OnTextInputReturn {
741                update: Update::DoNothing,
742                valid: TextInputValid::Yes,
743            },
744        }
745    };
746
747    if result.valid == TextInputValid::No {
748        return Some(result.update);
749    }
750
751    match c {
752        VirtualKeyCode::Back => {
753            text_area.inner.text = {
754                let mut internal = text_area.inner.text.clone().into_library_owned_vec();
755                internal.pop();
756                internal.into()
757            };
758            text_area.inner.cursor_pos = text_area.inner.cursor_pos.saturating_sub(1);
759            info.change_node_text(label_node_id, text_area.inner.get_text().into());
760
761            // re-show placeholder if the buffer is now empty
762            if text_area.inner.text.is_empty() {
763                info.set_css_property(
764                    placeholder_node_id,
765                    CssProperty::const_opacity(StyleOpacity::const_new(100)),
766                );
767            }
768        }
769        VirtualKeyCode::Return | VirtualKeyCode::NumpadEnter => {
770            // insert a newline
771            text_area.inner.text = {
772                let mut internal = text_area.inner.text.clone().into_library_owned_vec();
773                internal.push('\n' as u32);
774                internal.into()
775            };
776            text_area.inner.cursor_pos = text_area.inner.cursor_pos.saturating_add(1);
777            info.change_node_text(label_node_id, text_area.inner.get_text().into());
778            // hide placeholder (buffer is non-empty now)
779            info.set_css_property(
780                placeholder_node_id,
781                CssProperty::const_opacity(StyleOpacity::const_new(0)),
782            );
783        }
784        _ => return Some(result.update),
785    }
786
787    Some(result.update)
788}
789
790impl From<TextArea> for Dom {
791    fn from(t: TextArea) -> Self {
792        t.dom()
793    }
794}
795
796#[cfg(test)]
797// `redundant_closure`: NOT redundant here. `run()` takes
798// `impl FnOnce(RefAny, CallbackInfo) -> R`; `CallbackInfo` carries an elided
799// lifetime, so the bound is higher-ranked (`for<'a> FnOnce(_, CallbackInfo<'a>)`).
800// The handlers are `extern "C" fn` items, which do NOT satisfy a higher-ranked
801// `FnOnce` bound — passing one bare fails to compile with E0277. The `|r, ci| f(r, ci)`
802// wrapper is what makes the coercion happen and must stay.
803#[allow(clippy::redundant_closure)]
804mod autotest_generated {
805    use std::{
806        collections::{BTreeMap, HashMap},
807        sync::{Arc, Mutex},
808    };
809
810    use azul_core::{
811        dom::{DomId, DomNodeId, EventFilter, FocusEventFilter, NodeId, NodeType},
812        geom::{LogicalRect, OptionLogicalPosition},
813        gl::OptionGlContextPtr,
814        hit_test::ScrollPosition,
815        refany::OptionRefAny,
816        resources::RendererResources,
817        styled_dom::{NodeHierarchyItemId, StyledDom},
818        window::{MonitorVec, RawWindowHandle},
819    };
820    use rust_fontconfig::FcFontCache;
821
822    use super::*;
823    #[cfg(feature = "icu")]
824    use crate::icu::IcuLocalizerHandle;
825    use crate::{
826        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
827        managers::text_input::PendingTextEdit,
828        solver3::{display_list::DisplayList, layout_tree::LayoutTree},
829        window::{DomLayoutResult, LayoutWindow},
830        window_state::FullWindowState,
831    };
832
833    // ==================================================================
834    // Sample data
835    // ==================================================================
836
837    /// Strings the buffer must round-trip verbatim through `set_text` ->
838    /// `get_text`. Every entry is a valid Rust `str`, so none of them can be
839    /// lost to the `char::from_u32` filter in `get_text` — anything that does
840    /// not come back is real damage, not an encoding limit.
841    const ROUND_TRIP: [&str; 22] = [
842        "",
843        " ",
844        "a",
845        "hello",
846        "\n",
847        "\n\n\n",
848        "a\nb",
849        "a\r\nb",           // CRLF: *both* units have to survive
850        "trailing\n",
851        "\nleading",
852        "\t\ttabbed",
853        "\0",               // NUL is a perfectly good `char`
854        "line1\nline2\nline3",
855        "ünïcödé",
856        "e\u{301}",         // combining acute: 2 chars, 1 grapheme
857        "😀",               // astral plane: 1 char, 4 bytes
858        "👩‍👩‍👧‍👦",     // ZWJ family: 7 chars, 25 bytes
859        "🇩🇪",              // regional-indicator pair
860        "مرحبا",            // RTL
861        "日本語",
862        "a\u{200b}b",       // zero-width space wedged between two letters
863        "\u{10FFFF}",       // the largest scalar value there is
864    ];
865
866    /// `u32` values that are *not* Unicode scalar values. The buffer is a
867    /// `U32Vec`, not a `String`, so it can hold them — `get_text` has to drop
868    /// them rather than panic.
869    const NON_SCALAR: [u32; 6] = [
870        0xD800,      // lone high surrogate
871        0xDBFF,
872        0xDC00,      // lone low surrogate
873        0xDFFF,
874        0x0011_0000, // one past the last scalar value
875        u32::MAX,
876    ];
877
878    // ==================================================================
879    // Fixtures
880    // ==================================================================
881
882    /// A state buffer built from a `&str` exactly the way `set_text` builds it.
883    fn buffer(text: &str) -> U32Vec {
884        text.chars().map(|c| c as u32).collect::<Vec<_>>().into()
885    }
886
887    /// A `TextAreaStateWrapper` with no user hooks, holding `text`.
888    fn wrapper(text: &str) -> TextAreaStateWrapper {
889        TextAreaStateWrapper {
890            inner: TextAreaState {
891                text: buffer(text),
892                ..TextAreaState::default()
893            },
894            ..TextAreaStateWrapper::default()
895        }
896    }
897
898    /// The state currently stored behind a `TextAreaStateWrapper` payload.
899    fn read(state: &RefAny) -> TextAreaState {
900        let mut handle = state.clone();
901        let w = handle
902            .downcast_ref::<TextAreaStateWrapper>()
903            .expect("the payload must still be a TextAreaStateWrapper");
904        w.inner.clone()
905    }
906
907    /// Mutates the shared state behind a payload (the borrow is released before
908    /// this returns, so a handler may be invoked right afterwards).
909    fn poke(state: &RefAny, f: impl FnOnce(&mut TextAreaStateWrapper)) {
910        let mut handle = state.clone();
911        let mut w = handle
912            .downcast_mut::<TextAreaStateWrapper>()
913            .expect("the payload must still be a TextAreaStateWrapper");
914        f(&mut w);
915    }
916
917    /// `n` properties lifted off the default container style — an easy way to
918    /// mint pairwise-distinct style vectors without hard-coding CSS.
919    fn style(n: usize) -> CssPropertyWithConditionsVec {
920        let all: Vec<CssPropertyWithConditions> =
921            TextArea::default().container_style.as_ref().to_vec();
922        assert!(n <= all.len(), "not enough default properties to slice");
923        CssPropertyWithConditionsVec::from_vec(all.into_iter().take(n).collect())
924    }
925
926    /// The text of a `NodeType::Text` node (`None` for any other node type).
927    fn text_of(node: &Dom) -> Option<&str> {
928        match node.root.get_node_type() {
929            NodeType::Text(s) => Some(s.as_ref().as_str()),
930            _ => None,
931        }
932    }
933
934    // ---- recording hooks -------------------------------------------------
935    //
936    // NOTE: each hook below has a deliberately *different* body. Identical
937    // function bodies can be folded onto a single symbol by the linker, and
938    // these callbacks are compared by function-pointer identity.
939
940    /// Records every `TextAreaState` an `on_text_input` / `on_virtual_key_down`
941    /// hook is handed, and answers with a fixed verdict.
942    struct EditLog {
943        seen: Vec<TextAreaState>,
944        ret: OnTextInputReturn,
945    }
946
947    /// Records every `TextAreaState` an `on_focus_lost` hook is handed.
948    struct FocusLog {
949        seen: Vec<TextAreaState>,
950        ret: Update,
951    }
952
953    extern "C" fn record_text_input(
954        mut data: RefAny,
955        _: CallbackInfo,
956        state: TextAreaState,
957    ) -> OnTextInputReturn {
958        let Some(mut log) = data.downcast_mut::<EditLog>() else {
959            return OnTextInputReturn {
960                update: Update::DoNothing,
961                valid: TextInputValid::Yes,
962            };
963        };
964        log.seen.push(state);
965        log.ret
966    }
967
968    extern "C" fn record_virtual_key(
969        mut data: RefAny,
970        _: CallbackInfo,
971        state: TextAreaState,
972    ) -> OnTextInputReturn {
973        match data.downcast_mut::<EditLog>() {
974            Some(mut log) => {
975                log.seen.push(state.clone());
976                log.ret
977            }
978            None => OnTextInputReturn {
979                update: Update::RefreshDom,
980                valid: TextInputValid::Yes,
981            },
982        }
983    }
984
985    extern "C" fn record_focus_lost(
986        mut data: RefAny,
987        _: CallbackInfo,
988        state: TextAreaState,
989    ) -> Update {
990        let mut update = Update::DoNothing;
991        if let Some(mut log) = data.downcast_mut::<FocusLog>() {
992            log.seen.push(state);
993            update = log.ret;
994        }
995        update
996    }
997
998    fn edit_log(ret: OnTextInputReturn) -> RefAny {
999        RefAny::new(EditLog {
1000            seen: Vec::new(),
1001            ret,
1002        })
1003    }
1004
1005    fn focus_log(ret: Update) -> RefAny {
1006        RefAny::new(FocusLog {
1007            seen: Vec::new(),
1008            ret,
1009        })
1010    }
1011
1012    fn edits_seen(log: &RefAny) -> Vec<TextAreaState> {
1013        let mut handle = log.clone();
1014        let l = handle
1015            .downcast_ref::<EditLog>()
1016            .expect("the payload must still be an EditLog");
1017        l.seen.clone()
1018    }
1019
1020    fn focus_seen(log: &RefAny) -> Vec<TextAreaState> {
1021        let mut handle = log.clone();
1022        let l = handle
1023            .downcast_ref::<FocusLog>()
1024            .expect("the payload must still be a FocusLog");
1025        l.seen.clone()
1026    }
1027
1028    const ACCEPT: OnTextInputReturn = OnTextInputReturn {
1029        update: Update::RefreshDom,
1030        valid: TextInputValid::Yes,
1031    };
1032    const REJECT: OnTextInputReturn = OnTextInputReturn {
1033        update: Update::RefreshDomAllWindows,
1034        valid: TextInputValid::No,
1035    };
1036
1037    // ==================================================================
1038    // CallbackInfo harness
1039    // ==================================================================
1040
1041    /// Flattened node indices of a `TextArea::dom()`.
1042    #[derive(Copy, Clone, Debug)]
1043    struct Nodes {
1044        container: usize,
1045        placeholder: usize,
1046        label: usize,
1047        cursor: usize,
1048    }
1049
1050    /// Which node the event hit.
1051    #[derive(Copy, Clone, Debug)]
1052    enum Hit {
1053        /// `NodeHierarchyItemId::NONE` — no node was hit at all.
1054        Nothing,
1055        Container,
1056        Placeholder,
1057        /// A leaf: it has no children, so every handler must bail out.
1058        Cursor,
1059    }
1060
1061    /// Flattened indices of every node carrying `class`, in tree order.
1062    fn nodes_with_class(styled: &StyledDom, class: &str) -> Vec<usize> {
1063        styled
1064            .node_data
1065            .as_ref()
1066            .iter()
1067            .enumerate()
1068            .filter(|(_, nd)| nd.has_class(class))
1069            .map(|(i, _)| i)
1070            .collect()
1071    }
1072
1073    /// A styled, but never laid out, `TextArea::dom()` — the handlers only walk
1074    /// `styled_dom.node_hierarchy`, so no real layout (and no font) is needed.
1075    /// The DOM here is a pure *navigation skeleton*: the state a handler edits
1076    /// is always the `RefAny` passed to it, never this DOM's own dataset.
1077    fn skeleton() -> (StyledDom, Nodes) {
1078        let styled = StyledDom::create_from_dom(TextArea::create().dom());
1079
1080        fn one(styled: &StyledDom, class: &str) -> usize {
1081            let found = nodes_with_class(styled, class);
1082            assert_eq!(found.len(), 1, "expected exactly one `{class}` node");
1083            found[0]
1084        }
1085
1086        let nodes = Nodes {
1087            container: one(&styled, "__azul-native-text-area-container"),
1088            placeholder: one(&styled, "__azul-native-text-area-placeholder"),
1089            label: one(&styled, "__azul-native-text-area-label"),
1090            cursor: one(&styled, "__azul-native-text-area-cursor"),
1091        };
1092        (styled, nodes)
1093    }
1094
1095    fn dom_node(idx: usize) -> DomNodeId {
1096        DomNodeId {
1097            dom: DomId::ROOT_ID,
1098            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(idx))),
1099        }
1100    }
1101
1102    /// A `DomLayoutResult` with an empty layout tree and no display list.
1103    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
1104        DomLayoutResult {
1105            styled_dom,
1106            layout_tree: LayoutTree {
1107                nodes: Vec::new(),
1108                warm: Vec::new(),
1109                cold: Vec::new(),
1110                root: 0,
1111                dom_to_layout: BTreeMap::new(),
1112                children_arena: Vec::new(),
1113                children_offsets: Vec::new(),
1114                subtree_needs_intrinsic: Vec::new(),
1115            },
1116            calculated_positions: Vec::new(),
1117            viewport: LogicalRect::zero(),
1118            display_list: DisplayList::default(),
1119            scroll_ids: HashMap::new(),
1120            scroll_id_to_node_id: HashMap::new(),
1121        }
1122    }
1123
1124    /// Everything the handlers read out of the window.
1125    struct Env {
1126        /// `false` installs a `LayoutWindow` with no layout result at all — the
1127        /// "callback fired before the first layout" case.
1128        with_dom: bool,
1129        changeset: Option<PendingTextEdit>,
1130        keycode: Option<VirtualKeyCode>,
1131        hit: Hit,
1132    }
1133
1134    impl Default for Env {
1135        fn default() -> Self {
1136            Self {
1137                with_dom: true,
1138                changeset: None,
1139                keycode: None,
1140                hit: Hit::Container,
1141            }
1142        }
1143    }
1144
1145    impl Env {
1146        fn typed(text: &str) -> Self {
1147            Self {
1148                changeset: Some(PendingTextEdit {
1149                    node: dom_node(0),
1150                    inserted_text: AzString::from(text),
1151                    old_text: AzString::from(""),
1152                }),
1153                ..Self::default()
1154            }
1155        }
1156
1157        fn key(code: VirtualKeyCode) -> Self {
1158            Self {
1159                keycode: Some(code),
1160                ..Self::default()
1161            }
1162        }
1163
1164        fn hitting(mut self, hit: Hit) -> Self {
1165            self.hit = hit;
1166            self
1167        }
1168    }
1169
1170    /// Invokes `call` against a `LayoutWindow` built from `env`. Returns the
1171    /// handler's value, every recorded `CallbackChange`, and the node indices.
1172    fn run<R>(
1173        env: Env,
1174        data: &RefAny,
1175        call: impl FnOnce(RefAny, CallbackInfo) -> R,
1176    ) -> (R, Vec<CallbackChange>, Nodes) {
1177        let (styled, nodes) = skeleton();
1178
1179        let mut layout_window =
1180            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
1181        if env.with_dom {
1182            layout_window
1183                .layout_results
1184                .insert(DomId::ROOT_ID, layout_result(styled));
1185        }
1186        layout_window.text_input_manager.pending_changeset = env.changeset;
1187
1188        let renderer_resources = RendererResources::default();
1189        let previous_window_state: Option<FullWindowState> = None;
1190        let mut current_window_state = FullWindowState::default();
1191        current_window_state.keyboard_state.current_virtual_keycode = env.keycode.into();
1192        let gl_context = OptionGlContextPtr::None;
1193        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
1194            BTreeMap::new();
1195        let window_handle = RawWindowHandle::Unsupported;
1196        let system_callbacks = ExternalSystemCallbacks::rust_internal();
1197
1198        let ref_data = CallbackInfoRefData {
1199            layout_window: &layout_window,
1200            renderer_resources: &renderer_resources,
1201            previous_window_state: &previous_window_state,
1202            current_window_state: &current_window_state,
1203            gl_context: &gl_context,
1204            current_scroll_manager: &scroll_states,
1205            current_window_handle: &window_handle,
1206            system_callbacks: &system_callbacks,
1207            system_style: Arc::new(azul_css::system::SystemStyle::default()),
1208            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
1209            #[cfg(feature = "icu")]
1210            icu_localizer: IcuLocalizerHandle::default(),
1211            ctx: OptionRefAny::None,
1212        };
1213
1214        let hit = match env.hit {
1215            Hit::Nothing => DomNodeId {
1216                dom: DomId::ROOT_ID,
1217                node: NodeHierarchyItemId::NONE,
1218            },
1219            Hit::Container => dom_node(nodes.container),
1220            Hit::Placeholder => dom_node(nodes.placeholder),
1221            Hit::Cursor => dom_node(nodes.cursor),
1222        };
1223
1224        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
1225        let info = CallbackInfo::new(
1226            &ref_data,
1227            &changes,
1228            hit,
1229            OptionLogicalPosition::None,
1230            OptionLogicalPosition::None,
1231        );
1232
1233        let out = call(data.clone(), info);
1234        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
1235        (out, recorded, nodes)
1236    }
1237
1238    /// Every opacity write in the change log, as `(node index, normalized opacity)`.
1239    fn opacity_writes(changes: &[CallbackChange]) -> Vec<(usize, f32)> {
1240        let mut out = Vec::new();
1241        for change in changes {
1242            if let CallbackChange::ChangeNodeCssProperties {
1243                node_id, properties, ..
1244            } = change
1245            {
1246                for p in properties.as_ref() {
1247                    if let CssProperty::Opacity(v) = p {
1248                        if let Some(o) = v.get_property() {
1249                            out.push((node_id.index(), o.inner.normalized()));
1250                        }
1251                    }
1252                }
1253            }
1254        }
1255        out
1256    }
1257
1258    /// Every text write in the change log, as `(node index, new text)`.
1259    fn text_writes(changes: &[CallbackChange]) -> Vec<(usize, String)> {
1260        changes
1261            .iter()
1262            .filter_map(|change| match change {
1263                CallbackChange::ChangeNodeText { node_id, text } => Some((
1264                    node_id
1265                        .node
1266                        .into_crate_internal()
1267                        .expect("a text write always targets a real node")
1268                        .index(),
1269                    text.as_str().to_string(),
1270                )),
1271                _ => None,
1272            })
1273            .collect()
1274    }
1275
1276    // ==================================================================
1277    // TextAreaState::get_text
1278    // ==================================================================
1279
1280    #[test]
1281    fn get_text_on_a_default_state_is_empty() {
1282        let state = TextAreaState::default();
1283        assert_eq!(state.get_text(), "");
1284        assert!(state.text.is_empty());
1285        assert_eq!(state.cursor_pos, 0);
1286        assert_eq!(state.max_len, 1000);
1287        assert!(state.placeholder.is_none());
1288    }
1289
1290    #[test]
1291    fn get_text_round_trips_every_sample_string() {
1292        for s in ROUND_TRIP {
1293            let area = TextArea::create().with_text(AzString::from(s));
1294            assert_eq!(
1295                area.text_area_state.inner.get_text(),
1296                s,
1297                "set_text -> get_text must be lossless for {s:?}"
1298            );
1299            assert_eq!(
1300                area.text_area_state.inner.text.len(),
1301                s.chars().count(),
1302                "the buffer counts chars, not bytes, for {s:?}"
1303            );
1304        }
1305    }
1306
1307    #[test]
1308    fn get_text_drops_code_units_that_are_not_scalar_values() {
1309        // A `U32Vec` is not a `String`: it can hold surrogates and out-of-range
1310        // values. `get_text` must silently drop them, never panic.
1311        for unit in NON_SCALAR {
1312            let state = TextAreaState {
1313                text: vec![unit].into(),
1314                ..TextAreaState::default()
1315            };
1316            assert_eq!(
1317                state.get_text(),
1318                "",
1319                "0x{unit:X} is not a scalar value and must not reach the string"
1320            );
1321            assert_eq!(state.text.len(), 1, "the raw buffer keeps the unit");
1322        }
1323    }
1324
1325    #[test]
1326    fn get_text_keeps_the_scalars_around_dropped_units() {
1327        let mut units = vec!['a' as u32];
1328        units.extend(NON_SCALAR);
1329        units.push('b' as u32);
1330        let state = TextAreaState {
1331            text: units.into(),
1332            ..TextAreaState::default()
1333        };
1334
1335        assert_eq!(state.get_text(), "ab", "only the non-scalars may be dropped");
1336        assert_eq!(state.text.len(), NON_SCALAR.len() + 2);
1337    }
1338
1339    #[test]
1340    fn get_text_accepts_the_boundary_scalars() {
1341        // The exact edges of the two legal ranges: 0, the last code point below
1342        // the surrogate block, the first above it, and the very last scalar.
1343        let units = vec![0x0000, 0xD7FF, 0xE000, 0x0010_FFFF];
1344        let state = TextAreaState {
1345            text: units.clone().into(),
1346            ..TextAreaState::default()
1347        };
1348        assert_eq!(
1349            state.get_text().chars().count(),
1350            units.len(),
1351            "every boundary scalar must survive"
1352        );
1353    }
1354
1355    #[test]
1356    fn get_text_handles_a_very_large_buffer() {
1357        let big: String = "line 😀 ünicode\n".repeat(20_000);
1358        let area = TextArea::create().with_text(AzString::from(big.as_str()));
1359
1360        assert_eq!(area.text_area_state.inner.text.len(), big.chars().count());
1361        assert_eq!(area.text_area_state.inner.get_text(), big);
1362    }
1363
1364    // ==================================================================
1365    // TextArea::create
1366    // ==================================================================
1367
1368    #[test]
1369    fn create_equals_default() {
1370        assert_eq!(TextArea::create(), TextArea::default());
1371    }
1372
1373    #[test]
1374    fn create_starts_empty_with_no_hooks() {
1375        let area = TextArea::create();
1376        let s = &area.text_area_state;
1377
1378        assert!(s.inner.text.is_empty());
1379        assert!(s.inner.placeholder.is_none());
1380        assert_eq!(s.inner.max_len, 1000);
1381        assert_eq!(s.inner.cursor_pos, 0);
1382        assert!(s.on_text_input.is_none());
1383        assert!(s.on_virtual_key_down.is_none());
1384        assert!(s.on_focus_lost.is_none());
1385        assert!(s.update_text_area_before_calling_focus_lost_fn);
1386    }
1387
1388    #[test]
1389    fn create_ships_all_three_style_vectors_non_empty() {
1390        let area = TextArea::create();
1391        assert!(!area.container_style.as_ref().is_empty());
1392        assert!(!area.label_style.as_ref().is_empty());
1393        assert!(!area.placeholder_style.as_ref().is_empty());
1394    }
1395
1396    #[test]
1397    fn create_is_repeatable_and_unshared() {
1398        // Two areas must be equal but must not alias: editing one may not be
1399        // visible in the other.
1400        let mut a = TextArea::create();
1401        let b = TextArea::create();
1402        a.set_text(AzString::from("mutated"));
1403
1404        assert_ne!(a, b);
1405        assert_eq!(b.text_area_state.inner.get_text(), "");
1406    }
1407
1408    // ==================================================================
1409    // TextArea::set_text / with_text
1410    // ==================================================================
1411
1412    #[test]
1413    fn set_text_preserves_newlines() {
1414        let mut area = TextArea::create();
1415        area.set_text(AzString::from("a\nb\n\nc\n"));
1416
1417        assert_eq!(area.text_area_state.inner.get_text(), "a\nb\n\nc\n");
1418        assert_eq!(
1419            area.text_area_state
1420                .inner
1421                .text
1422                .iter()
1423                .filter(|c| **c == '\n' as u32)
1424                .count(),
1425            4,
1426            "all four newlines have to be stored"
1427        );
1428    }
1429
1430    #[test]
1431    fn set_text_replaces_rather_than_appends() {
1432        let mut area = TextArea::create();
1433        area.set_text(AzString::from("first"));
1434        area.set_text(AzString::from("second"));
1435
1436        assert_eq!(area.text_area_state.inner.get_text(), "second");
1437        assert_eq!(area.text_area_state.inner.text.len(), 6);
1438    }
1439
1440    #[test]
1441    fn set_text_with_an_empty_string_clears_the_buffer() {
1442        let mut area = TextArea::create().with_text(AzString::from("something"));
1443        area.set_text(AzString::from(""));
1444
1445        assert!(area.text_area_state.inner.text.is_empty());
1446        assert_eq!(area.text_area_state.inner.get_text(), "");
1447    }
1448
1449    #[test]
1450    fn with_text_is_exactly_set_text() {
1451        for s in ROUND_TRIP {
1452            let mut a = TextArea::create();
1453            a.set_text(AzString::from(s));
1454            let b = TextArea::create().with_text(AzString::from(s));
1455            assert_eq!(a, b, "the builder and the setter must agree for {s:?}");
1456        }
1457    }
1458
1459    #[test]
1460    fn set_text_ignores_max_len() {
1461        // `max_len` is stored but never enforced anywhere in this widget.
1462        // Pinning that here so a future limit check is a deliberate change and
1463        // not a silent behaviour flip.
1464        let mut area = TextArea::create();
1465        area.text_area_state.inner.max_len = 3;
1466        area.set_text(AzString::from("far past the limit"));
1467
1468        assert_eq!(area.text_area_state.inner.text.len(), 18);
1469        assert_eq!(area.text_area_state.inner.max_len, 3);
1470    }
1471
1472    #[test]
1473    fn set_text_leaves_a_stale_cursor_behind() {
1474        // `set_text` does not touch `cursor_pos`, so shrinking the text can
1475        // leave the cursor pointing past the end. `dom()` is what repairs it.
1476        let mut area = TextArea::create().with_text(AzString::from("0123456789"));
1477        area.text_area_state.inner.cursor_pos = 10;
1478        area.set_text(AzString::from(""));
1479
1480        assert_eq!(
1481            area.text_area_state.inner.cursor_pos, 10,
1482            "the setter deliberately leaves the cursor alone"
1483        );
1484        assert!(area.text_area_state.inner.text.is_empty());
1485
1486        let dom = area.dom();
1487        let mut dataset = dom
1488            .root
1489            .get_dataset()
1490            .cloned()
1491            .expect("dom() must attach the state");
1492        let w = dataset
1493            .downcast_ref::<TextAreaStateWrapper>()
1494            .expect("the dataset must be a TextAreaStateWrapper");
1495        assert_eq!(w.inner.cursor_pos, 0, "dom() must repair the stale cursor");
1496    }
1497
1498    #[test]
1499    fn set_text_does_not_disturb_the_other_fields() {
1500        let area = TextArea::create()
1501            .with_placeholder(AzString::from("type here"))
1502            .with_container_style(style(3))
1503            .with_text(AzString::from("body"));
1504
1505        assert_eq!(
1506            area.text_area_state.inner.placeholder.as_ref().map(AzString::as_str),
1507            Some("type here")
1508        );
1509        assert_eq!(area.container_style.len(), 3);
1510        assert_eq!(area.text_area_state.inner.get_text(), "body");
1511    }
1512
1513    // ==================================================================
1514    // TextArea::set_placeholder / with_placeholder
1515    // ==================================================================
1516
1517    #[test]
1518    fn placeholder_round_trips_every_sample_string() {
1519        for s in ROUND_TRIP {
1520            let area = TextArea::create().with_placeholder(AzString::from(s));
1521            assert_eq!(
1522                area.text_area_state.inner.placeholder.as_ref().map(AzString::as_str),
1523                Some(s)
1524            );
1525        }
1526    }
1527
1528    #[test]
1529    fn an_empty_placeholder_is_some_not_none() {
1530        // `Some("")` and `None` are different states: only the former means
1531        // "the user explicitly asked for no placeholder text".
1532        let area = TextArea::create().with_placeholder(AzString::from(""));
1533        assert!(area.text_area_state.inner.placeholder.is_some());
1534        assert_eq!(
1535            area.text_area_state.inner.placeholder.as_ref().map(AzString::as_str),
1536            Some("")
1537        );
1538    }
1539
1540    #[test]
1541    fn set_placeholder_overwrites_the_previous_one() {
1542        let mut area = TextArea::create();
1543        area.set_placeholder(AzString::from("one"));
1544        area.set_placeholder(AzString::from("two"));
1545
1546        assert_eq!(
1547            area.text_area_state.inner.placeholder.as_ref().map(AzString::as_str),
1548            Some("two")
1549        );
1550    }
1551
1552    #[test]
1553    fn with_placeholder_is_exactly_set_placeholder() {
1554        let mut a = TextArea::create();
1555        a.set_placeholder(AzString::from("hint"));
1556        let b = TextArea::create().with_placeholder(AzString::from("hint"));
1557        assert_eq!(a, b);
1558    }
1559
1560    #[test]
1561    fn set_placeholder_does_not_touch_the_text() {
1562        let area = TextArea::create()
1563            .with_text(AzString::from("body\ntext"))
1564            .with_placeholder(AzString::from("hint"));
1565
1566        assert_eq!(area.text_area_state.inner.get_text(), "body\ntext");
1567        assert_eq!(area.text_area_state.inner.cursor_pos, 0);
1568    }
1569
1570    // ==================================================================
1571    // TextArea::set_on_* / with_on_*
1572    // ==================================================================
1573
1574    #[test]
1575    fn each_hook_setter_touches_only_its_own_slot() {
1576        let text_in = TextArea::create()
1577            .with_on_text_input(RefAny::new(1u32), record_text_input as TextAreaOnTextInputCallbackType);
1578        assert!(text_in.text_area_state.on_text_input.is_some());
1579        assert!(text_in.text_area_state.on_virtual_key_down.is_none());
1580        assert!(text_in.text_area_state.on_focus_lost.is_none());
1581
1582        let key_down = TextArea::create().with_on_virtual_key_down(
1583            RefAny::new(2u32),
1584            record_virtual_key as TextAreaOnVirtualKeyDownCallbackType,
1585        );
1586        assert!(key_down.text_area_state.on_text_input.is_none());
1587        assert!(key_down.text_area_state.on_virtual_key_down.is_some());
1588        assert!(key_down.text_area_state.on_focus_lost.is_none());
1589
1590        let focus = TextArea::create()
1591            .with_on_focus_lost(RefAny::new(3u32), record_focus_lost as TextAreaOnFocusLostCallbackType);
1592        assert!(focus.text_area_state.on_text_input.is_none());
1593        assert!(focus.text_area_state.on_virtual_key_down.is_none());
1594        assert!(focus.text_area_state.on_focus_lost.is_some());
1595    }
1596
1597    #[test]
1598    fn hook_setters_keep_the_user_payload_reachable() {
1599        let payload = RefAny::new(0xDEAD_BEEF_u32);
1600        let area = TextArea::create()
1601            .with_on_text_input(payload.clone(), record_text_input as TextAreaOnTextInputCallbackType);
1602
1603        let mut stored = area
1604            .text_area_state
1605            .on_text_input
1606            .as_ref()
1607            .expect("the hook must be stored")
1608            .refany
1609            .clone();
1610        assert_eq!(
1611            *stored.downcast_ref::<u32>().expect("payload type must survive"),
1612            0xDEAD_BEEF_u32
1613        );
1614    }
1615
1616    #[test]
1617    fn setting_a_hook_twice_replaces_it_and_keeps_the_old_payload_alive() {
1618        let first = RefAny::new(11u32);
1619        let second = RefAny::new(22u32);
1620        let mut area = TextArea::create();
1621        area.set_on_text_input(first.clone(), record_text_input as TextAreaOnTextInputCallbackType);
1622        area.set_on_text_input(second, record_text_input as TextAreaOnTextInputCallbackType);
1623
1624        let mut stored = area
1625            .text_area_state
1626            .on_text_input
1627            .as_ref()
1628            .expect("the hook must be stored")
1629            .refany
1630            .clone();
1631        assert_eq!(*stored.downcast_ref::<u32>().expect("payload"), 22);
1632
1633        // The replaced handle must not have been freed out from under us.
1634        let mut first = first;
1635        assert_eq!(*first.downcast_ref::<u32>().expect("payload"), 11);
1636    }
1637
1638    #[test]
1639    fn with_on_hooks_are_exactly_their_setters() {
1640        let payload = RefAny::new(7u32);
1641
1642        let mut a = TextArea::create();
1643        a.set_on_focus_lost(payload.clone(), record_focus_lost as TextAreaOnFocusLostCallbackType);
1644        let b = TextArea::create()
1645            .with_on_focus_lost(payload, record_focus_lost as TextAreaOnFocusLostCallbackType);
1646        assert_eq!(a, b);
1647    }
1648
1649    #[test]
1650    fn all_three_hooks_can_coexist() {
1651        let area = TextArea::create()
1652            .with_on_text_input(RefAny::new(1u32), record_text_input as TextAreaOnTextInputCallbackType)
1653            .with_on_virtual_key_down(
1654                RefAny::new(2u32),
1655                record_virtual_key as TextAreaOnVirtualKeyDownCallbackType,
1656            )
1657            .with_on_focus_lost(RefAny::new(3u32), record_focus_lost as TextAreaOnFocusLostCallbackType)
1658            .with_text(AzString::from("still here"));
1659
1660        assert!(area.text_area_state.on_text_input.is_some());
1661        assert!(area.text_area_state.on_virtual_key_down.is_some());
1662        assert!(area.text_area_state.on_focus_lost.is_some());
1663        assert_eq!(area.text_area_state.inner.get_text(), "still here");
1664    }
1665
1666    #[test]
1667    fn hook_setters_leave_a_zero_sized_payload_usable() {
1668        // A `RefAny` over a ZST is the degenerate case for the refcount /
1669        // destructor plumbing.
1670        struct Zst;
1671        let area = TextArea::create()
1672            .with_on_text_input(RefAny::new(Zst), record_text_input as TextAreaOnTextInputCallbackType);
1673
1674        let mut stored = area
1675            .text_area_state
1676            .on_text_input
1677            .as_ref()
1678            .expect("the hook must be stored")
1679            .refany
1680            .clone();
1681        assert!(stored.downcast_ref::<Zst>().is_some());
1682        assert!(stored.downcast_ref::<u32>().is_none(), "the type tag must still discriminate");
1683    }
1684
1685    // ==================================================================
1686    // TextArea::set_container_style / with_container_style
1687    // ==================================================================
1688
1689    #[test]
1690    fn set_container_style_replaces_the_whole_vector() {
1691        let mut area = TextArea::create();
1692        let before = area.container_style.len();
1693        area.set_container_style(style(2));
1694
1695        assert_eq!(area.container_style.len(), 2);
1696        assert_ne!(before, 2, "the fixture has to actually change something");
1697    }
1698
1699    #[test]
1700    fn an_empty_container_style_is_accepted() {
1701        let area = TextArea::create()
1702            .with_container_style(CssPropertyWithConditionsVec::from_vec(Vec::new()));
1703        assert!(area.container_style.as_ref().is_empty());
1704
1705        // ...and still produces a DOM.
1706        let dom = area.dom();
1707        assert_eq!(dom.children.as_ref().len(), 2);
1708    }
1709
1710    #[test]
1711    fn container_style_does_not_leak_into_the_other_style_slots() {
1712        let default_label = TextArea::create().label_style;
1713        let default_placeholder = TextArea::create().placeholder_style;
1714        let area = TextArea::create().with_container_style(style(1));
1715
1716        assert_eq!(area.label_style, default_label);
1717        assert_eq!(area.placeholder_style, default_placeholder);
1718    }
1719
1720    // ==================================================================
1721    // TextArea::swap_with_default
1722    // ==================================================================
1723
1724    #[test]
1725    fn swap_with_default_hands_back_the_old_value_and_resets_self() {
1726        let mut area = TextArea::create()
1727            .with_text(AzString::from("keep\nme"))
1728            .with_placeholder(AzString::from("hint"));
1729
1730        let old = area.swap_with_default();
1731
1732        assert_eq!(old.text_area_state.inner.get_text(), "keep\nme");
1733        assert_eq!(
1734            old.text_area_state.inner.placeholder.as_ref().map(AzString::as_str),
1735            Some("hint")
1736        );
1737        assert_eq!(area, TextArea::default(), "self must be a fresh default");
1738    }
1739
1740    #[test]
1741    fn swap_with_default_twice_yields_a_default_the_second_time() {
1742        let mut area = TextArea::create().with_text(AzString::from("x"));
1743        let first = area.swap_with_default();
1744        let second = area.swap_with_default();
1745
1746        assert_eq!(first.text_area_state.inner.get_text(), "x");
1747        assert_eq!(second, TextArea::default());
1748        assert_eq!(area, TextArea::default());
1749    }
1750
1751    #[test]
1752    fn swap_with_default_carries_the_hooks_out_with_it() {
1753        let payload = RefAny::new(99u32);
1754        let mut area = TextArea::create()
1755            .with_on_focus_lost(payload, record_focus_lost as TextAreaOnFocusLostCallbackType);
1756
1757        let old = area.swap_with_default();
1758
1759        assert!(old.text_area_state.on_focus_lost.is_some());
1760        assert!(area.text_area_state.on_focus_lost.is_none());
1761
1762        let mut stored = old
1763            .text_area_state
1764            .on_focus_lost
1765            .as_ref()
1766            .expect("hook")
1767            .refany
1768            .clone();
1769        assert_eq!(*stored.downcast_ref::<u32>().expect("payload"), 99);
1770    }
1771
1772    // ==================================================================
1773    // TextArea::dom
1774    // ==================================================================
1775
1776    #[test]
1777    fn dom_has_the_shape_the_handlers_navigate() {
1778        let dom = TextArea::create().dom();
1779        let children = dom.children.as_ref();
1780
1781        assert_eq!(children.len(), 2, "a text area is exactly [placeholder, label]");
1782        assert!(dom.root.has_class("__azul-native-text-area-container"));
1783        assert!(children[0].root.has_class("__azul-native-text-area-placeholder"));
1784        assert!(children[1].root.has_class("__azul-native-text-area-label"));
1785
1786        let label_children = children[1].children.as_ref();
1787        assert_eq!(label_children.len(), 1, "the label owns exactly the cursor");
1788        assert!(label_children[0].root.has_class("__azul-native-text-area-cursor"));
1789        assert!(
1790            label_children[0].children.as_ref().is_empty(),
1791            "the cursor is a leaf"
1792        );
1793    }
1794
1795    #[test]
1796    fn dom_renders_the_text_into_the_label_and_the_placeholder_into_its_own_node() {
1797        let dom = TextArea::create()
1798            .with_text(AzString::from("body\nlines"))
1799            .with_placeholder(AzString::from("hint"))
1800            .dom();
1801        let children = dom.children.as_ref();
1802
1803        assert_eq!(text_of(&children[0]), Some("hint"));
1804        assert_eq!(text_of(&children[1]), Some("body\nlines"));
1805    }
1806
1807    #[test]
1808    fn dom_renders_an_empty_placeholder_node_when_none_was_set() {
1809        // The node must still exist: every handler navigates *through* it to
1810        // reach the label.
1811        let dom = TextArea::create().with_text(AzString::from("x")).dom();
1812        assert_eq!(text_of(&dom.children.as_ref()[0]), Some(""));
1813    }
1814
1815    #[test]
1816    fn dom_round_trips_every_sample_string_into_the_label() {
1817        for s in ROUND_TRIP {
1818            let dom = TextArea::create().with_text(AzString::from(s)).dom();
1819            assert_eq!(
1820                text_of(&dom.children.as_ref()[1]),
1821                Some(s),
1822                "the label must render {s:?} verbatim"
1823            );
1824        }
1825    }
1826
1827    #[test]
1828    fn dom_drops_non_scalar_units_from_the_label() {
1829        let mut area = TextArea::create().with_text(AzString::from("ab"));
1830        let mut units = area.text_area_state.inner.text.clone().into_library_owned_vec();
1831        units.extend(NON_SCALAR);
1832        area.text_area_state.inner.text = units.into();
1833
1834        let dom = area.dom();
1835        assert_eq!(
1836            text_of(&dom.children.as_ref()[1]),
1837            Some("ab"),
1838            "the rendered label may only contain real scalars"
1839        );
1840    }
1841
1842    #[test]
1843    fn dom_snaps_the_cursor_to_the_end_of_the_buffer() {
1844        for (text, expected) in [("", 0), ("abc", 3), ("😀😀", 2), ("a\nb", 3)] {
1845            let mut area = TextArea::create().with_text(AzString::from(text));
1846            area.text_area_state.inner.cursor_pos = usize::MAX;
1847
1848            let dom = area.dom();
1849            let mut dataset = dom.root.get_dataset().cloned().expect("dataset");
1850            let w = dataset
1851                .downcast_ref::<TextAreaStateWrapper>()
1852                .expect("the dataset must be a TextAreaStateWrapper");
1853            assert_eq!(
1854                w.inner.cursor_pos, expected,
1855                "dom() must clamp the cursor to the buffer for {text:?}"
1856            );
1857        }
1858    }
1859
1860    #[test]
1861    fn dom_wires_up_all_four_focus_callbacks() {
1862        let dom = TextArea::create().dom();
1863        let callbacks = dom.root.get_callbacks();
1864        assert_eq!(callbacks.len(), 4);
1865
1866        let expected = [
1867            (
1868                EventFilter::Focus(FocusEventFilter::FocusReceived),
1869                default_on_focus_received as usize,
1870            ),
1871            (
1872                EventFilter::Focus(FocusEventFilter::FocusLost),
1873                default_on_focus_lost as usize,
1874            ),
1875            (
1876                EventFilter::Focus(FocusEventFilter::TextInput),
1877                default_on_text_input as usize,
1878            ),
1879            (
1880                EventFilter::Focus(FocusEventFilter::VirtualKeyDown),
1881                default_on_virtual_key_down as usize,
1882            ),
1883        ];
1884
1885        for (cd, (event, cb)) in callbacks.as_ref().iter().zip(expected) {
1886            assert_eq!(cd.event, event);
1887            assert_eq!(cd.callback.cb, cb, "wrong handler wired to {event:?}");
1888        }
1889    }
1890
1891    #[test]
1892    fn dom_shares_one_state_handle_between_the_dataset_and_every_callback() {
1893        let dom = TextArea::create().with_text(AzString::from("seed")).dom();
1894        let dataset = dom.root.get_dataset().cloned().expect("dataset");
1895        poke(&dataset, |w| w.inner.max_len = 7);
1896
1897        for cd in dom.root.get_callbacks().as_ref() {
1898            let mut handle = cd.refany.clone();
1899            let w = handle
1900                .downcast_ref::<TextAreaStateWrapper>()
1901                .expect("every callback must carry the state wrapper");
1902            assert_eq!(
1903                w.inner.max_len, 7,
1904                "every callback must see the *same* state object as the dataset"
1905            );
1906        }
1907    }
1908
1909    #[test]
1910    fn dom_survives_a_very_large_buffer() {
1911        let big: String = "wide 😀 line\n".repeat(20_000);
1912        let dom = TextArea::create().with_text(AzString::from(big.as_str())).dom();
1913        assert_eq!(text_of(&dom.children.as_ref()[1]), Some(big.as_str()));
1914    }
1915
1916    #[test]
1917    fn styled_dom_navigation_matches_what_the_handlers_assume() {
1918        // The three handlers all walk container -> first child (placeholder)
1919        // -> next sibling (label) -> first child (cursor). If that walk ever
1920        // stops matching the DOM, every one of them silently no-ops.
1921        let (styled, nodes) = skeleton();
1922        let hierarchy = styled.node_hierarchy.as_container();
1923
1924        let placeholder = hierarchy[NodeId::new(nodes.container)]
1925            .first_child_id(NodeId::new(nodes.container))
1926            .expect("the container must have a first child");
1927        assert_eq!(placeholder.index(), nodes.placeholder);
1928
1929        let label = hierarchy[placeholder]
1930            .next_sibling_id()
1931            .expect("the placeholder must have a next sibling");
1932        assert_eq!(label.index(), nodes.label);
1933
1934        let cursor = hierarchy[label]
1935            .first_child_id(label)
1936            .expect("the label must have a first child");
1937        assert_eq!(cursor.index(), nodes.cursor);
1938    }
1939
1940    // ==================================================================
1941    // default_on_focus_received
1942    // ==================================================================
1943
1944    #[test]
1945    fn focus_received_ignores_a_foreign_payload() {
1946        let data = RefAny::new(0u8);
1947        let (update, changes, _) = run(Env::default(), &data, |r, ci| default_on_focus_received(r, ci));
1948
1949        assert_eq!(update, Update::DoNothing);
1950        assert!(changes.is_empty(), "a foreign payload must not touch the DOM");
1951    }
1952
1953    #[test]
1954    fn focus_received_bails_out_without_a_hit_node() {
1955        let data = RefAny::new(wrapper(""));
1956        poke(&data, |w| w.inner.cursor_pos = 42);
1957
1958        let (update, changes, _) = run(
1959            Env::default().hitting(Hit::Nothing),
1960            &data,
1961            |r, ci| default_on_focus_received(r, ci),
1962        );
1963
1964        assert_eq!(update, Update::DoNothing);
1965        assert!(changes.is_empty());
1966        assert_eq!(
1967            read(&data).cursor_pos,
1968            42,
1969            "the early return happens *before* the cursor is repaired"
1970        );
1971    }
1972
1973    #[test]
1974    fn focus_received_bails_out_on_a_childless_hit_node() {
1975        let data = RefAny::new(wrapper("text"));
1976        let (update, changes, _) = run(
1977            Env::default().hitting(Hit::Cursor),
1978            &data,
1979            |r, ci| default_on_focus_received(r, ci),
1980        );
1981
1982        assert_eq!(update, Update::DoNothing);
1983        assert!(changes.is_empty());
1984    }
1985
1986    #[test]
1987    fn focus_received_hides_the_placeholder_only_while_the_buffer_is_empty() {
1988        let empty = RefAny::new(wrapper(""));
1989        let (update, changes, nodes) = run(Env::default(), &empty, |r, ci| default_on_focus_received(r, ci));
1990        assert_eq!(update, Update::DoNothing);
1991        assert_eq!(
1992            opacity_writes(&changes),
1993            vec![(nodes.placeholder, 0.0)],
1994            "an empty area hides its placeholder on focus"
1995        );
1996
1997        let filled = RefAny::new(wrapper("typed"));
1998        let (update, changes, _) = run(Env::default(), &filled, |r, ci| default_on_focus_received(r, ci));
1999        assert_eq!(update, Update::DoNothing);
2000        assert!(
2001            changes.is_empty(),
2002            "a non-empty area has nothing to hide — the placeholder is already gone"
2003        );
2004    }
2005
2006    #[test]
2007    fn focus_received_repairs_a_stale_cursor() {
2008        for (text, expected) in [("", 0usize), ("abc", 3), ("😀 x", 3)] {
2009            let data = RefAny::new(wrapper(text));
2010            poke(&data, |w| w.inner.cursor_pos = usize::MAX);
2011
2012            let (_, _, _) = run(Env::default(), &data, |r, ci| default_on_focus_received(r, ci));
2013            assert_eq!(
2014                read(&data).cursor_pos,
2015                expected,
2016                "focus must snap the cursor to the end for {text:?}"
2017            );
2018        }
2019    }
2020
2021    #[test]
2022    fn focus_received_does_not_edit_the_buffer() {
2023        let data = RefAny::new(wrapper("untouched\ntext"));
2024        let (_, _, _) = run(Env::default(), &data, |r, ci| default_on_focus_received(r, ci));
2025        assert_eq!(read(&data).get_text(), "untouched\ntext");
2026    }
2027
2028    // ==================================================================
2029    // default_on_focus_lost
2030    // ==================================================================
2031
2032    #[test]
2033    fn focus_lost_ignores_a_foreign_payload() {
2034        let data = RefAny::new("not a text area".to_string());
2035        let (update, changes, _) = run(Env::default(), &data, |r, ci| default_on_focus_lost(r, ci));
2036
2037        assert_eq!(update, Update::DoNothing);
2038        assert!(changes.is_empty());
2039    }
2040
2041    #[test]
2042    fn focus_lost_shows_the_placeholder_only_while_the_buffer_is_empty() {
2043        let empty = RefAny::new(wrapper(""));
2044        let (update, changes, nodes) = run(Env::default(), &empty, |r, ci| default_on_focus_lost(r, ci));
2045        assert_eq!(update, Update::DoNothing);
2046        assert_eq!(opacity_writes(&changes), vec![(nodes.placeholder, 1.0)]);
2047
2048        let filled = RefAny::new(wrapper("typed"));
2049        let (update, changes, _) = run(Env::default(), &filled, |r, ci| default_on_focus_lost(r, ci));
2050        assert_eq!(update, Update::DoNothing);
2051        assert!(changes.is_empty());
2052    }
2053
2054    #[test]
2055    fn focus_lost_forwards_the_state_to_the_user_hook() {
2056        let log = focus_log(Update::RefreshDomAllWindows);
2057        let mut state = wrapper("saved\ntext");
2058        state.on_focus_lost = Some(TextAreaOnFocusLost {
2059            callback: (record_focus_lost as TextAreaOnFocusLostCallbackType).into(),
2060            refany: log.clone(),
2061        })
2062        .into();
2063        let data = RefAny::new(state);
2064
2065        let (update, _, _) = run(Env::default(), &data, |r, ci| default_on_focus_lost(r, ci));
2066
2067        assert_eq!(
2068            update,
2069            Update::RefreshDomAllWindows,
2070            "the hook's Update must be propagated verbatim"
2071        );
2072        let seen = focus_seen(&log);
2073        assert_eq!(seen.len(), 1);
2074        assert_eq!(seen[0].get_text(), "saved\ntext");
2075    }
2076
2077    #[test]
2078    fn focus_lost_skips_the_user_hook_when_the_dom_has_no_children() {
2079        // The DOM walk happens *before* the hook is dispatched, so a text area
2080        // whose node has no children never notifies its owner. Pinned as the
2081        // current contract, not endorsed as ideal.
2082        let log = focus_log(Update::RefreshDom);
2083        let mut state = wrapper("x");
2084        state.on_focus_lost = Some(TextAreaOnFocusLost {
2085            callback: (record_focus_lost as TextAreaOnFocusLostCallbackType).into(),
2086            refany: log.clone(),
2087        })
2088        .into();
2089        let data = RefAny::new(state);
2090
2091        let (update, changes, _) = run(
2092            Env::default().hitting(Hit::Cursor),
2093            &data,
2094            |r, ci| default_on_focus_lost(r, ci),
2095        );
2096
2097        assert_eq!(update, Update::DoNothing);
2098        assert!(changes.is_empty());
2099        assert!(focus_seen(&log).is_empty(), "the hook must not have run");
2100    }
2101
2102    #[test]
2103    fn focus_lost_hands_the_hook_a_snapshot_it_cannot_write_back_through() {
2104        // The hook receives a *clone* of the inner state; mutating it (which the
2105        // signature allows, it is by value) must not reach the widget.
2106        let log = focus_log(Update::DoNothing);
2107        let mut state = wrapper("original");
2108        state.on_focus_lost = Some(TextAreaOnFocusLost {
2109            callback: (record_focus_lost as TextAreaOnFocusLostCallbackType).into(),
2110            refany: log.clone(),
2111        })
2112        .into();
2113        let data = RefAny::new(state);
2114
2115        let (_, _, _) = run(Env::default(), &data, |r, ci| default_on_focus_lost(r, ci));
2116
2117        let mut seen = focus_seen(&log);
2118        assert_eq!(seen.len(), 1);
2119        seen[0].text = buffer("clobbered");
2120        assert_eq!(read(&data).get_text(), "original");
2121    }
2122
2123    #[test]
2124    fn focus_lost_without_a_hook_reports_do_nothing() {
2125        let data = RefAny::new(wrapper("text"));
2126        let (update, _, _) = run(Env::default(), &data, |r, ci| default_on_focus_lost(r, ci));
2127        assert_eq!(update, Update::DoNothing);
2128    }
2129
2130    #[test]
2131    fn focus_lost_does_not_move_the_cursor() {
2132        let data = RefAny::new(wrapper("abcdef"));
2133        poke(&data, |w| w.inner.cursor_pos = 2);
2134
2135        let (_, _, _) = run(Env::default(), &data, |r, ci| default_on_focus_lost(r, ci));
2136
2137        assert_eq!(
2138            read(&data).cursor_pos,
2139            2,
2140            "only focus-received re-snaps the cursor"
2141        );
2142    }
2143
2144    // ==================================================================
2145    // default_on_text_input / default_on_text_input_inner
2146    // ==================================================================
2147
2148    #[test]
2149    fn text_input_without_a_changeset_does_nothing() {
2150        let data = RefAny::new(wrapper("abc"));
2151        let (out, changes, _) = run(Env::default(), &data, default_on_text_input_inner);
2152
2153        assert_eq!(out, None);
2154        assert!(changes.is_empty());
2155        assert_eq!(read(&data).get_text(), "abc");
2156    }
2157
2158    #[test]
2159    fn text_input_with_an_empty_insertion_does_nothing() {
2160        let data = RefAny::new(wrapper("abc"));
2161        let (out, changes, _) = run(Env::typed(""), &data, default_on_text_input_inner);
2162
2163        assert_eq!(out, None, "an empty insertion is not an edit");
2164        assert!(changes.is_empty());
2165        assert_eq!(read(&data).get_text(), "abc");
2166    }
2167
2168    #[test]
2169    fn text_input_ignores_a_foreign_payload() {
2170        let data = RefAny::new(1234u64);
2171        let (out, changes, _) = run(Env::typed("x"), &data, default_on_text_input_inner);
2172
2173        assert_eq!(out, None);
2174        assert!(changes.is_empty());
2175    }
2176
2177    #[test]
2178    fn text_input_bails_out_on_a_childless_hit_node() {
2179        let data = RefAny::new(wrapper("abc"));
2180        let (out, changes, _) = run(
2181            Env::typed("x").hitting(Hit::Cursor),
2182            &data,
2183            default_on_text_input_inner,
2184        );
2185
2186        assert_eq!(out, None);
2187        assert!(changes.is_empty());
2188        assert_eq!(read(&data).get_text(), "abc", "no DOM, no edit");
2189    }
2190
2191    #[test]
2192    fn text_input_bails_out_when_the_hit_node_has_no_sibling_chain() {
2193        // Hitting the placeholder: it has no children at all, so the walk stops
2194        // at the very first step.
2195        let data = RefAny::new(wrapper("abc"));
2196        let (out, changes, _) = run(
2197            Env::typed("x").hitting(Hit::Placeholder),
2198            &data,
2199            default_on_text_input_inner,
2200        );
2201
2202        assert_eq!(out, None);
2203        assert!(changes.is_empty());
2204        assert_eq!(read(&data).get_text(), "abc");
2205    }
2206
2207    #[test]
2208    fn text_input_appends_and_repaints() {
2209        let data = RefAny::new(wrapper("ab"));
2210        let (out, changes, nodes) = run(Env::typed("cd"), &data, default_on_text_input_inner);
2211
2212        assert_eq!(out, Some(Update::DoNothing), "no hook means no refresh");
2213        assert_eq!(read(&data).get_text(), "abcd");
2214        assert_eq!(
2215            opacity_writes(&changes),
2216            vec![(nodes.placeholder, 0.0)],
2217            "typing hides the placeholder"
2218        );
2219        assert_eq!(
2220            text_writes(&changes),
2221            vec![(nodes.label, "abcd".to_string())],
2222            "the label is rewritten with the *whole* buffer, not the delta"
2223        );
2224    }
2225
2226    #[test]
2227    fn text_input_preserves_embedded_newlines() {
2228        let data = RefAny::new(wrapper("first"));
2229        let (out, changes, nodes) = run(Env::typed("\nsecond\n"), &data, default_on_text_input_inner);
2230
2231        assert_eq!(out, Some(Update::DoNothing));
2232        assert_eq!(read(&data).get_text(), "first\nsecond\n");
2233        assert_eq!(
2234            text_writes(&changes),
2235            vec![(nodes.label, "first\nsecond\n".to_string())]
2236        );
2237    }
2238
2239    #[test]
2240    fn text_input_stores_pasted_unicode_by_char() {
2241        for s in ROUND_TRIP {
2242            if s.is_empty() {
2243                continue; // an empty insertion is a documented no-op
2244            }
2245            let data = RefAny::new(wrapper(""));
2246            let (out, _, _) = run(Env::typed(s), &data, default_on_text_input_inner);
2247
2248            assert_eq!(out, Some(Update::DoNothing), "insertion of {s:?}");
2249            let state = read(&data);
2250            assert_eq!(state.get_text(), s, "insertion of {s:?} must be lossless");
2251            assert_eq!(state.text.len(), s.chars().count());
2252        }
2253    }
2254
2255    #[test]
2256    fn text_input_advances_the_cursor_by_bytes_not_chars() {
2257        // KNOWN QUIRK: the buffer grows by `chars`, but `cursor_pos` is advanced
2258        // by `inserted_text.len()`, which is a *byte* count. For any non-ASCII
2259        // insertion the cursor therefore ends up past the end of the buffer.
2260        // `dom()` and `default_on_focus_received` both re-snap it, which is why
2261        // this is survivable — pinned here so the divergence is visible.
2262        let data = RefAny::new(wrapper(""));
2263        let (_, _, _) = run(Env::typed("😀"), &data, default_on_text_input_inner);
2264
2265        let state = read(&data);
2266        assert_eq!(state.text.len(), 1, "one char went into the buffer");
2267        assert_eq!(state.cursor_pos, 4, "but the cursor moved by four bytes");
2268        assert!(
2269            state.cursor_pos > state.text.len(),
2270            "the cursor is left past the end of the buffer"
2271        );
2272
2273        // ASCII is the case where the two counts happen to agree.
2274        let ascii = RefAny::new(wrapper(""));
2275        let (_, _, _) = run(Env::typed("abcd"), &ascii, default_on_text_input_inner);
2276        let ascii_state = read(&ascii);
2277        assert_eq!(ascii_state.cursor_pos, ascii_state.text.len());
2278    }
2279
2280    #[test]
2281    fn text_input_does_not_enforce_max_len() {
2282        // KNOWN GAP: `max_len` is never consulted by the edit path. Typing past
2283        // it is accepted silently.
2284        let data = RefAny::new(wrapper("ab"));
2285        poke(&data, |w| w.inner.max_len = 2);
2286
2287        let (out, _, _) = run(Env::typed("cdefgh"), &data, default_on_text_input_inner);
2288
2289        assert_eq!(out, Some(Update::DoNothing));
2290        assert_eq!(read(&data).get_text(), "abcdefgh");
2291        assert_eq!(read(&data).max_len, 2, "the limit is stored, just not applied");
2292    }
2293
2294    #[test]
2295    fn text_input_shows_the_hook_the_would_be_text_before_committing() {
2296        let log = edit_log(ACCEPT);
2297        let mut state = wrapper("old");
2298        state.on_text_input = Some(TextAreaOnTextInput {
2299            callback: (record_text_input as TextAreaOnTextInputCallbackType).into(),
2300            refany: log.clone(),
2301        })
2302        .into();
2303        let data = RefAny::new(state);
2304
2305        let (out, _, _) = run(Env::typed("+new"), &data, default_on_text_input_inner);
2306
2307        assert_eq!(out, Some(Update::RefreshDom), "the hook's Update wins");
2308        let seen = edits_seen(&log);
2309        assert_eq!(seen.len(), 1);
2310        assert_eq!(
2311            seen[0].get_text(),
2312            "old+new",
2313            "the hook is shown the text as it *would* be after the edit"
2314        );
2315        assert_eq!(read(&data).get_text(), "old+new");
2316    }
2317
2318    #[test]
2319    fn text_input_rejected_by_the_hook_changes_nothing() {
2320        let log = edit_log(REJECT);
2321        let mut state = wrapper("locked");
2322        state.on_text_input = Some(TextAreaOnTextInput {
2323            callback: (record_text_input as TextAreaOnTextInputCallbackType).into(),
2324            refany: log.clone(),
2325        })
2326        .into();
2327        let data = RefAny::new(state);
2328
2329        let (out, changes, _) = run(Env::typed("nope"), &data, default_on_text_input_inner);
2330
2331        assert_eq!(
2332            out,
2333            Some(Update::RefreshDomAllWindows),
2334            "a rejected edit still returns the hook's Update"
2335        );
2336        assert!(changes.is_empty(), "a rejected edit must not repaint");
2337        let state = read(&data);
2338        assert_eq!(state.get_text(), "locked");
2339        assert_eq!(state.cursor_pos, 0, "and must not move the cursor");
2340        assert_eq!(edits_seen(&log).len(), 1, "the hook still ran exactly once");
2341    }
2342
2343    #[test]
2344    fn text_input_accumulates_across_edits() {
2345        let data = RefAny::new(wrapper(""));
2346        for chunk in ["a", "b\n", "c"] {
2347            let (out, _, _) = run(Env::typed(chunk), &data, default_on_text_input_inner);
2348            assert_eq!(out, Some(Update::DoNothing));
2349        }
2350
2351        let state = read(&data);
2352        assert_eq!(state.get_text(), "ab\nc");
2353        assert_eq!(state.cursor_pos, 4);
2354    }
2355
2356    #[test]
2357    fn text_input_writes_the_filtered_text_not_the_raw_buffer() {
2358        // Non-scalar units already in the buffer survive the edit but never
2359        // reach the label, so the rendered string is shorter than the buffer.
2360        let data = RefAny::new(wrapper("a"));
2361        poke(&data, |w| {
2362            let mut units = w.inner.text.clone().into_library_owned_vec();
2363            units.extend(NON_SCALAR);
2364            w.inner.text = units.into();
2365        });
2366
2367        let (out, changes, nodes) = run(Env::typed("b"), &data, default_on_text_input_inner);
2368
2369        assert_eq!(out, Some(Update::DoNothing));
2370        assert_eq!(
2371            text_writes(&changes),
2372            vec![(nodes.label, "ab".to_string())],
2373            "only the scalars are rendered"
2374        );
2375        assert_eq!(
2376            read(&data).text.len(),
2377            NON_SCALAR.len() + 2,
2378            "the raw buffer keeps everything"
2379        );
2380    }
2381
2382    #[test]
2383    fn text_input_extern_wrapper_maps_none_onto_do_nothing() {
2384        let data = RefAny::new(wrapper("abc"));
2385        let (update, changes, _) = run(Env::default(), &data, |r, ci| default_on_text_input(r, ci));
2386
2387        assert_eq!(update, Update::DoNothing);
2388        assert!(changes.is_empty());
2389    }
2390
2391    #[test]
2392    fn text_input_survives_a_very_large_insertion() {
2393        let big: String = "chunk 😀\n".repeat(10_000);
2394        let data = RefAny::new(wrapper(""));
2395
2396        let (out, changes, nodes) = run(Env::typed(&big), &data, default_on_text_input_inner);
2397
2398        assert_eq!(out, Some(Update::DoNothing));
2399        assert_eq!(read(&data).text.len(), big.chars().count());
2400        assert_eq!(text_writes(&changes), vec![(nodes.label, big)]);
2401    }
2402
2403    // ==================================================================
2404    // default_on_virtual_key_down / default_on_virtual_key_down_inner
2405    // ==================================================================
2406
2407    #[test]
2408    fn virtual_key_down_without_a_keycode_does_nothing() {
2409        let data = RefAny::new(wrapper("abc"));
2410        let (out, changes, _) = run(Env::default(), &data, default_on_virtual_key_down_inner);
2411
2412        assert_eq!(out, None);
2413        assert!(changes.is_empty());
2414        assert_eq!(read(&data).get_text(), "abc");
2415    }
2416
2417    #[test]
2418    fn virtual_key_down_ignores_a_foreign_payload() {
2419        let data = RefAny::new(0i64);
2420        let (out, changes, _) = run(
2421            Env::key(VirtualKeyCode::Back),
2422            &data,
2423            default_on_virtual_key_down_inner,
2424        );
2425
2426        assert_eq!(out, None);
2427        assert!(changes.is_empty());
2428    }
2429
2430    #[test]
2431    fn virtual_key_down_bails_out_on_a_childless_hit_node() {
2432        let log = edit_log(ACCEPT);
2433        let mut state = wrapper("abc");
2434        state.on_virtual_key_down = Some(TextAreaOnVirtualKeyDown {
2435            callback: (record_virtual_key as TextAreaOnVirtualKeyDownCallbackType).into(),
2436            refany: log.clone(),
2437        })
2438        .into();
2439        let data = RefAny::new(state);
2440
2441        let (out, changes, _) = run(
2442            Env::key(VirtualKeyCode::Back).hitting(Hit::Cursor),
2443            &data,
2444            default_on_virtual_key_down_inner,
2445        );
2446
2447        assert_eq!(out, None);
2448        assert!(changes.is_empty());
2449        assert_eq!(read(&data).get_text(), "abc");
2450        assert!(
2451            edits_seen(&log).is_empty(),
2452            "the DOM walk precedes the hook, so it never ran"
2453        );
2454    }
2455
2456    #[test]
2457    fn backspace_on_an_empty_buffer_does_not_underflow() {
2458        let data = RefAny::new(wrapper(""));
2459        let (out, changes, nodes) = run(
2460            Env::key(VirtualKeyCode::Back),
2461            &data,
2462            default_on_virtual_key_down_inner,
2463        );
2464
2465        assert_eq!(out, Some(Update::DoNothing));
2466        let state = read(&data);
2467        assert!(state.text.is_empty());
2468        assert_eq!(state.cursor_pos, 0, "saturating_sub must hold the floor");
2469        // It still repaints: an empty buffer re-shows the placeholder.
2470        assert_eq!(text_writes(&changes), vec![(nodes.label, String::new())]);
2471        assert_eq!(opacity_writes(&changes), vec![(nodes.placeholder, 1.0)]);
2472    }
2473
2474    #[test]
2475    fn backspace_removes_exactly_one_char() {
2476        for (before, after) in [
2477            ("abc", "ab"),
2478            ("a", ""),
2479            ("a\n", "a"),
2480            ("😀😀", "😀"),        // astral chars are one buffer slot each
2481            ("e\u{301}", "e"),     // a combining mark is its own char
2482            ("日本語", "日本"),
2483        ] {
2484            let data = RefAny::new(wrapper(before));
2485            let (out, changes, nodes) = run(
2486                Env::key(VirtualKeyCode::Back),
2487                &data,
2488                default_on_virtual_key_down_inner,
2489            );
2490
2491            assert_eq!(out, Some(Update::DoNothing));
2492            let state = read(&data);
2493            assert_eq!(state.get_text(), after, "backspace on {before:?}");
2494            assert_eq!(state.text.len(), after.chars().count());
2495            assert_eq!(text_writes(&changes), vec![(nodes.label, after.to_string())]);
2496        }
2497    }
2498
2499    #[test]
2500    fn backspace_re_shows_the_placeholder_only_once_the_buffer_empties() {
2501        let data = RefAny::new(wrapper("ab"));
2502
2503        let (_, changes, _) = run(
2504            Env::key(VirtualKeyCode::Back),
2505            &data,
2506            default_on_virtual_key_down_inner,
2507        );
2508        assert!(
2509            opacity_writes(&changes).is_empty(),
2510            "still one char left — the placeholder stays hidden"
2511        );
2512
2513        let (_, changes, nodes) = run(
2514            Env::key(VirtualKeyCode::Back),
2515            &data,
2516            default_on_virtual_key_down_inner,
2517        );
2518        assert_eq!(opacity_writes(&changes), vec![(nodes.placeholder, 1.0)]);
2519    }
2520
2521    #[test]
2522    fn backspace_clamps_the_cursor_instead_of_wrapping() {
2523        let data = RefAny::new(wrapper("abc"));
2524        poke(&data, |w| w.inner.cursor_pos = 0);
2525
2526        let (_, _, _) = run(
2527            Env::key(VirtualKeyCode::Back),
2528            &data,
2529            default_on_virtual_key_down_inner,
2530        );
2531
2532        assert_eq!(
2533            read(&data).cursor_pos,
2534            0,
2535            "0 - 1 must saturate, never wrap to usize::MAX"
2536        );
2537    }
2538
2539    #[test]
2540    fn backspace_pops_a_non_scalar_unit_too() {
2541        let data = RefAny::new(wrapper("ab"));
2542        poke(&data, |w| {
2543            let mut units = w.inner.text.clone().into_library_owned_vec();
2544            units.push(0xD800); // a lone surrogate, invisible to get_text
2545            w.inner.text = units.into();
2546        });
2547
2548        let (_, changes, nodes) = run(
2549            Env::key(VirtualKeyCode::Back),
2550            &data,
2551            default_on_virtual_key_down_inner,
2552        );
2553
2554        let state = read(&data);
2555        assert_eq!(state.text.len(), 2, "the surrogate was the unit popped");
2556        assert_eq!(state.get_text(), "ab");
2557        assert_eq!(text_writes(&changes), vec![(nodes.label, "ab".to_string())]);
2558    }
2559
2560    #[test]
2561    fn return_inserts_a_newline() {
2562        let data = RefAny::new(wrapper("line"));
2563        let (out, changes, nodes) = run(
2564            Env::key(VirtualKeyCode::Return),
2565            &data,
2566            default_on_virtual_key_down_inner,
2567        );
2568
2569        assert_eq!(out, Some(Update::DoNothing));
2570        let state = read(&data);
2571        assert_eq!(state.get_text(), "line\n");
2572        assert_eq!(state.cursor_pos, 1, "cursor_pos started at 0 and moved by one");
2573        assert_eq!(text_writes(&changes), vec![(nodes.label, "line\n".to_string())]);
2574        assert_eq!(
2575            opacity_writes(&changes),
2576            vec![(nodes.placeholder, 0.0)],
2577            "the buffer is non-empty, so the placeholder must be hidden"
2578        );
2579    }
2580
2581    #[test]
2582    fn numpad_enter_behaves_exactly_like_return() {
2583        let via_return = RefAny::new(wrapper("x"));
2584        let (a_out, a_changes, _) = run(
2585            Env::key(VirtualKeyCode::Return),
2586            &via_return,
2587            default_on_virtual_key_down_inner,
2588        );
2589
2590        let via_numpad = RefAny::new(wrapper("x"));
2591        let (b_out, b_changes, _) = run(
2592            Env::key(VirtualKeyCode::NumpadEnter),
2593            &via_numpad,
2594            default_on_virtual_key_down_inner,
2595        );
2596
2597        assert_eq!(a_out, b_out);
2598        assert_eq!(read(&via_return), read(&via_numpad));
2599        assert_eq!(text_writes(&a_changes), text_writes(&b_changes));
2600        assert_eq!(opacity_writes(&a_changes), opacity_writes(&b_changes));
2601    }
2602
2603    #[test]
2604    fn repeated_returns_accumulate_blank_lines() {
2605        let data = RefAny::new(wrapper(""));
2606        for _ in 0..5 {
2607            let (out, _, _) = run(
2608                Env::key(VirtualKeyCode::Return),
2609                &data,
2610                default_on_virtual_key_down_inner,
2611            );
2612            assert_eq!(out, Some(Update::DoNothing));
2613        }
2614
2615        let state = read(&data);
2616        assert_eq!(state.get_text(), "\n\n\n\n\n");
2617        assert_eq!(state.cursor_pos, 5);
2618    }
2619
2620    #[test]
2621    fn a_plain_character_key_is_left_to_the_text_input_path() {
2622        // Printable keys arrive through `default_on_text_input`; the key handler
2623        // must not double-insert them.
2624        for key in [
2625            VirtualKeyCode::A,
2626            VirtualKeyCode::Space,
2627            VirtualKeyCode::Tab,
2628            VirtualKeyCode::Escape,
2629            VirtualKeyCode::Left,
2630        ] {
2631            let data = RefAny::new(wrapper("abc"));
2632            let (out, changes, _) = run(Env::key(key), &data, default_on_virtual_key_down_inner);
2633
2634            assert_eq!(out, Some(Update::DoNothing), "{key:?}");
2635            assert!(changes.is_empty(), "{key:?} must not repaint");
2636            assert_eq!(read(&data).get_text(), "abc", "{key:?} must not edit");
2637        }
2638    }
2639
2640    #[test]
2641    fn the_hook_runs_even_for_keys_that_do_not_edit() {
2642        let log = edit_log(ACCEPT);
2643        let mut state = wrapper("abc");
2644        state.on_virtual_key_down = Some(TextAreaOnVirtualKeyDown {
2645            callback: (record_virtual_key as TextAreaOnVirtualKeyDownCallbackType).into(),
2646            refany: log.clone(),
2647        })
2648        .into();
2649        let data = RefAny::new(state);
2650
2651        let (out, changes, _) = run(
2652            Env::key(VirtualKeyCode::F1),
2653            &data,
2654            default_on_virtual_key_down_inner,
2655        );
2656
2657        assert_eq!(out, Some(Update::RefreshDom), "the hook's Update is returned");
2658        assert!(changes.is_empty());
2659        let seen = edits_seen(&log);
2660        assert_eq!(seen.len(), 1);
2661        assert_eq!(seen[0].get_text(), "abc", "the hook sees the pre-edit state");
2662    }
2663
2664    #[test]
2665    fn a_rejecting_hook_suppresses_the_built_in_editing() {
2666        for key in [VirtualKeyCode::Back, VirtualKeyCode::Return] {
2667            let log = edit_log(REJECT);
2668            let mut state = wrapper("frozen");
2669            state.on_virtual_key_down = Some(TextAreaOnVirtualKeyDown {
2670                callback: (record_virtual_key as TextAreaOnVirtualKeyDownCallbackType).into(),
2671                refany: log.clone(),
2672            })
2673            .into();
2674            let data = RefAny::new(state);
2675
2676            let (out, changes, _) = run(Env::key(key), &data, default_on_virtual_key_down_inner);
2677
2678            assert_eq!(out, Some(Update::RefreshDomAllWindows), "{key:?}");
2679            assert!(changes.is_empty(), "{key:?} must not repaint");
2680            assert_eq!(read(&data).get_text(), "frozen", "{key:?} must not edit");
2681            assert_eq!(edits_seen(&log).len(), 1);
2682        }
2683    }
2684
2685    #[test]
2686    fn an_accepting_hook_lets_the_edit_through_and_still_sets_the_update() {
2687        let log = edit_log(ACCEPT);
2688        let mut state = wrapper("ab");
2689        state.on_virtual_key_down = Some(TextAreaOnVirtualKeyDown {
2690            callback: (record_virtual_key as TextAreaOnVirtualKeyDownCallbackType).into(),
2691            refany: log.clone(),
2692        })
2693        .into();
2694        let data = RefAny::new(state);
2695
2696        let (out, changes, nodes) = run(
2697            Env::key(VirtualKeyCode::Back),
2698            &data,
2699            default_on_virtual_key_down_inner,
2700        );
2701
2702        assert_eq!(out, Some(Update::RefreshDom));
2703        assert_eq!(read(&data).get_text(), "a");
2704        assert_eq!(text_writes(&changes), vec![(nodes.label, "a".to_string())]);
2705    }
2706
2707    #[test]
2708    fn virtual_key_down_extern_wrapper_maps_none_onto_do_nothing() {
2709        let data = RefAny::new(wrapper("abc"));
2710        let (update, changes, _) = run(Env::default(), &data, |r, ci| default_on_virtual_key_down(r, ci));
2711
2712        assert_eq!(update, Update::DoNothing);
2713        assert!(changes.is_empty());
2714    }
2715
2716    #[test]
2717    fn typing_then_editing_keeps_buffer_and_label_in_agreement() {
2718        // One end-to-end pass over the three edit paths: paste, newline,
2719        // backspace. The rendered label must equal `get_text()` at every step.
2720        let data = RefAny::new(wrapper(""));
2721
2722        let (_, changes, nodes) = run(Env::typed("hello"), &data, default_on_text_input_inner);
2723        assert_eq!(text_writes(&changes), vec![(nodes.label, "hello".to_string())]);
2724
2725        let (_, changes, nodes) = run(
2726            Env::key(VirtualKeyCode::Return),
2727            &data,
2728            default_on_virtual_key_down_inner,
2729        );
2730        assert_eq!(text_writes(&changes), vec![(nodes.label, "hello\n".to_string())]);
2731
2732        let (_, changes, nodes) = run(Env::typed("world"), &data, default_on_text_input_inner);
2733        assert_eq!(
2734            text_writes(&changes),
2735            vec![(nodes.label, "hello\nworld".to_string())]
2736        );
2737
2738        let (_, changes, nodes) = run(
2739            Env::key(VirtualKeyCode::Back),
2740            &data,
2741            default_on_virtual_key_down_inner,
2742        );
2743        assert_eq!(
2744            text_writes(&changes),
2745            vec![(nodes.label, "hello\nworl".to_string())]
2746        );
2747
2748        assert_eq!(read(&data).get_text(), "hello\nworl");
2749    }
2750
2751    // ==================================================================
2752    // Every handler, fired before the first layout
2753    // ==================================================================
2754
2755    /// An `Env` whose `LayoutWindow` holds no layout result at all — the state a
2756    /// callback sees if it is dispatched before the DOM has ever been laid out.
2757    fn before_first_layout() -> Env {
2758        Env {
2759            with_dom: false,
2760            ..Env::default()
2761        }
2762    }
2763
2764    #[test]
2765    fn focus_received_is_inert_before_the_first_layout() {
2766        let data = RefAny::new(wrapper(""));
2767        poke(&data, |w| w.inner.cursor_pos = 5);
2768
2769        let (update, changes, _) = run(before_first_layout(), &data, |r, ci| default_on_focus_received(r, ci));
2770
2771        assert_eq!(update, Update::DoNothing);
2772        assert!(changes.is_empty());
2773        assert_eq!(read(&data).cursor_pos, 5, "the cursor is not repaired either");
2774    }
2775
2776    #[test]
2777    fn focus_lost_is_inert_before_the_first_layout() {
2778        let log = focus_log(Update::RefreshDom);
2779        let mut state = wrapper("");
2780        state.on_focus_lost = Some(TextAreaOnFocusLost {
2781            callback: (record_focus_lost as TextAreaOnFocusLostCallbackType).into(),
2782            refany: log.clone(),
2783        })
2784        .into();
2785        let data = RefAny::new(state);
2786
2787        let (update, changes, _) = run(before_first_layout(), &data, |r, ci| default_on_focus_lost(r, ci));
2788
2789        assert_eq!(update, Update::DoNothing);
2790        assert!(changes.is_empty());
2791        assert!(focus_seen(&log).is_empty());
2792    }
2793
2794    #[test]
2795    fn text_input_is_inert_before_the_first_layout() {
2796        let data = RefAny::new(wrapper("abc"));
2797        let env = Env {
2798            with_dom: false,
2799            ..Env::typed("xyz")
2800        };
2801
2802        let (out, changes, _) = run(env, &data, default_on_text_input_inner);
2803
2804        assert_eq!(out, None);
2805        assert!(changes.is_empty());
2806        assert_eq!(read(&data).get_text(), "abc", "no DOM to walk, no edit");
2807    }
2808
2809    #[test]
2810    fn virtual_key_down_is_inert_before_the_first_layout() {
2811        for key in [VirtualKeyCode::Back, VirtualKeyCode::Return] {
2812            let data = RefAny::new(wrapper("abc"));
2813            let env = Env {
2814                with_dom: false,
2815                ..Env::key(key)
2816            };
2817
2818            let (out, changes, _) = run(env, &data, default_on_virtual_key_down_inner);
2819
2820            assert_eq!(out, None, "{key:?}");
2821            assert!(changes.is_empty(), "{key:?}");
2822            assert_eq!(read(&data).get_text(), "abc", "{key:?}");
2823        }
2824    }
2825}