1use 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
33pub use crate::text_edit_core::{
36 DisplayTransform, TextEditorController as TextInputController,
37 TransformedText as DisplayTransformText,
38};
39
40pub(crate) const DISABLED_CONTENT_ALPHA: f32 = 0.38;
42
43pub(crate) const DISABLED_OUTLINE_ALPHA: f32 = 0.12;
45
46pub(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 pub enabled: bool,
207 pub presentation_state: Option<InteractionVisualState>,
208 pub read_only: bool,
210 pub is_error: bool,
212 pub(crate) is_secure: bool,
213 pub modifier: Modifier,
215 pub on_change: CallbackWith<String, String>,
218 pub on_submit: Callback,
220 pub min_width: Option<Dp>,
223 pub min_height: Option<Dp>,
226 pub background_color: Option<Color>,
228 pub border_width: Dp,
230 pub border_color: Option<Color>,
232 pub shape: Shape,
234 pub padding: Dp,
236 pub focus_border_color: Option<Color>,
238 pub focus_border_width: Option<Dp>,
240 pub focus_background_color: Option<Color>,
242 pub selection_color: Option<Color>,
245 pub text_color: Option<Color>,
247 pub cursor_color: Option<Color>,
249 pub accessibility_label: Option<String>,
251 pub accessibility_description: Option<String>,
253 pub initial_text: Option<String>,
255 pub font_size: Dp,
257 pub line_height: Option<Dp>,
259 pub style: Option<TextStyle>,
261 pub single_line: bool,
266 pub input_transform: Option<CallbackWith<String, String>>,
268 pub display_transform: Option<DisplayTransform>,
270 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
315impl TextInputBuilder {
349 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#[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 != ¤t) {
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 != ¤t) {
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; 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 if is_cursor_in_editor {
727 if tap_result.pressed {
728 if !controller.with(|s| s.focus_handler().is_focused()) {
730 controller.with(|s| *s.focus_handler()).request_focus();
731 }
732
733 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 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 controller.with_mut(|s| s.start_drag(click_type));
790 }
791 }
792
793 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 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 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 let consumed = controller.with_mut(|s| s.scroll_vertical_consumed(-dy));
855 event.delta_y = dy + consumed;
856 }
857 },
858 );
859 });
860 }
861
862 if tap_result.released {
865 controller.with_mut(|s| s.stop_drag());
866 }
867
868 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
1408pub(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
1546impl TextInputBuilder {
1548 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 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 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}