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