Skip to main content

tessera_components/
text_field.rs

1//! Material text fields for filled, outlined, and secure input.
2//!
3//! ## Usage
4//!
5//! Collect short-form inputs like names, passwords, or search queries.
6use glyphon::Action as GlyphonAction;
7use tessera_foundation::gesture::{ScrollRecognizer, TapRecognizer, TapSettings};
8use tessera_ui::{
9    AxisConstraint, Callback, CallbackWith, Color, ComputedData, Constraint, Dp, LayoutResult,
10    MeasurementError, Modifier, PressKeyEventType, Px, PxPosition, PxSize, RenderSlot,
11    ScrollDeltaUnit, State,
12    layout::{MeasureScope, layout},
13    modifier::{CursorModifierExt as _, FocusModifierExt as _},
14    normalize_platform_scroll_delta, provide_context, remember, tessera, use_context, winit,
15};
16
17use crate::{
18    alignment::{Alignment, CrossAxisAlignment},
19    boxed::boxed,
20    divider::horizontal_divider,
21    menus::{MenuAnchor, MenuController, MenuPlacement, menu_item, menu_provider},
22    modifier::{InteractionVisualState, ModifierExt as _, Padding, with_pointer_input},
23    pos_misc::is_position_inside_bounds,
24    res::I18N,
25    row::row,
26    shape_def::{RoundedCorner, Shape},
27    spacer::spacer,
28    surface::{SurfaceStyle, surface},
29    text::text,
30    text_edit_core::DisplayTransform,
31    text_input::{
32        DISABLED_CONTENT_ALPHA, DisplayTransformText, TextInputController, TextInputProps,
33        create_surface_args, text_input_core, visual_focus,
34    },
35    theme::{
36        ContentColor, MaterialColorScheme, MaterialShapes, MaterialTheme, TextSelectionColors,
37        TextStyle,
38    },
39};
40
41#[cfg(test)]
42use tessera_ui::PxRect;
43
44/// Defaults for Material text fields.
45pub struct TextFieldDefaults;
46
47impl TextFieldDefaults {
48    /// Default minimum width for text fields.
49    pub const MIN_WIDTH: Dp = Dp(280.0);
50    /// Default minimum height for text fields.
51    pub const MIN_HEIGHT: Dp = Dp(56.0);
52    /// Default padding applied on all sides.
53    pub const CONTENT_PADDING: Dp = Dp(16.0);
54    /// Default outline border width for outlined fields.
55    pub const OUTLINED_BORDER_WIDTH: Dp = Dp(1.0);
56    /// Default focused border width for outlined fields.
57    pub const OUTLINED_FOCUS_BORDER_WIDTH: Dp = Dp(2.0);
58    /// Default unfocused indicator thickness for filled fields.
59    pub const INDICATOR_THICKNESS: Dp = Dp(1.0);
60    /// Default focused indicator thickness for filled fields.
61    pub const INDICATOR_FOCUSED_THICKNESS: Dp = Dp(2.0);
62    /// Suggested vertical spacing for custom label arrangements.
63    pub const LABEL_BOTTOM_PADDING: Dp = Dp(4.0);
64    /// Vertical offset from the container top for floating labels.
65    pub const FLOATING_LABEL_Y_OFFSET: Dp = Dp(8.0);
66    /// Horizontal padding applied around outlined floating labels.
67    pub const OUTLINED_LABEL_PADDING: Dp = Dp(2.0);
68    /// Extra height applied to outlined label notches.
69    pub const OUTLINED_NOTCH_VERTICAL_PADDING: Dp = Dp(2.0);
70    /// Horizontal padding between icon slots and the text content.
71    pub const ICON_TEXT_PADDING: Dp = Dp(16.0);
72    /// Horizontal padding between prefix/suffix and the input text.
73    pub const PREFIX_SUFFIX_PADDING: Dp = Dp(2.0);
74    /// Default obfuscation character for secure fields.
75    pub const OBFUSCATION_CHAR: char = '*';
76}
77
78/// Line limit behavior for a text field.
79#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
80pub enum TextFieldLineLimit {
81    /// Restrict input to a single line.
82    #[default]
83    SingleLine,
84    /// Allow multiple lines of input.
85    MultiLine,
86}
87
88/// Controls which context menu actions are available.
89#[derive(Clone, PartialEq, Copy, Debug)]
90pub struct TextFieldContextMenu {
91    /// Whether the context menu is enabled.
92    pub enabled: bool,
93    /// Whether the Cut action is available.
94    pub allow_cut: bool,
95    /// Whether the Copy action is available.
96    pub allow_copy: bool,
97    /// Whether the Paste action is available.
98    pub allow_paste: bool,
99    /// Whether the Select All action is available.
100    pub allow_select_all: bool,
101}
102
103impl TextFieldContextMenu {
104    /// Disables the context menu.
105    pub fn disabled() -> Self {
106        Self {
107            enabled: false,
108            allow_cut: false,
109            allow_copy: false,
110            allow_paste: false,
111            allow_select_all: false,
112        }
113    }
114
115    /// Default policy for secure fields (no cut/copy).
116    pub fn secure_default() -> Self {
117        Self {
118            enabled: true,
119            allow_cut: false,
120            allow_copy: false,
121            allow_paste: true,
122            allow_select_all: true,
123        }
124    }
125}
126
127impl Default for TextFieldContextMenu {
128    fn default() -> Self {
129        Self {
130            enabled: true,
131            allow_cut: true,
132            allow_copy: true,
133            allow_paste: true,
134            allow_select_all: true,
135        }
136    }
137}
138
139/// Arguments for configuring a Material text field.
140#[derive(Clone, PartialEq)]
141struct TextFieldProps {
142    pub presentation_state: Option<InteractionVisualState>,
143    /// Whether the text field is enabled for user input.
144    pub enabled: bool,
145    /// Whether the text field is read-only.
146    pub read_only: bool,
147    /// Whether the field is displaying an error state.
148    pub is_error: bool,
149    /// Optional modifier chain applied to the field container.
150    pub modifier: Modifier,
151    /// Called when the text content changes. The closure receives the new text
152    /// content and returns the updated content.
153    pub on_change: CallbackWith<String, String>,
154    /// Called when the user submits a single-line field with the Enter key.
155    pub on_submit: Callback,
156    /// Minimum width in density-independent pixels.
157    pub min_width: Option<Dp>,
158    /// Minimum height in density-independent pixels.
159    pub min_height: Option<Dp>,
160    /// Background color of the text field.
161    pub background_color: Option<Color>,
162    /// Border width in Dp.
163    pub border_width: Dp,
164    /// Border color of the text field.
165    pub border_color: Option<Color>,
166    /// The shape of the text field container.
167    pub shape: Shape,
168    /// Padding inside the text field.
169    pub padding: Dp,
170    /// Horizontal content padding, defaulting to the uniform padding.
171    pub horizontal_padding: Option<Dp>,
172    /// Border color when focused.
173    pub focus_border_color: Option<Color>,
174    /// Border width when focused.
175    pub focus_border_width: Option<Dp>,
176    /// Background color when focused.
177    pub focus_background_color: Option<Color>,
178    /// Color for text selection highlight.
179    pub selection_color: Option<Color>,
180    /// Color of the text content.
181    pub text_color: Option<Color>,
182    /// Color of the text cursor.
183    pub cursor_color: Option<Color>,
184    /// Accessible name, defaulting to the visible label.
185    pub accessibility_label: Option<String>,
186    /// Optional description announced by assistive technologies.
187    pub accessibility_description: Option<String>,
188    /// Initial text content.
189    pub initial_text: Option<String>,
190    /// Font size in Dp.
191    pub font_size: Dp,
192    /// Line height in Dp.
193    pub line_height: Option<Dp>,
194    /// Optional typography override for editable text.
195    pub style: Option<TextStyle>,
196    /// Optional label text shown inside the field and floated when focused or
197    /// non-empty.
198    pub label: Option<String>,
199    /// Optional placeholder text shown when input is empty (and the label is
200    /// floating, if any).
201    pub placeholder: Option<String>,
202    /// Optional leading icon shown before the input text.
203    pub leading_icon: Option<RenderSlot>,
204    /// Optional trailing icon shown after the input text.
205    pub trailing_icon: Option<RenderSlot>,
206    /// Optional prefix content shown before the input text.
207    pub prefix: Option<RenderSlot>,
208    /// Optional suffix content shown after the input text.
209    pub suffix: Option<RenderSlot>,
210    /// Whether to show the filled-style indicator line.
211    pub show_indicator: bool,
212    /// Input line limit policy.
213    pub line_limit: TextFieldLineLimit,
214    /// Context menu configuration.
215    pub context_menu: TextFieldContextMenu,
216    /// Optional transform applied to text changes before on_change.
217    pub input_transform: Option<CallbackWith<String, String>>,
218    /// Optional obfuscation character for secure fields.
219    pub obfuscation_char: Option<char>,
220    /// Optional transform applied only for display.
221    pub display_transform: Option<DisplayTransform>,
222    /// Optional external controller for text, cursor, and selection state.
223    ///
224    /// When this is `None`, `text_field` creates and owns an internal
225    /// controller.
226    pub controller: Option<State<TextInputController>>,
227}
228
229impl TextFieldBuilder {
230    /// Applies filled text field defaults.
231    pub fn filled(self) -> Self {
232        self.background_color_optional(None)
233            .border_width(Dp::ZERO)
234            .focus_border_width_optional(None)
235            .focus_background_color_optional(None)
236    }
237
238    /// Applies outlined text field defaults.
239    pub fn outlined(self) -> Self {
240        self.background_color(Color::TRANSPARENT)
241            .border_width(TextFieldDefaults::OUTLINED_BORDER_WIDTH)
242            .focus_border_width(TextFieldDefaults::OUTLINED_FOCUS_BORDER_WIDTH)
243            .focus_background_color(Color::TRANSPARENT)
244    }
245
246    /// Applies secure text field defaults.
247    pub fn secure(self) -> Self {
248        self.obfuscation_char(TextFieldDefaults::OBFUSCATION_CHAR)
249            .line_limit(TextFieldLineLimit::SingleLine)
250            .context_menu(TextFieldContextMenu::secure_default())
251    }
252
253    /// Applies outlined secure text field defaults.
254    pub fn outlined_secure(self) -> Self {
255        self.outlined()
256            .obfuscation_char(TextFieldDefaults::OBFUSCATION_CHAR)
257            .line_limit(TextFieldLineLimit::SingleLine)
258            .context_menu(TextFieldContextMenu::secure_default())
259    }
260}
261
262impl Default for TextFieldProps {
263    fn default() -> Self {
264        let shape = filled_container_shape(&MaterialShapes::default());
265        Self {
266            presentation_state: None,
267            enabled: true,
268            read_only: false,
269            is_error: false,
270            modifier: Modifier::new(),
271            on_change: CallbackWith::identity(),
272            on_submit: Callback::noop(),
273            min_width: Some(TextFieldDefaults::MIN_WIDTH),
274            min_height: Some(TextFieldDefaults::MIN_HEIGHT),
275            background_color: None,
276            border_width: Dp(0.0),
277            border_color: None,
278            shape,
279            padding: TextFieldDefaults::CONTENT_PADDING,
280            horizontal_padding: None,
281            focus_border_color: None,
282            focus_border_width: None,
283            focus_background_color: None,
284            selection_color: None,
285            text_color: None,
286            cursor_color: None,
287            accessibility_label: None,
288            accessibility_description: None,
289            initial_text: None,
290            font_size: Dp(16.0),
291            line_height: Some(Dp(24.0)),
292            style: None,
293            label: None,
294            placeholder: None,
295            leading_icon: None,
296            trailing_icon: None,
297            prefix: None,
298            suffix: None,
299            show_indicator: true,
300            line_limit: TextFieldLineLimit::SingleLine,
301            context_menu: TextFieldContextMenu::default(),
302            input_transform: None,
303            obfuscation_char: None,
304            display_transform: None,
305            controller: None,
306        }
307    }
308}
309
310#[derive(Clone, Copy, Debug, PartialEq, Eq)]
311enum TextFieldMenuAction {
312    Cut,
313    Copy,
314    Paste,
315    SelectAll,
316}
317
318fn obfuscate_text(value: &str, mask: char) -> String {
319    value
320        .chars()
321        .map(|ch| if ch == '\n' || ch == '\r' { ch } else { mask })
322        .collect()
323}
324
325fn filled_container_shape(shapes: &MaterialShapes) -> Shape {
326    match shapes.extra_small {
327        Shape::RoundedRectangle {
328            top_left,
329            top_right,
330            ..
331        } => Shape::RoundedRectangle {
332            top_left,
333            top_right,
334            bottom_right: RoundedCorner::ZERO,
335            bottom_left: RoundedCorner::ZERO,
336        },
337        other => other,
338    }
339}
340
341fn apply_single_line(value: String) -> String {
342    value.replace(['\n', '\r'], "")
343}
344
345fn merge_input_transforms(
346    line_limit: TextFieldLineLimit,
347    input_transform: Option<CallbackWith<String, String>>,
348) -> Option<CallbackWith<String, String>> {
349    let line_transform = match line_limit {
350        TextFieldLineLimit::SingleLine => Some(CallbackWith::new(apply_single_line)),
351        TextFieldLineLimit::MultiLine => None,
352    };
353
354    match (line_transform, input_transform) {
355        (None, None) => None,
356        (Some(transform), None) => Some(transform),
357        (None, Some(transform)) => Some(transform),
358        (Some(line_transform), Some(input_transform)) => Some(CallbackWith::new(move |value| {
359            let value = input_transform.call(value);
360            line_transform.call(value)
361        })),
362    }
363}
364
365fn build_editor_args(
366    args: &TextFieldProps,
367    input_transform: Option<CallbackWith<String, String>>,
368    display_transform: Option<DisplayTransform>,
369) -> TextInputProps {
370    TextInputProps {
371        enabled: args.enabled,
372        presentation_state: args.presentation_state,
373        read_only: args.read_only,
374        is_error: args.is_error,
375        is_secure: args.obfuscation_char.is_some(),
376        modifier: args.modifier.clone(),
377        on_change: args.on_change,
378        on_submit: args.on_submit,
379        min_width: args.min_width,
380        min_height: args.min_height,
381        background_color: args.background_color,
382        border_width: args.border_width,
383        border_color: args.border_color,
384        shape: args.shape,
385        padding: args.padding,
386        focus_border_color: args.focus_border_color,
387        focus_border_width: args.focus_border_width,
388        focus_background_color: args.focus_background_color,
389        selection_color: args.selection_color,
390        text_color: args.text_color,
391        cursor_color: args.cursor_color,
392        accessibility_label: args
393            .accessibility_label
394            .clone()
395            .or_else(|| args.label.clone().filter(|label| !label.is_empty())),
396        accessibility_description: args.accessibility_description.clone(),
397        initial_text: args.initial_text.clone(),
398        font_size: args.font_size,
399        line_height: args.line_height,
400        style: args.style,
401        single_line: matches!(args.line_limit, TextFieldLineLimit::SingleLine),
402        input_transform,
403        display_transform,
404        controller: None,
405    }
406}
407
408fn editor_content_len(controller: &State<TextInputController>) -> usize {
409    controller.with(|c| c.text().len())
410}
411
412fn label_floating_text_style(theme: &MaterialTheme) -> TextStyle {
413    theme.typography.body_small
414}
415
416fn label_resting_text_style(theme: &MaterialTheme) -> TextStyle {
417    theme.typography.body_large
418}
419
420fn placeholder_text_style(theme: &MaterialTheme) -> TextStyle {
421    theme.typography.body_large
422}
423
424fn resolve_label_color(
425    scheme: &MaterialColorScheme,
426    focused: bool,
427    is_error: bool,
428    enabled: bool,
429) -> Color {
430    if !enabled {
431        return scheme.on_surface.with_alpha(DISABLED_CONTENT_ALPHA);
432    }
433    if is_error {
434        return scheme.error;
435    }
436    if focused {
437        scheme.primary
438    } else {
439        scheme.on_surface_variant
440    }
441}
442
443fn resolve_placeholder_color(scheme: &MaterialColorScheme, enabled: bool) -> Color {
444    if !enabled {
445        return scheme.on_surface.with_alpha(DISABLED_CONTENT_ALPHA);
446    }
447    scheme.on_surface_variant.with_alpha(0.7)
448}
449
450fn resolve_indicator_style(
451    scheme: &MaterialColorScheme,
452    focused: bool,
453    is_error: bool,
454    enabled: bool,
455) -> (Color, Dp) {
456    if !enabled {
457        return (
458            scheme.on_surface.with_alpha(DISABLED_CONTENT_ALPHA),
459            TextFieldDefaults::INDICATOR_THICKNESS,
460        );
461    }
462    if is_error {
463        let thickness = if focused {
464            TextFieldDefaults::INDICATOR_FOCUSED_THICKNESS
465        } else {
466            TextFieldDefaults::INDICATOR_THICKNESS
467        };
468        return (scheme.error, thickness);
469    }
470    if focused {
471        (
472            scheme.primary,
473            TextFieldDefaults::INDICATOR_FOCUSED_THICKNESS,
474        )
475    } else {
476        (
477            scheme.on_surface_variant,
478            TextFieldDefaults::INDICATOR_THICKNESS,
479        )
480    }
481}
482
483fn resolve_border_width(args: &TextFieldProps, focused: bool) -> Dp {
484    if focused {
485        args.focus_border_width.unwrap_or(args.border_width)
486    } else {
487        args.border_width
488    }
489}
490
491fn resolve_text_field_menu_policy(
492    mut menu: TextFieldContextMenu,
493    enabled: bool,
494    read_only: bool,
495) -> TextFieldContextMenu {
496    if !enabled {
497        return TextFieldContextMenu::disabled();
498    }
499    if !menu.enabled {
500        return menu;
501    }
502    if read_only {
503        menu.allow_cut = false;
504        menu.allow_paste = false;
505    }
506    menu
507}
508
509#[derive(Default)]
510struct FieldGeometry {
511    outer: Option<tessera_ui::LayoutCoordinates>,
512    editor: Option<tessera_ui::LayoutCoordinates>,
513}
514
515fn field_click_pointer_position(
516    cursor_pos: PxPosition,
517    geometry: State<FieldGeometry>,
518    controller: State<TextInputController>,
519) -> Option<PxPosition> {
520    let (outer, editor) = geometry.with(|geometry| (geometry.outer, geometry.editor));
521    let (outer, editor) = (outer?, editor?);
522    let editor_cursor = editor
523        .local_to_window
524        .inverse_point(outer.local_to_window.map_point(cursor_pos));
525    let (horizontal, vertical) = controller.with(|controller| {
526        (
527            controller.scroll_state().horizontal(),
528            controller.scroll_state().vertical(),
529        )
530    });
531    Some(PxPosition::from_f32_xy(
532        editor_cursor.x.clamp(0.0, editor.local_size.width.to_f32()) + horizontal,
533        editor_cursor
534            .y
535            .clamp(0.0, editor.local_size.height.to_f32())
536            + vertical,
537    ))
538}
539
540#[derive(Clone, PartialEq)]
541struct OutlinedFloatingLabelLayout {
542    label_offset: PxPosition,
543}
544
545impl tessera_ui::LayoutModifierNode for OutlinedFloatingLabelLayout {
546    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
547        let mut result = LayoutResult::default();
548        let children = input.children();
549        debug_assert_eq!(
550            children.len(),
551            1,
552            "OutlinedFloatingLabel expects exactly one child"
553        );
554        let label = children[0];
555
556        let parent_constraint = input.parent_constraint().without_min();
557        label.measure(input, &parent_constraint)?;
558        result.place_child(label, self.label_offset);
559
560        Ok(result.with_size(ComputedData {
561            width: Px(0),
562            height: Px(0),
563        }))
564    }
565}
566
567#[tessera]
568fn outlined_floating_label(
569    label_text: Option<String>,
570    label_color: Option<Color>,
571    label_font_size: Option<Dp>,
572    label_line_height: Option<Dp>,
573    label_offset_x: Option<Dp>,
574    label_offset_y: Option<Dp>,
575    outline: Option<State<OutlineGeometry>>,
576) {
577    let typography = use_context::<MaterialTheme>().unwrap().get().typography;
578    let label_text = label_text.unwrap_or_default();
579    let label_color = label_color.unwrap_or(Color::TRANSPARENT);
580    let label_font_size = label_font_size.unwrap_or(Dp(0.0));
581    let label_line_height = label_line_height.unwrap_or(Dp(0.0));
582    let label_offset_x = label_offset_x.unwrap_or(Dp(0.0));
583    let label_offset_y = label_offset_y.unwrap_or(Dp(0.0));
584    let outline = outline.expect("floating outlined label requires outline geometry");
585    layout()
586        .policy(OutlinedFloatingLabelLayout {
587            label_offset: PxPosition::new(label_offset_x.into(), label_offset_y.into()),
588        })
589        .child(move || {
590            text(label_text.clone())
591                .modifier(Modifier::new().on_layout_size_changed(
592                    move |size: tessera_ui::PxSize| {
593                        if outline.with(|outline| outline.label_width) != size.width {
594                            outline.with_mut(|outline| outline.label_width = size.width);
595                        }
596                    },
597                ))
598                .color(label_color)
599                .style(TextStyle {
600                    font_size: label_font_size,
601                    line_height: Some(label_line_height),
602                    ..typography.body_small
603                });
604        });
605}
606
607#[derive(Default, Clone, Copy, PartialEq)]
608struct OutlineGeometry {
609    size: PxSize,
610    label_width: Px,
611}
612
613#[derive(Clone, PartialEq)]
614struct OutlineSlicesLayout {
615    size: PxSize,
616    left: Px,
617    right: Px,
618    band: Px,
619}
620
621impl tessera_ui::LayoutModifierNode for OutlineSlicesLayout {
622    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
623        let mut result = LayoutResult::default();
624        if input.is_intrinsic() {
625            return Ok(result);
626        }
627        let slices = [
628            (
629                PxPosition::new(Px::ZERO, self.band),
630                PxSize::new(
631                    self.size.width,
632                    Px((self.size.height.0 - self.band.0).max(0)),
633                ),
634            ),
635            (PxPosition::ZERO, PxSize::new(self.left, self.band)),
636            (
637                PxPosition::new(self.right, Px::ZERO),
638                PxSize::new(Px((self.size.width.0 - self.right.0).max(0)), self.band),
639            ),
640        ];
641        for (child, (position, size)) in input.children().into_iter().zip(slices) {
642            if size.width <= Px::ZERO || size.height <= Px::ZERO {
643                continue;
644            }
645            child.measure(input, &Constraint::exact(size.width, size.height))?;
646            result.place_child(child, position);
647        }
648        Ok(result)
649    }
650}
651
652#[derive(Clone, PartialEq)]
653struct OutlineSliceLayout {
654    size: PxSize,
655    origin: PxPosition,
656}
657
658impl tessera_ui::LayoutModifierNode for OutlineSliceLayout {
659    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
660        let Some(child) = input.children().first().copied() else {
661            return Ok(LayoutResult::default());
662        };
663        child.measure(input, &Constraint::exact(self.size.width, self.size.height))?;
664        let mut result = LayoutResult::new(ComputedData {
665            width: input.parent_constraint().width().clamp(Px::ZERO),
666            height: input.parent_constraint().height().clamp(Px::ZERO),
667        });
668        result.place_child(
669            child,
670            PxPosition::from_f32_xy(-self.origin.x, -self.origin.y),
671        );
672        Ok(result)
673    }
674}
675
676#[tessera]
677fn outlined_border(
678    geometry: OutlineGeometry,
679    padding: Dp,
680    width: Dp,
681    color: Color,
682    shape: Shape,
683    floating: bool,
684) {
685    let size = geometry.size;
686    let left = if floating {
687        Px(
688            (padding.to_px().0 - TextFieldDefaults::OUTLINED_LABEL_PADDING.to_px().0)
689                .clamp(0, size.width.0),
690        )
691    } else {
692        size.width
693    };
694    let right = if floating {
695        Px((padding.to_px().0
696            + geometry.label_width.0
697            + TextFieldDefaults::OUTLINED_LABEL_PADDING.to_px().0)
698            .clamp(left.0, size.width.0))
699    } else {
700        size.width
701    };
702    let band = Px((width.to_pixels_f32().ceil() as i32 + 1).min(size.height.0));
703    layout()
704        .policy(OutlineSlicesLayout {
705            size,
706            left,
707            right,
708            band,
709        })
710        .child(move || {
711            for origin in [
712                PxPosition::new(Px::ZERO, band),
713                PxPosition::ZERO,
714                PxPosition::new(right, Px::ZERO),
715            ] {
716                layout()
717                    .modifier(Modifier::new().clip_shape(Shape::RECTANGLE))
718                    .policy(OutlineSliceLayout { size, origin })
719                    .child(move || {
720                        surface()
721                            .style(SurfaceStyle::Outlined { color, width })
722                            .shape(shape)
723                            .block_input(false);
724                    });
725            }
726        });
727}
728
729type FieldContentData = (TextFieldProps, TextInputProps, MaterialTheme, bool, bool);
730
731#[derive(Clone, PartialEq)]
732struct FieldBodyHeight;
733
734impl tessera_ui::LayoutModifierNode for FieldBodyHeight {
735    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
736        let Some(child) = input.children().first().copied() else {
737            return Ok(LayoutResult::default());
738        };
739        let natural_constraints =
740            Constraint::new(input.parent_constraint().width(), AxisConstraint::NONE);
741        let natural = child.probe_intrinsic(input, &natural_constraints)?;
742        let height = input.parent_constraint().height().clamp(natural.height);
743        let constraints = Constraint::new(
744            input.parent_constraint().width(),
745            AxisConstraint::exact(height),
746        );
747        let measured = child.measure(input, &constraints)?;
748        let mut result = LayoutResult::new(measured.size());
749        result.place_child(child, PxPosition::ZERO);
750        Ok(result)
751    }
752}
753
754#[derive(Clone, PartialEq)]
755struct FieldDecorationLayout {
756    single_line: bool,
757    line_height: Px,
758}
759
760impl tessera_ui::LayoutModifierNode for FieldDecorationLayout {
761    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
762        let Some(child) = input.children().first().copied() else {
763            return Ok(LayoutResult::default());
764        };
765        let constraints = if self.single_line {
766            Constraint::new(
767                AxisConstraint::NONE,
768                AxisConstraint::exact(self.line_height),
769            )
770        } else {
771            input.parent_constraint().without_min()
772        };
773        let measured = child.measure(input, &constraints)?;
774        let mut result = LayoutResult::new(ComputedData {
775            width: input.parent_constraint().width().clamp(measured.width),
776            height: input.parent_constraint().height().clamp(measured.height),
777        });
778        result.place_child(child, PxPosition::ZERO);
779        Ok(result)
780    }
781}
782
783#[tessera]
784fn field_decoration(content: String, color: Color, style: TextStyle, single_line: bool) {
785    let modifier = if single_line {
786        Modifier::new().clip_to_bounds()
787    } else {
788        Modifier::new()
789    };
790    layout()
791        .modifier(modifier)
792        .policy(FieldDecorationLayout {
793            single_line,
794            line_height: style
795                .line_height
796                .unwrap_or(Dp(style.font_size.0 * 1.2))
797                .to_px(),
798        })
799        .child(move || {
800            text(content.clone()).color(color).style(style);
801        });
802}
803
804#[tessera]
805fn text_field_content(
806    args: Option<TextFieldProps>,
807    controller: Option<State<TextInputController>>,
808    editor: Option<TextInputProps>,
809    geometry: Option<State<FieldGeometry>>,
810) {
811    let args = args.expect("text_field_content requires args");
812    let controller = controller.expect("text_field_content requires controller");
813    let editor = editor.expect("text_field_content requires editor");
814    let geometry = geometry.expect("text_field_content requires geometry");
815    let theme = use_context::<MaterialTheme>()
816        .expect("MaterialTheme must be provided")
817        .get();
818    let focused = visual_focus(&editor, controller);
819    let empty = editor_content_len(&controller) == 0;
820    let surface_args = create_surface_args(&editor, &controller, &theme.color_scheme);
821    let outline = remember(OutlineGeometry::default);
822    let border = match surface_args.style {
823        SurfaceStyle::FilledOutlined {
824            border_color,
825            border_width,
826            ..
827        } => Some((border_color, border_width)),
828        SurfaceStyle::Outlined { color, width } => Some((color, width)),
829        _ => None,
830    };
831    let surface_style = match surface_args.style {
832        SurfaceStyle::FilledOutlined { fill_color, .. } => {
833            SurfaceStyle::Filled { color: fill_color }
834        }
835        SurfaceStyle::Outlined { .. } => SurfaceStyle::Filled {
836            color: Color::TRANSPARENT,
837        },
838        ref style => style.clone(),
839    };
840    let color = args.text_color.unwrap_or(if args.enabled {
841        theme.color_scheme.on_surface
842    } else {
843        theme
844            .color_scheme
845            .on_surface
846            .with_alpha(DISABLED_CONTENT_ALPHA)
847    });
848    let next = (args.clone(), editor, theme, focused, empty);
849    let data = remember(|| next.clone());
850    if data.with(|current| current != &next) {
851        data.set(next);
852    }
853    surface()
854        .style(surface_style)
855        .shape(surface_args.shape)
856        .block_input(!args.enabled)
857        .modifier(
858            surface_args
859                .modifier
860                .on_layout_size_changed(move |size: tessera_ui::PxSize| {
861                    if outline.with(|outline| outline.size) != size {
862                        outline.with_mut(|outline| outline.size = size);
863                    }
864                }),
865        )
866        .content_color(color)
867        .child(move || {
868            boxed().children(move || {
869                text_field_body(data, controller, geometry, outline);
870                if let Some((color, width)) = border {
871                    let args = data.with(|data| data.0.clone());
872                    outlined_border(
873                        outline.get(),
874                        args.horizontal_padding.unwrap_or(args.padding),
875                        width,
876                        color,
877                        args.shape,
878                        args.label.is_some() && (focused || !empty),
879                    );
880                }
881            });
882        });
883}
884
885#[tessera]
886fn text_field_body(
887    data: State<FieldContentData>,
888    controller: State<TextInputController>,
889    geometry: State<FieldGeometry>,
890    outline: State<OutlineGeometry>,
891) {
892    let (args, _, theme, focused, empty) = data.get();
893    let outlined = resolve_border_width(&args, focused).0 > 0.0;
894    let floating = args.label.is_some() && (focused || !empty);
895    let affixes = args.label.is_none() || floating;
896    let padding = args.padding;
897    let horizontal_padding = args.horizontal_padding.unwrap_or(padding);
898    let icon_inset = Dp(36.0 + TextFieldDefaults::ICON_TEXT_PADDING.0);
899    let left = if args.leading_icon.is_some() {
900        icon_inset
901    } else {
902        horizontal_padding
903    };
904    let right = if args.trailing_icon.is_some() {
905        icon_inset
906    } else {
907        horizontal_padding
908    };
909    let label_style = label_floating_text_style(&theme);
910    let line_height = label_style
911        .line_height
912        .unwrap_or(Dp(label_style.font_size.0 * 1.2));
913    let top = if !outlined && floating {
914        Dp(padding
915            .0
916            .max(TextFieldDefaults::FLOATING_LABEL_Y_OFFSET.0 + line_height.0))
917    } else {
918        padding
919    };
920    let bottom = Dp((padding.0 * 2.0 - top.0).max(0.0));
921    let decoration = if args.enabled {
922        theme.color_scheme.on_surface_variant
923    } else {
924        theme
925            .color_scheme
926            .on_surface
927            .with_alpha(DISABLED_CONTENT_ALPHA)
928    };
929    layout().policy(FieldBodyHeight).child(move || {
930        boxed().children(move || {
931            row()
932                .modifier(
933                    Modifier::new()
934                        .fill_max_height()
935                        .padding(Padding::new(left, top, right, bottom)),
936                )
937                .cross_axis_alignment(CrossAxisAlignment::Center)
938                .children(move || {
939                    let (args, _, _, _, _) = data.get();
940                    if affixes && let Some(prefix) = args.prefix {
941                        text_field_slot(prefix, decoration);
942                        spacer().modifier(
943                            Modifier::new().width(TextFieldDefaults::PREFIX_SUFFIX_PADDING),
944                        );
945                    }
946                    boxed()
947                        .modifier(Modifier::new().weight(1.0))
948                        .children(move || {
949                            let (args, mut editor, theme, focused, empty) = data.get();
950                            editor.modifier = Modifier::new()
951                                .fill_max_size()
952                                .on_layout_coordinates(move |bounds| {
953                                    if geometry.with(|geometry| geometry.editor) != Some(bounds) {
954                                        geometry
955                                            .with_mut(|geometry| geometry.editor = Some(bounds));
956                                    }
957                                });
958                            editor.padding = Dp::ZERO;
959                            editor.border_width = Dp::ZERO;
960                            editor.focus_border_width = Some(Dp::ZERO);
961                            text_input_core(editor, controller);
962                            let floating = args.label.is_some() && (focused || !empty);
963                            if empty
964                                && (args.label.is_none() || floating)
965                                && let Some(placeholder) = args.placeholder
966                            {
967                                field_decoration(
968                                    placeholder,
969                                    resolve_placeholder_color(&theme.color_scheme, args.enabled),
970                                    placeholder_text_style(&theme),
971                                    matches!(args.line_limit, TextFieldLineLimit::SingleLine),
972                                );
973                            }
974                            if !floating && let Some(label) = args.label {
975                                field_decoration(
976                                    label,
977                                    resolve_label_color(
978                                        &theme.color_scheme,
979                                        focused,
980                                        args.is_error,
981                                        args.enabled,
982                                    ),
983                                    label_resting_text_style(&theme),
984                                    true,
985                                );
986                            }
987                        });
988                    let (args, _, _, _, _) = data.get();
989                    if affixes && let Some(suffix) = args.suffix {
990                        spacer().modifier(
991                            Modifier::new().width(TextFieldDefaults::PREFIX_SUFFIX_PADDING),
992                        );
993                        text_field_slot(suffix, decoration);
994                    }
995                });
996            let (args, _, theme, focused, _) = data.get();
997            if let Some(content) = args.leading_icon {
998                boxed()
999                    .modifier(
1000                        Modifier::new()
1001                            .align(Alignment::CenterStart)
1002                            .width(Dp(48.0))
1003                            .fill_max_height(),
1004                    )
1005                    .alignment(Alignment::Center)
1006                    .children(move || {
1007                        text_field_slot(content, decoration);
1008                    });
1009            }
1010            if let Some(content) = args.trailing_icon {
1011                let color = if args.is_error && args.enabled {
1012                    theme.color_scheme.error
1013                } else {
1014                    decoration
1015                };
1016                boxed()
1017                    .modifier(
1018                        Modifier::new()
1019                            .align(Alignment::CenterEnd)
1020                            .width(Dp(48.0))
1021                            .fill_max_height(),
1022                    )
1023                    .alignment(Alignment::Center)
1024                    .children(move || {
1025                        text_field_slot(content, color);
1026                    });
1027            }
1028            if floating && let Some(label) = args.label.clone() {
1029                let color =
1030                    resolve_label_color(&theme.color_scheme, focused, args.is_error, args.enabled);
1031                if outlined {
1032                    outlined_floating_label()
1033                        .label_text(label)
1034                        .label_color(color)
1035                        .label_font_size(label_style.font_size)
1036                        .label_line_height(line_height)
1037                        .label_offset_x(horizontal_padding)
1038                        .label_offset_y(Dp(-line_height.0 * 0.5))
1039                        .outline(outline);
1040                } else {
1041                    text(label).style(label_style).color(color).modifier(
1042                        Modifier::new().offset(left, TextFieldDefaults::FLOATING_LABEL_Y_OFFSET),
1043                    );
1044                }
1045            }
1046            if args.show_indicator && !outlined {
1047                let (color, thickness) = resolve_indicator_style(
1048                    &theme.color_scheme,
1049                    focused,
1050                    args.is_error,
1051                    args.enabled,
1052                );
1053                horizontal_divider()
1054                    .modifier(Modifier::new().align(Alignment::BottomStart))
1055                    .thickness(thickness)
1056                    .color(color);
1057            }
1058        });
1059    });
1060}
1061
1062#[tessera]
1063fn text_field_slot(content: RenderSlot, color: Color) {
1064    let next = (content, color);
1065    let data = remember(|| next);
1066    if data.get() != next {
1067        data.set(next);
1068    }
1069    provide_context(
1070        || ContentColor { current: color },
1071        || {
1072            let color = data.with(|(_, color)| *color);
1073            let context = use_context::<ContentColor>().expect("text field content color");
1074            if context.get().current != color {
1075                context.set(ContentColor { current: color });
1076            }
1077            data.with(|(content, _)| *content).render();
1078        },
1079    );
1080}
1081
1082fn apply_menu_action(
1083    action: TextFieldMenuAction,
1084    controller: State<TextInputController>,
1085    on_change: CallbackWith<String, String>,
1086    input_transform: Option<CallbackWith<String, String>>,
1087    enabled: bool,
1088    read_only: bool,
1089) {
1090    if !enabled {
1091        return;
1092    }
1093    match action {
1094        TextFieldMenuAction::Copy => {
1095            controller.with(|c| {
1096                c.copy_selection_to_clipboard();
1097            });
1098        }
1099        TextFieldMenuAction::Cut => {
1100            if read_only {
1101                return;
1102            }
1103            controller.with_mut(|c| {
1104                c.cut_selection_with_pipeline(on_change, input_transform);
1105            });
1106        }
1107        TextFieldMenuAction::Paste => {
1108            if read_only {
1109                return;
1110            }
1111            controller.with_mut(|c| {
1112                c.paste_from_clipboard_with_pipeline(on_change, input_transform);
1113            });
1114        }
1115        TextFieldMenuAction::SelectAll => {
1116            controller.with_mut(|c| c.select_all());
1117        }
1118    }
1119}
1120
1121#[tessera]
1122fn text_field_menu_content(
1123    scope: Option<crate::menus::MenuScope>,
1124    controller: Option<State<TextInputController>>,
1125    menu: Option<TextFieldContextMenu>,
1126    action_state: Option<State<Option<TextFieldMenuAction>>>,
1127) {
1128    let scope = scope.expect("text field menu scope");
1129    let controller = controller.expect("text_field_menu_content requires controller to be set");
1130    let menu = menu.expect("text_field_menu_content requires menu policy to be set");
1131    let action_state =
1132        action_state.expect("text_field_menu_content requires action state to be set");
1133    if !menu.enabled {
1134        return;
1135    }
1136
1137    let selection_available = controller.with(|c| c.has_selection());
1138
1139    if menu.allow_cut {
1140        menu_item()
1141            .menu(scope)
1142            .label(I18N.text("md3-cut"))
1143            .enabled(selection_available)
1144            .on_click(move || {
1145                action_state.set(Some(TextFieldMenuAction::Cut));
1146            });
1147    }
1148    if menu.allow_copy {
1149        menu_item()
1150            .menu(scope)
1151            .label(I18N.text("md3-copy"))
1152            .enabled(selection_available)
1153            .on_click(move || {
1154                action_state.set(Some(TextFieldMenuAction::Copy));
1155            });
1156    }
1157    if menu.allow_paste {
1158        menu_item()
1159            .menu(scope)
1160            .label(I18N.text("md3-paste"))
1161            .enabled(true)
1162            .on_click(move || {
1163                action_state.set(Some(TextFieldMenuAction::Paste));
1164            });
1165    }
1166    if menu.allow_select_all {
1167        menu_item()
1168            .menu(scope)
1169            .label(I18N.text("md3-select-all"))
1170            .enabled(true)
1171            .on_click(move || {
1172                action_state.set(Some(TextFieldMenuAction::SelectAll));
1173            });
1174    }
1175}
1176
1177/// # text_field
1178///
1179/// Render a Material text field for short-form input with optional secure
1180/// obfuscation.
1181///
1182/// ## Usage
1183///
1184/// Capture names, email addresses, or passwords with Material defaults.
1185///
1186/// ## Parameters
1187///
1188/// - `presentation_state` — optional visual interaction without changing input
1189///   or focus.
1190/// - `enabled` — whether the field accepts user input.
1191/// - `read_only` — whether the field is read-only.
1192/// - `is_error` — whether the field is displaying an error state.
1193/// - `modifier` — optional modifier chain applied to the field container.
1194/// - `on_change` — optional text change callback.
1195/// - `on_submit` — optional submit callback for single-line fields.
1196/// - `min_width` — optional minimum width.
1197/// - `min_height` — optional minimum height.
1198/// - `background_color` — optional container background color.
1199/// - `border_width` — base border width.
1200/// - `border_color` — optional border color.
1201/// - `shape` — field container shape.
1202/// - `padding` — content padding.
1203/// - `horizontal_padding` — optional horizontal content padding override.
1204/// - `focus_border_color` — optional focused border color.
1205/// - `focus_border_width` — optional focused border width.
1206/// - `focus_background_color` — optional focused background color.
1207/// - `selection_color` — optional text selection highlight color.
1208/// - `text_color` — optional text color.
1209/// - `cursor_color` — optional cursor color.
1210/// - `accessibility_label` — accessible name; defaults to the visible label.
1211/// - `accessibility_description` — optional accessibility description.
1212/// - `initial_text` — optional initial text content.
1213/// - `font_size` — font size in Dp.
1214/// - `style` — typography override, including weight and letter spacing.
1215/// - `line_height` — optional line height in Dp.
1216/// - `label` — optional label text.
1217/// - `placeholder` — optional placeholder text.
1218/// - `leading_icon` — optional leading icon slot.
1219/// - `trailing_icon` — optional trailing icon slot.
1220/// - `prefix` — optional prefix slot.
1221/// - `suffix` — optional suffix slot.
1222/// - `show_indicator` — whether filled-style indicator is rendered.
1223/// - `line_limit` — text line limit policy.
1224/// - `context_menu` — context menu policy.
1225/// - `input_transform` — optional input transform callback.
1226/// - `obfuscation_char` — optional obfuscation character.
1227/// - `display_transform` — optional display transform.
1228/// - `controller` — optional external text input controller.
1229///
1230/// ## Examples
1231///
1232/// ```
1233/// # use tessera_ui::tessera;
1234/// # #[tessera]
1235/// # fn component() {
1236/// use tessera_components::text_field::text_field;
1237/// use tessera_ui::CallbackWith;
1238/// # use tessera_components::theme::{MaterialTheme, material_theme};
1239/// # material_theme()
1240/// #     .theme(|| MaterialTheme::default())
1241/// #     .child(|| {
1242/// let transform = CallbackWith::new(|value: String| value.to_uppercase());
1243/// assert_eq!(transform.call("hello".to_string()), "HELLO");
1244/// text_field().input_transform_shared(transform);
1245/// # });
1246/// # }
1247/// # tessera_ui::testing::layout_test(component).run();
1248/// ```
1249#[tessera]
1250pub fn text_field(
1251    enabled: Option<bool>,
1252    presentation_state: Option<InteractionVisualState>,
1253    read_only: Option<bool>,
1254    is_error: Option<bool>,
1255    modifier: Option<Modifier>,
1256    on_change: Option<CallbackWith<String, String>>,
1257    on_submit: Option<Callback>,
1258    min_width: Option<Dp>,
1259    min_height: Option<Dp>,
1260    background_color: Option<Color>,
1261    border_width: Option<Dp>,
1262    border_color: Option<Color>,
1263    shape: Option<Shape>,
1264    padding: Option<Dp>,
1265    horizontal_padding: Option<Dp>,
1266    focus_border_color: Option<Color>,
1267    focus_border_width: Option<Dp>,
1268    focus_background_color: Option<Color>,
1269    selection_color: Option<Color>,
1270    text_color: Option<Color>,
1271    cursor_color: Option<Color>,
1272    #[prop(into)] accessibility_label: Option<String>,
1273    #[prop(into)] accessibility_description: Option<String>,
1274    #[prop(into)] initial_text: Option<String>,
1275    font_size: Option<Dp>,
1276    line_height: Option<Dp>,
1277    style: Option<TextStyle>,
1278    #[prop(into)] label: Option<String>,
1279    #[prop(into)] placeholder: Option<String>,
1280    leading_icon: Option<RenderSlot>,
1281    trailing_icon: Option<RenderSlot>,
1282    prefix: Option<RenderSlot>,
1283    suffix: Option<RenderSlot>,
1284    show_indicator: Option<bool>,
1285    line_limit: Option<TextFieldLineLimit>,
1286    context_menu: Option<TextFieldContextMenu>,
1287    input_transform: Option<CallbackWith<String, String>>,
1288    obfuscation_char: Option<char>,
1289    #[prop(skip_setter)] display_transform: Option<DisplayTransform>,
1290    controller: Option<State<TextInputController>>,
1291) {
1292    let theme = use_context::<MaterialTheme>()
1293        .expect("MaterialTheme must be provided")
1294        .get();
1295    let scheme = theme.color_scheme;
1296    let selection_colors = TextSelectionColors::from_scheme(&scheme);
1297    let enabled = enabled.unwrap_or(true);
1298    let read_only = read_only.unwrap_or(false);
1299    let is_error = is_error.unwrap_or(false);
1300    let modifier = modifier.unwrap_or_default();
1301    let min_width = min_width.or(Some(TextFieldDefaults::MIN_WIDTH));
1302    let min_height = min_height.or(Some(TextFieldDefaults::MIN_HEIGHT));
1303    let background_color = background_color.or(Some(scheme.surface_container_highest));
1304    let border_width = border_width.unwrap_or(TextFieldProps::default().border_width);
1305    let shape = shape.unwrap_or_else(|| {
1306        if border_width.0 > 0.0 {
1307            theme.shapes.extra_small
1308        } else {
1309            filled_container_shape(&theme.shapes)
1310        }
1311    });
1312    let padding = padding.unwrap_or(TextFieldProps::default().padding);
1313    let focus_border_color = focus_border_color.or(Some(scheme.primary));
1314    let focus_background_color = focus_background_color.or(Some(scheme.surface_container_highest));
1315    let selection_color = selection_color.or(Some(selection_colors.background));
1316    let text_color = text_color.or(Some(scheme.on_surface));
1317    let cursor_color = cursor_color.or(Some(scheme.primary));
1318    let style = style.unwrap_or(theme.typography.body_large);
1319    let font_size = font_size.unwrap_or(style.font_size);
1320    let line_height = line_height.or(style.line_height);
1321    let show_indicator = show_indicator.unwrap_or(true);
1322    let line_limit = line_limit.unwrap_or(TextFieldProps::default().line_limit);
1323    let context_menu = context_menu.unwrap_or(TextFieldProps::default().context_menu);
1324    let args = TextFieldProps {
1325        enabled,
1326        presentation_state,
1327        read_only,
1328        is_error,
1329        modifier,
1330        on_change: on_change.unwrap_or_else(CallbackWith::identity),
1331        on_submit: on_submit.unwrap_or_else(Callback::noop),
1332        min_width,
1333        min_height,
1334        background_color,
1335        border_width,
1336        border_color,
1337        shape,
1338        padding,
1339        horizontal_padding,
1340        focus_border_color,
1341        focus_border_width,
1342        focus_background_color,
1343        selection_color,
1344        text_color,
1345        cursor_color,
1346        accessibility_label,
1347        accessibility_description,
1348        initial_text,
1349        font_size,
1350        line_height,
1351        style: Some(TextStyle {
1352            font_size,
1353            line_height,
1354            ..style
1355        }),
1356        label,
1357        placeholder,
1358        leading_icon,
1359        trailing_icon,
1360        prefix,
1361        suffix,
1362        show_indicator,
1363        line_limit,
1364        context_menu,
1365        input_transform,
1366        obfuscation_char,
1367        display_transform,
1368        controller,
1369    };
1370    let controller = if let Some(controller) = args.controller {
1371        controller
1372    } else {
1373        remember(|| {
1374            let mut controller = TextInputController::new(args.font_size, args.line_height);
1375            if let Some(text) = &args.initial_text {
1376                controller.set_text(text);
1377            }
1378            controller
1379        })
1380    };
1381    let mut args = args.clone();
1382    args.controller = Some(controller);
1383    let geometry = remember(FieldGeometry::default);
1384
1385    let enabled = args.enabled;
1386    let read_only = args.read_only;
1387    let menu_controller = remember(MenuController::new);
1388    let action_state = remember(|| None::<TextFieldMenuAction>);
1389    let input_transform = merge_input_transforms(args.line_limit, args.input_transform);
1390    let display_transform = if let Some(mask) = args.obfuscation_char {
1391        Some(CallbackWith::new(move |value: String| {
1392            DisplayTransformText::from_strings(&value, obfuscate_text(&value, mask))
1393        }) as DisplayTransform)
1394    } else {
1395        args.display_transform
1396    };
1397    let editor_args = build_editor_args(&args, input_transform, display_transform);
1398    let menu_policy = resolve_text_field_menu_policy(args.context_menu, enabled, read_only);
1399    let on_change = args.on_change;
1400    let render_args = args.clone();
1401    let focus_tap_recognizer = remember(TapRecognizer::default);
1402    let scroll_recognizer = remember(ScrollRecognizer::default);
1403    let context_menu_tap_recognizer = remember(|| {
1404        TapRecognizer::new(TapSettings {
1405            button: PressKeyEventType::Right,
1406            ..Default::default()
1407        })
1408    });
1409    let modifier_base = if enabled {
1410        Modifier::new().hover_cursor_icon(winit::window::CursorIcon::Text)
1411    } else {
1412        Modifier::new()
1413    };
1414    let modifier_base = modifier_base.on_layout_coordinates(move |bounds| {
1415        if geometry.with(|geometry| geometry.outer) != Some(bounds) {
1416            geometry.with_mut(|geometry| geometry.outer = Some(bounds));
1417        }
1418    });
1419    let modifier = with_pointer_input(modifier_base, move |mut input| {
1420        let cursor_pos = input.cursor_position_rel;
1421        let is_inside = cursor_pos
1422            .map(|pos| is_position_inside_bounds(input.computed_data, pos))
1423            .unwrap_or(false);
1424        let context_menu_tap_result = context_menu_tap_recognizer.with_mut(|recognizer| {
1425            recognizer.update(input.pass, input.pointer_changes.as_mut_slice(), is_inside)
1426        });
1427        let focus_tap_result = focus_tap_recognizer.with_mut(|recognizer| {
1428            recognizer.update(input.pass, input.pointer_changes.as_mut_slice(), is_inside)
1429        });
1430
1431        if menu_policy.enabled {
1432            if let Some(action) = action_state.with_mut(|state| state.take()) {
1433                apply_menu_action(
1434                    action,
1435                    controller,
1436                    on_change,
1437                    input_transform,
1438                    enabled,
1439                    read_only,
1440                );
1441            }
1442
1443            if context_menu_tap_result.tapped
1444                && let Some(cursor_pos) = cursor_pos
1445            {
1446                menu_controller.with_mut(|menu| {
1447                    menu.open_at(MenuAnchor::at(cursor_pos));
1448                });
1449            }
1450        }
1451
1452        if enabled {
1453            if focus_tap_result.pressed
1454                && let Some(cursor_pos) = cursor_pos
1455                && is_inside
1456            {
1457                let press_timestamp = focus_tap_result.press_timestamp;
1458                controller.with(|c| *c.focus_handler()).request_focus();
1459                let inner_handled_click = press_timestamp
1460                    .map(|timestamp| controller.with(|c| c.last_click_time() == Some(timestamp)))
1461                    .unwrap_or(false);
1462                if !inner_handled_click {
1463                    if let Some(click_position) =
1464                        field_click_pointer_position(cursor_pos, geometry, controller)
1465                    {
1466                        controller.with_mut(|c| {
1467                            if input.key_modifiers.shift_key() {
1468                                c.extend_selection_to_point(click_position);
1469                            } else {
1470                                c.apply_pointer_action(GlyphonAction::Click {
1471                                    x: click_position.x_px().0,
1472                                    y: click_position.y_px().0,
1473                                });
1474                            }
1475                        });
1476                    }
1477                }
1478            }
1479        }
1480
1481        if enabled && is_inside {
1482            scroll_recognizer.with_mut(|recognizer| {
1483                recognizer.for_each(
1484                    input.pass,
1485                    input.pointer_changes.as_mut_slice(),
1486                    |_context, event| {
1487                        let (dx, dy) = normalize_platform_scroll_delta(
1488                            event.delta_x,
1489                            event.delta_y,
1490                            event.unit,
1491                            event.source,
1492                        );
1493                        if controller.with(|c| c.single_line()) {
1494                            let requested = if dx.abs() > f32::EPSILON { dx } else { dy };
1495                            if requested.abs() <= f32::EPSILON
1496                                || !controller.with_mut(|c| c.has_horizontal_overflow())
1497                            {
1498                                return;
1499                            }
1500                            let consumed =
1501                                controller.with_mut(|c| c.scroll_horizontal_consumed(-requested));
1502                            let consumed_raw = -consumed;
1503                            if dx.abs() > f32::EPSILON {
1504                                event.delta_x = dx - consumed_raw;
1505                                event.delta_y = dy;
1506                            } else {
1507                                event.delta_y = dy - consumed_raw;
1508                                event.delta_x = dx;
1509                            }
1510                        } else {
1511                            if dy.abs() <= f32::EPSILON
1512                                || !controller.with_mut(|c| c.has_vertical_overflow())
1513                            {
1514                                return;
1515                            }
1516                            // The editor engine clamps vertical scroll to the
1517                            // content extent, so `consumed` is the exact amount
1518                            // applied and the remainder goes to an ancestor.
1519                            let consumed = controller.with_mut(|c| c.scroll_vertical_consumed(-dy));
1520                            event.delta_y = dy + consumed;
1521                        }
1522                        event.unit = ScrollDeltaUnit::Pixel;
1523                    },
1524                );
1525            });
1526        }
1527
1528        if is_inside {
1529            input.block_cursor();
1530        }
1531    });
1532
1533    layout().modifier(modifier).child(move || {
1534        if menu_policy.enabled {
1535            let render_args = render_args.clone();
1536            let editor_args = editor_args.clone();
1537            menu_provider()
1538                .placement(MenuPlacement::BelowStart)
1539                .offset([Dp(0.0), Dp(4.0)])
1540                .controller(menu_controller)
1541                .main_content(move || {
1542                    text_field_content()
1543                        .args(render_args.clone())
1544                        .controller(controller)
1545                        .editor(editor_args.clone())
1546                        .geometry(geometry);
1547                })
1548                .menu_content(move |menu| {
1549                    text_field_menu_content()
1550                        .scope(menu)
1551                        .controller(controller)
1552                        .menu(menu_policy)
1553                        .action_state(action_state);
1554                });
1555        } else {
1556            text_field_content()
1557                .args(render_args.clone())
1558                .controller(controller)
1559                .editor(editor_args.clone())
1560                .geometry(geometry);
1561        }
1562    });
1563}
1564
1565#[cfg(test)]
1566mod tests {
1567    use crate::theme::MaterialColorScheme;
1568
1569    use super::{
1570        TextFieldDefaults, resolve_indicator_style, resolve_label_color, resolve_placeholder_color,
1571    };
1572
1573    fn scheme() -> MaterialColorScheme {
1574        MaterialColorScheme::default()
1575    }
1576
1577    #[test]
1578    fn label_color_follows_the_material_states() {
1579        let scheme = scheme();
1580
1581        assert_eq!(
1582            resolve_label_color(&scheme, false, false, true),
1583            scheme.on_surface_variant
1584        );
1585        assert_eq!(
1586            resolve_label_color(&scheme, true, false, true),
1587            scheme.primary
1588        );
1589        assert_eq!(
1590            resolve_label_color(&scheme, false, true, true),
1591            scheme.error
1592        );
1593        assert_eq!(
1594            resolve_label_color(&scheme, false, false, false),
1595            scheme
1596                .on_surface
1597                .with_alpha(crate::text_input::DISABLED_CONTENT_ALPHA)
1598        );
1599    }
1600
1601    #[test]
1602    fn placeholder_color_dims_when_disabled() {
1603        let scheme = scheme();
1604
1605        assert_eq!(
1606            resolve_placeholder_color(&scheme, true),
1607            scheme.on_surface_variant.with_alpha(0.7)
1608        );
1609        assert_eq!(
1610            resolve_placeholder_color(&scheme, false),
1611            scheme
1612                .on_surface
1613                .with_alpha(crate::text_input::DISABLED_CONTENT_ALPHA)
1614        );
1615    }
1616
1617    #[test]
1618    fn indicator_style_follows_the_material_states() {
1619        let scheme = scheme();
1620
1621        assert_eq!(
1622            resolve_indicator_style(&scheme, false, false, true),
1623            (
1624                scheme.on_surface_variant,
1625                TextFieldDefaults::INDICATOR_THICKNESS
1626            )
1627        );
1628        assert_eq!(
1629            resolve_indicator_style(&scheme, true, false, true),
1630            (
1631                scheme.primary,
1632                TextFieldDefaults::INDICATOR_FOCUSED_THICKNESS
1633            )
1634        );
1635        assert_eq!(
1636            resolve_indicator_style(&scheme, true, true, true),
1637            (scheme.error, TextFieldDefaults::INDICATOR_FOCUSED_THICKNESS)
1638        );
1639        assert_eq!(
1640            resolve_indicator_style(&scheme, true, false, false),
1641            (
1642                scheme
1643                    .on_surface
1644                    .with_alpha(crate::text_input::DISABLED_CONTENT_ALPHA),
1645                TextFieldDefaults::INDICATOR_THICKNESS
1646            )
1647        );
1648    }
1649
1650    #[tessera_ui::tessera]
1651    fn pointer_focus_fixture(
1652        output: tessera_ui::testing::TestProbe<
1653            Option<tessera_ui::State<crate::text_input::TextInputController>>,
1654        >,
1655    ) {
1656        let controller = tessera_ui::remember(|| {
1657            crate::text_input::TextInputController::new(
1658                tessera_ui::Dp(16.0),
1659                Some(tessera_ui::Dp(24.0)),
1660            )
1661        });
1662        output.set(Some(controller));
1663        crate::theme::material_theme().child(move || {
1664            crate::column::column().children(move || {
1665                crate::text_field::text_field()
1666                    .outlined()
1667                    .label("Date")
1668                    .controller(controller);
1669            });
1670        });
1671    }
1672
1673    #[test]
1674    fn clicking_an_empty_outlined_field_focuses_its_editor() {
1675        use tessera_ui::{
1676            CursorEventContent, GestureState, PointerChange, PressKeyEventType, PxPosition,
1677            testing::{
1678                layout_test, pump_layout_test_session, pump_layout_test_session_with_input,
1679                start_layout_test_session,
1680            },
1681        };
1682        let output = tessera_ui::testing::TestProbe::default();
1683        let probe = output.clone();
1684        let mut session = start_layout_test_session(
1685            layout_test(move || {
1686                pointer_focus_fixture(probe.clone());
1687            })
1688            .viewport_px(400, 200),
1689        );
1690        pump_layout_test_session(&mut session, 0);
1691        for (index, content) in [
1692            CursorEventContent::Pressed(PressKeyEventType::Left),
1693            CursorEventContent::Released(PressKeyEventType::Left),
1694        ]
1695        .into_iter()
1696        .enumerate()
1697        {
1698            pump_layout_test_session_with_input(
1699                &mut session,
1700                (index as u64 + 1) * 16_666_667,
1701                vec![PointerChange::new(
1702                    tessera_ui::current_frame_time().unwrap(),
1703                    1,
1704                    content,
1705                    GestureState::TapCandidate,
1706                )],
1707                Some(PxPosition::from_f32_xy(100.0, 28.0)),
1708            );
1709        }
1710        pump_layout_test_session(&mut session, 50_000_001);
1711        assert!(
1712            output
1713                .get()
1714                .unwrap()
1715                .with(|c| c.focus_handler().is_focused())
1716        );
1717    }
1718}
1719
1720#[cfg(all(test, not(target_family = "wasm")))]
1721mod native_field_regressions {
1722    use std::time::Duration;
1723
1724    use tessera_ui::{
1725        HeadlessConfig, HeadlessRenderer, MOUSE_POINTER_ID, PressKeyEventType, accesskit::Role,
1726        winit::event::Ime,
1727    };
1728
1729    use super::*;
1730
1731    #[derive(Clone, Copy, PartialEq)]
1732    enum Case {
1733        Filled,
1734        Outlined,
1735        Multiline,
1736        Affixes,
1737        Secure,
1738        ExplicitHeight,
1739    }
1740
1741    #[derive(Clone, Default, PartialEq)]
1742    struct Probe {
1743        editor: tessera_ui::testing::TestProbe<Option<State<TextInputController>>>,
1744        bounds: tessera_ui::testing::TestProbe<Option<PxRect>>,
1745        changes: tessera_ui::testing::TestProbe<Vec<String>>,
1746    }
1747
1748    #[tessera]
1749    fn fixture(case: Case, probe: Probe) {
1750        let controller = remember(|| TextInputController::new(Dp(16.0), Some(Dp(24.0))));
1751        let changes = remember(|| 0_usize);
1752        probe.editor.set(Some(controller));
1753        let bounds = probe.bounds.clone();
1754        let observe_bounds = tessera_ui::CallbackWith::new(move |rect| bounds.set(Some(rect)));
1755        let on_change = tessera_ui::CallbackWith::new(move |value: String| {
1756            probe.changes.with_mut(|values| values.push(value.clone()));
1757            changes.set(changes.get() + 1);
1758            value
1759        });
1760        crate::theme::material_theme().child(move || {
1761            boxed()
1762                .alignment(Alignment::TopStart)
1763                .modifier(Modifier::new().fill_max_size())
1764                .children(move || {
1765                    crate::column::column()
1766                        .modifier(Modifier::new().width(Dp(180.0)))
1767                        .children(move || {
1768                            let field = if matches!(case, Case::Outlined) {
1769                                crate::text_field::text_field().outlined()
1770                            } else {
1771                                crate::text_field::text_field().filled()
1772                            };
1773                            let mut field_modifier = Modifier::new()
1774                                .fill_max_width()
1775                                .on_globally_positioned(move |rect| observe_bounds.call(rect));
1776                            if matches!(case, Case::ExplicitHeight) {
1777                                field_modifier = field_modifier.height(Dp(96.0));
1778                            }
1779                            field
1780                                .controller(controller)
1781                                .modifier(field_modifier)
1782                                .min_width(Dp::ZERO)
1783                                .min_height(Dp(if matches!(case, Case::Multiline) {
1784                                    96.0
1785                                } else {
1786                                    56.0
1787                                }))
1788                                .label("Label")
1789                                .placeholder("Placeholder text longer than the available input")
1790                                .line_limit(if matches!(case, Case::Multiline) {
1791                                    TextFieldLineLimit::MultiLine
1792                                } else {
1793                                    TextFieldLineLimit::SingleLine
1794                                })
1795                                .accessibility_label("Regression field")
1796                                .leading_icon(|| {
1797                                    boxed()
1798                                        .modifier(Modifier::new().semantics(
1799                                            crate::modifier::SemanticsArgs {
1800                                                role: Some(Role::Image),
1801                                                label: Some("Leading search decoration".into()),
1802                                                ..Default::default()
1803                                            },
1804                                        ))
1805                                        .children(|| {
1806                                            crate::icon::icon()
1807                                                .painter(crate::material_icons::filled::SEARCH_SVG)
1808                                                .size(Dp(24.0));
1809                                        });
1810                                })
1811                                .trailing_icon(|| {
1812                                    crate::icon::icon()
1813                                        .painter(crate::material_icons::filled::CANCEL_SVG)
1814                                        .size(Dp(24.0));
1815                                })
1816                                .on_change_shared(on_change);
1817                            if matches!(case, Case::Affixes) {
1818                                field
1819                                    .prefix(|| {
1820                                        text("$").accessibility_label("Dollar prefix");
1821                                    })
1822                                    .suffix(|| {
1823                                        text("USD");
1824                                    });
1825                            }
1826                            if matches!(case, Case::Secure) {
1827                                field
1828                                    .obfuscation_char('•')
1829                                    .context_menu(TextFieldContextMenu::secure_default());
1830                            }
1831                            text(format!("Changes: {}", changes.get()));
1832                        });
1833                });
1834        });
1835    }
1836
1837    #[tessera]
1838    fn notch_fixture(dark: bool, enabled: bool, probe: Probe) {
1839        let bounds = probe.bounds.clone();
1840        let observe_bounds = tessera_ui::CallbackWith::new(move |rect| bounds.set(Some(rect)));
1841        let observe_editor =
1842            tessera_ui::CallbackWith::new(move |editor| probe.editor.set(Some(editor)));
1843        crate::theme::material_theme()
1844            .theme(move || MaterialTheme::from_seed(Color::from_rgb(0.4, 0.3, 0.6), dark))
1845            .child(move || {
1846                surface()
1847                    .style(SurfaceStyle::Filled {
1848                        color: Color::from_rgb(0.21, 0.55, 0.31),
1849                    })
1850                    .modifier(Modifier::new().fill_max_size())
1851                    .child(move || {
1852                        boxed()
1853                            .alignment(Alignment::TopStart)
1854                            .modifier(Modifier::new().fill_max_size())
1855                            .children(move || {
1856                                let scheme =
1857                                    use_context::<MaterialTheme>().unwrap().get().color_scheme;
1858                                for (x, label) in [(24.5, true), (244.5, false)] {
1859                                    if !label {
1860                                        surface()
1861                                            .modifier(
1862                                                Modifier::new()
1863                                                    .offset(Dp(x), Dp(40.5))
1864                                                    .size(Dp(180.0), Dp(56.0)),
1865                                            )
1866                                            .shape(MaterialShapes::default().extra_small)
1867                                            .style(SurfaceStyle::Outlined {
1868                                                color: if enabled {
1869                                                    scheme.outline
1870                                                } else {
1871                                                    scheme.on_surface.with_alpha(
1872                                                        crate::text_input::DISABLED_OUTLINE_ALPHA,
1873                                                    )
1874                                                },
1875                                                width: Dp(1.0),
1876                                            });
1877                                        continue;
1878                                    }
1879                                    let controller = remember(|| {
1880                                        let mut controller =
1881                                            TextInputController::new(Dp(16.0), Some(Dp(24.0)));
1882                                        controller.set_text("Input");
1883                                        controller
1884                                    });
1885                                    if label {
1886                                        observe_editor.call(controller);
1887                                    }
1888                                    let field = crate::text_field::text_field()
1889                                        .outlined()
1890                                        .shape(MaterialShapes::default().extra_small)
1891                                        .min_width(Dp::ZERO)
1892                                        .controller(controller)
1893                                        .enabled(enabled)
1894                                        .modifier(
1895                                            Modifier::new()
1896                                                .offset(Dp(x), Dp(40.5))
1897                                                .width(Dp(180.0))
1898                                                .on_globally_positioned(move |bounds| {
1899                                                    if label {
1900                                                        observe_bounds.call(bounds);
1901                                                    }
1902                                                }),
1903                                        );
1904                                    if label {
1905                                        field.label("Label");
1906                                    }
1907                                }
1908                            });
1909                    });
1910            });
1911    }
1912
1913    #[test]
1914    #[ignore = "Requires a native GPU adapter for outlined field pixels"]
1915    fn outlined_label_notch_preserves_parent_pixels_and_fractional_stroke_opacity() {
1916        for dark in [false, true] {
1917            for enabled in [false, true] {
1918                let probe = Probe::default();
1919                let output = probe.clone();
1920                let mut renderer = HeadlessRenderer::new(
1921                    Box::new(move || notch_fixture(dark, enabled, output.clone())),
1922                    vec![Box::new(crate::TesseraComponents)],
1923                    HeadlessConfig {
1924                        width: 460,
1925                        height: 160,
1926                        sample_count: 1,
1927                        frame_time: Duration::from_nanos(16_666_667),
1928                        ..Default::default()
1929                    },
1930                )
1931                .unwrap();
1932                renderer.render_frames(7);
1933                for focused in [false, true] {
1934                    if focused {
1935                        if !enabled {
1936                            break;
1937                        }
1938                        probe
1939                            .editor
1940                            .get()
1941                            .unwrap()
1942                            .with(|editor| editor.focus_handler().request_focus());
1943                        renderer.render_frames(4);
1944                    }
1945                    let pixels = renderer.read_pixels().unwrap();
1946                    let pixel = |x: usize, y: usize| -> [u8; 4] {
1947                        pixels[(y * 460 + x) * 4..][..4].try_into().unwrap()
1948                    };
1949                    let bounds = probe.bounds.get().unwrap();
1950                    assert_eq!(bounds.height, Dp(56.0).to_px());
1951                    let x = bounds.x.0 as usize;
1952                    let y = bounds.y.0 as usize;
1953                    let background = pixel(2, 2);
1954                    let tree = renderer.accessibility_snapshot().unwrap();
1955                    let label = tree
1956                        .nodes
1957                        .iter()
1958                        .find(|(_, node)| {
1959                            node.role() == Role::Label && node.label() == Some("Label")
1960                        })
1961                        .unwrap()
1962                        .1
1963                        .bounds()
1964                        .unwrap();
1965                    let gap_x = label.x1.ceil() as usize + 1;
1966                    assert_eq!(
1967                        pixel(gap_x, y),
1968                        background,
1969                        "top stroke must stop at floating label: dark={dark}, enabled={enabled}, focused={focused}"
1970                    );
1971                    assert_eq!(
1972                        pixel(label.x0.floor() as usize - 1, y - 3),
1973                        background,
1974                        "transparent notch must preserve arbitrary parent background"
1975                    );
1976                    assert_ne!(
1977                        pixel(x + 120, y),
1978                        background,
1979                        "upper stroke outside notch remains painted: dark={dark}, enabled={enabled}, focused={focused}, bounds={bounds:?}, samples={:?}, reference={:?}",
1980                        [
1981                            pixel(x + 120, y - 1),
1982                            pixel(x + 120, y),
1983                            pixel(x + 120, y + 1)
1984                        ],
1985                        [
1986                            pixel(x + 340, y - 1),
1987                            pixel(x + 340, y),
1988                            pixel(x + 340, y + 1)
1989                        ]
1990                    );
1991                    if !focused {
1992                        assert_eq!(
1993                            pixel(x + 120, y),
1994                            pixel(x + 340, y),
1995                            "unfocused upper stroke must match the unsplit native reference"
1996                        );
1997                        for dy in 0..12 {
1998                            for dx in 0..6 {
1999                                assert_eq!(
2000                                    pixel(x + dx, y + dy),
2001                                    pixel(x + 220 + dx, y + dy),
2002                                    "fractional clipped outline must match unsplit native stroke at {dx},{dy}; dark={dark}, enabled={enabled}"
2003                                );
2004                            }
2005                        }
2006                    }
2007                }
2008            }
2009        }
2010    }
2011
2012    fn renderer(case: Case) -> (HeadlessRenderer, Probe) {
2013        let probe = Probe::default();
2014        let output = probe.clone();
2015        let mut renderer = HeadlessRenderer::new(
2016            Box::new(move || fixture(case, output.clone())),
2017            vec![Box::new(crate::TesseraComponents)],
2018            HeadlessConfig {
2019                width: 400,
2020                height: 320,
2021                sample_count: 1,
2022                frame_time: Duration::from_nanos(16_666_667),
2023                ..Default::default()
2024            },
2025        )
2026        .expect("native field renderer");
2027        renderer.render_frames(5);
2028        (renderer, probe)
2029    }
2030
2031    fn click(renderer: &mut HeadlessRenderer, label: &str, role: Role) {
2032        let bounds = renderer
2033            .accessibility_snapshot()
2034            .expect("field semantics")
2035            .nodes
2036            .iter()
2037            .find(|(_, node)| node.role() == role && node.label() == Some(label))
2038            .unwrap_or_else(|| panic!("missing {label}"))
2039            .1
2040            .bounds()
2041            .expect("decoration bounds");
2042        let position = PxPosition::from_f32_xy(
2043            ((bounds.x0 + bounds.x1) * 0.5) as f32,
2044            ((bounds.y0 + bounds.y1) * 0.5) as f32,
2045        );
2046        renderer.pointer_move(MOUSE_POINTER_ID, position);
2047        renderer.pointer_press(MOUSE_POINTER_ID, position, PressKeyEventType::Left);
2048        renderer.pointer_release(MOUSE_POINTER_ID, position, PressKeyEventType::Left);
2049        renderer.render_frames(4);
2050    }
2051
2052    #[test]
2053    #[ignore = "Requires a native GPU adapter for actual text shaping and field rendering"]
2054    fn narrow_single_line_fields_keep_one_line_decorations_and_queued_commits() {
2055        for case in [Case::Filled, Case::Outlined] {
2056            let (mut renderer, probe) = renderer(case);
2057            let editor = probe.editor.get().expect("editor");
2058            assert_eq!(
2059                probe.bounds.get().expect("resting bounds").height,
2060                Dp(56.0).to_px()
2061            );
2062            editor.with(|editor| editor.focus_handler().request_focus());
2063            renderer.render_frames(4);
2064            assert_eq!(
2065                probe.bounds.get().expect("focused bounds").height,
2066                Dp(56.0).to_px()
2067            );
2068            let tree = renderer.accessibility_snapshot().expect("placeholder tree");
2069            let placeholder = tree
2070                .nodes
2071                .iter()
2072                .find(|(_, node)| {
2073                    node.role() == Role::Label
2074                        && node.label() == Some("Placeholder text longer than the available input")
2075                })
2076                .expect("native placeholder")
2077                .1
2078                .bounds()
2079                .expect("placeholder bounds");
2080            assert_eq!(placeholder.height(), 24.0);
2081            for character in ["a", "l", "p", "h", "a"] {
2082                renderer.inject_ime_event(Ime::Commit(character.into()));
2083            }
2084            renderer.render_frames(4);
2085            assert_eq!(editor.with(|editor| editor.text()), "alpha");
2086            assert_eq!(
2087                probe.changes.with(|values| values.clone()),
2088                ["a", "al", "alp", "alph", "alpha"]
2089            );
2090            renderer.inject_ime_event(Ime::Commit(" beta".into()));
2091            renderer.render_frames(3);
2092            assert_eq!(editor.with(|editor| editor.text()), "alpha beta");
2093            let long = "AAAAAAAAAAAAAAAAAAAAAAAA zzzzzzzzzzzzzzzzzzzzzzzz";
2094            editor.with_mut(|editor| {
2095                editor.set_text_and_selection(
2096                    long,
2097                    crate::text_edit_core::TextSelection::collapsed(long.len()),
2098                );
2099            });
2100            editor.with(|editor| *editor.focus_handler()).unfocus();
2101            renderer.render_frames(4);
2102            assert!(editor.with(|editor| editor.scroll_state().horizontal()) > 0.0);
2103            let end_pixels = renderer.read_pixels().unwrap();
2104            let input = renderer
2105                .accessibility_snapshot()
2106                .unwrap()
2107                .nodes
2108                .into_iter()
2109                .find(|(_, node)| node.role() == Role::TextInput)
2110                .unwrap()
2111                .1
2112                .bounds()
2113                .unwrap();
2114            editor.with_mut(|editor| {
2115                editor.set_text_and_selection(
2116                    long,
2117                    crate::text_edit_core::TextSelection::collapsed(0),
2118                );
2119            });
2120            renderer.render_frames(4);
2121            assert_eq!(
2122                editor.with(|editor| editor.scroll_state().horizontal()),
2123                0.0
2124            );
2125            assert_eq!(editor.with(|editor| editor.text()), long);
2126            let start_pixels = renderer.read_pixels().unwrap();
2127            let mut changed_glyph_pixels = 0;
2128            for y in input.y0.ceil() as usize..input.y1.floor() as usize {
2129                for x in (input.x0.ceil() as usize + 3)..(input.x1.floor() as usize - 3) {
2130                    let offset = (y * 400 + x) * 4;
2131                    changed_glyph_pixels += usize::from(
2132                        end_pixels[offset..offset + 4] != start_pixels[offset..offset + 4],
2133                    );
2134                }
2135            }
2136            assert!(
2137                changed_glyph_pixels > 30,
2138                "horizontal editor scrolling must move the visible glyphs, not only its caret"
2139            );
2140        }
2141        let (mut tall, probe) = renderer(Case::ExplicitHeight);
2142        assert_eq!(probe.bounds.get().unwrap().height, Dp(96.0).to_px());
2143        probe
2144            .editor
2145            .get()
2146            .unwrap()
2147            .with(|editor| editor.focus_handler().request_focus());
2148        tall.render_frames(4);
2149        assert_eq!(
2150            probe.bounds.get().unwrap().height,
2151            Dp(96.0).to_px(),
2152            "explicit caller height remains authoritative"
2153        );
2154        drop(tall);
2155        let (mut renderer, probe) = renderer(Case::Multiline);
2156        probe
2157            .editor
2158            .get()
2159            .unwrap()
2160            .with(|editor| editor.focus_handler().request_focus());
2161        renderer.render_frames(4);
2162        assert!(probe.bounds.get().expect("multiline bounds").height >= Dp(96.0).to_px());
2163        let tree = renderer.accessibility_snapshot().unwrap();
2164        let placeholder = tree
2165            .nodes
2166            .iter()
2167            .find(|(_, node)| {
2168                node.role() == Role::Label
2169                    && node.label() == Some("Placeholder text longer than the available input")
2170            })
2171            .expect("multiline placeholder")
2172            .1
2173            .bounds()
2174            .unwrap();
2175        assert!(
2176            placeholder.height() > 24.0,
2177            "multiline placeholders retain wrapping"
2178        );
2179    }
2180
2181    #[test]
2182    #[ignore = "Requires a native GPU adapter for actual glyph caret placement"]
2183    fn decorated_field_fallback_clicks_use_the_measured_editor_origin() {
2184        let (mut renderer, probe) = renderer(Case::Affixes);
2185        let editor = probe.editor.get().unwrap();
2186        let text = "iii";
2187        editor.with_mut(|editor| {
2188            editor.set_text_and_selection(
2189                text,
2190                crate::text_edit_core::TextSelection {
2191                    start: text.len(),
2192                    end: text.len(),
2193                },
2194            )
2195        });
2196        renderer.render_frames(4);
2197        click(&mut renderer, "Leading search decoration", Role::Image);
2198        assert_eq!(editor.with(|editor| editor.selection().start), 0);
2199        editor.with_mut(|editor| {
2200            editor.set_text_and_selection(
2201                text,
2202                crate::text_edit_core::TextSelection {
2203                    start: text.len(),
2204                    end: text.len(),
2205                },
2206            )
2207        });
2208        renderer.render_frames(3);
2209        click(&mut renderer, "Dollar prefix", Role::Label);
2210        assert_eq!(editor.with(|editor| editor.selection().start), 0);
2211    }
2212    #[test]
2213    #[ignore = "Requires a native GPU adapter for an actual protected text field"]
2214    fn obfuscated_field_uses_password_semantics_without_publishing_plaintext() {
2215        let (mut renderer, probe) = renderer(Case::Secure);
2216        let editor = probe.editor.get().unwrap();
2217        editor.with(|editor| editor.focus_handler().request_focus());
2218        renderer.render_frames(3);
2219        renderer.inject_ime_event(Ime::Commit("secret".into()));
2220        renderer.render_frames(4);
2221        assert_eq!(editor.with(|editor| editor.text()), "secret");
2222        let tree = renderer.accessibility_snapshot().unwrap();
2223        let node = &tree
2224            .nodes
2225            .iter()
2226            .find(|(_, node)| {
2227                node.label() == Some("Regression field") && node.role() == Role::PasswordInput
2228            })
2229            .expect("actual protected editor semantics")
2230            .1;
2231        assert_eq!(
2232            node.value(),
2233            None,
2234            "protected text is not exposed through the accessibility value"
2235        );
2236        assert!(
2237            !tree
2238                .nodes
2239                .iter()
2240                .any(|(_, node)| node.value() == Some("secret"))
2241        );
2242    }
2243
2244    #[tessera]
2245    fn empty_viewport_fixture(label: bool, leading: bool, probe: Probe) {
2246        let editor = remember(|| TextInputController::new(Dp(16.0), Some(Dp(24.0))));
2247        probe.editor.set(Some(editor));
2248        crate::theme::material_theme().child(move || {
2249            boxed()
2250                .modifier(Modifier::new().fill_max_size())
2251                .children(move || {
2252                    let field = crate::text_field::text_field()
2253                        .outlined()
2254                        .controller(editor)
2255                        .modifier(
2256                            Modifier::new()
2257                                .offset(Dp(24.0), Dp(32.0))
2258                                .size(Dp(327.0), Dp(56.0)),
2259                        )
2260                        .min_width(Dp::ZERO)
2261                        .min_height(Dp(56.0))
2262                        .padding(Dp(16.0))
2263                        .font_size(Dp(16.0))
2264                        .line_height(Dp(24.0))
2265                        .accessibility_label("Viewport editor");
2266                    if label {
2267                        field.label("Value");
2268                    }
2269                    if leading {
2270                        field.leading_icon(|| {
2271                            crate::icon::icon()
2272                                .painter(crate::material_icons::filled::SEARCH_SVG)
2273                                .size(Dp(24.0));
2274                        });
2275                    }
2276                });
2277        });
2278    }
2279
2280    #[test]
2281    #[ignore = "requires a native GPU adapter for editor geometry and pointer editing"]
2282    fn empty_editor_viewport_keeps_its_hit_region_through_edits_and_clear() {
2283        crate::test_utils::run_isolated_test(
2284            "text_field::native_field_regressions::empty_editor_viewport_keeps_its_hit_region_through_edits_and_clear",
2285            || {
2286                for (label, leading, left, width) in [
2287                    (false, false, 40.0, 295.0),
2288                    (true, false, 40.0, 295.0),
2289                    (true, true, 76.0, 259.0),
2290                ] {
2291                    let probe = Probe::default();
2292                    let output = probe.clone();
2293                    let mut renderer = HeadlessRenderer::new(
2294                        Box::new(move || empty_viewport_fixture(label, leading, output.clone())),
2295                        vec![Box::new(crate::TesseraComponents)],
2296                        HeadlessConfig {
2297                            width: 400,
2298                            height: 160,
2299                            sample_count: 1,
2300                            frame_time: Duration::from_nanos(16_666_667),
2301                            ..Default::default()
2302                        },
2303                    )
2304                    .unwrap();
2305                    renderer.render_frames(6);
2306                    let editor = probe.editor.get().unwrap();
2307                    let check_bounds = |renderer: &HeadlessRenderer| {
2308                        let tree = renderer.accessibility_snapshot().unwrap();
2309                        let (id, node) = tree
2310                            .nodes
2311                            .iter()
2312                            .find(|(_, node)| {
2313                                node.role() == Role::TextInput
2314                                    && node.label() == Some("Viewport editor")
2315                            })
2316                            .unwrap();
2317                        let bounds = node.bounds().unwrap();
2318                        assert_eq!(
2319                            (bounds.x0, bounds.y0, bounds.width(), bounds.height()),
2320                            (left, 48.0, width, 24.0),
2321                            "editor viewport must not shrink to text or caret: label={label}, leading={leading}"
2322                        );
2323                        *id
2324                    };
2325                    let click_fixed = |renderer: &mut HeadlessRenderer| {
2326                        let point = PxPosition::from_f32_xy(224.0, 60.0);
2327                        renderer.pointer_move(MOUSE_POINTER_ID, point);
2328                        renderer.step_frame();
2329                        renderer.pointer_press(MOUSE_POINTER_ID, point, PressKeyEventType::Left);
2330                        renderer.step_frame();
2331                        renderer.pointer_release(MOUSE_POINTER_ID, point, PressKeyEventType::Left);
2332                        renderer.render_frames(3);
2333                    };
2334                    let id = check_bounds(&renderer);
2335                    click_fixed(&mut renderer);
2336                    assert_eq!(renderer.accessibility_snapshot().unwrap().focus, id);
2337                    renderer.inject_ime_event(Ime::Commit("Q".into()));
2338                    renderer.render_frames(3);
2339                    assert_eq!(editor.with(|editor| editor.text()), "Q");
2340                    check_bounds(&renderer);
2341                    editor.with_mut(|editor| editor.set_text(""));
2342                    tessera_ui::FocusManager::current().clear_focus(true);
2343                    renderer.render_frames(4);
2344                    check_bounds(&renderer);
2345                    click_fixed(&mut renderer);
2346                    assert_eq!(renderer.accessibility_snapshot().unwrap().focus, id);
2347                    renderer.inject_ime_event(Ime::Commit("R".into()));
2348                    renderer.render_frames(3);
2349                    assert_eq!(editor.with(|editor| editor.text()), "R");
2350                    check_bounds(&renderer);
2351                }
2352            },
2353        );
2354    }
2355}
2356
2357#[cfg(all(test, not(target_family = "wasm")))]
2358mod presentation_regressions {
2359    use std::time::Duration;
2360
2361    use tessera_ui::{HeadlessConfig, HeadlessRenderer, State};
2362
2363    use super::*;
2364
2365    type Presentation = (Option<InteractionVisualState>, bool);
2366
2367    #[derive(Clone, Default, PartialEq)]
2368    struct Probe {
2369        presentation: tessera_ui::testing::TestProbe<Option<State<Presentation>>>,
2370        editors: tessera_ui::testing::TestProbe<[Option<State<TextInputController>>; 2]>,
2371        changes: tessera_ui::testing::TestProbe<usize>,
2372    }
2373
2374    #[tessera]
2375    fn fixture(probe: Probe) {
2376        let presentation = remember(|| (Some(InteractionVisualState::Focus), true));
2377        probe.presentation.set(Some(presentation));
2378        let changes = probe.changes.clone();
2379        let on_change = tessera_ui::CallbackWith::new(move |value: String| {
2380            changes.with_mut(|count| *count += 1);
2381            value
2382        });
2383        let observe_editor = tessera_ui::CallbackWith::new(move |(index, editor)| {
2384            probe
2385                .editors
2386                .with_mut(|editors| editors[index] = Some(editor))
2387        });
2388        crate::theme::material_theme().child(move || {
2389            boxed()
2390                .modifier(Modifier::new().fill_max_size())
2391                .children(move || {
2392                    for index in 0..2 {
2393                        let editor =
2394                            remember(|| TextInputController::new(Dp(16.0), Some(Dp(24.0))));
2395                        observe_editor.call((index, editor));
2396                        let (visual, enabled) = presentation.get();
2397                        crate::text_field::text_field()
2398                            .outlined()
2399                            .controller(editor)
2400                            .presentation_state_optional(visual)
2401                            .enabled(enabled)
2402                            .label("Label")
2403                            .accessibility_label(format!("Preview {index}"))
2404                            .modifier(
2405                                Modifier::new()
2406                                    .offset(Dp(20.0 + index as f64 * 300.0), Dp(20.0))
2407                                    .width(Dp(280.0)),
2408                            )
2409                            .on_change_shared(on_change);
2410                    }
2411                });
2412        });
2413    }
2414
2415    fn crop(pixels: &[u8], index: usize) -> Vec<u8> {
2416        let mut result = Vec::new();
2417        for y in 15..85 {
2418            let start = (y * 620 + 15 + index * 300) * 4;
2419            result.extend_from_slice(&pixels[start..start + 290 * 4]);
2420        }
2421        result
2422    }
2423
2424    #[test]
2425    #[ignore = "requires a native GPU adapter"]
2426    fn focus_presentation_draws_static_native_fields_without_taking_focus() {
2427        crate::test_utils::run_isolated_test(
2428            "text_field::presentation_regressions::focus_presentation_draws_static_native_fields_without_taking_focus",
2429            || {
2430                let probe = Probe::default();
2431                let output = probe.clone();
2432                let mut renderer = HeadlessRenderer::new(
2433                    Box::new(move || fixture(output.clone())),
2434                    vec![Box::new(crate::TesseraComponents)],
2435                    HeadlessConfig {
2436                        width: 620,
2437                        height: 120,
2438                        frame_time: Duration::from_millis(100),
2439                        ..Default::default()
2440                    },
2441                )
2442                .unwrap();
2443                renderer.render_frames(4);
2444                let editors = probe.editors.get().map(Option::unwrap);
2445                assert!(
2446                    editors
2447                        .iter()
2448                        .all(|editor| !editor.with(|editor| editor.focus_handler().is_focused()))
2449                );
2450                let semantics = renderer.accessibility_snapshot().unwrap();
2451                let field_ids: Vec<_> = semantics
2452                    .nodes
2453                    .iter()
2454                    .filter(|(_, node)| node.role() == tessera_ui::accesskit::Role::TextInput)
2455                    .map(|(id, _)| *id)
2456                    .collect();
2457                assert_eq!(field_ids.len(), 2);
2458                assert!(!field_ids.contains(&semantics.focus));
2459                let preview = renderer.read_pixels().unwrap();
2460                assert_eq!(crop(&preview, 0), crop(&preview, 1));
2461                renderer.render_frames(6);
2462                assert_eq!(
2463                    renderer.read_pixels().unwrap(),
2464                    preview,
2465                    "preview carets stay visible across blink phases"
2466                );
2467                assert_eq!(
2468                    renderer.work().phase(tessera_ui::WorkPhase::Paint).count(),
2469                    0
2470                );
2471                let presentation = probe.presentation.get().unwrap();
2472                presentation.set((None, true));
2473                renderer.render_frames(3);
2474                assert_ne!(
2475                    crop(&renderer.read_pixels().unwrap(), 0),
2476                    crop(&preview, 0),
2477                    "native resting decoration differs from focused presentation"
2478                );
2479                let rest = renderer.read_pixels().unwrap();
2480                presentation.set((Some(InteractionVisualState::Focus), true));
2481                renderer.render_frames(3);
2482                assert_eq!(
2483                    renderer.read_pixels().unwrap(),
2484                    preview,
2485                    "retained props restore the static focus presentation"
2486                );
2487                presentation.set((None, true));
2488                renderer.render_frames(3);
2489                assert_eq!(
2490                    renderer.read_pixels().unwrap(),
2491                    rest,
2492                    "retained props clear the static focus presentation again"
2493                );
2494                assert!(
2495                    editors
2496                        .iter()
2497                        .all(|editor| !editor.with(|editor| editor.focus_handler().is_focused()))
2498                );
2499
2500                presentation.set((Some(InteractionVisualState::Focus), false));
2501                renderer.render_frames(3);
2502                let disabled = renderer.read_pixels().unwrap();
2503                presentation.set((None, false));
2504                renderer.render_frames(3);
2505                assert_eq!(
2506                    renderer.read_pixels().unwrap(),
2507                    disabled,
2508                    "disabled style takes precedence over presentation"
2509                );
2510                presentation.set((Some(InteractionVisualState::Focus), true));
2511                renderer.render_frames(3);
2512                editors[0].with(|editor| editor.focus_handler().request_focus());
2513                editors[0].with_mut(|editor| editor.set_text(""));
2514                renderer.render_frames(2);
2515                assert!(editors[0].with(|editor| editor.focus_handler().is_focused()));
2516                let focused = renderer.read_pixels().unwrap();
2517                assert_eq!(
2518                    crop(&focused, 0),
2519                    crop(&focused, 1),
2520                    "actual focus and presentation share the native focused appearance"
2521                );
2522                renderer.render_frames(4);
2523                let blink_off = renderer.read_pixels().unwrap();
2524                assert_ne!(
2525                    crop(&blink_off, 0),
2526                    crop(&blink_off, 1),
2527                    "only the actual focus caret blinks"
2528                );
2529                assert_eq!(
2530                    renderer
2531                        .work()
2532                        .phase(tessera_ui::WorkPhase::Measure)
2533                        .count(),
2534                    0
2535                );
2536                assert_eq!(renderer.diagnostics().geometry_nodes_updated, 0);
2537                assert_eq!(probe.changes.get(), 0);
2538            },
2539        );
2540    }
2541}