Skip to main content

tessera_components/
text_input.rs

1//! Editable text input with Material outlined styling.
2//!
3//! ## Usage
4//!
5//! Collect short-form input when the full text field decoration is not needed.
6use glyphon::Action as GlyphonAction;
7use tessera_foundation::gesture::{ScrollRecognizer, TapRecognizer};
8use tessera_ui::{
9    AccessibilityActionHandler, AccessibilityNode, Callback, CallbackWith, Color, ComputedData,
10    CursorEventContent, Dp, ImeInput, ImeInputModifierNode, ImeRequest, KeyboardInput,
11    KeyboardInputModifierNode, Modifier, PointerInput, PointerInputModifierNode, Px, PxPosition,
12    PxSize, SemanticsModifierNode, State,
13    accesskit::{Action, Role},
14    clipboard::{ClipboardAction, ClipboardInput, ClipboardInputModifierNode},
15    layout::layout,
16    modifier::{CursorModifierExt as _, FocusModifierExt as _, ModifierCapabilityExt as _},
17    remember, tessera, use_context, winit,
18};
19
20use crate::{
21    alignment::Alignment,
22    boxed::boxed,
23    modifier::{InteractionState, InteractionVisualState, ModifierExt as _},
24    pos_misc::is_position_inside_bounds,
25    shape_def::{RoundedCorner, Shape},
26    surface::{SurfaceStyle, surface},
27    text_edit_core::{
28        ClickType, ImeEditResult, PlannedImeEvent, RectDef, TextSelection, text_edit_core,
29    },
30    theme::{MaterialColorScheme, MaterialShapes, MaterialTheme, TextSelectionColors, TextStyle},
31};
32
33/// State structure for the text input, managing text content, cursor,
34/// selection, and editing logic.
35pub use crate::text_edit_core::{
36    DisplayTransform, TextEditorController as TextInputController,
37    TransformedText as DisplayTransformText,
38};
39
40/// Material 3 opacity applied to disabled field text and labels.
41pub(crate) const DISABLED_CONTENT_ALPHA: f32 = 0.38;
42
43/// Material 3 opacity applied to the outline of a disabled field.
44pub(crate) const DISABLED_OUTLINE_ALPHA: f32 = 0.12;
45
46/// Material 3 opacity applied to the container of a disabled filled field.
47pub(crate) const DISABLED_FILL_ALPHA: f32 = 0.04;
48
49struct TextInputPointerModifierNode {
50    args: TextInputProps,
51    controller: State<TextInputController>,
52    tap_recognizer: State<TapRecognizer>,
53    scroll_recognizer: State<ScrollRecognizer>,
54}
55
56impl PointerInputModifierNode for TextInputPointerModifierNode {
57    fn on_pointer_input(&self, mut input: PointerInput<'_>) {
58        handle_text_input(
59            &mut input,
60            &self.args,
61            &self.controller,
62            self.tap_recognizer,
63            self.scroll_recognizer,
64        );
65    }
66}
67
68struct TextInputKeyboardModifierNode {
69    args: TextInputProps,
70    controller: State<TextInputController>,
71}
72
73impl KeyboardInputModifierNode for TextInputKeyboardModifierNode {
74    fn on_keyboard_input(&self, mut input: KeyboardInput<'_>) {
75        handle_text_input_keyboard(
76            &mut input,
77            &self.args,
78            &self.controller,
79            &self.args.on_change,
80            &self.args.input_transform,
81        );
82    }
83}
84
85struct TextInputImeModifierNode {
86    args: TextInputProps,
87    controller: State<TextInputController>,
88}
89
90struct TextInputClipboardModifierNode {
91    args: TextInputProps,
92    controller: State<TextInputController>,
93}
94
95impl ClipboardInputModifierNode for TextInputClipboardModifierNode {
96    fn on_clipboard_input(&self, mut input: ClipboardInput<'_>) {
97        let controller = self.controller;
98        if !self.args.enabled || !controller.with(|c| c.focus_handler().is_focused()) {
99            return;
100        }
101        input.handle();
102        match input.action {
103            ClipboardAction::Paste(text) => {
104                if !self.args.read_only {
105                    controller.with_mut(|c| {
106                        c.replace_selected_text_with_pipeline(
107                            text,
108                            self.args.on_change,
109                            self.args.input_transform,
110                        );
111                    });
112                }
113            }
114            ClipboardAction::Copy | ClipboardAction::Cut => {
115                if self.args.read_only && matches!(input.action, ClipboardAction::Cut) {
116                    return;
117                }
118                let Some(text) = controller.with(|c| c.selected_text()) else {
119                    return;
120                };
121                if matches!(input.action, ClipboardAction::Cut) {
122                    let (version, selection) =
123                        controller.with(|c| (c.text_layout_version(), c.selection()));
124                    let on_change = self.args.on_change;
125                    let input_transform = self.args.input_transform;
126                    input.write_text(text, move || {
127                        if controller.is_alive()
128                            && controller.with(|c| {
129                                c.text_layout_version() == version && c.selection() == selection
130                            })
131                        {
132                            controller.with_mut(|c| {
133                                c.replace_selected_text_with_pipeline(
134                                    "",
135                                    on_change,
136                                    input_transform,
137                                );
138                            });
139                        }
140                    });
141                } else {
142                    input.write_text(text, || {});
143                }
144            }
145        }
146    }
147}
148
149impl ImeInputModifierNode for TextInputImeModifierNode {
150    fn on_ime_input(&self, mut input: ImeInput<'_>) {
151        handle_text_input_ime(
152            &mut input,
153            &self.args,
154            &self.controller,
155            &self.args.on_change,
156            &self.args.input_transform,
157        );
158    }
159}
160
161fn apply_text_input_input_modifiers(
162    base: Modifier,
163    args: TextInputProps,
164    controller: State<TextInputController>,
165    tap_recognizer: State<TapRecognizer>,
166    scroll_recognizer: State<ScrollRecognizer>,
167) -> Modifier {
168    let modifier = if args.enabled {
169        base.hover_cursor_icon(winit::window::CursorIcon::Text)
170    } else {
171        base
172    };
173    modifier
174        .on_focus_changed(move |focus: tessera_ui::FocusState| {
175            if !focus.is_focused()
176                && controller.is_alive()
177                && controller.with(|c| c.composition().is_some())
178            {
179                controller.with_mut(|c| c.clear_composition());
180            }
181        })
182        .push_semantics(TextInputSemanticsModifierNode {
183            args: args.clone(),
184            controller,
185        })
186        .push_pointer_input(TextInputPointerModifierNode {
187            args: args.clone(),
188            controller,
189            tap_recognizer,
190            scroll_recognizer,
191        })
192        .push_keyboard_input(TextInputKeyboardModifierNode {
193            args: args.clone(),
194            controller,
195        })
196        .push_clipboard_input(TextInputClipboardModifierNode {
197            args: args.clone(),
198            controller,
199        })
200        .push_ime_input(TextInputImeModifierNode { args, controller })
201}
202
203#[derive(Clone, PartialEq)]
204pub(crate) struct TextInputProps {
205    /// Whether the editor is enabled for user input.
206    pub enabled: bool,
207    pub presentation_state: Option<InteractionVisualState>,
208    /// Whether the editor is read-only.
209    pub read_only: bool,
210    /// Whether the field is displaying an error state.
211    pub is_error: bool,
212    pub(crate) is_secure: bool,
213    /// Optional modifier chain applied to the editor container.
214    pub modifier: Modifier,
215    /// Called when the text content changes. The closure receives the new text
216    /// content and returns the updated content.
217    pub on_change: CallbackWith<String, String>,
218    /// Called when the user submits a single-line field with the Enter key.
219    pub on_submit: Callback,
220    /// Minimum width in density-independent pixels. Defaults to 120dp if not
221    /// specified.
222    pub min_width: Option<Dp>,
223    /// Minimum height in density-independent pixels. Defaults to line height +
224    /// padding if not specified.
225    pub min_height: Option<Dp>,
226    /// Background color of the text input (RGBA). Defaults to light gray.
227    pub background_color: Option<Color>,
228    /// Border width in Dp. Defaults to 1.0 Dp.
229    pub border_width: Dp,
230    /// Border color (RGBA). Defaults to gray.
231    pub border_color: Option<Color>,
232    /// The shape of the text input container.
233    pub shape: Shape,
234    /// Padding inside the text input. Defaults to 5.0 Dp.
235    pub padding: Dp,
236    /// Border color when focused (RGBA). Defaults to blue.
237    pub focus_border_color: Option<Color>,
238    /// Border width when focused. Defaults to the unfocused border width.
239    pub focus_border_width: Option<Dp>,
240    /// Background color when focused (RGBA). Defaults to white.
241    pub focus_background_color: Option<Color>,
242    /// Color for text selection highlight (RGBA). Defaults to light blue with
243    /// transparency.
244    pub selection_color: Option<Color>,
245    /// Color of the text content. Defaults to the theme on-surface color.
246    pub text_color: Option<Color>,
247    /// Color of the text cursor. Defaults to the theme primary color.
248    pub cursor_color: Option<Color>,
249    /// Optional label announced by assistive technologies.
250    pub accessibility_label: Option<String>,
251    /// Optional description announced by assistive technologies.
252    pub accessibility_description: Option<String>,
253    /// Initial text content.
254    pub initial_text: Option<String>,
255    /// Font size in Dp. Defaults to 14.0.
256    pub font_size: Dp,
257    /// Line height in Dp. Defaults to None (1.2x font size).
258    pub line_height: Option<Dp>,
259    /// Optional typography override for editable text.
260    pub style: Option<TextStyle>,
261    /// Whether the editor behaves as a single-line field.
262    ///
263    /// When `true`, text does not wrap and the internal text buffer uses
264    /// horizontal scrolling semantics.
265    pub single_line: bool,
266    /// Optional transform applied to text changes before on_change.
267    pub input_transform: Option<CallbackWith<String, String>>,
268    /// Optional transform applied only for display.
269    pub display_transform: Option<DisplayTransform>,
270    /// Optional external controller for text, cursor, and selection state.
271    ///
272    /// When this is `None`, `text_input` creates and owns an internal
273    /// controller.
274    pub controller: Option<State<TextInputController>>,
275}
276
277impl Default for TextInputProps {
278    fn default() -> Self {
279        Self {
280            enabled: true,
281            presentation_state: None,
282            read_only: false,
283            is_error: false,
284            is_secure: false,
285            modifier: Modifier::new(),
286            on_change: CallbackWith::default_value(),
287            on_submit: Callback::noop(),
288            min_width: Some(Dp(280.0)),
289            min_height: Some(Dp(56.0)),
290            background_color: None,
291            border_width: Dp(1.0),
292            border_color: None,
293            shape: MaterialShapes::default().extra_small,
294            padding: Dp(16.0),
295            focus_border_color: None,
296            focus_border_width: Some(Dp(2.0)),
297            focus_background_color: None,
298            selection_color: None,
299            text_color: None,
300            cursor_color: None,
301            accessibility_label: None,
302            accessibility_description: None,
303            initial_text: None,
304            font_size: Dp(16.0),
305            line_height: Some(Dp(24.0)),
306            style: None,
307            single_line: false,
308            input_transform: None,
309            display_transform: None,
310            controller: None,
311        }
312    }
313}
314
315/// # text_input
316///
317/// Renders an editable text field with Material outlined styling and colors
318/// resolved from the active theme.
319///
320/// ## Usage
321///
322/// Create an interactive text input for forms, note-taking, or other text
323/// input scenarios.
324///
325/// ## Parameters
326///
327/// - Parameters configure the editor's appearance, layout, transforms, and
328///   controller state.
329///
330/// ## Examples
331///
332/// ```
333/// # use tessera_ui::tessera;
334/// # #[tessera]
335/// # fn component() {
336/// use tessera_components::text_input::text_input;
337/// use tessera_ui::Dp;
338/// # use tessera_components::theme::{MaterialTheme, material_theme};
339///
340/// # material_theme()
341/// #     .theme(|| MaterialTheme::default())
342/// #     .child(|| {
343/// text_input().padding(Dp(8.0)).initial_text("Hello World");
344/// # });
345/// # }
346/// # tessera_ui::testing::layout_test(component).run();
347/// ```
348impl TextInputBuilder {
349    /// Set a display-only transform with explicit offset mapping.
350    pub fn display_transform_mapped<F>(&self, transform: F) -> Self
351    where
352        F: Fn(&str) -> DisplayTransformText + Send + Sync + 'static,
353    {
354        self.props
355            .configure_deferred("display_transform", move |props| {
356                props.display_transform =
357                    Some(CallbackWith::new(move |value: String| transform(&value)))
358            });
359        Self { props: self.props }
360    }
361}
362
363/// # text_input
364///
365/// Renders an editable text field with Material outlined styling and colors
366/// resolved from the active theme.
367///
368/// ## Usage
369///
370/// Create an interactive text input for forms, note-taking, or other text
371/// input scenarios.
372///
373/// ## Parameters
374///
375/// - `enabled` — whether the editor is enabled for user input.
376/// - `read_only` — whether the editor is read-only.
377/// - `is_error` — whether the field is displaying an error state.
378/// - `modifier` — optional modifier chain applied to the editor container.
379/// - `on_change` — called when text changes.
380/// - `on_submit` — called when the user submits a single-line field.
381/// - `min_width` — optional minimum width.
382/// - `min_height` — optional minimum height.
383/// - `background_color` — optional background color.
384/// - `border_width` — border width in Dp.
385/// - `border_color` — optional border color.
386/// - `shape` — container shape.
387/// - `padding` — internal padding.
388/// - `presentation_state` — optional visual interaction; live interaction takes
389///   precedence.
390/// - `focus_border_color` — optional focused border color.
391/// - `focus_border_width` — optional focused border width.
392/// - `focus_background_color` — optional focused background color.
393/// - `selection_color` — optional selection highlight color.
394/// - `text_color` — optional text color.
395/// - `cursor_color` — optional cursor color.
396/// - `accessibility_label` — optional accessibility label.
397/// - `accessibility_description` — optional accessibility description.
398/// - `initial_text` — optional initial text content.
399/// - `font_size` — font size in Dp.
400/// - `style` — typography override, including weight and letter spacing.
401/// - `line_height` — optional line height in Dp.
402/// - `single_line` — whether the editor behaves as a single-line field.
403/// - `input_transform` — optional transform applied to text changes before
404///   `on_change`.
405/// - `display_transform` — optional display-only transform.
406/// - `controller` — optional external controller for text, cursor, and
407///   selection state.
408///
409/// ## Examples
410/// ```rust
411/// # use tessera_ui::tessera;
412/// # #[tessera]
413/// # fn component() {
414/// use tessera_components::text_input::text_input;
415/// use tessera_ui::Dp;
416/// # use tessera_components::theme::{MaterialTheme, material_theme};
417///
418/// # material_theme()
419/// #     .theme(|| MaterialTheme::default())
420/// #     .child(|| {
421/// text_input().padding(Dp(8.0)).initial_text("Hello World");
422/// # });
423/// # }
424/// # tessera_ui::testing::layout_test(component).run();
425/// ```
426#[tessera]
427pub fn text_input(
428    enabled: Option<bool>,
429    presentation_state: Option<InteractionVisualState>,
430    read_only: Option<bool>,
431    is_error: Option<bool>,
432    modifier: Option<Modifier>,
433    on_change: Option<CallbackWith<String, String>>,
434    on_submit: Option<Callback>,
435    min_width: Option<Dp>,
436    min_height: Option<Dp>,
437    background_color: Option<Color>,
438    border_width: Option<Dp>,
439    border_color: Option<Color>,
440    shape: Option<Shape>,
441    padding: Option<Dp>,
442    focus_border_color: Option<Color>,
443    focus_border_width: Option<Dp>,
444    focus_background_color: Option<Color>,
445    selection_color: Option<Color>,
446    text_color: Option<Color>,
447    cursor_color: Option<Color>,
448    #[prop(into)] accessibility_label: Option<String>,
449    #[prop(into)] accessibility_description: Option<String>,
450    #[prop(into)] initial_text: Option<String>,
451    font_size: Option<Dp>,
452    line_height: Option<Dp>,
453    style: Option<TextStyle>,
454    single_line: Option<bool>,
455    input_transform: Option<CallbackWith<String, String>>,
456    display_transform: Option<DisplayTransform>,
457    controller: Option<State<TextInputController>>,
458) {
459    let theme = use_context::<MaterialTheme>()
460        .expect("MaterialTheme must be provided")
461        .get();
462    let scheme = theme.color_scheme;
463    let props_default = TextInputProps::default();
464    let enabled = enabled.unwrap_or(true);
465    let read_only = read_only.unwrap_or(false);
466    let is_error = is_error.unwrap_or(false);
467    let modifier = modifier.unwrap_or_default();
468    let min_width = min_width.or(props_default.min_width);
469    let min_height = min_height.or(props_default.min_height);
470    let background_color = background_color.or(Some(Color::TRANSPARENT));
471    let border_width = border_width.unwrap_or(props_default.border_width);
472    let border_color = border_color.or(Some(scheme.outline));
473    let shape = shape.unwrap_or(theme.shapes.extra_small);
474    let padding = padding.unwrap_or(props_default.padding);
475    let focus_border_color = focus_border_color.or(Some(scheme.primary));
476    let focus_border_width = focus_border_width.or(props_default.focus_border_width);
477    let focus_background_color = focus_background_color.or(Some(Color::TRANSPARENT));
478    let selection_color =
479        selection_color.or(Some(TextSelectionColors::from_scheme(&scheme).background));
480    let text_color = text_color.or(Some(if enabled {
481        scheme.on_surface
482    } else {
483        scheme.on_surface.with_alpha(DISABLED_CONTENT_ALPHA)
484    }));
485    let cursor_color = cursor_color.or(Some(if is_error {
486        scheme.error
487    } else {
488        scheme.primary
489    }));
490    let style = style.unwrap_or(theme.typography.body_large);
491    let font_size = font_size.unwrap_or(style.font_size);
492    let line_height = line_height.or(style.line_height);
493    let single_line = single_line.unwrap_or(false);
494    let args = TextInputProps {
495        enabled,
496        presentation_state,
497        read_only,
498        is_error,
499        is_secure: false,
500        modifier,
501        on_change: on_change.unwrap_or_else(CallbackWith::default_value),
502        on_submit: on_submit.unwrap_or_else(Callback::noop),
503        min_width,
504        min_height,
505        background_color,
506        border_width,
507        border_color,
508        shape,
509        padding,
510        focus_border_color,
511        focus_border_width,
512        focus_background_color,
513        selection_color,
514        text_color,
515        cursor_color,
516        accessibility_label,
517        accessibility_description,
518        initial_text,
519        font_size,
520        line_height,
521        style: Some(TextStyle {
522            font_size,
523            line_height,
524            ..style
525        }),
526        single_line,
527        input_transform,
528        display_transform,
529        controller,
530    };
531    let controller = if let Some(controller) = args.controller {
532        controller
533    } else {
534        remember(|| {
535            let mut c = TextInputController::new(args.font_size, args.line_height);
536            if let Some(text) = &args.initial_text {
537                c.set_text(text);
538            }
539            c
540        })
541    };
542    let mut editor_args = args.clone();
543    editor_args.controller = Some(controller);
544
545    if !editor_args.enabled {
546        controller.with(|c| *c.focus_handler()).unfocus();
547    }
548    sync_text_input_controller(&controller, &editor_args);
549    let focus = controller.with(|c| *c.focus_handler());
550    let tap_recognizer = remember(TapRecognizer::default);
551    let scroll_recognizer = remember(ScrollRecognizer::default);
552    let modifier = apply_text_input_input_modifiers(
553        Modifier::new()
554            .focus_requester(focus)
555            .focusable()
556            .focus_properties(
557                tessera_ui::FocusProperties::new()
558                    .can_focus(editor_args.enabled)
559                    .can_request_focus(editor_args.enabled),
560            ),
561        editor_args.clone(),
562        controller,
563        tap_recognizer,
564        scroll_recognizer,
565    );
566
567    let current = editor_args;
568    let editor_args = remember(|| current.clone());
569    if editor_args.with(|args| args != &current) {
570        editor_args.set(current);
571    }
572    layout().modifier(modifier).child(move || {
573        let args = editor_args.get();
574        let text_input_surface = create_surface_args(&args, &controller, &scheme);
575        surface()
576            .style(text_input_surface.style)
577            .shape(text_input_surface.shape)
578            .block_input(text_input_surface.block_input)
579            .modifier(text_input_surface.modifier)
580            .child(move || {
581                text_input_padded_content()
582                    .props(editor_args)
583                    .controller(controller);
584            });
585    });
586}
587
588#[tessera]
589fn text_input_editor(props: TextInputProps) {
590    let controller = props
591        .controller
592        .expect("text_input_editor requires controller to be set");
593    let editor_args: TextInputProps = props.clone();
594
595    if !editor_args.enabled {
596        controller.with(|c| *c.focus_handler()).unfocus();
597    }
598    sync_text_input_controller(&controller, &editor_args);
599    let focus = controller.with(|c| *c.focus_handler());
600    let tap_recognizer = remember(TapRecognizer::default);
601    let scroll_recognizer = remember(ScrollRecognizer::default);
602    let modifier = apply_text_input_input_modifiers(
603        editor_args.modifier.clone().then(
604            Modifier::new()
605                .focus_requester(focus)
606                .focusable()
607                .focus_properties(
608                    tessera_ui::FocusProperties::new()
609                        .can_focus(editor_args.enabled)
610                        .can_request_focus(editor_args.enabled),
611                ),
612        ),
613        editor_args.clone(),
614        controller,
615        tap_recognizer,
616        scroll_recognizer,
617    );
618
619    let current = editor_args;
620    let editor_args = remember(|| current.clone());
621    if editor_args.with(|args| args != &current) {
622        editor_args.set(current);
623    }
624    boxed()
625        .alignment(Alignment::TopStart)
626        .modifier(modifier)
627        .children(move || {
628            text_input_padded_content()
629                .props(editor_args)
630                .controller(controller);
631        });
632}
633
634#[tessera]
635fn text_input_padded_content(
636    props: Option<State<TextInputProps>>,
637    controller: Option<State<TextInputController>>,
638) {
639    let props = props.expect("text_input_padded_content requires props to be set");
640    let padding = props.with(|args| args.padding);
641    let controller = controller.expect("text_input_padded_content requires controller to be set");
642    layout()
643        .modifier(Modifier::new().padding_all(padding))
644        .child(move || {
645            text_edit_core()
646                .controller(controller)
647                .presentation_focused(visual_focus(&props.get(), controller));
648        });
649}
650
651pub(crate) fn visual_focus(args: &TextInputProps, controller: State<TextInputController>) -> bool {
652    let mut live = InteractionState::new();
653    live.set_focused(controller.with(|c| c.focus_handler().is_focused()));
654    live.with_presentation(args.presentation_state, args.enabled)
655        .is_focused()
656}
657
658#[tessera]
659pub(crate) fn text_input_core(args: TextInputProps, controller: State<TextInputController>) {
660    let mut core_args = args;
661    core_args.controller = Some(controller);
662    text_input_editor(core_args);
663}
664
665fn sync_text_input_controller(controller: &State<TextInputController>, args: &TextInputProps) {
666    if let Some(style) = args.style
667        && controller.with(|c| c.text_style()) != style
668    {
669        controller.with_mut(|c| c.set_text_style(style));
670    }
671
672    if let Some(selection_color) = args.selection_color {
673        let needs_update = controller.with(|c| c.selection_color() != selection_color);
674        if needs_update {
675            controller.with_mut(|c| c.set_selection_color(selection_color));
676        }
677    }
678    if let Some(text_color) = args.text_color {
679        let needs_update = controller.with(|c| c.text_color() != text_color);
680        if needs_update {
681            controller.with_mut(|c| c.set_text_color(text_color));
682        }
683    }
684    if let Some(cursor_color) = args.cursor_color {
685        let needs_update = controller.with(|c| c.cursor_color() != cursor_color);
686        if needs_update {
687            controller.with_mut(|c| c.set_cursor_color(cursor_color));
688        }
689    }
690    let display_transform = args.display_transform;
691    let needs_display_transform_update =
692        controller.with(|c| c.display_transform() != display_transform);
693    if needs_display_transform_update {
694        controller.with_mut(|c| c.set_display_transform(display_transform));
695    }
696    let needs_single_line_update = controller.with(|c| c.single_line() != args.single_line);
697    if needs_single_line_update {
698        controller.with_mut(|c| c.set_single_line(args.single_line));
699    }
700}
701
702fn handle_text_input(
703    input: &mut tessera_ui::PointerInput<'_>,
704    args: &TextInputProps,
705    controller: &State<TextInputController>,
706    tap_recognizer: State<TapRecognizer>,
707    scroll_recognizer: State<ScrollRecognizer>,
708) {
709    if !args.enabled {
710        return;
711    }
712    let size = input.computed_data; // This is the full surface size
713    let cursor_pos_option = input.cursor_position_rel;
714    let is_cursor_in_editor = cursor_pos_option
715        .map(|pos| is_position_inside_bounds(size, pos))
716        .unwrap_or(false);
717    let tap_result = tap_recognizer.with_mut(|recognizer| {
718        recognizer.update(
719            input.pass,
720            input.pointer_changes.as_mut_slice(),
721            is_cursor_in_editor,
722        )
723    });
724
725    // Handle click events - now we have a full clickable area from surface
726    if is_cursor_in_editor {
727        if tap_result.pressed {
728            // Request focus if not already focused
729            if !controller.with(|s| s.focus_handler().is_focused()) {
730                controller.with(|s| *s.focus_handler()).request_focus();
731            }
732
733            // Handle cursor positioning for clicks
734            if let Some(cursor_pos) = cursor_pos_option {
735                let text_relative_pos =
736                    click_selection_pointer_position(cursor_pos, args, controller, size);
737                let Some(click_timestamp) = tap_result.press_timestamp else {
738                    return;
739                };
740                // Determine click type and handle accordingly
741                let window_position = input
742                    .pointer_changes
743                    .iter()
744                    .find(|change| {
745                        change.timestamp == click_timestamp
746                            && matches!(change.content, CursorEventContent::Pressed(_))
747                    })
748                    .and_then(|change| change.position())
749                    .or_else(|| input.cursor_position_abs())
750                    .unwrap_or_else(|| input.local_to_window.map_point(cursor_pos));
751                let click_type = controller.with_mut(|s| {
752                    s.handle_click(text_relative_pos, window_position, click_timestamp)
753                });
754
755                match click_type {
756                    ClickType::Single => {
757                        if input.key_modifiers.shift_key() {
758                            controller.with_mut(|s| {
759                                s.extend_selection_to_point(text_relative_pos);
760                            });
761                        } else {
762                            controller.with_mut(|s| {
763                                s.apply_pointer_action(GlyphonAction::Click {
764                                    x: text_relative_pos.x_px().0,
765                                    y: text_relative_pos.y_px().0,
766                                });
767                            });
768                        }
769                    }
770                    ClickType::Double => {
771                        controller.with_mut(|s| {
772                            s.apply_pointer_action(GlyphonAction::DoubleClick {
773                                x: text_relative_pos.x_px().0,
774                                y: text_relative_pos.y_px().0,
775                            });
776                        });
777                    }
778                    ClickType::Triple => {
779                        controller.with_mut(|s| {
780                            s.apply_pointer_action(GlyphonAction::TripleClick {
781                                x: text_relative_pos.x_px().0,
782                                y: text_relative_pos.y_px().0,
783                            });
784                        });
785                    }
786                }
787
788                // Start potential drag operation
789                controller.with_mut(|s| s.start_drag(click_type));
790            }
791        }
792
793        // Handle drag events (mouse move while dragging)
794        // This happens every frame when cursor position changes during drag
795        if controller.with(|s| s.is_dragging())
796            && let Some(cursor_pos) = cursor_pos_option
797        {
798            let drag_target = drag_selection_pointer_position(cursor_pos, args, controller, size);
799            auto_scroll_drag_selection(&drag_target, controller);
800            let current_pos_px =
801                drag_selection_pointer_position(cursor_pos, args, controller, size).position;
802            let last_pos_px = controller.with(|s| s.last_click_position());
803
804            if last_pos_px != Some(current_pos_px) {
805                controller.with_mut(|s| {
806                    s.apply_pointer_action(GlyphonAction::Drag {
807                        x: current_pos_px.x_px().0,
808                        y: current_pos_px.y_px().0,
809                    });
810                });
811
812                // Update last position to current position
813                controller.with_mut(|s| {
814                    s.update_last_click_position(current_pos_px);
815                });
816            }
817        }
818
819        scroll_recognizer.with_mut(|recognizer| {
820            recognizer.for_each(
821                input.pass,
822                input.pointer_changes.as_mut_slice(),
823                |_context, event| {
824                    event.normalize_to_pixels();
825                    let (dx, dy) = (event.delta_x, event.delta_y);
826                    if controller.with(|s| s.single_line()) {
827                        let requested = if dx.abs() > f32::EPSILON { dx } else { dy };
828                        if requested.abs() <= f32::EPSILON
829                            || !controller.with_mut(|s| s.has_horizontal_overflow())
830                        {
831                            return;
832                        }
833                        let consumed =
834                            controller.with_mut(|s| s.scroll_horizontal_consumed(-requested));
835                        // `consumed` is in editor scroll space; the raw scroll
836                        // event uses the opposite sign.
837                        let consumed_raw = -consumed;
838                        if dx.abs() > f32::EPSILON {
839                            event.delta_x = dx - consumed_raw;
840                            event.delta_y = dy;
841                        } else {
842                            event.delta_y = dy - consumed_raw;
843                            event.delta_x = dx;
844                        }
845                    } else {
846                        if dy.abs() <= f32::EPSILON
847                            || !controller.with_mut(|s| s.has_vertical_overflow())
848                        {
849                            return;
850                        }
851                        // The editor engine clamps vertical scroll to the
852                        // content extent, so `consumed` is the exact amount
853                        // applied and the remainder goes to an ancestor.
854                        let consumed = controller.with_mut(|s| s.scroll_vertical_consumed(-dy));
855                        event.delta_y = dy + consumed;
856                    }
857                },
858            );
859        });
860    }
861
862    // Handle mouse release events (end drag), even if the pointer is outside
863    // the editor bounds.
864    if tap_result.released {
865        controller.with_mut(|s| s.stop_drag());
866    }
867
868    // Only block cursor events when focused to prevent propagation.
869    let should_consume_pointer = controller.with(|s| {
870        s.focus_handler().is_focused()
871            && (is_cursor_in_editor || s.is_dragging() || tap_result.released)
872    });
873    if should_consume_pointer {
874        input.consume_pointer_changes();
875    }
876
877    if args.enabled && !args.read_only && controller.with(|s| s.focus_handler().is_focused()) {
878        let computed_data = input.computed_data;
879        let request = current_ime_request(args, controller, computed_data);
880        if tap_result.pressed && is_cursor_in_editor {
881            input.ime_session().show(request);
882        } else {
883            input.ime_session().update(request);
884        }
885    }
886}
887
888fn click_selection_pointer_position(
889    cursor_pos: PxPosition,
890    args: &TextInputProps,
891    controller: &State<TextInputController>,
892    size: ComputedData,
893) -> PxPosition {
894    drag_selection_pointer_position(cursor_pos, args, controller, size).position
895}
896
897fn text_content_origin(args: &TextInputProps) -> PxPosition {
898    let padding_px: Px = args.padding.into();
899    let border_width_px = Px(args.border_width.to_pixels_u32() as i32);
900    text_content_origin_from_values(padding_px, border_width_px)
901}
902
903fn text_content_origin_from_values(padding_px: Px, border_width_px: Px) -> PxPosition {
904    PxPosition::new(padding_px + border_width_px, padding_px + border_width_px)
905}
906
907#[derive(Clone, Copy, Debug, PartialEq)]
908struct DragSelectionPointerPosition {
909    position: PxPosition,
910    overflow_x: Px,
911    overflow_y: Px,
912}
913
914fn drag_selection_pointer_position(
915    cursor_pos: PxPosition,
916    args: &TextInputProps,
917    controller: &State<TextInputController>,
918    size: ComputedData,
919) -> DragSelectionPointerPosition {
920    let (horizontal_scroll, vertical_scroll) = controller.with(|s| {
921        (
922            Px(s.scroll_state().horizontal().round() as i32),
923            Px(s.scroll_state().vertical().round() as i32),
924        )
925    });
926    let text_origin = text_content_origin(args);
927    drag_selection_pointer_position_with_scroll(
928        cursor_pos,
929        size,
930        text_origin,
931        horizontal_scroll,
932        vertical_scroll,
933    )
934}
935
936fn drag_selection_pointer_position_with_scroll(
937    cursor_pos: PxPosition,
938    size: ComputedData,
939    text_origin: PxPosition,
940    horizontal_scroll: Px,
941    vertical_scroll: Px,
942) -> DragSelectionPointerPosition {
943    let viewport_size = text_viewport_size_from_origin(size, text_origin);
944    let viewport_width = viewport_size.width.0 as f32;
945    let viewport_height = viewport_size.height.0 as f32;
946    let text_relative_x = cursor_pos.x - text_origin.x;
947    let text_relative_y = cursor_pos.y - text_origin.y;
948    let overflow_x = if text_relative_x < 0.0 {
949        Px(text_relative_x.round() as i32)
950    } else if text_relative_x > viewport_width {
951        Px((text_relative_x - viewport_width).round() as i32)
952    } else {
953        Px::ZERO
954    };
955    let overflow_y = if text_relative_y < 0.0 {
956        Px(text_relative_y.round() as i32)
957    } else if text_relative_y > viewport_height {
958        Px((text_relative_y - viewport_height).round() as i32)
959    } else {
960        Px::ZERO
961    };
962    let clamped_x = text_relative_x.clamp(0.0, viewport_width);
963    let clamped_y = text_relative_y.clamp(0.0, viewport_height);
964
965    DragSelectionPointerPosition {
966        position: PxPosition::from_f32_xy(
967            clamped_x + horizontal_scroll.0 as f32,
968            clamped_y + vertical_scroll.0 as f32,
969        ),
970        overflow_x,
971        overflow_y,
972    }
973}
974
975fn text_viewport_size_from_origin(size: ComputedData, text_origin: PxPosition) -> ComputedData {
976    let width = (size.width.0 as f32 - text_origin.x * 2.0).round() as i32;
977    let height = (size.height.0 as f32 - text_origin.y * 2.0).round() as i32;
978    ComputedData {
979        width: Px(width).max(Px::ZERO),
980        height: Px(height).max(Px::ZERO),
981    }
982}
983
984fn auto_scroll_drag_selection(
985    drag_target: &DragSelectionPointerPosition,
986    controller: &State<TextInputController>,
987) {
988    controller.with_mut(|s| {
989        if s.single_line() {
990            if drag_target.overflow_x != Px::ZERO {
991                s.scroll_horizontal_by(drag_target.overflow_x.to_f32());
992            }
993            return;
994        }
995        if drag_target.overflow_y != Px::ZERO {
996            s.scroll_vertical_by(drag_target.overflow_y.to_f32());
997        }
998    });
999}
1000
1001fn handle_editor_escape(
1002    controller: &mut TextInputController,
1003    state: winit::event::ElementState,
1004    on_change: CallbackWith<String, String>,
1005    input_transform: Option<CallbackWith<String, String>>,
1006) -> bool {
1007    if state != winit::event::ElementState::Pressed
1008        || (controller.composition().is_none() && !controller.has_selection())
1009    {
1010        return false;
1011    }
1012    controller.apply_action_with_pipeline(GlyphonAction::Escape, on_change, input_transform);
1013    true
1014}
1015
1016fn handle_text_input_keyboard(
1017    input: &mut tessera_ui::KeyboardInput<'_>,
1018    args: &TextInputProps,
1019    controller: &State<TextInputController>,
1020    on_change: &CallbackWith<String, String>,
1021    input_transform: &Option<CallbackWith<String, String>>,
1022) {
1023    if !args.enabled || !controller.with(|s| s.focus_handler().is_focused()) {
1024        return;
1025    }
1026
1027    let is_ctrl = input.key_modifiers.control_key() || input.key_modifiers.super_key();
1028    let is_shift = input.key_modifiers.shift_key();
1029    let mut all_actions = Vec::new();
1030    let mut should_block_keyboard = false;
1031    for key_event in input.keyboard_events.iter().cloned() {
1032        if key_event.logical_key == winit::keyboard::Key::Named(winit::keyboard::NamedKey::Escape) {
1033            should_block_keyboard |= controller.with_mut(|controller| {
1034                handle_editor_escape(controller, key_event.state, *on_change, *input_transform)
1035            });
1036            continue;
1037        }
1038        if let Some(behavior) =
1039            single_line_key_behavior(args.single_line, key_event.state, &key_event.logical_key)
1040        {
1041            match behavior {
1042                SingleLineKeyBehavior::Submit => {
1043                    if args.on_submit != Callback::noop() {
1044                        args.on_submit.call();
1045                        should_block_keyboard = true;
1046                    }
1047                }
1048                SingleLineKeyBehavior::Propagate => {}
1049            }
1050            continue;
1051        }
1052
1053        if let Some(behavior) =
1054            clipboard_shortcut_for_key(is_ctrl, is_shift, key_event.state, &key_event.logical_key)
1055        {
1056            should_block_keyboard = true;
1057            #[cfg(not(target_family = "wasm"))]
1058            match behavior {
1059                ClipboardShortcutBehavior::Copy => {
1060                    controller.with(|s| {
1061                        s.copy_selection_to_clipboard();
1062                    });
1063                }
1064                ClipboardShortcutBehavior::Cut => {
1065                    if !args.read_only {
1066                        controller.with_mut(|s| {
1067                            s.cut_selection_with_pipeline(*on_change, *input_transform);
1068                        });
1069                    }
1070                }
1071                ClipboardShortcutBehavior::Paste => {
1072                    if !args.read_only {
1073                        controller.with_mut(|s| {
1074                            s.paste_from_clipboard_with_pipeline(*on_change, *input_transform);
1075                        });
1076                    }
1077                }
1078            }
1079            #[cfg(target_family = "wasm")]
1080            let _ = behavior;
1081            continue;
1082        }
1083
1084        if let Some(motion) =
1085            deletion_motion_for_key(is_ctrl, key_event.state, &key_event.logical_key)
1086        {
1087            should_block_keyboard = true;
1088            if !args.read_only {
1089                controller.with_mut(|s| {
1090                    s.delete_motion_with_pipeline(motion, *on_change, *input_transform);
1091                });
1092            }
1093            continue;
1094        }
1095
1096        should_block_keyboard = true;
1097        let shortcut = if let winit::keyboard::Key::Character(s) = &key_event.logical_key {
1098            if is_ctrl && key_event.state == winit::event::ElementState::Pressed {
1099                Some(s.to_lowercase())
1100            } else {
1101                None
1102            }
1103        } else {
1104            None
1105        };
1106
1107        match shortcut.as_deref() {
1108            Some("a") => {
1109                controller.with_mut(|s| s.select_all());
1110                continue;
1111            }
1112            Some("c") => {
1113                controller.with(|s| {
1114                    s.copy_selection_to_clipboard();
1115                });
1116                continue;
1117            }
1118            Some("x") => {
1119                if !args.read_only {
1120                    controller.with_mut(|s| {
1121                        s.cut_selection_with_pipeline(*on_change, *input_transform);
1122                    });
1123                }
1124                continue;
1125            }
1126            Some("v") => {
1127                if !args.read_only {
1128                    controller.with_mut(|s| {
1129                        s.paste_from_clipboard_with_pipeline(*on_change, *input_transform);
1130                    });
1131                }
1132                continue;
1133            }
1134            _ => {}
1135        }
1136
1137        controller.with_mut(|s| {
1138            if let Some(actions) = s.map_key_event_to_action(key_event, input.key_modifiers) {
1139                all_actions.extend(actions);
1140            }
1141        });
1142    }
1143    if !all_actions.is_empty() {
1144        if args.read_only {
1145            all_actions.retain(|action| !is_editing_action(action));
1146        }
1147        for action in all_actions {
1148            handle_action(controller, action, *on_change, *input_transform);
1149        }
1150    }
1151
1152    if !args.read_only && controller.with(|s| s.focus_handler().is_focused()) {
1153        let computed_data = input.computed_data;
1154        input
1155            .ime_session()
1156            .update(current_ime_request(args, controller, computed_data));
1157    }
1158
1159    if should_block_keyboard {
1160        input.block_keyboard();
1161    }
1162}
1163
1164fn handle_text_input_ime(
1165    input: &mut tessera_ui::ImeInput<'_>,
1166    args: &TextInputProps,
1167    controller: &State<TextInputController>,
1168    on_change: &CallbackWith<String, String>,
1169    input_transform: &Option<CallbackWith<String, String>>,
1170) {
1171    if !args.enabled || !controller.with(|s| s.focus_handler().is_focused()) {
1172        return;
1173    }
1174    if args.read_only {
1175        input.block_ime();
1176        return;
1177    }
1178
1179    for event in input.ime_events.drain(..) {
1180        if !controller.with(|controller| controller.focus_handler().is_focused()) {
1181            break;
1182        }
1183        let direction = match event {
1184            winit::event::Ime::Action(winit::event::ImeAction::Next) => {
1185                Some(tessera_ui::FocusDirection::Next)
1186            }
1187            winit::event::Ime::Action(winit::event::ImeAction::Previous) => {
1188                Some(tessera_ui::FocusDirection::Previous)
1189            }
1190            _ => None,
1191        };
1192        if let Some(direction) = direction {
1193            tessera_ui::FocusManager::current().request_move_focus(direction);
1194            break;
1195        }
1196        let Some(plan) = controller.with(|c| c.plan_ime_event(args.single_line, &event)) else {
1197            continue;
1198        };
1199        match plan {
1200            PlannedImeEvent::Submit => {
1201                controller.with_mut(|c| c.clear_composition());
1202                if args.on_submit != Callback::noop() {
1203                    args.on_submit.call();
1204                }
1205            }
1206            PlannedImeEvent::Edit(plan) => {
1207                let result = replace_text_range_with_selection(
1208                    controller,
1209                    plan.replacement_range.clone(),
1210                    &plan.replacement_text,
1211                    plan.selection.clone(),
1212                    *on_change,
1213                    *input_transform,
1214                );
1215                controller.with_mut(|c| c.commit_ime_edit(&plan, &result));
1216            }
1217        }
1218    }
1219
1220    let computed_data = input.computed_data;
1221    input
1222        .ime_session()
1223        .update(current_ime_request(args, controller, computed_data));
1224    input.block_ime();
1225}
1226
1227fn current_ime_request(
1228    args: &TextInputProps,
1229    controller: &State<TextInputController>,
1230    computed_data: ComputedData,
1231) -> ImeRequest {
1232    let (ime_rect, state) = controller.with(|c| {
1233        (
1234            c.current_ime_rect(),
1235            winit::event::ImeTextState {
1236                text: c.text(),
1237                selection: c.selection().start..c.selection().end,
1238                composition: c.composition().map(|composition| composition.range.clone()),
1239            },
1240        )
1241    });
1242    build_ime_request(text_content_origin(args), computed_data, ime_rect)
1243        .with_text_state(state)
1244        .with_options(winit::window::ImeOptions {
1245            purpose: if args.is_secure {
1246                winit::window::ImePurpose::Password
1247            } else {
1248                winit::window::ImePurpose::Normal
1249            },
1250            action: if args.single_line {
1251                winit::event::ImeAction::Submit
1252            } else {
1253                winit::event::ImeAction::Newline
1254            },
1255        })
1256}
1257
1258fn build_ime_request(
1259    text_origin: PxPosition,
1260    computed_data: ComputedData,
1261    ime_rect: Option<RectDef>,
1262) -> ImeRequest {
1263    ime_rect
1264        .map(|ime_rect| {
1265            ImeRequest::new(PxSize::new(ime_rect.width, ime_rect.height))
1266                .with_local_position(text_origin + PxPosition::new(ime_rect.x, ime_rect.y))
1267        })
1268        .unwrap_or_else(|| ImeRequest::new(computed_data.into()))
1269}
1270
1271#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1272enum SingleLineKeyBehavior {
1273    Submit,
1274    Propagate,
1275}
1276
1277#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1278enum ClipboardShortcutBehavior {
1279    Copy,
1280    Cut,
1281    Paste,
1282}
1283
1284fn deletion_motion_for_key(
1285    is_ctrl: bool,
1286    key_state: winit::event::ElementState,
1287    logical_key: &winit::keyboard::Key,
1288) -> Option<glyphon::cosmic_text::Motion> {
1289    if !is_ctrl || key_state != winit::event::ElementState::Pressed {
1290        return None;
1291    }
1292
1293    match logical_key {
1294        winit::keyboard::Key::Named(winit::keyboard::NamedKey::Backspace) => {
1295            Some(glyphon::cosmic_text::Motion::LeftWord)
1296        }
1297        winit::keyboard::Key::Named(winit::keyboard::NamedKey::Delete) => {
1298            Some(glyphon::cosmic_text::Motion::RightWord)
1299        }
1300        _ => None,
1301    }
1302}
1303
1304fn clipboard_shortcut_for_key(
1305    is_ctrl: bool,
1306    is_shift: bool,
1307    key_state: winit::event::ElementState,
1308    logical_key: &winit::keyboard::Key,
1309) -> Option<ClipboardShortcutBehavior> {
1310    if key_state != winit::event::ElementState::Pressed {
1311        return None;
1312    }
1313
1314    match logical_key {
1315        winit::keyboard::Key::Named(winit::keyboard::NamedKey::Insert) if is_ctrl => {
1316            Some(ClipboardShortcutBehavior::Copy)
1317        }
1318        winit::keyboard::Key::Named(winit::keyboard::NamedKey::Insert) if is_shift => {
1319            Some(ClipboardShortcutBehavior::Paste)
1320        }
1321        winit::keyboard::Key::Named(winit::keyboard::NamedKey::Delete) if is_shift => {
1322            Some(ClipboardShortcutBehavior::Cut)
1323        }
1324        _ => None,
1325    }
1326}
1327
1328fn single_line_key_behavior(
1329    single_line: bool,
1330    key_state: winit::event::ElementState,
1331    logical_key: &winit::keyboard::Key,
1332) -> Option<SingleLineKeyBehavior> {
1333    if !single_line || key_state != winit::event::ElementState::Pressed {
1334        return None;
1335    }
1336
1337    match logical_key {
1338        winit::keyboard::Key::Named(winit::keyboard::NamedKey::Enter) => {
1339            Some(SingleLineKeyBehavior::Submit)
1340        }
1341        winit::keyboard::Key::Named(winit::keyboard::NamedKey::Tab) => {
1342            Some(SingleLineKeyBehavior::Propagate)
1343        }
1344        _ => None,
1345    }
1346}
1347
1348pub(crate) fn handle_action(
1349    state: &State<TextInputController>,
1350    action: GlyphonAction,
1351    on_change: CallbackWith<String, String>,
1352    input_transform: Option<CallbackWith<String, String>>,
1353) {
1354    state.with_mut(|c| c.apply_action_with_pipeline(action, on_change, input_transform));
1355}
1356
1357fn replace_text_range_with_selection(
1358    state: &State<TextInputController>,
1359    range: std::ops::Range<usize>,
1360    replacement: &str,
1361    selection: TextSelection,
1362    on_change: CallbackWith<String, String>,
1363    input_transform: Option<CallbackWith<String, String>>,
1364) -> ImeEditResult {
1365    state.with_mut(|c| {
1366        c.replace_text_range_with_pipeline(
1367            range,
1368            replacement,
1369            selection,
1370            on_change,
1371            input_transform,
1372        )
1373    })
1374}
1375
1376struct TextInputSemanticsModifierNode {
1377    args: TextInputProps,
1378    controller: State<TextInputController>,
1379}
1380
1381impl SemanticsModifierNode for TextInputSemanticsModifierNode {
1382    fn apply(
1383        &self,
1384        accessibility: &mut AccessibilityNode,
1385        action_handler: &mut Option<AccessibilityActionHandler>,
1386    ) {
1387        apply_text_input_semantics(accessibility, action_handler, &self.args, &self.controller);
1388    }
1389}
1390
1391fn is_editing_action(action: &GlyphonAction) -> bool {
1392    matches!(
1393        action,
1394        GlyphonAction::Insert(_)
1395            | GlyphonAction::Backspace
1396            | GlyphonAction::Delete
1397            | GlyphonAction::Enter
1398    )
1399}
1400
1401pub(crate) struct TextInputSurfaceArgs {
1402    pub(crate) style: SurfaceStyle,
1403    pub(crate) shape: Shape,
1404    pub(crate) block_input: bool,
1405    pub(crate) modifier: Modifier,
1406}
1407
1408/// Create surface arguments based on editor configuration and state
1409pub(crate) fn create_surface_args(
1410    args: &TextInputProps,
1411    state: &State<TextInputController>,
1412    scheme: &MaterialColorScheme,
1413) -> TextInputSurfaceArgs {
1414    let focused = visual_focus(args, *state);
1415    let border_width = determine_border_width(args, focused);
1416    let is_outlined = border_width.to_pixels_f32() > 0.0;
1417    let style = if is_outlined {
1418        SurfaceStyle::FilledOutlined {
1419            fill_color: determine_background_color(args, focused, is_outlined, scheme),
1420            border_color: determine_border_color(args, focused, scheme),
1421            border_width,
1422        }
1423    } else {
1424        SurfaceStyle::Filled {
1425            color: determine_background_color(args, focused, is_outlined, scheme),
1426        }
1427    };
1428
1429    let mut modifier = args.modifier.clone();
1430    if args.min_width.is_some() || args.min_height.is_some() {
1431        modifier = modifier.default_min_size(
1432            args.min_width.unwrap_or(Dp::ZERO),
1433            args.min_height.unwrap_or(Dp::ZERO),
1434        );
1435    }
1436
1437    TextInputSurfaceArgs {
1438        style,
1439        shape: args.shape,
1440        block_input: !args.enabled,
1441        modifier,
1442    }
1443}
1444
1445#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1446struct FieldVisualState {
1447    focused: bool,
1448    enabled: bool,
1449    is_error: bool,
1450    outlined: bool,
1451}
1452
1453fn default_fill_color(scheme: &MaterialColorScheme, state: FieldVisualState) -> Color {
1454    if !state.enabled && !state.outlined {
1455        return scheme.on_surface.with_alpha(DISABLED_FILL_ALPHA);
1456    }
1457    Color::TRANSPARENT
1458}
1459
1460fn default_outline_color(scheme: &MaterialColorScheme, state: FieldVisualState) -> Color {
1461    if !state.enabled {
1462        return scheme.on_surface.with_alpha(DISABLED_OUTLINE_ALPHA);
1463    }
1464    if state.is_error {
1465        return scheme.error;
1466    }
1467    if state.focused {
1468        scheme.primary
1469    } else {
1470        scheme.outline
1471    }
1472}
1473
1474fn determine_background_color(
1475    args: &TextInputProps,
1476    focused: bool,
1477    is_outlined: bool,
1478    scheme: &MaterialColorScheme,
1479) -> Color {
1480    let state = FieldVisualState {
1481        focused,
1482        enabled: args.enabled,
1483        is_error: args.is_error,
1484        outlined: is_outlined,
1485    };
1486    if !args.enabled {
1487        return default_fill_color(scheme, state);
1488    }
1489    let explicit = if focused {
1490        args.focus_background_color.or(args.background_color)
1491    } else {
1492        args.background_color
1493    };
1494    explicit.unwrap_or_else(|| default_fill_color(scheme, state))
1495}
1496
1497fn determine_border_color(
1498    args: &TextInputProps,
1499    focused: bool,
1500    scheme: &MaterialColorScheme,
1501) -> Color {
1502    let state = FieldVisualState {
1503        focused,
1504        enabled: args.enabled,
1505        is_error: args.is_error,
1506        outlined: true,
1507    };
1508    if !args.enabled || args.is_error {
1509        return default_outline_color(scheme, state);
1510    }
1511    if focused {
1512        args.focus_border_color
1513            .or(args.border_color)
1514            .unwrap_or_else(|| default_outline_color(scheme, state))
1515    } else {
1516        args.border_color
1517            .unwrap_or_else(|| default_outline_color(scheme, state))
1518    }
1519}
1520
1521fn resolve_border_width(
1522    border_width: Dp,
1523    focus_border_width: Option<Dp>,
1524    enabled: bool,
1525    focused: bool,
1526) -> Dp {
1527    if !enabled {
1528        return border_width;
1529    }
1530    if focused {
1531        focus_border_width.unwrap_or(border_width)
1532    } else {
1533        border_width
1534    }
1535}
1536
1537fn determine_border_width(args: &TextInputProps, focused: bool) -> Dp {
1538    resolve_border_width(
1539        args.border_width,
1540        args.focus_border_width,
1541        args.enabled,
1542        focused,
1543    )
1544}
1545
1546/// Presets for an already declared text input.
1547impl TextInputBuilder {
1548    /// Creates a simple text input with default styling.
1549    ///
1550    /// - Minimum width: 120dp
1551    /// - Background: white
1552    /// - Border: 1px gray, rounded rectangle
1553    ///
1554    /// # Example
1555    ///
1556    /// ```
1557    /// # use tessera_ui::tessera;
1558    /// # #[tessera]
1559    /// # fn component() {
1560    /// use tessera_components::text_input::text_input;
1561    /// # use tessera_components::theme::{MaterialTheme, material_theme};
1562    /// # material_theme()
1563    /// #     .theme(|| MaterialTheme::default())
1564    /// #     .child(|| {
1565    /// let args = text_input().simple();
1566    /// # });
1567    /// # }
1568    /// # tessera_ui::testing::layout_test(component).run();
1569    /// ```
1570    pub fn simple(&self) -> Self {
1571        self.min_width(Dp(120.0))
1572            .background_color(Color::new(0.9, 0.9, 0.9, 1.0))
1573            .border_width(Dp(1.0))
1574            .border_color(Color::new(0.7, 0.7, 0.7, 1.0))
1575            .shape(Shape::RoundedRectangle {
1576                top_left: RoundedCorner::manual(Dp(0.0), 3.0),
1577                top_right: RoundedCorner::manual(Dp(0.0), 3.0),
1578                bottom_right: RoundedCorner::manual(Dp(0.0), 3.0),
1579                bottom_left: RoundedCorner::manual(Dp(0.0), 3.0),
1580            })
1581    }
1582
1583    /// Creates a text input with an emphasized border for better visibility.
1584    ///
1585    /// - Border: 2px, blue focus border
1586    ///
1587    /// # Example
1588    /// ```
1589    /// # use tessera_ui::tessera;
1590    /// # #[tessera]
1591    /// # fn component() {
1592    /// use tessera_components::text_input::text_input;
1593    /// # use tessera_components::theme::{MaterialTheme, material_theme};
1594    /// # material_theme()
1595    /// #     .theme(|| MaterialTheme::default())
1596    /// #     .child(|| {
1597    /// let args = text_input().outlined();
1598    /// # });
1599    /// # }
1600    /// # tessera_ui::testing::layout_test(component).run();
1601    /// ```
1602    pub fn outlined(&self) -> Self {
1603        self.simple()
1604            .border_width(Dp(1.0))
1605            .focus_border_color(Color::new(0.0, 0.5, 1.0, 1.0))
1606    }
1607
1608    /// Creates a text input with no border (minimal style).
1609    ///
1610    /// - Border: 0px, square corners
1611    ///
1612    /// # Example
1613    /// ```
1614    /// # use tessera_ui::tessera;
1615    /// # #[tessera]
1616    /// # fn component() {
1617    /// use tessera_components::text_input::text_input;
1618    /// # use tessera_components::theme::{MaterialTheme, material_theme};
1619    /// # material_theme()
1620    /// #     .theme(|| MaterialTheme::default())
1621    /// #     .child(|| {
1622    /// let args = text_input().minimal();
1623    /// # });
1624    /// # }
1625    /// # tessera_ui::testing::layout_test(component).run();
1626    /// ```
1627    pub fn minimal(&self) -> Self {
1628        self.min_width(Dp(120.0))
1629            .background_color(Color::WHITE)
1630            .shape(Shape::RoundedRectangle {
1631                top_left: RoundedCorner::manual(Dp(0.0), 3.0),
1632                top_right: RoundedCorner::manual(Dp(0.0), 3.0),
1633                bottom_right: RoundedCorner::manual(Dp(0.0), 3.0),
1634                bottom_left: RoundedCorner::manual(Dp(0.0), 3.0),
1635            })
1636    }
1637}
1638
1639fn apply_text_input_semantics(
1640    accessibility: &mut AccessibilityNode,
1641    action_handler: &mut Option<AccessibilityActionHandler>,
1642    args: &TextInputProps,
1643    state: &State<TextInputController>,
1644) {
1645    let focus = state.with(|c| *c.focus_handler());
1646    let submit_action_enabled = should_expose_submit_accessibility_action(
1647        args.single_line,
1648        focus.is_focused(),
1649        args.on_submit != Callback::noop(),
1650    );
1651    accessibility.role = Some(if args.is_secure {
1652        Role::PasswordInput
1653    } else {
1654        text_input_accessibility_role(args.single_line)
1655    });
1656    accessibility.disabled = !args.enabled;
1657    accessibility.label = args.accessibility_label.clone();
1658    accessibility.description = args.accessibility_description.clone();
1659
1660    let current_text = state.with(|c| c.text());
1661    accessibility.value = (!args.is_secure && !current_text.is_empty()).then_some(current_text);
1662
1663    accessibility.is_editable_text = args.enabled && !args.read_only;
1664    accessibility.focusable = args.enabled;
1665    accessibility.actions.clear();
1666    if args.enabled && submit_action_enabled {
1667        accessibility.actions.push(Action::Click);
1668    }
1669
1670    if args.enabled {
1671        let on_submit = args.on_submit;
1672        *action_handler = Some(Box::new(move |action| match action {
1673            Action::Focus => focus.request_focus(),
1674            Action::Blur => focus.clear_focus(),
1675            Action::Click => {
1676                if focus.is_focused() && on_submit != Callback::noop() {
1677                    on_submit.call();
1678                } else {
1679                    focus.request_focus();
1680                }
1681            }
1682            _ => {}
1683        }));
1684    } else {
1685        *action_handler = None;
1686    }
1687}
1688
1689fn text_input_accessibility_role(single_line: bool) -> Role {
1690    if single_line {
1691        Role::TextInput
1692    } else {
1693        Role::MultilineTextInput
1694    }
1695}
1696
1697fn should_expose_submit_accessibility_action(
1698    single_line: bool,
1699    focused: bool,
1700    has_submit_handler: bool,
1701) -> bool {
1702    single_line && focused && has_submit_handler
1703}
1704
1705#[cfg(all(test, not(target_family = "wasm")))]
1706#[path = "text_input/clipboard_tests.rs"]
1707mod clipboard_tests;
1708
1709#[cfg(test)]
1710mod tests {
1711    use std::time::Duration;
1712
1713    use glyphon::{Action as GlyphonAction, Edit as _};
1714    use tessera_ui::{
1715        CallbackWith, Color, ComputedData, Dp, Px, PxPosition, PxSize, accesskit::Role,
1716        time::Instant, winit,
1717    };
1718
1719    use crate::text_edit_core::{
1720        ClickType, ImeComposition, PlannedImeEdit, PlannedImeEvent, RectDef, TextEditorController,
1721        TextSelection,
1722    };
1723
1724    use super::{
1725        ClipboardShortcutBehavior, DISABLED_FILL_ALPHA, DISABLED_OUTLINE_ALPHA,
1726        DragSelectionPointerPosition, FieldVisualState, SingleLineKeyBehavior, build_ime_request,
1727        clipboard_shortcut_for_key, default_fill_color, default_outline_color,
1728        deletion_motion_for_key, drag_selection_pointer_position_with_scroll, handle_editor_escape,
1729        resolve_border_width, should_expose_submit_accessibility_action, single_line_key_behavior,
1730        text_content_origin_from_values, text_input_accessibility_role,
1731        text_viewport_size_from_origin,
1732    };
1733
1734    #[test]
1735    fn escape_disposition_leaves_a_plain_caret_unhandled_without_mutating_text() {
1736        let runtime = tessera_ui::RuntimeSession::new();
1737        let _scope = runtime.enter();
1738        let mut controller = TextEditorController::new(Dp(16.0), Some(Dp(24.0)));
1739        controller.set_text_and_selection("café", TextSelection::collapsed(5));
1740        assert!(!handle_editor_escape(
1741            &mut controller,
1742            winit::event::ElementState::Pressed,
1743            CallbackWith::identity(),
1744            None,
1745        ));
1746        assert_eq!(controller.text(), "café");
1747        assert_eq!(controller.selection(), TextSelection::collapsed(5));
1748        assert!(controller.composition().is_none());
1749    }
1750
1751    #[test]
1752    fn escape_disposition_cancels_a_nonempty_selection_once_without_editing_text() {
1753        let runtime = tessera_ui::RuntimeSession::new();
1754        let _scope = runtime.enter();
1755        let mut controller = TextEditorController::new(Dp(16.0), Some(Dp(24.0)));
1756        controller.set_text_and_selection("café", TextSelection { start: 0, end: 3 });
1757        assert_eq!(controller.selection(), TextSelection { start: 0, end: 3 });
1758        assert!(handle_editor_escape(
1759            &mut controller,
1760            winit::event::ElementState::Pressed,
1761            CallbackWith::identity(),
1762            None,
1763        ));
1764        assert_eq!(controller.text(), "café");
1765        assert_eq!(controller.selection(), TextSelection::collapsed(3));
1766        assert!(controller.composition().is_none());
1767        assert!(!handle_editor_escape(
1768            &mut controller,
1769            winit::event::ElementState::Pressed,
1770            CallbackWith::identity(),
1771            None,
1772        ));
1773    }
1774
1775    #[test]
1776    fn escape_disposition_clears_active_composition_and_selection_before_becoming_unhandled() {
1777        let runtime = tessera_ui::RuntimeSession::new();
1778        let _scope = runtime.enter();
1779        let mut controller = TextEditorController::new(Dp(16.0), Some(Dp(24.0)));
1780        controller.set_text_and_selection("preedit", TextSelection { start: 2, end: 5 });
1781        controller.set_composition(Some(ImeComposition {
1782            range: 0..7,
1783            selection: TextSelection { start: 2, end: 5 },
1784        }));
1785        assert!(handle_editor_escape(
1786            &mut controller,
1787            winit::event::ElementState::Pressed,
1788            CallbackWith::identity(),
1789            None,
1790        ));
1791        assert_eq!(controller.text(), "preedit");
1792        assert_eq!(controller.selection(), TextSelection::collapsed(5));
1793        assert!(controller.composition().is_none());
1794        assert!(!handle_editor_escape(
1795            &mut controller,
1796            winit::event::ElementState::Pressed,
1797            CallbackWith::identity(),
1798            None,
1799        ));
1800    }
1801
1802    #[test]
1803    fn escape_release_does_not_cancel_an_owned_selection_or_composition() {
1804        let runtime = tessera_ui::RuntimeSession::new();
1805        let _scope = runtime.enter();
1806        let mut controller = TextEditorController::new(Dp(16.0), Some(Dp(24.0)));
1807        let selection = TextSelection { start: 0, end: 3 };
1808        let composition = ImeComposition {
1809            range: 0..3,
1810            selection: selection.clone(),
1811        };
1812        controller.set_text_and_selection("abc", selection.clone());
1813        controller.set_composition(Some(composition.clone()));
1814        assert!(!handle_editor_escape(
1815            &mut controller,
1816            winit::event::ElementState::Released,
1817            CallbackWith::identity(),
1818            None,
1819        ));
1820        assert_eq!(controller.text(), "abc");
1821        assert_eq!(controller.selection(), selection);
1822        assert_eq!(controller.composition(), Some(&composition));
1823    }
1824
1825    fn plan_ime_event_for_state(
1826        selection: TextSelection,
1827        composition: Option<ImeComposition>,
1828        single_line: bool,
1829        event: &winit::event::Ime,
1830    ) -> Option<PlannedImeEvent> {
1831        let text_len = selection.ordered_range().end.max(
1832            composition
1833                .as_ref()
1834                .map(|composition| composition.range.end)
1835                .unwrap_or(0),
1836        );
1837        let mut controller = TextEditorController::new(tessera_ui::Dp(14.0), None);
1838        controller.set_text_and_selection(&"x".repeat(text_len), selection);
1839        controller.set_composition(composition);
1840        controller.plan_ime_event(single_line, event)
1841    }
1842
1843    fn pointer_position_for_offset(text: &str, offset: usize) -> PxPosition {
1844        let mut controller = TextEditorController::new(tessera_ui::Dp(14.0), None);
1845        controller.set_text_and_selection(text, TextSelection::collapsed(offset));
1846        let (x, y) = controller
1847            .editor()
1848            .cursor_position()
1849            .expect("cursor position should be available");
1850        PxPosition::new(Px(x), Px(y))
1851    }
1852
1853    #[test]
1854    fn drag_selection_pointer_position_clamps_and_reports_overflow() {
1855        let text_origin = text_content_origin_from_values(Px(8), Px(1));
1856        let size = ComputedData {
1857            width: Px(120),
1858            height: Px(40),
1859        };
1860
1861        let drag_target = drag_selection_pointer_position_with_scroll(
1862            PxPosition::new(Px(-4), Px(60)),
1863            size,
1864            text_origin,
1865            Px::ZERO,
1866            Px::ZERO,
1867        );
1868
1869        assert_eq!(
1870            drag_target,
1871            DragSelectionPointerPosition {
1872                position: PxPosition::new(Px::ZERO, Px(22)),
1873                overflow_x: Px(-13),
1874                overflow_y: Px(29),
1875            }
1876        );
1877    }
1878
1879    #[test]
1880    fn drag_selection_pointer_position_applies_vertical_scroll_to_clamped_position() {
1881        let text_origin = text_content_origin_from_values(Px(8), Px(1));
1882        let size = ComputedData {
1883            width: Px(120),
1884            height: Px(40),
1885        };
1886
1887        let drag_target = drag_selection_pointer_position_with_scroll(
1888            PxPosition::new(Px(20), Px(20)),
1889            size,
1890            text_origin,
1891            Px(5),
1892            Px(11),
1893        );
1894
1895        assert_eq!(
1896            drag_target,
1897            DragSelectionPointerPosition {
1898                position: PxPosition::new(Px(16), Px(22)),
1899                overflow_x: Px::ZERO,
1900                overflow_y: Px::ZERO,
1901            }
1902        );
1903    }
1904
1905    #[test]
1906    fn click_selection_pointer_position_clamps_inside_text_viewport() {
1907        let text_origin = text_content_origin_from_values(Px(8), Px(1));
1908        let size = ComputedData {
1909            width: Px(120),
1910            height: Px(40),
1911        };
1912
1913        let click_position = drag_selection_pointer_position_with_scroll(
1914            PxPosition::new(Px(2), Px(4)),
1915            size,
1916            text_origin,
1917            Px::ZERO,
1918            Px::ZERO,
1919        )
1920        .position;
1921
1922        assert_eq!(click_position, PxPosition::new(Px::ZERO, Px::ZERO));
1923    }
1924
1925    #[test]
1926    fn text_viewport_size_removes_padding_and_border_from_surface() {
1927        let viewport = text_viewport_size_from_origin(
1928            ComputedData {
1929                width: Px(120),
1930                height: Px(40),
1931            },
1932            text_content_origin_from_values(Px(8), Px(1)),
1933        );
1934
1935        assert_eq!(viewport.width, Px(102));
1936        assert_eq!(viewport.height, Px(22));
1937    }
1938
1939    #[test]
1940    fn plan_ime_edit_preedit_reuses_existing_composition_range() {
1941        let runtime = tessera_ui::RuntimeSession::new();
1942        let _scope = runtime.enter();
1943        let composition = ImeComposition {
1944            range: 2..5,
1945            selection: TextSelection::collapsed(5),
1946        };
1947
1948        assert_eq!(
1949            plan_ime_event_for_state(
1950                TextSelection::collapsed(5),
1951                Some(composition),
1952                false,
1953                &winit::event::Ime::Preedit("xy".to_string(), Some((1, 1))),
1954            ),
1955            Some(PlannedImeEvent::Edit(PlannedImeEdit {
1956                replacement_range: 2..5,
1957                replacement_text: "xy".to_string(),
1958                selection: TextSelection::collapsed(3),
1959                composition_range: Some(2..4),
1960            }))
1961        );
1962    }
1963
1964    #[test]
1965    fn plan_ime_edit_empty_preedit_clears_composition_range() {
1966        let runtime = tessera_ui::RuntimeSession::new();
1967        let _scope = runtime.enter();
1968        let composition = ImeComposition {
1969            range: 2..5,
1970            selection: TextSelection::collapsed(5),
1971        };
1972
1973        assert_eq!(
1974            plan_ime_event_for_state(
1975                TextSelection::collapsed(5),
1976                Some(composition),
1977                false,
1978                &winit::event::Ime::Preedit(String::new(), None),
1979            ),
1980            Some(PlannedImeEvent::Edit(PlannedImeEdit {
1981                replacement_range: 2..5,
1982                replacement_text: String::new(),
1983                selection: TextSelection::collapsed(2),
1984                composition_range: None,
1985            }))
1986        );
1987    }
1988
1989    #[test]
1990    fn plan_ime_edit_preedit_without_cursor_offset_preserves_previous_composition_selection() {
1991        let runtime = tessera_ui::RuntimeSession::new();
1992        let _scope = runtime.enter();
1993        let composition = ImeComposition {
1994            range: 2..5,
1995            selection: TextSelection { start: 3, end: 4 },
1996        };
1997
1998        assert_eq!(
1999            plan_ime_event_for_state(
2000                TextSelection::collapsed(5),
2001                Some(composition),
2002                false,
2003                &winit::event::Ime::Preedit("xyzq".to_string(), None),
2004            ),
2005            Some(PlannedImeEvent::Edit(PlannedImeEdit {
2006                replacement_range: 2..5,
2007                replacement_text: "xyzq".to_string(),
2008                selection: TextSelection { start: 3, end: 4 },
2009                composition_range: Some(2..6),
2010            }))
2011        );
2012    }
2013
2014    #[test]
2015    fn plan_ime_edit_commit_replaces_current_composition_range() {
2016        let runtime = tessera_ui::RuntimeSession::new();
2017        let _scope = runtime.enter();
2018        let composition = ImeComposition {
2019            range: 3..6,
2020            selection: TextSelection::collapsed(6),
2021        };
2022
2023        assert_eq!(
2024            plan_ime_event_for_state(
2025                TextSelection::collapsed(6),
2026                Some(composition),
2027                false,
2028                &winit::event::Ime::Commit("z".to_string()),
2029            ),
2030            Some(PlannedImeEvent::Edit(PlannedImeEdit {
2031                replacement_range: 3..6,
2032                replacement_text: "z".to_string(),
2033                selection: TextSelection::collapsed(4),
2034                composition_range: None,
2035            }))
2036        );
2037    }
2038
2039    #[test]
2040    fn plan_ime_edit_commit_without_composition_replaces_current_selection_range() {
2041        let runtime = tessera_ui::RuntimeSession::new();
2042        let _scope = runtime.enter();
2043        assert_eq!(
2044            plan_ime_event_for_state(
2045                TextSelection { start: 6, end: 3 },
2046                None,
2047                false,
2048                &winit::event::Ime::Commit("z".to_string()),
2049            ),
2050            Some(PlannedImeEvent::Edit(PlannedImeEdit {
2051                replacement_range: 3..6,
2052                replacement_text: "z".to_string(),
2053                selection: TextSelection::collapsed(4),
2054                composition_range: None,
2055            }))
2056        );
2057    }
2058
2059    #[test]
2060    fn plan_ime_edit_preedit_tracks_full_composition_range_instead_of_caret_range() {
2061        let runtime = tessera_ui::RuntimeSession::new();
2062        let _scope = runtime.enter();
2063        assert_eq!(
2064            plan_ime_event_for_state(
2065                TextSelection::collapsed(1),
2066                None,
2067                false,
2068                &winit::event::Ime::Preedit("abcd".to_string(), Some((2, 2))),
2069            ),
2070            Some(PlannedImeEvent::Edit(PlannedImeEdit {
2071                replacement_range: 1..1,
2072                replacement_text: "abcd".to_string(),
2073                selection: TextSelection::collapsed(3),
2074                composition_range: Some(1..5),
2075            }))
2076        );
2077    }
2078
2079    #[test]
2080    fn text_input_pointer_sequence_single_click_drag_selects_backward_range() {
2081        let runtime = tessera_ui::RuntimeSession::new();
2082        let _scope = runtime.enter();
2083        let text = "hello world";
2084        let mut controller = TextEditorController::new(tessera_ui::Dp(14.0), None);
2085        controller.set_text_and_selection(text, TextSelection::collapsed(6));
2086        let start_position = pointer_position_for_offset(text, 6);
2087        let drag_position = pointer_position_for_offset(text, 1);
2088        let click_time = Instant::now();
2089
2090        let click_type = controller.handle_click(start_position, start_position, click_time);
2091        assert!(matches!(click_type, ClickType::Single));
2092        controller.apply_pointer_action(GlyphonAction::Click {
2093            x: start_position.x_px().0,
2094            y: start_position.y_px().0,
2095        });
2096        controller.start_drag(click_type);
2097        controller.apply_pointer_action(GlyphonAction::Drag {
2098            x: drag_position.x_px().0,
2099            y: drag_position.y_px().0,
2100        });
2101
2102        assert_eq!(controller.selection(), TextSelection { start: 6, end: 1 });
2103        assert_eq!(controller.cursor_offset(), 1);
2104    }
2105
2106    #[test]
2107    fn text_input_pointer_sequence_double_click_drag_selects_word_range() {
2108        let runtime = tessera_ui::RuntimeSession::new();
2109        let _scope = runtime.enter();
2110        let text = "foo bar baz";
2111        let mut controller = TextEditorController::new(tessera_ui::Dp(14.0), None);
2112        controller.set_text_and_selection(text, TextSelection::collapsed(5));
2113        let first_position = pointer_position_for_offset(text, 5);
2114        let drag_position = pointer_position_for_offset(text, 9);
2115        let first_click_time = Instant::now();
2116        let second_click_time = first_click_time + Duration::from_millis(100);
2117
2118        let first_click_type =
2119            controller.handle_click(first_position, first_position, first_click_time);
2120        assert!(matches!(first_click_type, ClickType::Single));
2121        controller.apply_pointer_action(GlyphonAction::Click {
2122            x: first_position.x_px().0,
2123            y: first_position.y_px().0,
2124        });
2125
2126        let second_click_type =
2127            controller.handle_click(first_position, first_position, second_click_time);
2128        assert!(matches!(second_click_type, ClickType::Double));
2129        controller.apply_pointer_action(GlyphonAction::DoubleClick {
2130            x: first_position.x_px().0,
2131            y: first_position.y_px().0,
2132        });
2133        controller.start_drag(second_click_type);
2134        controller.apply_pointer_action(GlyphonAction::Drag {
2135            x: drag_position.x_px().0,
2136            y: drag_position.y_px().0,
2137        });
2138
2139        assert_eq!(controller.selection(), TextSelection { start: 4, end: 11 });
2140        assert_eq!(controller.cursor_offset(), 11);
2141    }
2142
2143    #[test]
2144    fn text_input_pointer_sequence_triple_click_drag_selects_line_range() {
2145        let runtime = tessera_ui::RuntimeSession::new();
2146        let _scope = runtime.enter();
2147        let text = "111\n222\n333";
2148        let mut controller = TextEditorController::new(tessera_ui::Dp(14.0), None);
2149        controller.set_text_and_selection(text, TextSelection::collapsed(5));
2150        let line_position = pointer_position_for_offset(text, 5);
2151        let drag_position = pointer_position_for_offset(text, 9);
2152        let first_click_time = Instant::now();
2153        let second_click_time = first_click_time + Duration::from_millis(100);
2154        let third_click_time = second_click_time + Duration::from_millis(100);
2155
2156        let first_click_type =
2157            controller.handle_click(line_position, line_position, first_click_time);
2158        assert!(matches!(first_click_type, ClickType::Single));
2159        controller.apply_pointer_action(GlyphonAction::Click {
2160            x: line_position.x_px().0,
2161            y: line_position.y_px().0,
2162        });
2163
2164        let second_click_type =
2165            controller.handle_click(line_position, line_position, second_click_time);
2166        assert!(matches!(second_click_type, ClickType::Double));
2167        controller.apply_pointer_action(GlyphonAction::DoubleClick {
2168            x: line_position.x_px().0,
2169            y: line_position.y_px().0,
2170        });
2171
2172        let third_click_type =
2173            controller.handle_click(line_position, line_position, third_click_time);
2174        assert!(matches!(third_click_type, ClickType::Triple));
2175        controller.apply_pointer_action(GlyphonAction::TripleClick {
2176            x: line_position.x_px().0,
2177            y: line_position.y_px().0,
2178        });
2179        controller.start_drag(third_click_type);
2180        controller.apply_pointer_action(GlyphonAction::Drag {
2181            x: drag_position.x_px().0,
2182            y: drag_position.y_px().0,
2183        });
2184
2185        assert_eq!(controller.selection(), TextSelection { start: 4, end: 11 });
2186        assert_eq!(controller.cursor_offset(), 11);
2187    }
2188
2189    #[test]
2190    fn single_line_enter_uses_submit_behavior() {
2191        assert_eq!(
2192            single_line_key_behavior(
2193                true,
2194                winit::event::ElementState::Pressed,
2195                &winit::keyboard::Key::Named(winit::keyboard::NamedKey::Enter),
2196            ),
2197            Some(SingleLineKeyBehavior::Submit)
2198        );
2199    }
2200
2201    #[test]
2202    fn single_line_tab_propagates_focus_navigation() {
2203        assert_eq!(
2204            single_line_key_behavior(
2205                true,
2206                winit::event::ElementState::Pressed,
2207                &winit::keyboard::Key::Named(winit::keyboard::NamedKey::Tab),
2208            ),
2209            Some(SingleLineKeyBehavior::Propagate)
2210        );
2211    }
2212
2213    #[test]
2214    fn multi_line_enter_keeps_default_editing_behavior() {
2215        assert_eq!(
2216            single_line_key_behavior(
2217                false,
2218                winit::event::ElementState::Pressed,
2219                &winit::keyboard::Key::Named(winit::keyboard::NamedKey::Enter),
2220            ),
2221            None
2222        );
2223    }
2224
2225    #[test]
2226    fn released_enter_does_not_trigger_single_line_submit_behavior() {
2227        assert_eq!(
2228            single_line_key_behavior(
2229                true,
2230                winit::event::ElementState::Released,
2231                &winit::keyboard::Key::Named(winit::keyboard::NamedKey::Enter),
2232            ),
2233            None
2234        );
2235    }
2236
2237    #[test]
2238    fn single_line_ime_commit_newline_uses_submit_behavior() {
2239        let runtime = tessera_ui::RuntimeSession::new();
2240        let _scope = runtime.enter();
2241        assert_eq!(
2242            plan_ime_event_for_state(
2243                TextSelection::collapsed(0),
2244                None,
2245                true,
2246                &winit::event::Ime::Commit("\n".to_string()),
2247            ),
2248            Some(PlannedImeEvent::Submit)
2249        );
2250        assert_eq!(
2251            plan_ime_event_for_state(
2252                TextSelection::collapsed(0),
2253                None,
2254                true,
2255                &winit::event::Ime::Commit("\r\n".to_string()),
2256            ),
2257            Some(PlannedImeEvent::Submit)
2258        );
2259    }
2260
2261    #[test]
2262    fn multi_line_ime_commit_newline_keeps_default_editing_behavior() {
2263        let runtime = tessera_ui::RuntimeSession::new();
2264        let _scope = runtime.enter();
2265        assert_eq!(
2266            plan_ime_event_for_state(
2267                TextSelection::collapsed(0),
2268                None,
2269                false,
2270                &winit::event::Ime::Commit("\n".to_string()),
2271            ),
2272            Some(PlannedImeEvent::Edit(PlannedImeEdit {
2273                replacement_range: 0..0,
2274                replacement_text: "\n".to_string(),
2275                selection: TextSelection::collapsed(1),
2276                composition_range: None,
2277            }))
2278        );
2279    }
2280
2281    #[test]
2282    fn single_line_ime_commit_text_does_not_trigger_submit_behavior() {
2283        let runtime = tessera_ui::RuntimeSession::new();
2284        let _scope = runtime.enter();
2285        assert_eq!(
2286            plan_ime_event_for_state(
2287                TextSelection::collapsed(0),
2288                None,
2289                true,
2290                &winit::event::Ime::Commit("done".to_string()),
2291            ),
2292            Some(PlannedImeEvent::Edit(PlannedImeEdit {
2293                replacement_range: 0..0,
2294                replacement_text: "done".to_string(),
2295                selection: TextSelection::collapsed(4),
2296                composition_range: None,
2297            }))
2298        );
2299    }
2300
2301    #[test]
2302    fn non_submit_single_line_commit_keeps_edit_plan_instead_of_submit_plan() {
2303        let runtime = tessera_ui::RuntimeSession::new();
2304        let _scope = runtime.enter();
2305        assert!(matches!(
2306            plan_ime_event_for_state(
2307                TextSelection::collapsed(0),
2308                Some(ImeComposition {
2309                    range: 1..4,
2310                    selection: TextSelection::collapsed(3),
2311                }),
2312                true,
2313                &winit::event::Ime::Commit("done".to_string()),
2314            ),
2315            Some(PlannedImeEvent::Edit(_))
2316        ));
2317    }
2318
2319    #[test]
2320    fn ctrl_backspace_uses_left_word_deletion_motion() {
2321        assert_eq!(
2322            deletion_motion_for_key(
2323                true,
2324                winit::event::ElementState::Pressed,
2325                &winit::keyboard::Key::Named(winit::keyboard::NamedKey::Backspace),
2326            ),
2327            Some(glyphon::cosmic_text::Motion::LeftWord)
2328        );
2329    }
2330
2331    #[test]
2332    fn ctrl_delete_uses_right_word_deletion_motion() {
2333        assert_eq!(
2334            deletion_motion_for_key(
2335                true,
2336                winit::event::ElementState::Pressed,
2337                &winit::keyboard::Key::Named(winit::keyboard::NamedKey::Delete),
2338            ),
2339            Some(glyphon::cosmic_text::Motion::RightWord)
2340        );
2341    }
2342
2343    #[test]
2344    fn ctrl_insert_uses_copy_clipboard_shortcut_behavior() {
2345        assert_eq!(
2346            clipboard_shortcut_for_key(
2347                true,
2348                false,
2349                winit::event::ElementState::Pressed,
2350                &winit::keyboard::Key::Named(winit::keyboard::NamedKey::Insert),
2351            ),
2352            Some(ClipboardShortcutBehavior::Copy)
2353        );
2354    }
2355
2356    #[test]
2357    fn shift_insert_uses_paste_clipboard_shortcut_behavior() {
2358        assert_eq!(
2359            clipboard_shortcut_for_key(
2360                false,
2361                true,
2362                winit::event::ElementState::Pressed,
2363                &winit::keyboard::Key::Named(winit::keyboard::NamedKey::Insert),
2364            ),
2365            Some(ClipboardShortcutBehavior::Paste)
2366        );
2367    }
2368
2369    #[test]
2370    fn shift_delete_uses_cut_clipboard_shortcut_behavior() {
2371        assert_eq!(
2372            clipboard_shortcut_for_key(
2373                false,
2374                true,
2375                winit::event::ElementState::Pressed,
2376                &winit::keyboard::Key::Named(winit::keyboard::NamedKey::Delete),
2377            ),
2378            Some(ClipboardShortcutBehavior::Cut)
2379        );
2380    }
2381
2382    #[test]
2383    fn released_insert_does_not_trigger_clipboard_shortcut_behavior() {
2384        assert_eq!(
2385            clipboard_shortcut_for_key(
2386                true,
2387                false,
2388                winit::event::ElementState::Released,
2389                &winit::keyboard::Key::Named(winit::keyboard::NamedKey::Insert),
2390            ),
2391            None
2392        );
2393    }
2394
2395    #[test]
2396    fn single_line_accessibility_uses_text_input_role() {
2397        assert_eq!(text_input_accessibility_role(true), Role::TextInput);
2398    }
2399
2400    #[test]
2401    fn multi_line_accessibility_uses_multiline_role() {
2402        assert_eq!(
2403            text_input_accessibility_role(false),
2404            Role::MultilineTextInput
2405        );
2406    }
2407
2408    #[test]
2409    fn submit_accessibility_action_requires_focused_single_line_handler() {
2410        assert!(should_expose_submit_accessibility_action(true, true, true,));
2411        assert!(!should_expose_submit_accessibility_action(
2412            false, true, true,
2413        ));
2414        assert!(!should_expose_submit_accessibility_action(
2415            true, false, true,
2416        ));
2417        assert!(!should_expose_submit_accessibility_action(
2418            true, true, false
2419        ));
2420    }
2421
2422    #[test]
2423    fn build_ime_request_uses_active_ime_rect_when_available() {
2424        let ime_request = build_ime_request(
2425            PxPosition::new(Px(9), Px(11)),
2426            ComputedData {
2427                width: Px(120),
2428                height: Px(40),
2429            },
2430            Some(RectDef {
2431                x: Px(3),
2432                y: Px(5),
2433                width: Px(7),
2434                height: Px(13),
2435            }),
2436        );
2437
2438        assert_eq!(ime_request.local_position, PxPosition::new(Px(12), Px(16)));
2439        assert_eq!(ime_request.size, PxSize::new(Px(7), Px(13)));
2440    }
2441
2442    #[test]
2443    fn build_ime_request_falls_back_to_component_bounds_without_active_rect() {
2444        let ime_request = build_ime_request(
2445            PxPosition::new(Px(9), Px(11)),
2446            ComputedData {
2447                width: Px(120),
2448                height: Px(40),
2449            },
2450            None,
2451        );
2452
2453        assert_eq!(ime_request.local_position, PxPosition::ZERO);
2454        assert_eq!(ime_request.size, PxSize::new(Px(120), Px(40)));
2455    }
2456
2457    fn scheme() -> crate::theme::MaterialColorScheme {
2458        crate::theme::MaterialColorScheme::default()
2459    }
2460
2461    fn resting_state() -> FieldVisualState {
2462        FieldVisualState {
2463            focused: false,
2464            enabled: true,
2465            is_error: false,
2466            outlined: true,
2467        }
2468    }
2469
2470    #[test]
2471    fn outline_color_follows_the_material_states() {
2472        let scheme = scheme();
2473        let resting = resting_state();
2474
2475        assert_eq!(default_outline_color(&scheme, resting), scheme.outline);
2476        assert_eq!(
2477            default_outline_color(
2478                &scheme,
2479                FieldVisualState {
2480                    focused: true,
2481                    ..resting
2482                }
2483            ),
2484            scheme.primary
2485        );
2486        assert_eq!(
2487            default_outline_color(
2488                &scheme,
2489                FieldVisualState {
2490                    is_error: true,
2491                    ..resting
2492                }
2493            ),
2494            scheme.error
2495        );
2496        assert_eq!(
2497            default_outline_color(
2498                &scheme,
2499                FieldVisualState {
2500                    enabled: false,
2501                    focused: true,
2502                    ..resting
2503                }
2504            ),
2505            scheme.on_surface.with_alpha(DISABLED_OUTLINE_ALPHA)
2506        );
2507    }
2508
2509    #[test]
2510    fn disabled_fill_depends_on_the_variant() {
2511        let scheme = scheme();
2512        let disabled = FieldVisualState {
2513            enabled: false,
2514            ..resting_state()
2515        };
2516
2517        assert_eq!(
2518            default_fill_color(
2519                &scheme,
2520                FieldVisualState {
2521                    outlined: false,
2522                    ..disabled
2523                }
2524            ),
2525            scheme.on_surface.with_alpha(DISABLED_FILL_ALPHA)
2526        );
2527        assert_eq!(default_fill_color(&scheme, disabled), Color::TRANSPARENT);
2528        assert_eq!(
2529            default_fill_color(&scheme, resting_state()),
2530            Color::TRANSPARENT
2531        );
2532    }
2533
2534    #[test]
2535    fn disabled_field_keeps_the_resting_outline_width() {
2536        assert_eq!(
2537            resolve_border_width(Dp(1.0), Some(Dp(2.0)), true, true),
2538            Dp(2.0)
2539        );
2540        assert_eq!(
2541            resolve_border_width(Dp(1.0), Some(Dp(2.0)), false, true),
2542            Dp(1.0)
2543        );
2544    }
2545
2546    mod scroll_propagation {
2547
2548        use tessera_ui::{
2549            CursorEventContent, Dp, GestureState, Modifier, PointerChange, PressKeyEventType, Px,
2550            PxPosition, ScrollDeltaUnit, ScrollEventContent, ScrollEventSource, State,
2551            layout::layout,
2552            remember, tessera,
2553            testing::{
2554                layout_test, pump_layout_test_session, pump_layout_test_session_with_input,
2555                start_layout_test_session,
2556            },
2557        };
2558
2559        use crate::{
2560            column::column,
2561            modifier::ModifierExt as _,
2562            scrollable::ScrollableController,
2563            spacer::spacer,
2564            text_field::text_field,
2565            text_input::text_input,
2566            theme::{MaterialTheme, material_theme},
2567        };
2568
2569        #[derive(Clone, Copy)]
2570        enum ProbeKind {
2571            Plain,
2572            Input,
2573            Field,
2574        }
2575
2576        #[tessera]
2577        fn scroll_probe_with_input(
2578            output: tessera_ui::testing::TestProbe<Option<State<ScrollableController>>>,
2579        ) {
2580            let controller = remember(ScrollableController::new);
2581            output.set(Some(controller));
2582            material_theme()
2583                .theme(|| MaterialTheme::default())
2584                .child(move || {
2585                    layout()
2586                        .modifier(
2587                            Modifier::new()
2588                                .width(Dp(200.0))
2589                                .height(Dp(100.0))
2590                                .vertical_scroll(controller),
2591                        )
2592                        .child(move || {
2593                            column().children(move || {
2594                                text_input();
2595                                spacer().modifier(Modifier::new().height(Dp(400.0)));
2596                            });
2597                        });
2598                });
2599        }
2600
2601        #[tessera]
2602        fn scroll_probe_plain(
2603            output: tessera_ui::testing::TestProbe<Option<State<ScrollableController>>>,
2604        ) {
2605            let controller = remember(ScrollableController::new);
2606            output.set(Some(controller));
2607            material_theme()
2608                .theme(|| MaterialTheme::default())
2609                .child(move || {
2610                    layout()
2611                        .modifier(
2612                            Modifier::new()
2613                                .width(Dp(200.0))
2614                                .height(Dp(100.0))
2615                                .vertical_scroll(controller),
2616                        )
2617                        .child(move || {
2618                            column().children(move || {
2619                                spacer().modifier(Modifier::new().height(Dp(56.0)));
2620                                spacer().modifier(Modifier::new().height(Dp(400.0)));
2621                            });
2622                        });
2623                });
2624        }
2625
2626        fn scrolled_after_wheel(kind: ProbeKind, focus_first: bool) -> f32 {
2627            let output = tessera_ui::testing::TestProbe::default();
2628            let probe = output.clone();
2629            let mut session = start_layout_test_session(
2630                layout_test(move || match kind {
2631                    ProbeKind::Plain => {
2632                        scroll_probe_plain(probe.clone());
2633                    }
2634                    ProbeKind::Input => {
2635                        scroll_probe_with_input(probe.clone());
2636                    }
2637                    ProbeKind::Field => {
2638                        scroll_probe_with_field(probe.clone());
2639                    }
2640                })
2641                .viewport_px(200, 100),
2642            );
2643            let _ = pump_layout_test_session(&mut session, 0);
2644            let controller = output.get().expect("scrollable controller");
2645            let cursor = Some(PxPosition::new(Px::new(100), Px::new(20)));
2646
2647            if focus_first {
2648                let press = PointerChange::new(
2649                    tessera_ui::current_frame_time().unwrap(),
2650                    1,
2651                    CursorEventContent::Pressed(PressKeyEventType::Left),
2652                    GestureState::TapCandidate,
2653                );
2654                let _ = pump_layout_test_session_with_input(
2655                    &mut session,
2656                    16_666_667,
2657                    vec![press],
2658                    cursor,
2659                );
2660                let release = PointerChange::new(
2661                    tessera_ui::current_frame_time().unwrap(),
2662                    1,
2663                    CursorEventContent::Released(PressKeyEventType::Left),
2664                    GestureState::TapCandidate,
2665                );
2666                let _ = pump_layout_test_session_with_input(
2667                    &mut session,
2668                    33_333_334,
2669                    vec![release],
2670                    cursor,
2671                );
2672            }
2673
2674            let scroll = PointerChange::new(
2675                tessera_ui::current_frame_time().unwrap(),
2676                1,
2677                CursorEventContent::Scroll(ScrollEventContent {
2678                    delta_x: 0.0,
2679                    delta_y: -40.0,
2680                    unit: ScrollDeltaUnit::Pixel,
2681                    source: ScrollEventSource::Wheel,
2682                }),
2683                GestureState::TapCandidate,
2684            );
2685
2686            let _ =
2687                pump_layout_test_session_with_input(&mut session, 50_000_000, vec![scroll], cursor);
2688
2689            for frame in 4..40u64 {
2690                let _ = pump_layout_test_session(&mut session, 16_666_667 * frame);
2691            }
2692
2693            controller.with(|c| c.child_position().y)
2694        }
2695
2696        #[test]
2697        fn parent_scrollable_scrolls_without_text_input() {
2698            let offset = scrolled_after_wheel(ProbeKind::Plain, false);
2699            assert!(
2700                offset != 0.0,
2701                "plain content should scroll, got y={offset:?}"
2702            );
2703        }
2704
2705        #[test]
2706        fn wheel_over_unfocused_input_reaches_parent_scrollable() {
2707            let offset = scrolled_after_wheel(ProbeKind::Input, false);
2708            assert!(
2709                offset != 0.0,
2710                "wheel over an unfocused text input should scroll the parent, got y={offset:?}"
2711            );
2712        }
2713
2714        #[test]
2715        fn wheel_over_focused_input_reaches_parent_scrollable() {
2716            let offset = scrolled_after_wheel(ProbeKind::Input, true);
2717            assert!(
2718                offset != 0.0,
2719                "wheel over a focused text input should scroll the parent, got y={offset:?}"
2720            );
2721        }
2722
2723        #[tessera]
2724        fn scroll_probe_with_field(
2725            output: tessera_ui::testing::TestProbe<Option<State<ScrollableController>>>,
2726        ) {
2727            let controller = remember(ScrollableController::new);
2728            output.set(Some(controller));
2729            material_theme()
2730                .theme(|| MaterialTheme::default())
2731                .child(move || {
2732                    layout()
2733                        .modifier(
2734                            Modifier::new()
2735                                .width(Dp(200.0))
2736                                .height(Dp(100.0))
2737                                .vertical_scroll(controller),
2738                        )
2739                        .child(move || {
2740                            column().children(move || {
2741                                text_field();
2742                                spacer().modifier(Modifier::new().height(Dp(400.0)));
2743                            });
2744                        });
2745                });
2746        }
2747
2748        #[test]
2749        fn wheel_over_text_field_reaches_parent_scrollable() {
2750            let offset = scrolled_after_wheel(ProbeKind::Field, false);
2751            assert!(
2752                offset != 0.0,
2753                "wheel over a text field should scroll the parent, got y={offset:?}"
2754            );
2755        }
2756    }
2757}