1use frus_core::{Color, Point, Rect, ResolvedTextStyle, Scene, TextAlign, TextStyle};
8use frus_layout::{Dimension, Style};
9use frus_text::TextLayout;
10
11use crate::autofill::AutofillHint;
12use crate::disabled::DISABLED_CONTENT_OPACITY;
13use crate::icons::IconData;
14use crate::ime::{Capitalization, Ime, KeyboardType, TextInputAction};
15use crate::interaction::{Key, Status};
16use crate::runtime::Edit;
17use crate::selectiontoolbar::{SelectionToolbar, ToolbarContext, ToolbarItem};
18use crate::theme::Theme;
19use crate::widget::Widget;
20
21pub const FIELD_PADDING_X: f32 = 12.0;
23pub const FIELD_PADDING_Y: f32 = 8.0;
30pub const FIELD_OUTLINED_PADDING_TOP: f32 = 20.0;
32pub const FIELD_OUTLINED_PADDING_BOTTOM: f32 = 12.0;
34
35pub const FIELD_DENSE_PADDING_Y: f32 = 4.0;
38pub const FIELD_DENSE_OUTLINED_PADDING_TOP: f32 = 16.0;
42pub const FIELD_DENSE_OUTLINED_PADDING_BOTTOM: f32 = 8.0;
44
45pub const FIELD_TEXT_SIZE: f32 = 16.0;
47pub const FIELD_LABEL_SCALE: f32 = 0.75;
50pub const FIELD_SUB_SIZE: f32 = 12.0;
52pub const FIELD_GAP: f32 = 4.0;
54pub const FIELD_NOTCH_GAP: f32 = 4.0;
57
58pub const FIELD_RADIUS: f32 = 4.0;
60pub const FIELD_BORDER_WIDTH: f32 = 1.0;
63pub const FIELD_FOCUSED_BORDER_WIDTH: f32 = 2.0;
65
66pub const FIELD_ICON_SIZE: f32 = 24.0;
68const ICON_PAD: f32 = 6.0;
69pub const FIELD_DISABLED_OPACITY: f32 = DISABLED_CONTENT_OPACITY;
75const OBSCURE_CHAR: char = '•';
77
78#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
81pub enum FloatingLabelBehavior {
82 #[default]
85 Auto,
86 Always,
88 Never,
91}
92
93#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
96pub enum FloatingLabelAlignment {
97 #[default]
99 Start,
100 Center,
102}
103
104#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
106pub enum TextFieldVariant {
107 #[default]
110 Outlined,
111 Filled,
114 None,
127}
128
129#[derive(Clone, Copy, Debug, PartialEq)]
131struct CursorShape {
132 width: f32,
134 height: Option<f32>,
136 radius: Option<f32>,
139 shown: bool,
141}
142
143impl Default for CursorShape {
144 fn default() -> Self {
145 Self {
146 width: 2.0,
147 height: None,
148 radius: None,
149 shown: true,
150 }
151 }
152}
153
154#[derive(Clone, Copy, Debug, Default)]
157pub struct TextFieldStyle {
158 pub fill: Option<frus_core::Color>,
161 pub border_color: Option<frus_core::Color>,
163 pub focused_border_color: Option<frus_core::Color>,
165 pub error_color: Option<frus_core::Color>,
167 pub error_hover_color: Option<frus_core::Color>,
170 pub text_color: Option<frus_core::Color>,
172 pub label_color: Option<frus_core::Color>,
174 pub focused_label_color: Option<frus_core::Color>,
176 pub helper_color: Option<frus_core::Color>,
178 pub icon_color: Option<frus_core::Color>,
180 pub handle_color: Option<frus_core::Color>,
183 pub cursor_color: Option<frus_core::Color>,
186 pub cursor_error_color: Option<frus_core::Color>,
188 pub radius: Option<f32>,
190 pub border_width: Option<f32>,
192 pub focused_border_width: Option<f32>,
194 pub icon_size: Option<f32>,
196 pub padding_x: Option<f32>,
198}
199
200pub struct TextField<Msg = crate::callback::Callback> {
206 value: String,
207 size: f32,
208 width: Dimension,
209 sized: bool,
212 on_input: Option<Box<dyn Fn(String) -> Msg>>,
213 on_submit: Option<Msg>,
214 on_tap_outside: Option<Msg>,
217 announce: Option<String>,
219 label: Option<String>,
221 placeholder: Option<String>,
223 helper: Option<String>,
225 error: Option<String>,
228 obscure: bool,
231 obscuring: char,
233 cursor: CursorShape,
235 autofocus: bool,
237 text_align: TextAlign,
240 keyboard: Option<KeyboardType>,
243 action: Option<TextInputAction>,
245 icon: Option<IconData>,
247 prefix: Option<IconData>,
249 prefix_text: Option<String>,
251 suffix_text: Option<String>,
253 floating: Option<FloatingLabelBehavior>,
255 floating_alignment: Option<FloatingLabelAlignment>,
257 counter_text: Option<String>,
259 suffix: Option<IconData>,
261 suffix_action: Option<Msg>,
264 multiline: bool,
267 rows: u16,
269 max_rows: Option<u16>,
272 rows_told: bool,
275 variant: TextFieldVariant,
277 enabled: bool,
280 dense: bool,
283 max_length: Option<usize>,
285 input_filter: Option<Box<dyn Fn(char) -> Option<char>>>,
288 capitalization: Capitalization,
291 read_only: bool,
294 style: TextFieldStyle,
296 autofill: Vec<AutofillHint>,
299 toolbar_build: Option<ToolbarBuild<Msg>>,
302 toolbars:
306 [std::cell::OnceCell<Option<Box<dyn Widget<Msg>>>>; ToolbarContext::VARIANTS as usize],
307}
308
309pub(crate) type ToolbarBuild<Msg> =
312 Box<dyn Fn(&ToolbarContext, Vec<ToolbarItem<Msg>>) -> Vec<ToolbarItem<Msg>>>;
313
314impl TextField<crate::callback::Callback> {
315 pub fn controller(mut self, controller: &crate::TextEditingController) -> Self {
321 self.value = controller.text();
322 let write = controller.clone();
323 self.on_input(move |text| write.set_text(text))
324 }
325}
326
327fn is_word(c: char) -> bool {
329 c.is_alphanumeric() || c == '_'
330}
331
332pub(crate) fn word_boundary_left(chars: &[char], cursor: usize) -> usize {
335 let mut i = cursor;
336 while i > 0 && !is_word(chars[i - 1]) {
337 i -= 1;
338 }
339 while i > 0 && is_word(chars[i - 1]) {
340 i -= 1;
341 }
342 i
343}
344
345pub(crate) fn word_boundary_right(chars: &[char], cursor: usize) -> usize {
348 let len = chars.len();
349 let mut i = cursor;
350 while i < len && !is_word(chars[i]) {
351 i += 1;
352 }
353 while i < len && is_word(chars[i]) {
354 i += 1;
355 }
356 i
357}
358
359pub(crate) fn line_start(chars: &[char], cursor: usize) -> usize {
361 let mut i = cursor;
362 while i > 0 && chars[i - 1] != '\n' {
363 i -= 1;
364 }
365 i
366}
367
368pub(crate) fn line_end(chars: &[char], cursor: usize) -> usize {
370 let len = chars.len();
371 let mut i = cursor;
372 while i < len && chars[i] != '\n' {
373 i += 1;
374 }
375 i
376}
377
378pub(crate) const HANDLE_SIZE: f32 = 22.0;
380
381pub(crate) fn handles_in(
386 layout: &TextLayout,
387 origin: Point,
388 len: usize,
389 edit: &Edit,
390) -> Option<[crate::SelectionHandle; 2]> {
391 let (start, end) = edit.selection_range()?;
392 let (start, end) = (start.min(len), end.min(len));
393 if start >= end {
394 return None;
395 }
396 Some([
397 handle_at(layout, origin, start, true),
398 handle_at(layout, origin, end, false),
399 ])
400}
401
402pub(crate) fn handle_at(
405 layout: &TextLayout,
406 origin: Point,
407 index: usize,
408 start: bool,
409) -> crate::SelectionHandle {
410 let caret = layout.caret_rect(index);
411 let x = origin.x + caret.x;
412 let top = origin.y + caret.y;
413 crate::SelectionHandle {
414 rect: Rect::new(
417 if start { x - HANDLE_SIZE } else { x },
418 top + caret.height,
419 HANDLE_SIZE,
420 HANDLE_SIZE,
421 ),
422 line_center: Point::new(x, top + caret.height * 0.5),
423 }
424}
425
426pub(crate) fn paint_handle(
429 scene: &mut Scene,
430 handle: crate::SelectionHandle,
431 offset: Point,
432 color: Color,
433 start: bool,
434) {
435 scene.fill_path(
436 &handle_path(handle.rect.translate(offset.x, offset.y), start),
437 color,
438 );
439}
440
441pub(crate) fn selection_box_in(
445 layout: &TextLayout,
446 origin: Point,
447 len: usize,
448 edit: &Edit,
449) -> Rect {
450 let selected = edit
451 .selection_range()
452 .map(|(start, end)| layout.selection_rects(start.min(len), end.min(len)))
453 .unwrap_or_default();
454 let mut boxed = selected.first().copied();
455 for rect in selected.iter().skip(1) {
456 boxed = boxed.map(|so_far| so_far.union(*rect));
457 }
458 let boxed = boxed.unwrap_or_else(|| layout.caret_rect(edit.cursor.min(len)));
459 boxed.translate(origin.x, origin.y)
460}
461
462pub(crate) fn paint_handles(
465 scene: &mut Scene,
466 handles: [crate::SelectionHandle; 2],
467 offset: Point,
468 color: Color,
469) {
470 let [start, end] = handles;
471 let place = |r: Rect| r.translate(offset.x, offset.y);
472 scene.fill_path(&handle_path(place(start.rect), true), color);
473 scene.fill_path(&handle_path(place(end.rect), false), color);
474}
475
476pub(crate) fn word_range(chars: &[char], index: usize) -> Option<(usize, usize)> {
479 let len = chars.len();
480 if len == 0 {
481 return Some((0, 0));
482 }
483 let i = index.min(len - 1);
484 if !is_word(chars[i]) {
485 return Some((i, (i + 1).min(len)));
486 }
487 let mut start = i;
488 while start > 0 && is_word(chars[start - 1]) {
489 start -= 1;
490 }
491 let mut end = i + 1;
492 while end < len && is_word(chars[end]) {
493 end += 1;
494 }
495 Some((start, end))
496}
497
498pub fn word_bounds(text: &str, index: usize) -> (usize, usize) {
501 let chars: Vec<char> = text.chars().collect();
502 word_range(&chars, index).unwrap_or((0, 0))
503}
504
505pub(crate) fn move_caret(
510 chars: &[char],
511 cursor: &mut usize,
512 anchor: &mut Option<usize>,
513 key: &Key,
514) -> bool {
515 let len = chars.len();
516 let target = match key {
517 Key::Left { word, .. } => {
518 if *word {
519 word_boundary_left(chars, *cursor)
520 } else {
521 cursor.saturating_sub(1)
522 }
523 }
524 Key::Right { word, .. } => {
525 if *word {
526 word_boundary_right(chars, *cursor)
527 } else {
528 (*cursor + 1).min(len)
529 }
530 }
531 Key::Home { doc, .. } => {
533 if *doc {
534 0
535 } else {
536 line_start(chars, *cursor)
537 }
538 }
539 Key::End { doc, .. } => {
540 if *doc {
541 len
542 } else {
543 line_end(chars, *cursor)
544 }
545 }
546 _ => return false,
547 };
548 let shift = matches!(
549 key,
550 Key::Left { shift: true, .. }
551 | Key::Right { shift: true, .. }
552 | Key::Home { shift: true, .. }
553 | Key::End { shift: true, .. }
554 );
555 move_cursor(cursor, anchor, target, shift);
556 true
557}
558
559fn handle_path(rect: Rect, start: bool) -> frus_core::Path {
563 use std::f32::consts::{FRAC_PI_2, PI};
564 let r = rect.width * 0.5;
565 let centre = Point::new(rect.x + r, rect.y + r);
566 if start {
567 frus_core::Path::new()
570 .move_to(Point::new(rect.x + rect.width, rect.y))
571 .line_to(Point::new(rect.x + rect.width, centre.y))
572 .arc_to(centre, r, 0.0, 3.0 * FRAC_PI_2)
573 .close()
574 } else {
575 frus_core::Path::new()
578 .move_to(Point::new(rect.x, rect.y))
579 .line_to(Point::new(centre.x, rect.y))
580 .arc_to(centre, r, -FRAC_PI_2, PI)
581 .close()
582 }
583}
584
585fn move_cursor(cursor: &mut usize, anchor: &mut Option<usize>, target: usize, shift: bool) {
587 if shift {
588 if anchor.is_none() {
589 *anchor = Some(*cursor);
590 }
591 } else {
592 *anchor = None;
593 }
594 *cursor = target;
595}
596
597impl<Msg> TextField<Msg> {
598 pub fn new(value: impl Into<String>) -> Self {
600 Self {
601 value: value.into(),
602 size: FIELD_TEXT_SIZE,
603 width: Dimension::Length(220.0),
604 sized: false,
605 on_input: None,
606 on_submit: None,
607 on_tap_outside: None,
608 announce: None,
609 label: None,
610 placeholder: None,
611 helper: None,
612 error: None,
613 obscure: false,
614 obscuring: OBSCURE_CHAR,
615 cursor: CursorShape::default(),
616 autofocus: false,
617 text_align: TextAlign::Start,
618 keyboard: None,
619 action: None,
620 icon: None,
621 prefix: None,
622 prefix_text: None,
623 suffix_text: None,
624 floating: None,
625 floating_alignment: None,
626 counter_text: None,
627 suffix: None,
628 suffix_action: None,
629 multiline: false,
630 rows: 3,
631 max_rows: Some(3),
632 rows_told: false,
633 variant: TextFieldVariant::Outlined,
634 enabled: true,
635 dense: false,
636 max_length: None,
637 input_filter: None,
638 capitalization: Capitalization::Auto,
639 read_only: false,
640 style: TextFieldStyle::default(),
641 autofill: Vec::new(),
642 toolbar_build: None,
643 toolbars: Default::default(),
644 }
645 }
646
647 pub fn outlined(mut self) -> Self {
650 self.variant = TextFieldVariant::Outlined;
651 self
652 }
653
654 pub fn borderless(mut self) -> Self {
660 self.variant = TextFieldVariant::None;
661 self
662 }
663
664 pub fn filled(mut self) -> Self {
667 self.variant = TextFieldVariant::Filled;
668 self
669 }
670
671 pub fn variant(mut self, variant: TextFieldVariant) -> Self {
673 self.variant = variant;
674 self
675 }
676
677 pub fn enabled(mut self, enabled: bool) -> Self {
681 self.enabled = enabled;
682 self
683 }
684
685 pub fn style(mut self, style: TextFieldStyle) -> Self {
689 self.style = style;
690 self
691 }
692
693 pub fn dense(mut self, dense: bool) -> Self {
697 self.dense = dense;
698 self
699 }
700
701 fn is_outlined(&self) -> bool {
703 self.variant == TextFieldVariant::Outlined
704 }
705
706 fn is_borderless(&self) -> bool {
708 self.variant == TextFieldVariant::None
709 }
710
711 fn pad_x(&self) -> f32 {
713 self.style.padding_x.unwrap_or(FIELD_PADDING_X)
714 }
715
716 fn pad_top(&self) -> f32 {
719 match (self.is_outlined(), self.dense) {
720 (true, false) => FIELD_OUTLINED_PADDING_TOP,
721 (true, true) => FIELD_DENSE_OUTLINED_PADDING_TOP,
722 (false, false) => FIELD_PADDING_Y,
723 (false, true) => FIELD_DENSE_PADDING_Y,
724 }
725 }
726
727 fn pad_bottom(&self) -> f32 {
729 match (self.is_outlined(), self.dense) {
730 (true, false) => FIELD_OUTLINED_PADDING_BOTTOM,
731 (true, true) => FIELD_DENSE_OUTLINED_PADDING_BOTTOM,
732 (false, false) => FIELD_PADDING_Y,
733 (false, true) => FIELD_DENSE_PADDING_Y,
734 }
735 }
736
737 fn text_style(&self) -> ResolvedTextStyle {
744 TextStyle::new(self.size).resolved()
745 }
746
747 fn sub_style(&self) -> ResolvedTextStyle {
749 TextStyle::new(FIELD_SUB_SIZE).resolved()
750 }
751
752 fn label_size(&self) -> f32 {
755 self.text_style().size * FIELD_LABEL_SCALE
756 }
757
758 fn icon_size(&self) -> f32 {
760 self.style.icon_size.unwrap_or(FIELD_ICON_SIZE)
761 }
762
763 fn text_top(&self) -> f32 {
766 self.pad_top() + self.floating_label_height()
767 }
768
769 fn floating_label_height(&self) -> f32 {
773 if self.is_outlined() || self.label.is_none() {
774 0.0
775 } else {
776 FIELD_GAP + self.label_size()
777 }
778 }
779
780 fn resolved(&self, theme: &Theme) -> TextFieldStyle {
782 let t = theme.widgets.text_field;
783 let pick = |a: Option<frus_core::Color>,
784 b: Option<frus_core::Color>,
785 c: frus_core::Color| { Some(a.or(b).unwrap_or(c)) };
786 TextFieldStyle {
787 fill: pick(
788 self.style.fill,
789 t.fill,
790 if self.is_outlined() || self.is_borderless() {
791 frus_core::Color::TRANSPARENT
792 } else {
793 theme.scheme.surface_container_highest
796 },
797 ),
798 border_color: pick(
799 self.style.border_color,
800 t.border_color,
801 if self.is_outlined() {
802 theme.scheme.outline
803 } else {
804 theme.scheme.on_surface_variant
805 },
806 ),
807 focused_border_color: pick(
808 self.style.focused_border_color,
809 t.focused_border_color,
810 theme.scheme.primary,
811 ),
812 error_color: pick(self.style.error_color, t.error_color, theme.scheme.error),
813 error_hover_color: pick(
814 self.style.error_hover_color,
815 t.error_hover_color,
816 theme.scheme.on_error_container,
817 ),
818 text_color: pick(self.style.text_color, t.text_color, theme.scheme.on_surface),
819 label_color: pick(
820 self.style.label_color,
821 t.label_color,
822 theme.scheme.on_surface_variant,
823 ),
824 focused_label_color: pick(
825 self.style.focused_label_color,
826 t.focused_label_color,
827 theme.scheme.primary,
828 ),
829 helper_color: pick(
830 self.style.helper_color,
831 t.helper_color,
832 theme.scheme.on_surface_variant,
833 ),
834 icon_color: pick(
835 self.style.icon_color,
836 t.icon_color,
837 theme.scheme.on_surface_variant,
838 ),
839 handle_color: pick(
840 self.style.handle_color,
841 t.handle_color,
842 theme.scheme.primary,
843 ),
844 radius: Some(self.style.radius.or(t.radius).unwrap_or(FIELD_RADIUS)),
845 border_width: Some(
846 self.style
847 .border_width
848 .or(t.border_width)
849 .unwrap_or(FIELD_BORDER_WIDTH),
850 ),
851 focused_border_width: Some(
852 self.style
853 .focused_border_width
854 .or(t.focused_border_width)
855 .unwrap_or(FIELD_FOCUSED_BORDER_WIDTH),
856 ),
857 cursor_color: Some(
858 self.style
859 .cursor_color
860 .or(t.cursor_color)
861 .or(theme.widgets.text_selection.cursor_color)
862 .unwrap_or(theme.scheme.primary),
863 ),
864 cursor_error_color: Some(
865 self.style
866 .cursor_error_color
867 .or(t.cursor_error_color)
868 .or(self.style.error_color)
869 .or(t.error_color)
870 .unwrap_or(theme.scheme.error),
871 ),
872 icon_size: Some(self.icon_size()),
873 padding_x: Some(self.pad_x()),
874 }
875 }
876
877 pub fn autofill(mut self, hints: impl IntoIterator<Item = AutofillHint>) -> Self {
890 self.autofill = hints.into_iter().collect();
891 self
892 }
893
894 pub fn multiline(mut self) -> Self {
897 self.multiline = true;
898 self
899 }
900
901 pub fn rows(mut self, rows: u16) -> Self {
904 self.multiline = true;
905 self.rows = rows.max(1);
906 self.max_rows = Some(self.rows);
907 self.rows_told = true;
908 self
909 }
910
911 pub fn min_lines(mut self, lines: u16) -> Self {
915 self.multiline = true;
916 self.rows = lines.max(1);
917 self.rows_told = true;
918 self.max_rows = self.max_rows.map(|max| max.max(self.rows));
919 self
920 }
921
922 pub fn max_lines(mut self, lines: impl Into<Option<u16>>) -> Self {
926 let lines = lines.into();
927 if lines == Some(1) {
928 self.multiline = false;
929 self.rows = 1;
930 self.max_rows = Some(1);
931 return self;
932 }
933 self.multiline = true;
934 if !self.rows_told {
935 self.rows = 1;
936 }
937 self.max_rows = lines.map(|max| max.max(self.rows));
938 self
939 }
940
941 pub fn obscure(mut self, obscure: bool) -> Self {
949 self.obscure = obscure;
950 self
951 }
952
953 pub fn obscuring_character(mut self, character: char) -> Self {
956 self.obscuring = character;
957 self
958 }
959
960 pub fn cursor_color(mut self, color: Color) -> Self {
962 self.style.cursor_color = Some(color);
963 self
964 }
965
966 pub fn cursor_error_color(mut self, color: Color) -> Self {
968 self.style.cursor_error_color = Some(color);
969 self
970 }
971
972 pub fn cursor_width(mut self, width: f32) -> Self {
974 self.cursor.width = width.max(0.0);
975 self
976 }
977
978 pub fn cursor_height(mut self, height: f32) -> Self {
980 self.cursor.height = Some(height.max(0.0));
981 self
982 }
983
984 pub fn cursor_radius(mut self, radius: f32) -> Self {
987 self.cursor.radius = Some(radius.max(0.0));
988 self
989 }
990
991 pub fn show_cursor(mut self, show: bool) -> Self {
994 self.cursor.shown = show;
995 self
996 }
997
998 pub fn autofocus(mut self, autofocus: bool) -> Self {
1002 self.autofocus = autofocus;
1003 self
1004 }
1005
1006 pub fn text_align(mut self, align: TextAlign) -> Self {
1020 self.text_align = align;
1021 self
1022 }
1023
1024 fn align_offset(&self, content_w: f32, text_w: f32) -> f32 {
1047 if self.multiline {
1048 return 0.0;
1049 }
1050 let slack = (content_w - text_w).max(0.0);
1051 match self.text_align {
1052 TextAlign::Start | TextAlign::Left | TextAlign::Justify => 0.0,
1056 TextAlign::End | TextAlign::Right => slack,
1057 TextAlign::Center => slack / 2.0,
1058 }
1059 }
1060
1061 pub fn keyboard_type(mut self, keyboard: KeyboardType) -> Self {
1067 self.keyboard = Some(keyboard);
1068 self
1069 }
1070
1071 pub fn action(mut self, action: TextInputAction) -> Self {
1076 self.action = Some(action);
1077 self
1078 }
1079
1080 fn ime_options(&self) -> Ime {
1082 let keyboard = self.keyboard.unwrap_or({
1083 if self.obscure {
1087 KeyboardType::Password
1088 } else if self.multiline {
1089 KeyboardType::Multiline
1090 } else {
1091 KeyboardType::Text
1092 }
1093 });
1094 let action = self.action.unwrap_or(match keyboard {
1095 KeyboardType::Multiline => TextInputAction::Newline,
1098 _ => TextInputAction::Done,
1099 });
1100 Ime {
1101 keyboard,
1102 action,
1103 capitalization: self.capitalization,
1104 }
1105 }
1106
1107 pub fn prefix_icon(mut self, icon: IconData) -> Self {
1109 self.prefix = Some(icon);
1110 self
1111 }
1112
1113 pub fn suffix_icon(mut self, icon: IconData) -> Self {
1115 self.suffix = Some(icon);
1116 self
1117 }
1118
1119 pub fn icon(mut self, icon: IconData) -> Self {
1123 self.icon = Some(icon);
1124 self
1125 }
1126
1127 pub fn prefix_text(mut self, text: impl Into<String>) -> Self {
1132 self.prefix_text = Some(text.into());
1133 self
1134 }
1135
1136 pub fn suffix_text(mut self, text: impl Into<String>) -> Self {
1139 self.suffix_text = Some(text.into());
1140 self
1141 }
1142
1143 pub fn floating_label_behavior(mut self, behavior: FloatingLabelBehavior) -> Self {
1146 self.floating = Some(behavior);
1147 self
1148 }
1149
1150 pub fn floating_label_alignment(mut self, alignment: FloatingLabelAlignment) -> Self {
1153 self.floating_alignment = Some(alignment);
1154 self
1155 }
1156
1157 pub fn counter_text(mut self, text: impl Into<String>) -> Self {
1161 self.counter_text = Some(text.into());
1162 self
1163 }
1164
1165 pub fn on_suffix(mut self, message: impl Into<Msg>) -> Self {
1169 self.suffix_action = Some(message.into());
1170 self
1171 }
1172
1173 fn suffix_hit(&self, local_x: f32, local_y: f32, width: f32) -> bool {
1176 if self.suffix.is_none() {
1177 return false;
1178 }
1179 let zone_left = width - (self.icon_size() + ICON_PAD * 2.0);
1180 let top = self.label_block();
1181 let bottom = top + self.field_height_at(width);
1182 local_x >= zone_left && local_y >= top && local_y <= bottom
1183 }
1184
1185 pub fn on_tap_outside(mut self, message: impl Into<Msg>) -> Self {
1189 self.on_tap_outside = Some(message.into());
1190 self
1191 }
1192
1193 pub fn label(mut self, label: impl Into<String>) -> Self {
1195 self.label = Some(label.into());
1196 self
1197 }
1198
1199 pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
1201 self.placeholder = Some(placeholder.into());
1202 self
1203 }
1204
1205 pub fn helper(mut self, helper: impl Into<String>) -> Self {
1207 self.helper = Some(helper.into());
1208 self
1209 }
1210
1211 pub fn error(mut self, message: impl Into<String>) -> Self {
1214 self.error = Some(message.into());
1215 self
1216 }
1217
1218 pub fn width(mut self, width: f32) -> Self {
1220 self.width = Dimension::Length(width);
1221 self.sized = true;
1222 self
1223 }
1224
1225 pub fn size(mut self, size: f32) -> Self {
1227 self.size = size;
1228 self
1229 }
1230
1231 pub fn max_length(mut self, max: usize) -> Self {
1245 self.max_length = Some(max);
1246 self
1247 }
1248
1249 pub fn input_filter(mut self, filter: impl Fn(char) -> Option<char> + 'static) -> Self {
1273 self.input_filter = Some(Box::new(filter));
1274 self
1275 }
1276
1277 pub fn digits_only(mut self) -> Self {
1284 self.input_filter = Some(Box::new(|c| c.is_ascii_digit().then_some(c)));
1285 self.keyboard = self.keyboard.or(Some(KeyboardType::Number));
1286 self
1287 }
1288
1289 pub fn capitalization(mut self, capitalization: Capitalization) -> Self {
1296 self.capitalization = capitalization;
1297 self
1298 }
1299
1300 pub fn read_only(mut self) -> Self {
1309 self.read_only = true;
1310 self
1311 }
1312
1313 pub fn selection_toolbar(
1344 mut self,
1345 build: impl Fn(&ToolbarContext, Vec<ToolbarItem<Msg>>) -> Vec<ToolbarItem<Msg>> + 'static,
1346 ) -> Self {
1347 self.toolbar_build = Some(Box::new(build));
1348 self.toolbars = Default::default();
1350 self
1351 }
1352
1353 pub fn on_input<R: crate::callback::IntoMsg<Msg>>(
1355 mut self,
1356 on_input: impl Fn(String) -> R + 'static,
1357 ) -> Self {
1358 self.on_input = Some(Box::new(crate::callback::handler1(on_input)));
1359 self
1360 }
1361
1362 pub fn on_submit(mut self, message: impl Into<Msg>) -> Self {
1364 self.on_submit = Some(message.into());
1365 self
1366 }
1367
1368 pub fn announce(mut self, message: impl Into<String>) -> Self {
1376 self.announce = Some(message.into());
1377 self
1378 }
1379
1380 fn display(&self) -> String {
1384 if self.obscure {
1385 self.obscuring
1386 .to_string()
1387 .repeat(self.value.chars().count())
1388 } else {
1389 self.value.clone()
1390 }
1391 }
1392
1393 fn layout(&self, wrap_width: Option<f32>) -> TextLayout {
1397 let style = self.text_style();
1398 TextLayout::wrapped(
1399 &self.display(),
1400 style.size,
1401 style.weight,
1402 style.italic,
1403 wrap_width,
1404 )
1405 }
1406
1407 fn prefix_icon_w(&self) -> f32 {
1409 if self.prefix.is_some() {
1410 self.icon_size() + ICON_PAD
1411 } else {
1412 0.0
1413 }
1414 }
1415
1416 fn suffix_icon_w(&self) -> f32 {
1418 if self.suffix.is_some() {
1419 self.icon_size() + ICON_PAD
1420 } else {
1421 0.0
1422 }
1423 }
1424
1425 fn affix_w(&self, text: &Option<String>) -> f32 {
1427 text.as_ref().map_or(0.0, |text| {
1428 frus_text::measure_resolved(text, &self.text_style()).width
1429 })
1430 }
1431
1432 fn prefix_w(&self) -> f32 {
1434 self.prefix_icon_w() + self.affix_w(&self.prefix_text)
1435 }
1436
1437 fn suffix_w(&self) -> f32 {
1439 self.suffix_icon_w() + self.affix_w(&self.suffix_text)
1440 }
1441
1442 fn outer_icon_size(&self) -> f32 {
1444 if self.dense {
1445 18.0
1446 } else {
1447 24.0
1448 }
1449 }
1450
1451 fn gutter(&self) -> f32 {
1453 if self.icon.is_some() {
1454 self.outer_icon_size() + 16.0
1455 } else {
1456 0.0
1457 }
1458 }
1459
1460 fn lead(&self) -> f32 {
1463 self.gutter() + self.pad_x() + self.prefix_w()
1464 }
1465
1466 fn floating_behavior(&self, theme: &Theme) -> FloatingLabelBehavior {
1468 self.floating
1469 .or(theme.widgets.text_field.floating_label_behavior)
1470 .unwrap_or_default()
1471 }
1472
1473 fn float_progress(&self, fp: f32, theme: &Theme) -> f32 {
1475 match self.floating_behavior(theme) {
1476 FloatingLabelBehavior::Always => 1.0,
1477 FloatingLabelBehavior::Never => 0.0,
1478 FloatingLabelBehavior::Auto if self.value.is_empty() => fp,
1479 FloatingLabelBehavior::Auto => 1.0,
1480 }
1481 }
1482
1483 fn text_frame(&self, width: f32, edit: &Edit, scroll_y: f32) -> (TextLayout, f32, f32) {
1490 let len = self.value.chars().count();
1491 let content_w = self.content_width(width);
1492 let layout = self.layout(self.multiline.then_some(content_w));
1493 let scroll = (layout.caret_rect(edit.cursor.min(len)).x - content_w).max(0.0);
1494 let align = self.align_offset(content_w, layout.size().width);
1495 let vscroll = if self.multiline {
1496 let visible = self.field_height_at(width) - self.text_top() - self.pad_bottom();
1497 scroll_y.clamp(0.0, (layout.size().height - visible).max(0.0))
1498 } else {
1499 0.0
1500 };
1501 let origin_x = self.lead() + align - scroll;
1502 let origin_y = self.label_block() + self.text_top() - vscroll;
1503 (layout, origin_x, origin_y)
1504 }
1505
1506 fn handles(
1509 &self,
1510 width: f32,
1511 edit: &Edit,
1512 scroll_y: f32,
1513 ) -> Option<[crate::SelectionHandle; 2]> {
1514 let len = self.value.chars().count();
1515 edit.selection_range()?;
1516 let (layout, origin_x, origin_y) = self.text_frame(width, edit, scroll_y);
1517 handles_in(&layout, Point::new(origin_x, origin_y), len, edit)
1518 }
1519
1520 fn selection_box(&self, width: f32, edit: &Edit, scroll_y: f32) -> Rect {
1523 let len = self.value.chars().count();
1524 let (layout, origin_x, origin_y) = self.text_frame(width, edit, scroll_y);
1525 selection_box_in(&layout, Point::new(origin_x, origin_y), len, edit)
1526 }
1527
1528 fn default_toolbar_items(&self, context: &ToolbarContext) -> Vec<ToolbarItem<Msg>> {
1533 let editable = self.enabled && !self.read_only;
1534 let mut items = Vec::new();
1535 if context.has_selection && editable && !self.obscure {
1536 items.push(ToolbarItem::cut());
1537 }
1538 if context.has_selection && !self.obscure {
1539 items.push(ToolbarItem::copy());
1540 }
1541 if context.can_paste && editable {
1542 items.push(ToolbarItem::paste());
1543 }
1544 if !context.all_selected {
1545 items.push(ToolbarItem::select_all());
1546 }
1547 items
1548 }
1549
1550 fn content_width(&self, width: f32) -> f32 {
1552 (width - self.lead() - self.pad_x() - self.suffix_w()).max(0.0)
1553 }
1554
1555 fn label_block(&self) -> f32 {
1559 if self.label.is_some() {
1560 if self.is_outlined() {
1561 (frus_text::line_height(self.label_size()) * 0.5).ceil()
1562 } else {
1563 frus_text::line_height(self.label_size()) + FIELD_GAP
1564 }
1565 } else {
1566 0.0
1567 }
1568 }
1569
1570 fn counter(&self) -> Option<String> {
1572 match &self.counter_text {
1573 Some(text) if text.is_empty() => None,
1574 Some(text) => Some(text.clone()),
1575 None => self
1576 .max_length
1577 .map(|max| format!("{}/{}", self.value.chars().count(), max)),
1578 }
1579 }
1580
1581 fn sub_block(&self) -> f32 {
1583 if self.error.is_some() || self.helper.is_some() || self.counter().is_some() {
1584 self.sub_style().line_height() + FIELD_GAP
1588 } else {
1589 0.0
1590 }
1591 }
1592
1593 fn field_height(&self) -> f32 {
1596 let lines = if self.multiline {
1597 self.rows.max(1) as f32
1598 } else {
1599 1.0
1600 };
1601 self.box_height(lines)
1602 }
1603
1604 fn box_height(&self, lines: f32) -> f32 {
1606 (self.text_style().line_height() * lines + self.text_top() + self.pad_bottom()).ceil()
1607 }
1608
1609 fn grows(&self) -> bool {
1612 self.multiline && self.max_rows != Some(self.rows)
1613 }
1614
1615 fn lines_at(&self, width: f32) -> f32 {
1618 if !self.grows() {
1619 return if self.multiline {
1620 self.rows.max(1) as f32
1621 } else {
1622 1.0
1623 };
1624 }
1625 let layout = self.layout(Some(self.content_width(width)));
1626 let line_h = self.text_style().line_height();
1627 let wrapped = if line_h > 0.0 {
1628 (layout.size().height / line_h).round().max(1.0)
1629 } else {
1630 1.0
1631 };
1632 wrapped.clamp(
1633 self.rows.max(1) as f32,
1634 self.max_rows.map_or(f32::INFINITY, |max| max as f32),
1635 )
1636 }
1637
1638 fn field_height_at(&self, width: f32) -> f32 {
1640 self.box_height(self.lines_at(width))
1641 }
1642}
1643
1644impl<Msg: Clone + 'static> Widget<Msg> for TextField<Msg> {
1645 fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
1648 if false {
1649 return crate::widget::FillAxes::NONE;
1650 }
1651 crate::widget::FillAxes {
1652 horizontal: !self.sized,
1653 vertical: false,
1654 }
1655 }
1656
1657 fn soft_extent(&self) -> crate::widget::FillAxes {
1660 crate::widget::FillAxes {
1661 horizontal: !self.sized,
1662 vertical: false,
1663 }
1664 }
1665
1666 fn style(&self) -> Style {
1667 let height = if self.grows() {
1670 Dimension::Auto
1671 } else {
1672 Dimension::Length(self.label_block() + self.field_height() + self.sub_block())
1673 };
1674 Style {
1675 width: self.width,
1676 height,
1677 ..Default::default()
1678 }
1679 }
1680
1681 fn measure(&self, _theme: &Theme) -> Option<frus_layout::MeasureFn<'_>> {
1682 if !self.grows() {
1683 return None;
1684 }
1685 Some(Box::new(move |width, _| {
1686 let width = width.unwrap_or(match self.width {
1687 Dimension::Length(w) => w,
1688 _ => 220.0,
1689 });
1690 frus_core::Size::new(
1693 width,
1694 frus_core::fits(
1695 self.label_block() + self.field_height_at(width) + self.sub_block(),
1696 ),
1697 )
1698 }))
1699 }
1700
1701 fn measure_key(&self, _theme: &Theme) -> Option<u64> {
1702 if !self.grows() {
1703 return None;
1704 }
1705 use std::hash::{Hash, Hasher};
1706 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1707 self.display().hash(&mut hasher);
1708 self.rows.hash(&mut hasher);
1709 self.max_rows.hash(&mut hasher);
1710 self.size.to_bits().hash(&mut hasher);
1711 for part in [
1714 self.lead(),
1715 self.pad_x(),
1716 self.suffix_w(),
1717 self.text_top(),
1718 self.pad_bottom(),
1719 self.label_block(),
1720 self.sub_block(),
1721 ] {
1722 part.to_bits().hash(&mut hasher);
1723 }
1724 Some(hasher.finish())
1725 }
1726
1727 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
1728 &[]
1729 }
1730
1731 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
1732 let s = self.resolved(theme);
1733 let o = if self.enabled {
1737 status.opacity
1738 } else {
1739 status.opacity * FIELD_DISABLED_OPACITY
1740 };
1741 let has_error = self.error.is_some() && self.enabled;
1742 let fp = if self.enabled {
1743 status.focus_progress.clamp(0.0, 1.0)
1744 } else {
1745 0.0
1746 };
1747 let error_ink = s.error_color.unwrap().lerp(s.error_hover_color.unwrap(), {
1755 let hover = if self.enabled {
1756 status.hover_progress.clamp(0.0, 1.0)
1757 } else {
1758 0.0
1759 };
1760 hover * (1.0 - fp)
1761 });
1762
1763 let label_block = self.label_block();
1766 let gutter = self.gutter();
1767 let field = Rect::new(
1768 bounds.x + gutter,
1769 bounds.y + label_block,
1770 (bounds.width - gutter).max(0.0),
1771 self.field_height_at(bounds.width),
1772 );
1773 if let Some(icon) = self.icon {
1775 let side = self.outer_icon_size();
1776 let path = icon.placed(
1777 side,
1778 bounds.x,
1779 field.y + (field.height - side) * 0.5,
1780 theme.direction,
1781 );
1782 scene.fill_path(&path, s.icon_color.unwrap().fade(o));
1783 }
1784
1785 let float_t = self.float_progress(fp, theme);
1791 let label_shown = !(self.floating_behavior(theme) == FloatingLabelBehavior::Never
1793 && !self.value.is_empty());
1794 let content_left = bounds.x + self.lead();
1795 let label_rest = field.x + self.pad_x() + self.prefix_icon_w();
1798 let centred = self
1799 .floating_alignment
1800 .or(theme.widgets.text_field.floating_label_alignment)
1801 .unwrap_or_default()
1802 == FloatingLabelAlignment::Center;
1803 let label_geom = self.label.as_ref().filter(|_| label_shown).map(|label| {
1807 let rest = Point::new(label_rest, field.y + self.text_top());
1808 let (fx, fy) = if self.is_outlined() {
1809 (
1811 field.x + self.pad_x(),
1812 field.y - frus_text::line_height(self.label_size()) * 0.5,
1813 )
1814 } else {
1815 (label_rest, field.y + self.pad_top())
1818 };
1819 let fx = if centred {
1821 let floated = frus_text::measure_resolved(
1822 label,
1823 &ResolvedTextStyle::exact(self.label_size()),
1824 )
1825 .width;
1826 field.x + (field.width - floated) * 0.5
1827 } else {
1828 fx
1829 };
1830 let x = rest.x + (fx - rest.x) * float_t;
1831 let y = rest.y + (fy - rest.y) * float_t;
1832 let resolved = self.text_style().size;
1833 let size = resolved + (self.label_size() - resolved) * float_t;
1834 let color = if has_error {
1835 error_ink
1836 } else {
1837 s.label_color
1838 .unwrap()
1839 .lerp(s.focused_label_color.unwrap(), fp)
1840 };
1841 (label.clone(), x, y, size, color)
1842 });
1843 let sub = self.error.as_ref().or(self.helper.as_ref());
1845 if let Some(sub) = sub {
1846 let color = if has_error {
1851 s.error_color.unwrap()
1852 } else {
1853 s.helper_color.unwrap()
1854 };
1855 scene.text(
1856 Point::new(field.x, field.y + field.height + FIELD_GAP),
1857 sub.clone(),
1858 &self.sub_style(),
1859 color.fade(o),
1860 );
1861 }
1862 if let Some(counter) = self.counter() {
1867 let width = frus_text::measure_resolved(&counter, &self.sub_style()).width;
1868 scene.text(
1869 Point::new(
1870 bounds.x + bounds.width - width,
1871 field.y + field.height + FIELD_GAP,
1872 ),
1873 counter,
1874 &self.sub_style(),
1875 s.helper_color.unwrap().fade(o),
1876 );
1877 }
1878
1879 let border_color = if has_error {
1884 error_ink
1885 } else {
1886 s.border_color
1887 .unwrap()
1888 .lerp(s.focused_border_color.unwrap(), fp)
1889 }
1890 .fade(o);
1891 let rest_w = s.border_width.unwrap();
1892 let border_width = rest_w + (s.focused_border_width.unwrap() - rest_w) * fp;
1893 let radius = s.radius.unwrap();
1894 let fill = s.fill.unwrap();
1895 if self.is_borderless() {
1896 } else if self.is_outlined() {
1898 scene.draw_rect(field, fill.fade(o), radius, border_width, border_color);
1899 } else {
1900 scene.draw_rect(
1903 field,
1904 fill.fade(o),
1905 frus_core::BorderRadius {
1906 top_left: radius,
1907 top_right: radius,
1908 bottom_right: 0.0,
1909 bottom_left: 0.0,
1910 },
1911 0.0,
1912 frus_core::Color::TRANSPARENT,
1913 );
1914 scene.fill_rect(
1915 Rect::new(
1916 field.x,
1917 field.y + field.height - border_width,
1918 field.width,
1919 border_width,
1920 ),
1921 border_color,
1922 );
1923 }
1924
1925 if !self.is_outlined() {
1931 if let Some((label, x, y, size, color)) = &label_geom {
1932 let style = ResolvedTextStyle::exact(*size);
1935 scene.text(Point::new(*x, *y), label.clone(), &style, color.fade(o));
1936 }
1937 }
1938
1939 if self.is_outlined() {
1943 if let Some((label, x, y, size, color)) = &label_geom {
1944 if float_t > 0.01 {
1945 let label_w = frus_text::measure(label, self.label_size()).width;
1946 let notch = Rect::new(
1947 *x - FIELD_NOTCH_GAP,
1948 field.y - (border_width + FIELD_NOTCH_GAP) * 0.5,
1949 label_w + FIELD_NOTCH_GAP * 2.0,
1950 border_width + FIELD_NOTCH_GAP,
1951 );
1952 scene.fill_rect(notch, theme.surface.fade(o * float_t));
1953 }
1954 scene.text(
1955 Point::new(*x, *y),
1956 label.clone(),
1957 &ResolvedTextStyle::exact(*size),
1958 color.fade(o),
1959 );
1960 }
1961 }
1962
1963 let icon_color = s.icon_color.unwrap().fade(o);
1965 let icon_y = field.y + (field.height - self.icon_size()) * 0.5;
1966 if let Some(prefix) = self.prefix {
1967 let path = prefix.placed(
1968 self.icon_size(),
1969 field.x + ICON_PAD,
1970 icon_y,
1971 theme.direction,
1972 );
1973 scene.fill_path(&path, icon_color);
1974 }
1975 if let Some(suffix) = self.suffix {
1976 let x = field.x + field.width - self.icon_size() - ICON_PAD;
1977 if self.suffix_action.is_some() {
1981 if let Some(hc) = status.hover_cursor {
1982 if self.suffix_hit(hc.x - bounds.x, hc.y - bounds.y, bounds.width) {
1983 let halo = Rect::new(
1984 x - 4.0,
1985 icon_y - 4.0,
1986 self.icon_size() + 8.0,
1987 self.icon_size() + 8.0,
1988 );
1989 scene.draw_rect(
1990 halo,
1991 theme.muted.fade(o * 0.18),
1992 halo.height * 0.5,
1993 0.0,
1994 frus_core::Color::TRANSPARENT,
1995 );
1996 }
1997 }
1998 }
1999 let path = suffix.placed(self.icon_size(), x, icon_y, theme.direction);
2000 scene.fill_path(&path, icon_color);
2001 }
2002
2003 let len = self.value.chars().count();
2004 let content_x = content_left;
2006 let content_w = self.content_width(bounds.width);
2007 let text_y = field.y + self.text_top();
2008 let wrap = if self.multiline {
2011 Some(content_w)
2012 } else {
2013 None
2014 };
2015 let layout = self.layout(wrap);
2016 let align = self.align_offset(content_w, layout.size().width);
2020
2021 if self.value.is_empty() {
2025 if let Some(placeholder) = &self.placeholder {
2026 let ph_alpha = if self.label.is_some() { o * float_t } else { o };
2027 if ph_alpha > 0.01 {
2028 let style = self.text_style();
2031 let hint_w = frus_text::measure_resolved(placeholder, &style).width;
2032 let hint_align = self.align_offset(content_w, hint_w);
2033 scene.text(
2034 Point::new(content_x + hint_align, text_y),
2035 placeholder.clone(),
2036 &style,
2037 theme.muted.fade(ph_alpha),
2038 );
2039 }
2040 }
2041 }
2042
2043 let affix_alpha = if self.label.is_some() {
2046 float_t
2047 } else if self.value.is_empty() {
2048 match self.floating_behavior(theme) {
2049 FloatingLabelBehavior::Always => 1.0,
2050 _ => fp,
2051 }
2052 } else {
2053 1.0
2054 } * o;
2055 if affix_alpha > 0.01 {
2056 let style = self.text_style();
2057 if let Some(prefix) = &self.prefix_text {
2058 scene.text(
2059 Point::new(field.x + self.pad_x() + self.prefix_icon_w(), text_y),
2060 prefix.clone(),
2061 &style,
2062 theme.muted.fade(affix_alpha),
2063 );
2064 }
2065 if let Some(suffix) = &self.suffix_text {
2066 let w = self.affix_w(&self.suffix_text);
2067 scene.text(
2068 Point::new(
2069 field.x + field.width - self.pad_x() - self.suffix_icon_w() - w,
2070 text_y,
2071 ),
2072 suffix.clone(),
2073 &style,
2074 theme.muted.fade(affix_alpha),
2075 );
2076 }
2077 }
2078
2079 let cursor = status.cursor.unwrap_or(len).min(len);
2082 let caret = layout.caret_rect(cursor);
2083 let scroll = if status.focused {
2084 (caret.x - content_w).max(0.0)
2085 } else {
2086 0.0
2087 };
2088 let text_x = content_x + align - scroll;
2089 let vscroll = if self.multiline {
2092 let overflow = (layout.size().height
2093 - (field.height - self.text_top() - self.pad_bottom()))
2094 .max(0.0);
2095 status.scroll_y.clamp(0.0, overflow)
2096 } else {
2097 0.0
2098 };
2099 let text_top = text_y - vscroll;
2101
2102 let outer_clip = scene.current_clip();
2109 let content_clip = outer_clip.intersect(Rect::new(
2110 content_x,
2111 field.y + self.text_top(),
2112 content_w,
2113 (field.height - self.text_top() - self.pad_bottom()).max(0.0),
2114 ));
2115 scene.set_clip(content_clip);
2116
2117 if status.focused {
2119 if let Some((start, end)) = status.selection {
2120 for r in layout.selection_rects(start.min(len), end.min(len)) {
2121 scene.fill_rect(
2122 Rect::new(text_x + r.x, text_top + r.y, r.width, r.height),
2123 theme.selection.fade(o),
2124 );
2125 }
2126 }
2127 }
2128
2129 if !self.value.is_empty() {
2130 let pos = Point::new(text_x, text_top);
2131 let color = theme.on_surface.fade(o);
2132 match wrap {
2133 Some(max_w) => {
2135 scene.text_wrapped(pos, self.display(), &self.text_style(), color, max_w)
2136 }
2137 None => scene.text(pos, self.display(), &self.text_style(), color),
2138 }
2139 }
2140
2141 if status.focused {
2144 if let Some((start, end)) = status.composing {
2145 let (start, end) = (start.min(len), end.min(len));
2146 for r in layout.selection_rects(start, end) {
2147 scene.fill_rect(
2148 Rect::new(text_x + r.x, text_top + r.y + r.height - 1.5, r.width, 1.5),
2149 theme.on_surface.fade(o * 0.7),
2150 );
2151 }
2152 }
2153 }
2154
2155 if status.focused && !status.caret_hidden && self.cursor.shown {
2159 let color = if has_error {
2160 s.cursor_error_color.unwrap()
2161 } else {
2162 s.cursor_color.unwrap()
2163 };
2164 let height = self.cursor.height.unwrap_or(caret.height);
2165 let radius =
2166 self.cursor
2167 .radius
2168 .unwrap_or(if theme.platform.is_apple() { 2.0 } else { 0.0 });
2169 scene.draw_rect(
2170 Rect::new(
2171 text_x + caret.x,
2172 text_top + caret.y + (caret.height - height) * 0.5,
2173 self.cursor.width,
2174 height,
2175 ),
2176 color.fade(o),
2177 radius,
2178 0.0,
2179 Color::TRANSPARENT,
2180 );
2181 }
2182
2183 if status.focused && status.handles {
2186 scene.set_clip(outer_clip);
2187 let edit = Edit {
2188 cursor,
2189 anchor: status
2190 .selection
2191 .map(|(a, b)| if cursor == a { b } else { a }),
2192 composing: None,
2193 };
2194 if let Some(handles) = self.handles(bounds.width, &edit, status.scroll_y) {
2195 let color = s.handle_color.unwrap().fade(o);
2196 paint_handles(scene, handles, Point::new(bounds.x, bounds.y), color);
2197 }
2198 }
2199 }
2200
2201 fn on_click(&self) -> Option<Msg> {
2202 None
2203 }
2204
2205 fn positional_click(
2206 &self,
2207 local_x: f32,
2208 local_y: f32,
2209 width: f32,
2210 _height: f32,
2211 ) -> Option<Msg> {
2212 if !self.enabled {
2213 return None;
2214 }
2215 if self.suffix_action.is_some() && self.suffix_hit(local_x, local_y, width) {
2216 self.suffix_action.clone()
2217 } else {
2218 None
2219 }
2220 }
2221
2222 fn cursor_icon(
2223 &self,
2224 local_x: f32,
2225 local_y: f32,
2226 width: f32,
2227 _height: f32,
2228 ) -> Option<crate::interaction::Cursor> {
2229 if self.suffix_action.is_some() && self.suffix_hit(local_x, local_y, width) {
2231 Some(crate::interaction::Cursor::Pointer)
2232 } else {
2233 None
2234 }
2235 }
2236
2237 fn on_edit(&self, edit: &mut Edit, key: &Key) -> Option<Msg> {
2238 if !self.enabled {
2242 return None;
2243 }
2244 let mut chars: Vec<char> = self.value.chars().collect();
2245 let len = chars.len();
2246 let mut cursor = edit.cursor.min(len);
2247 let mut anchor = edit.anchor.map(|a| a.min(len));
2248 let selection = anchor
2249 .map(|a| (a.min(cursor), a.max(cursor)))
2250 .filter(|(s, e)| s < e);
2251
2252 let mut changed = false;
2253
2254 match key {
2255 Key::Text(text) => {
2256 let inserted: Vec<char> = match &self.input_filter {
2260 Some(filter) => text.chars().filter_map(filter).collect(),
2261 None => text.chars().collect(),
2262 };
2263 if selection.is_some() || !inserted.is_empty() {
2268 if let Some((s, e)) = selection {
2269 chars.drain(s..e);
2270 cursor = s;
2271 }
2272 let n = inserted.len();
2273 chars.splice(cursor..cursor, inserted);
2274 cursor += n;
2275 anchor = None;
2276 changed = true;
2277 }
2278 }
2279 Key::Backspace => {
2280 if let Some((s, e)) = selection {
2281 chars.drain(s..e);
2282 cursor = s;
2283 changed = true;
2284 } else if cursor > 0 {
2285 chars.remove(cursor - 1);
2286 cursor -= 1;
2287 changed = true;
2288 }
2289 anchor = None;
2290 }
2291 Key::Delete => {
2292 if let Some((s, e)) = selection {
2293 chars.drain(s..e);
2294 cursor = s;
2295 changed = true;
2296 } else if cursor < len {
2297 chars.remove(cursor);
2298 changed = true;
2299 }
2300 anchor = None;
2301 }
2302 Key::Left { .. } | Key::Right { .. } | Key::Home { .. } | Key::End { .. } => {
2305 move_caret(&chars, &mut cursor, &mut anchor, key);
2306 }
2307 Key::Escape => {}
2309 Key::Enter if self.multiline => {
2310 if let Some((s, e)) = selection {
2312 chars.drain(s..e);
2313 cursor = s;
2314 }
2315 chars.insert(cursor, '\n');
2316 cursor += 1;
2317 anchor = None;
2318 changed = true;
2319 }
2320 Key::Enter => {
2321 edit.cursor = cursor;
2323 edit.anchor = anchor;
2324 return self.on_submit.clone();
2325 }
2326 }
2327
2328 if self.read_only && changed {
2333 return None;
2334 }
2335
2336 if changed {
2345 if let Some(max) = self.max_length {
2346 if chars.len() > max {
2347 chars.truncate(max);
2348 cursor = cursor.min(max);
2349 anchor = anchor.map(|a| a.min(max));
2350 }
2351 }
2352 }
2353
2354 edit.cursor = cursor;
2355 edit.anchor = anchor;
2356
2357 if changed {
2358 let new_value: String = chars.into_iter().collect();
2359 self.on_input.as_ref().map(|make| make(new_value))
2360 } else {
2361 None
2362 }
2363 }
2364
2365 fn ime(&self) -> Ime {
2366 self.ime_options()
2367 }
2368
2369 fn cursor_at(
2370 &self,
2371 local_x: f32,
2372 local_y: f32,
2373 width: f32,
2374 scroll_cursor: usize,
2375 ) -> Option<usize> {
2376 if !self.enabled {
2379 return None;
2380 }
2381 if self.suffix_action.is_some() && self.suffix_hit(local_x, local_y, width) {
2384 return None;
2385 }
2386 let left = self.lead();
2390 let content_w = self.content_width(width);
2391 let layout = self.layout(if self.multiline {
2392 Some(content_w)
2393 } else {
2394 None
2395 });
2396 let scroll = (layout.caret_rect(scroll_cursor).x - content_w).max(0.0);
2397 let align = self.align_offset(content_w, layout.size().width);
2402 let target_x = local_x - left - align + scroll;
2405 let target_y = local_y - self.label_block() - self.text_top();
2406 Some(layout.hit_test(Point::new(target_x, target_y)))
2407 }
2408
2409 fn text_metrics(&self, width: f32, cursor: usize) -> Option<(f32, f32, f32, f32)> {
2410 if !self.multiline {
2411 return None;
2412 }
2413 let layout = self.layout(Some(self.content_width(width)));
2414 let caret = layout.caret_rect(cursor);
2415 let visible = self.field_height_at(width) - self.text_top() - self.pad_bottom();
2416 Some((layout.size().height, visible, caret.y, caret.height))
2417 }
2418
2419 fn text_viewport(&self, rect: Rect) -> Option<Rect> {
2420 if !self.multiline {
2421 return None;
2422 }
2423 Some(Rect::new(
2425 rect.x,
2426 rect.y + self.label_block(),
2427 rect.width,
2428 self.field_height_at(rect.width),
2429 ))
2430 }
2431
2432 fn caret_vertical(
2433 &self,
2434 width: f32,
2435 cursor: usize,
2436 down: bool,
2437 page: bool,
2438 goal_x: Option<f32>,
2439 ) -> Option<(usize, f32)> {
2440 if !self.multiline {
2441 return None;
2442 }
2443 let layout = self.layout(Some(self.content_width(width)));
2444 let caret = layout.caret_rect(cursor);
2445 let line_h = caret.height;
2446 let x = goal_x.unwrap_or(caret.x);
2448 let step = if page {
2450 (self.field_height_at(width) - self.text_top() - self.pad_bottom()).max(line_h)
2451 } else {
2452 line_h
2453 };
2454 let center = caret.y + line_h * 0.5;
2455 let target_y = if down { center + step } else { center - step };
2456 let full = layout.size().height;
2457 if page {
2458 let clamped = target_y.clamp(0.0, (full - line_h * 0.5).max(0.0));
2460 Some((layout.hit_test(Point::new(x, clamped)), x))
2461 } else {
2462 if target_y < 0.0 || target_y >= full {
2464 return None;
2465 }
2466 Some((layout.hit_test(Point::new(x, target_y)), x))
2467 }
2468 }
2469
2470 fn text_value(&self) -> Option<&str> {
2471 Some(&self.value)
2472 }
2473
2474 fn takes_typing(&self) -> bool {
2476 self.enabled && !self.read_only
2477 }
2478
2479 fn replace_value(&self, value: String) -> Option<Msg> {
2480 if !self.enabled || self.read_only {
2486 return None;
2487 }
2488 self.on_input.as_ref().map(|make| make(value))
2489 }
2490
2491 fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
2492 let mut s = frus_core::SemanticsProperties::new(frus_core::Role::TextInput)
2493 .value(self.value.clone());
2494 let label = match (&self.label, &self.error) {
2496 (Some(l), Some(e)) => Some(format!("{l}, {e}")),
2497 (Some(l), None) => Some(l.clone()),
2498 (None, Some(e)) => Some(e.clone()),
2499 (None, None) => None,
2500 };
2501 if let Some(label) = label {
2502 s = s.label(label);
2503 }
2504 if !self.enabled {
2505 s = s.disabled(true);
2506 }
2507 Some(s)
2508 }
2509
2510 fn selected_text(&self, edit: &Edit) -> Option<String> {
2511 if self.obscure {
2514 return None;
2515 }
2516 let chars: Vec<char> = self.value.chars().collect();
2517 let len = chars.len();
2518 let cursor = edit.cursor.min(len);
2519 let anchor = edit.anchor?.min(len);
2520 let (start, end) = (anchor.min(cursor), anchor.max(cursor));
2521 if start < end {
2522 Some(chars[start..end].iter().collect())
2523 } else {
2524 None
2525 }
2526 }
2527
2528 fn word_at(&self, index: usize) -> Option<(usize, usize)> {
2529 let chars: Vec<char> = self.value.chars().collect();
2530 word_range(&chars, index)
2531 }
2532
2533 fn selection_handles(
2534 &self,
2535 width: f32,
2536 edit: &Edit,
2537 scroll_y: f32,
2538 ) -> Option<[crate::SelectionHandle; 2]> {
2539 self.handles(width, edit, scroll_y)
2540 }
2541
2542 fn selection_toolbar(&self, context: ToolbarContext) -> Option<&dyn Widget<Msg>> {
2543 self.toolbars[usize::from(context.variant())]
2544 .get_or_init(|| {
2545 let defaults = self.default_toolbar_items(&context);
2546 let items = match &self.toolbar_build {
2547 Some(build) => build(&context, defaults),
2548 None => defaults,
2549 };
2550 (!items.is_empty()).then(|| {
2551 let bar: Box<dyn Widget<Msg>> = Box::new(SelectionToolbar::new(items));
2552 bar
2553 })
2554 })
2555 .as_deref()
2556 }
2557
2558 fn selection_anchor(&self, width: f32, edit: &Edit, scroll_y: f32) -> Option<Rect> {
2559 Some(self.selection_box(width, edit, scroll_y))
2560 }
2561
2562 fn autofill_hints(&self) -> &[AutofillHint] {
2563 &self.autofill
2564 }
2565
2566 fn autofocus(&self) -> bool {
2567 self.autofocus && self.enabled
2568 }
2569
2570 fn on_tap_outside(&self) -> Option<Msg> {
2571 self.on_tap_outside.clone()
2572 }
2573
2574 fn focusable(&self) -> bool {
2575 self.enabled
2576 }
2577
2578 fn draws_own_focus(&self) -> bool {
2579 true
2580 }
2581
2582 fn announce(&self) -> Option<String> {
2583 self.announce.clone()
2584 }
2585}
2586
2587#[cfg(test)]
2588mod tests {
2589 use super::*;
2590 use crate::icons::Icons;
2591
2592 #[derive(Clone, Debug, PartialEq)]
2593 enum Msg {
2594 Changed(String),
2595 Submitted,
2596 }
2597
2598 fn input(value: &str) -> TextField<Msg> {
2599 TextField::new(value).on_input(Msg::Changed)
2600 }
2601
2602 fn rects(field: &TextField<Msg>, theme: &Theme, w: f32, h: f32) -> Vec<frus_core::Primitive> {
2604 let mut scene = Scene::new();
2605 Widget::<Msg>::paint(
2606 field,
2607 Rect::new(0.0, 0.0, w, h),
2608 Status::default(),
2609 theme,
2610 &mut scene,
2611 );
2612 scene
2613 .primitives()
2614 .iter()
2615 .filter(|p| matches!(p, frus_core::Primitive::Rect { .. }))
2616 .cloned()
2617 .collect()
2618 }
2619
2620 #[test]
2625 fn a_field_is_the_references_size() {
2626 let line = input("x").text_style().line_height();
2627 let outlined = input("x");
2628 assert_eq!(
2629 outlined.field_height(),
2630 (line + FIELD_OUTLINED_PADDING_TOP + FIELD_OUTLINED_PADDING_BOTTOM).ceil()
2631 );
2632 let filled = input("x").filled();
2635 assert_eq!(filled.field_height(), (line + FIELD_PADDING_Y * 2.0).ceil());
2636 let labelled = input("x").filled().label("Name");
2637 assert_eq!(
2638 labelled.field_height() - filled.field_height(),
2639 (FIELD_GAP + FIELD_TEXT_SIZE * FIELD_LABEL_SCALE).ceil()
2640 );
2641 assert_eq!(input("x").size(32.0).label_size(), 24.0);
2644 }
2645
2646 #[test]
2650 fn a_dense_field_gives_back_its_padding() {
2651 let line = input("x").text_style().line_height();
2652 assert_eq!(
2653 input("x").dense(true).field_height(),
2654 (line + FIELD_DENSE_OUTLINED_PADDING_TOP + FIELD_DENSE_OUTLINED_PADDING_BOTTOM).ceil()
2655 );
2656 assert_eq!(
2657 input("x").filled().dense(true).field_height(),
2658 (line + FIELD_DENSE_PADDING_Y * 2.0).ceil()
2659 );
2660 for dense in [input("x").dense(true), input("x").filled().dense(true)] {
2663 let roomy = if dense.is_outlined() {
2664 input("x")
2665 } else {
2666 input("x").filled()
2667 };
2668 assert!(dense.field_height() < roomy.field_height());
2669 }
2670 }
2671
2672 #[test]
2676 fn a_filled_field_has_a_line_not_a_box() {
2677 let theme = Theme::default();
2678 let stroked = |field: &TextField<Msg>| {
2679 rects(field, &theme, 220.0, 80.0)
2680 .iter()
2681 .filter(|p| {
2682 matches!(p, frus_core::Primitive::Rect { border_width, .. } if *border_width > 0.0)
2683 })
2684 .count()
2685 };
2686 assert_eq!(
2687 stroked(&input("x").outlined()),
2688 1,
2689 "outlined: one stroked box"
2690 );
2691 assert_eq!(stroked(&input("x").filled()), 0, "filled: nothing stroked");
2692 let filled = input("x").filled();
2694 let height = filled.field_height();
2695 let has_underline = rects(&filled, &theme, 220.0, 80.0).iter().any(|p| {
2696 matches!(
2697 p,
2698 frus_core::Primitive::Rect { rect, border_width, .. }
2699 if *border_width == 0.0
2700 && (rect.width - 220.0).abs() < 0.5
2701 && (rect.height - FIELD_BORDER_WIDTH).abs() < 0.5
2702 && (rect.y + rect.height - height).abs() < 0.5
2703 )
2704 });
2705 assert!(has_underline, "a filled field is underlined");
2706 }
2707
2708 #[test]
2711 fn a_disabled_field_dims_and_never_looks_focused() {
2712 let theme = Theme::default();
2713 let focused = Status {
2714 focus_progress: 1.0,
2715 ..Default::default()
2716 };
2717 let border = |field: &TextField<Msg>, status: Status| {
2718 let mut scene = Scene::new();
2719 Widget::<Msg>::paint(
2720 field,
2721 Rect::new(0.0, 0.0, 220.0, 80.0),
2722 status,
2723 &theme,
2724 &mut scene,
2725 );
2726 scene.primitives().iter().find_map(|p| match p {
2727 frus_core::Primitive::Rect {
2728 border_width,
2729 border_color,
2730 ..
2731 } if *border_width > 0.0 => Some((*border_width, *border_color)),
2732 _ => None,
2733 })
2734 };
2735 let (live_w, live_c) = border(&input("x"), focused).unwrap();
2736 assert_eq!(
2737 live_w, FIELD_FOCUSED_BORDER_WIDTH,
2738 "focus widens the border"
2739 );
2740 assert_eq!(live_c.a, 1.0, "and it is fully opaque");
2741 let (dead_w, dead_c) = border(&input("x").enabled(false), focused).unwrap();
2742 assert_eq!(dead_w, FIELD_BORDER_WIDTH, "disabled: no focus weight");
2743 assert!(
2744 (dead_c.a - FIELD_DISABLED_OPACITY).abs() < 0.01,
2745 "disabled: dimmed to the reference's 38%, got {}",
2746 dead_c.a
2747 );
2748 }
2749
2750 #[test]
2754 fn scrolled_text_is_clipped_below_the_label() {
2755 let theme = Theme::default();
2756 let field = input("one\ntwo\nthree\nfour\nfive\nsix")
2757 .rows(3)
2758 .label("Notes");
2759 let mut scene = Scene::new();
2760 Widget::<Msg>::paint(
2761 &field,
2762 Rect::new(0.0, 0.0, 220.0, 140.0),
2763 Status {
2764 scroll_y: 40.0,
2765 ..Default::default()
2766 },
2767 &theme,
2768 &mut scene,
2769 );
2770 let box_top = field.label_block();
2771 let clip = scene
2772 .primitives()
2773 .iter()
2774 .find_map(|p| match p {
2775 frus_core::Primitive::Text { clip, .. } if clip.y > -1.0e6 => Some(*clip),
2778 _ => None,
2779 })
2780 .expect("the value is clipped");
2781 assert!(
2782 clip.y >= box_top + field.text_top() - 0.5,
2783 "the clip starts below the padding, not at the border: {} < {}",
2784 clip.y,
2785 box_top + field.text_top()
2786 );
2787 }
2788
2789 #[test]
2792 fn a_disabled_field_is_inert() {
2793 let live = input("hello");
2794 let dead = input("hello").enabled(false);
2795 assert!(Widget::<Msg>::focusable(&live));
2796 assert!(!Widget::<Msg>::focusable(&dead), "out of the tab order");
2797 assert!(
2798 Widget::<Msg>::cursor_at(&live, 40.0, 30.0, 220.0, 0).is_some(),
2799 "a live field takes a caret"
2800 );
2801 assert_eq!(
2802 Widget::<Msg>::cursor_at(&dead, 40.0, 30.0, 220.0, 0),
2803 None,
2804 "a disabled field takes none"
2805 );
2806 let mut edit = Edit::default();
2807 assert!(
2808 Widget::on_edit(&dead, &mut edit, &Key::Text("x".into())).is_none(),
2809 "and no key edits it"
2810 );
2811 let semantics = Widget::<Msg>::semantics(&dead).unwrap();
2812 assert!(semantics.disabled, "a reader is told it is disabled");
2813 }
2814
2815 #[test]
2817 fn the_caller_outranks_the_theme_which_outranks_the_framework() {
2818 let radius = |field: &TextField<Msg>, theme: &Theme| {
2819 rects(field, theme, 220.0, 80.0)
2820 .iter()
2821 .find_map(|p| match p {
2822 frus_core::Primitive::Rect {
2823 border_width,
2824 radius,
2825 ..
2826 } if *border_width > 0.0 => Some(radius.top_left),
2827 _ => None,
2828 })
2829 .unwrap()
2830 };
2831 let plain = Theme::default();
2832 assert_eq!(radius(&input("x"), &plain), FIELD_RADIUS);
2833 let mut themed = Theme::default();
2834 themed.widgets.text_field.radius = Some(10.0);
2835 assert_eq!(radius(&input("x"), &themed), 10.0);
2836 let overridden = input("x").style(TextFieldStyle {
2837 radius: Some(2.0),
2838 ..Default::default()
2839 });
2840 assert_eq!(radius(&overridden, &themed), 2.0);
2841 }
2842
2843 #[test]
2844 fn text_is_clipped_to_content_box() {
2845 let inp = input("a very long value that overflows the field width").width(100.0);
2848 let mut scene = Scene::new();
2849 Widget::<Msg>::paint(
2850 &inp,
2851 Rect::new(0.0, 0.0, 100.0, 30.0),
2852 Status::default(),
2853 &Theme::default(),
2854 &mut scene,
2855 );
2856 let clip = scene
2857 .primitives()
2858 .iter()
2859 .find_map(|p| match p {
2860 frus_core::Primitive::Text { clip, .. } => Some(*clip),
2861 _ => None,
2862 })
2863 .expect("text primitive");
2864 assert!(
2865 (clip.width - (100.0 - FIELD_PADDING_X * 2.0)).abs() < 0.5,
2866 "clip = {clip:?}"
2867 );
2868 }
2869
2870 #[test]
2871 fn composing_region_draws_an_underline() {
2872 let inp = input("konnichiwa");
2875 let base = Status {
2876 focused: true,
2877 cursor: Some(5),
2878 ..Default::default()
2879 };
2880 let composing = Status {
2881 composing: Some((0, 5)),
2882 ..base
2883 };
2884
2885 let count_thin_rects = |status: Status| {
2886 let mut scene = Scene::new();
2887 Widget::<Msg>::paint(
2888 &inp,
2889 Rect::new(0.0, 0.0, 220.0, 30.0),
2890 status,
2891 &Theme::default(),
2892 &mut scene,
2893 );
2894 scene
2895 .primitives()
2896 .iter()
2897 .filter(|p| matches!(p, frus_core::Primitive::Rect { rect, .. } if rect.height <= 2.0 && rect.width > 2.0))
2898 .count()
2899 };
2900 let plain = count_thin_rects(base);
2901 let underlined = count_thin_rects(composing);
2902 assert!(
2903 underlined > plain,
2904 "the composition must underline ({plain} → {underlined})"
2905 );
2906 }
2907
2908 #[test]
2909 fn text_value_exposes_the_field_content() {
2910 let inp = input("hello");
2912 assert_eq!(Widget::<Msg>::text_value(&inp), Some("hello"));
2913 }
2914
2915 #[test]
2916 fn cursor_at_accounts_for_scroll() {
2917 let inp = input("0123456789 abcdefghij");
2919 assert_eq!(
2921 Widget::<Msg>::cursor_at(&inp, FIELD_PADDING_X, FIELD_OUTLINED_PADDING_TOP, 80.0, 0),
2922 Some(0)
2923 );
2924 let end = inp.value.chars().count();
2927 let at_left =
2928 Widget::<Msg>::cursor_at(&inp, FIELD_PADDING_X, FIELD_OUTLINED_PADDING_TOP, 80.0, end)
2929 .unwrap();
2930 let at_right =
2931 Widget::<Msg>::cursor_at(&inp, 76.0, FIELD_OUTLINED_PADDING_TOP, 80.0, end).unwrap();
2932 assert!(at_left > 0, "scrolled: the left edge is no longer index 0");
2933 assert!(at_right > at_left, "a click on the right → a larger index");
2934 }
2935
2936 #[test]
2937 fn insert_at_cursor() {
2938 let inp = input("ac");
2939 let mut edit = Edit {
2940 cursor: 1,
2941 anchor: None,
2942 composing: None,
2943 };
2944 assert_eq!(
2945 inp.on_edit(&mut edit, &Key::Text("b".to_string())),
2946 Some(Msg::Changed("abc".to_string()))
2947 );
2948 assert_eq!(edit.cursor, 2);
2949 }
2950
2951 #[test]
2952 fn shift_arrow_selects_then_delete() {
2953 let inp = input("hello");
2954 let mut edit = Edit {
2956 cursor: 5,
2957 anchor: None,
2958 composing: None,
2959 };
2960 inp.on_edit(
2961 &mut edit,
2962 &Key::Left {
2963 shift: true,
2964 word: false,
2965 },
2966 );
2967 inp.on_edit(
2968 &mut edit,
2969 &Key::Left {
2970 shift: true,
2971 word: false,
2972 },
2973 );
2974 assert_eq!(edit.selection_range(), Some((3, 5)));
2975 assert_eq!(
2977 inp.on_edit(&mut edit, &Key::Backspace),
2978 Some(Msg::Changed("hel".to_string()))
2979 );
2980 assert_eq!(edit.cursor, 3);
2981 assert_eq!(edit.anchor, None);
2982 }
2983
2984 #[test]
2985 fn home_end_bounds() {
2986 let inp = input("abc");
2987 let mut edit = Edit {
2988 cursor: 1,
2989 anchor: None,
2990 composing: None,
2991 };
2992 inp.on_edit(
2993 &mut edit,
2994 &Key::End {
2995 shift: false,
2996 doc: false,
2997 },
2998 );
2999 assert_eq!(edit.cursor, 3);
3000 inp.on_edit(
3001 &mut edit,
3002 &Key::Home {
3003 shift: false,
3004 doc: false,
3005 },
3006 );
3007 assert_eq!(edit.cursor, 0);
3008 }
3009
3010 #[test]
3011 fn ctrl_arrow_jumps_by_word() {
3012 let inp = input("foo bar baz");
3013 let mut edit = Edit {
3015 cursor: 11,
3016 anchor: None,
3017 composing: None,
3018 };
3019 inp.on_edit(
3020 &mut edit,
3021 &Key::Left {
3022 shift: false,
3023 word: true,
3024 },
3025 );
3026 assert_eq!(edit.cursor, 8, "start of \"baz\"");
3027 inp.on_edit(
3028 &mut edit,
3029 &Key::Left {
3030 shift: false,
3031 word: true,
3032 },
3033 );
3034 assert_eq!(edit.cursor, 4, "start of \"bar\"");
3035 inp.on_edit(
3037 &mut edit,
3038 &Key::Right {
3039 shift: false,
3040 word: true,
3041 },
3042 );
3043 assert_eq!(edit.cursor, 7, "fin de \"bar\"");
3044 inp.on_edit(
3045 &mut edit,
3046 &Key::Right {
3047 shift: false,
3048 word: true,
3049 },
3050 );
3051 assert_eq!(edit.cursor, 11, "fin de \"baz\"");
3052 }
3053
3054 #[test]
3055 fn home_end_are_line_relative_but_ctrl_spans_the_field() {
3056 let inp = TextField::<Msg>::new("ab\ncd\nef")
3058 .on_input(Msg::Changed)
3059 .rows(3);
3060 let mut edit = Edit {
3061 cursor: 4,
3062 anchor: None,
3063 composing: None,
3064 };
3065 inp.on_edit(
3067 &mut edit,
3068 &Key::Home {
3069 shift: false,
3070 doc: false,
3071 },
3072 );
3073 assert_eq!(edit.cursor, 3, "start of the 2nd line");
3074 inp.on_edit(
3076 &mut edit,
3077 &Key::End {
3078 shift: false,
3079 doc: false,
3080 },
3081 );
3082 assert_eq!(edit.cursor, 5, "end of the 2nd line");
3083 inp.on_edit(
3085 &mut edit,
3086 &Key::Home {
3087 shift: false,
3088 doc: true,
3089 },
3090 );
3091 assert_eq!(edit.cursor, 0, "start of the field");
3092 inp.on_edit(
3093 &mut edit,
3094 &Key::End {
3095 shift: false,
3096 doc: true,
3097 },
3098 );
3099 assert_eq!(edit.cursor, 8, "end of the field");
3100 }
3101
3102 #[test]
3103 fn selected_text_reads_range() {
3104 let inp = input("hello");
3105 let edit = Edit {
3106 cursor: 5,
3107 anchor: Some(2),
3108 composing: None,
3109 };
3110 assert_eq!(inp.selected_text(&edit), Some("llo".to_string()));
3111 }
3112
3113 fn bar_words(field: &TextField<Msg>, context: ToolbarContext) -> Option<Vec<String>> {
3116 let bar = Widget::<Msg>::selection_toolbar(field, context)?;
3117 Some(
3118 bar.children()
3119 .iter()
3120 .map(|button| {
3121 button
3122 .semantics()
3123 .and_then(|semantics| semantics.label)
3124 .unwrap_or_default()
3125 })
3126 .collect(),
3127 )
3128 }
3129
3130 fn context(has_selection: bool, can_paste: bool, all_selected: bool) -> ToolbarContext {
3131 ToolbarContext {
3132 has_selection,
3133 can_paste,
3134 all_selected,
3135 }
3136 }
3137
3138 #[test]
3142 fn the_default_bar_follows_the_three_conditions() {
3143 let field = input("hello world");
3144 let words = |ctx| bar_words(&field, ctx);
3145 assert_eq!(
3146 words(context(true, true, false)),
3147 Some(vec![
3148 "Cut".into(),
3149 "Copy".into(),
3150 "Paste".into(),
3151 "Select all".into()
3152 ])
3153 );
3154 assert_eq!(
3155 words(context(false, true, false)),
3156 Some(vec!["Paste".into(), "Select all".into()])
3157 );
3158 assert_eq!(
3159 words(context(true, false, true)),
3160 Some(vec!["Cut".into(), "Copy".into()])
3161 );
3162 assert_eq!(words(context(false, false, true)), None, "nothing to offer");
3163 }
3164
3165 #[test]
3169 fn a_masked_field_offers_neither_cut_nor_copy_and_copies_nothing() {
3170 let field = input("hunter2").obscure(true);
3171 assert_eq!(
3172 bar_words(&field, context(true, true, false)),
3173 Some(vec!["Paste".into(), "Select all".into()])
3174 );
3175 let selected = Edit {
3176 cursor: 7,
3177 anchor: Some(0),
3178 composing: None,
3179 };
3180 assert_eq!(field.selected_text(&selected), None);
3181 assert_eq!(
3183 input("hunter2").selected_text(&selected),
3184 Some("hunter2".to_string())
3185 );
3186 }
3187
3188 #[test]
3191 fn a_read_only_field_offers_copy_and_select_all_only() {
3192 let field = input("REF-0042").read_only();
3193 assert_eq!(
3194 bar_words(&field, context(true, true, false)),
3195 Some(vec!["Copy".into(), "Select all".into()])
3196 );
3197 }
3198
3199 #[test]
3203 fn the_application_rewrites_the_list() {
3204 let field = input("hello").selection_toolbar(|context, mut items| {
3205 items.retain(|item| item.label() != "Paste");
3206 if context.has_selection {
3207 items.push(ToolbarItem::custom("Translate", Msg::Submitted));
3208 }
3209 items
3210 });
3211 assert_eq!(
3212 bar_words(&field, context(true, true, false)),
3213 Some(vec![
3214 "Cut".into(),
3215 "Copy".into(),
3216 "Select all".into(),
3217 "Translate".into()
3218 ])
3219 );
3220 assert_eq!(
3221 bar_words(&field, context(false, true, false)),
3222 Some(vec!["Select all".into()]),
3223 "the custom item only for a selection"
3224 );
3225 let none = input("hello").selection_toolbar(|_, _| Vec::new());
3226 assert_eq!(bar_words(&none, context(true, true, false)), None);
3227 }
3228
3229 #[test]
3232 fn an_application_item_sends_its_message_and_a_built_in_one_does_not() {
3233 let field = input("hello").selection_toolbar(|_, mut items| {
3234 items.push(ToolbarItem::custom("Translate", Msg::Submitted));
3235 items
3236 });
3237 let bar =
3238 Widget::<Msg>::selection_toolbar(&field, context(true, false, false)).expect("a bar");
3239 let buttons = bar.children();
3240 let clicks: Vec<Option<Msg>> = buttons.iter().map(|b| b.on_click()).collect();
3241 let actions: Vec<Option<crate::EditAction>> =
3242 buttons.iter().map(|b| b.edit_action()).collect();
3243 assert_eq!(clicks, vec![None, None, None, Some(Msg::Submitted)]);
3244 assert_eq!(
3245 actions,
3246 vec![
3247 Some(crate::EditAction::Cut),
3248 Some(crate::EditAction::Copy),
3249 Some(crate::EditAction::SelectAll),
3250 None
3251 ]
3252 );
3253 }
3254
3255 #[test]
3258 fn the_bar_is_anchored_on_the_selection_or_the_caret() {
3259 let field = input("hello world");
3260 let word = Edit {
3261 cursor: 5,
3262 anchor: Some(0),
3263 composing: None,
3264 };
3265 let all = Edit {
3266 cursor: 11,
3267 anchor: Some(0),
3268 composing: None,
3269 };
3270 let caret = Edit {
3271 cursor: 5,
3272 anchor: None,
3273 composing: None,
3274 };
3275 let anchor =
3276 |edit: &Edit| Widget::<Msg>::selection_anchor(&field, 200.0, edit, 0.0).unwrap();
3277 assert!(anchor(&all).width > anchor(&word).width);
3278 assert_eq!(anchor(&caret).width, 0.0);
3279 assert!(anchor(&caret).height > 0.0);
3280 let handles = Widget::<Msg>::selection_handles(&field, 200.0, &word, 0.0).unwrap();
3282 let word_box = anchor(&word);
3283 assert!((word_box.x - handles[0].line_center.x).abs() < 0.5);
3284 assert!((word_box.x + word_box.width - handles[1].line_center.x).abs() < 0.5);
3285 }
3286
3287 #[test]
3288 fn enter_submits_without_changing_value() {
3289 let inp = input("acheter du lait").on_submit(Msg::Submitted);
3290 let mut edit = Edit {
3291 cursor: 3,
3292 anchor: None,
3293 composing: None,
3294 };
3295 assert_eq!(inp.on_edit(&mut edit, &Key::Enter), Some(Msg::Submitted));
3297 assert_eq!(edit.cursor, 3); }
3299
3300 #[test]
3301 fn enter_without_submit_is_noop() {
3302 let inp = input("x");
3303 let mut edit = Edit {
3304 cursor: 1,
3305 anchor: None,
3306 composing: None,
3307 };
3308 assert_eq!(inp.on_edit(&mut edit, &Key::Enter), None);
3309 }
3310
3311 fn drawn_text_x(field: &TextField<()>, width: f32) -> f32 {
3313 let mut scene = Scene::new();
3314 Widget::<()>::paint(
3315 field,
3316 Rect::new(0.0, 0.0, width, 56.0),
3317 Status {
3318 focused: false,
3319 ..Default::default()
3320 },
3321 &Theme::default(),
3322 &mut scene,
3323 );
3324 scene
3325 .primitives()
3326 .iter()
3327 .find_map(|p| match p {
3328 frus_core::Primitive::Text { position, text, .. } if text == "42" => {
3329 Some(position.x)
3330 }
3331 _ => None,
3332 })
3333 .expect("the value is drawn")
3334 }
3335
3336 #[test]
3338 fn a_field_that_says_nothing_starts_at_the_left() {
3339 let plain: TextField<()> = TextField::new("42");
3340 let started: TextField<()> = TextField::new("42").text_align(TextAlign::Start);
3341 assert_eq!(drawn_text_x(&plain, 300.0), drawn_text_x(&started, 300.0));
3342 }
3343
3344 #[test]
3346 fn the_text_sits_where_it_was_told_to() {
3347 let left = drawn_text_x(&TextField::<()>::new("42"), 300.0);
3348 let centre = drawn_text_x(
3349 &TextField::<()>::new("42").text_align(TextAlign::Center),
3350 300.0,
3351 );
3352 let right = drawn_text_x(
3353 &TextField::<()>::new("42").text_align(TextAlign::Right),
3354 300.0,
3355 );
3356 assert!(left < centre, "centred is further right than left-aligned");
3357 assert!(centre < right, "right-aligned is further right again");
3358 assert!(
3360 ((centre - left) - (right - centre)).abs() < 0.5,
3361 "halfway: {left} {centre} {right}"
3362 );
3363 }
3364
3365 #[test]
3369 fn a_click_lands_where_the_glyphs_are() {
3370 let width = 300.0;
3371 for align in [TextAlign::Left, TextAlign::Center, TextAlign::Right] {
3372 let field: TextField<()> = TextField::new("42").text_align(align);
3373 let x = drawn_text_x(&field, width);
3374 let before = Widget::<()>::cursor_at(&field, x - 1.0, 20.0, width, 0);
3376 assert_eq!(before, Some(0), "{align:?}: before the first character");
3377 let text_w = frus_text::measure("42", field.size).width;
3379 let after = Widget::<()>::cursor_at(&field, x + text_w + 1.0, 20.0, width, 0);
3380 assert_eq!(after, Some(2), "{align:?}: after the last character");
3381 }
3382 }
3383
3384 #[test]
3388 fn text_wider_than_the_field_is_not_pushed_off_it() {
3389 let long: TextField<()> = TextField::new("42").text_align(TextAlign::Right);
3390 assert_eq!(long.align_offset(4.0, 40.0), 0.0);
3392 assert_eq!(long.align_offset(40.0, 40.0), 0.0);
3393 }
3394
3395 #[test]
3400 fn a_multiline_field_is_not_aligned() {
3401 let note: TextField<()> = TextField::new("42")
3402 .multiline()
3403 .text_align(TextAlign::Center);
3404 assert_eq!(note.align_offset(300.0, 40.0), 0.0);
3405 let one_line: TextField<()> = TextField::new("42").text_align(TextAlign::Center);
3407 assert_eq!(one_line.align_offset(300.0, 40.0), 130.0);
3408 }
3409
3410 #[test]
3413 fn the_placeholder_is_aligned_like_the_value() {
3414 let hint_x = |align: TextAlign| {
3415 let field: TextField<()> = TextField::new("").placeholder("Amount").text_align(align);
3416 let mut scene = Scene::new();
3417 Widget::<()>::paint(
3418 &field,
3419 Rect::new(0.0, 0.0, 300.0, 56.0),
3420 Status::default(),
3421 &Theme::default(),
3422 &mut scene,
3423 );
3424 scene
3425 .primitives()
3426 .iter()
3427 .find_map(|p| match p {
3428 frus_core::Primitive::Text { position, text, .. } if text == "Amount" => {
3429 Some(position.x)
3430 }
3431 _ => None,
3432 })
3433 .expect("the hint is drawn")
3434 };
3435 assert!(hint_x(TextAlign::Right) > hint_x(TextAlign::Center));
3436 assert!(hint_x(TextAlign::Center) > hint_x(TextAlign::Left));
3437 }
3438
3439 #[test]
3441 fn a_filter_drops_what_it_will_not_take() {
3442 let field: TextField<String> = TextField::new("").digits_only().on_input(|v| v);
3443 let mut edit = Edit::default();
3444 let out = Widget::on_edit(&field, &mut edit, &Key::Text("12 34-56".into()));
3446 assert_eq!(out.as_deref(), Some("123456"));
3447 assert_eq!(edit.cursor, 6, "the caret is past what actually arrived");
3448 }
3449
3450 #[test]
3453 fn a_refused_keystroke_says_nothing() {
3454 let field: TextField<String> = TextField::new("42").digits_only().on_input(|v| v);
3455 let mut edit = Edit {
3456 cursor: 2,
3457 ..Default::default()
3458 };
3459 assert_eq!(
3460 Widget::on_edit(&field, &mut edit, &Key::Text("x".into())),
3461 None
3462 );
3463 assert_eq!(edit.cursor, 2, "and the caret has not moved");
3464 }
3465
3466 #[test]
3468 fn a_refused_keystroke_over_a_selection_still_clears_it() {
3469 let field: TextField<String> = TextField::new("1234").digits_only().on_input(|v| v);
3470 let mut edit = Edit {
3471 cursor: 3,
3472 anchor: Some(1),
3473 ..Default::default()
3474 };
3475 let out = Widget::on_edit(&field, &mut edit, &Key::Text("x".into()));
3476 assert_eq!(out.as_deref(), Some("14"));
3477 assert_eq!(edit.cursor, 1);
3478 }
3479
3480 #[test]
3483 fn a_filter_can_substitute() {
3484 let field: TextField<String> = TextField::new("")
3485 .input_filter(|c| c.to_uppercase().next())
3486 .on_input(|v| v);
3487 let mut edit = Edit::default();
3488 let out = Widget::on_edit(&field, &mut edit, &Key::Text("ab".into()));
3489 assert_eq!(out.as_deref(), Some("AB"));
3490 assert_eq!(edit.cursor, 2);
3491 }
3492
3493 #[test]
3496 fn a_filter_does_not_rewrite_what_the_caller_gave() {
3497 let field: TextField<String> = TextField::new("a1b2").digits_only().on_input(|v| v);
3498 let mut edit = Edit {
3499 cursor: 4,
3500 ..Default::default()
3501 };
3502 let out = Widget::on_edit(&field, &mut edit, &Key::Text("3".into()));
3503 assert_eq!(
3504 out.as_deref(),
3505 Some("a1b23"),
3506 "only the keystroke is filtered"
3507 );
3508 }
3509
3510 #[test]
3512 fn digits_only_asks_for_a_keypad_without_overruling_one() {
3513 let plain: TextField<()> = TextField::new("").digits_only();
3514 assert_eq!(Widget::<()>::ime(&plain).keyboard, KeyboardType::Number);
3515 let before: TextField<()> = TextField::new("")
3516 .keyboard_type(KeyboardType::Phone)
3517 .digits_only();
3518 assert_eq!(Widget::<()>::ime(&before).keyboard, KeyboardType::Phone);
3519 let after: TextField<()> = TextField::new("")
3520 .digits_only()
3521 .keyboard_type(KeyboardType::Phone);
3522 assert_eq!(Widget::<()>::ime(&after).keyboard, KeyboardType::Phone);
3523 }
3524
3525 #[test]
3527 fn the_capitalisation_travels_with_the_field() {
3528 let field: TextField<()> = TextField::new("").capitalization(Capitalization::Characters);
3529 let ime = Widget::<()>::ime(&field);
3530 assert_eq!(ime.capitalization, Capitalization::Characters);
3531 assert_eq!(ime.android_input_type() & 0x0000_7000, 0x0000_1000);
3532 }
3533
3534 #[test]
3537 fn a_plain_field_asks_for_the_plain_keyboard() {
3538 let field: TextField<()> = TextField::new("hello");
3539 assert_eq!(
3540 Widget::<()>::ime(&field),
3541 Ime {
3542 keyboard: KeyboardType::Text,
3543 action: TextInputAction::Done,
3544 capitalization: Capitalization::Auto,
3545 }
3546 );
3547 }
3548
3549 #[test]
3554 fn an_obscured_field_tells_the_keyboard_it_is_a_secret() {
3555 let field: TextField<()> = TextField::new("hunter2").obscure(true);
3556 let ime = Widget::<()>::ime(&field);
3557 assert_eq!(ime.keyboard, KeyboardType::Password);
3558 assert!(ime.keyboard.is_secret());
3559 assert_eq!(ime.keyboard.android_input_type() & 0x0000_0ff0, 0x80);
3561 }
3562
3563 #[test]
3566 fn a_multiline_field_takes_a_newline_key() {
3567 let field: TextField<()> = TextField::new("a note").multiline();
3568 let ime = Widget::<()>::ime(&field);
3569 assert_eq!(ime.keyboard, KeyboardType::Multiline);
3570 assert_eq!(ime.action, TextInputAction::Newline);
3571 let rowed: TextField<()> = TextField::new("a note").rows(4);
3573 assert_eq!(Widget::<()>::ime(&rowed).keyboard, KeyboardType::Multiline);
3574 }
3575
3576 #[test]
3578 fn what_the_caller_says_wins() {
3579 let field: TextField<()> = TextField::new("")
3580 .obscure(true)
3581 .keyboard_type(KeyboardType::VisiblePassword);
3582 assert_eq!(
3583 Widget::<()>::ime(&field).keyboard,
3584 KeyboardType::VisiblePassword
3585 );
3586
3587 let query: TextField<()> = TextField::new("").action(TextInputAction::Search);
3589 let ime = Widget::<()>::ime(&query);
3590 assert_eq!(ime.keyboard, KeyboardType::Text);
3591 assert_eq!(ime.action, TextInputAction::Search);
3592
3593 let note: TextField<()> = TextField::new("").multiline().action(TextInputAction::Done);
3595 assert_eq!(Widget::<()>::ime(¬e).action, TextInputAction::Done);
3596 }
3597
3598 #[test]
3601 fn a_typed_field_gets_the_keyboard_for_it() {
3602 let phone: TextField<()> = TextField::new("").keyboard_type(KeyboardType::Phone);
3603 assert_eq!(Widget::<()>::ime(&phone).keyboard.android_input_type(), 3);
3605
3606 let email: TextField<()> = TextField::new("").keyboard_type(KeyboardType::Email);
3607 let bits = Widget::<()>::ime(&email).keyboard.android_input_type();
3608 assert_eq!(bits, 1 | 0x20);
3611 }
3612
3613 #[test]
3614 fn obscure_masks_the_displayed_text_but_not_the_value() {
3615 let theme = Theme::default();
3618 let field = TextField::<Msg>::new("secret").obscure(true);
3619 let mut scene = Scene::new();
3620 let status = Status {
3621 focused: true,
3622 cursor: Some(6),
3623 ..Default::default()
3624 };
3625 Widget::<Msg>::paint(
3626 &field,
3627 Rect::new(0.0, 0.0, 220.0, 30.0),
3628 status,
3629 &theme,
3630 &mut scene,
3631 );
3632 let drawn: Vec<&str> = scene
3633 .primitives()
3634 .iter()
3635 .filter_map(|p| match p {
3636 frus_core::Primitive::Text { text, .. } => Some(text.as_str()),
3637 _ => None,
3638 })
3639 .collect();
3640 assert!(
3641 drawn.iter().all(|t| !t.contains("secret")),
3642 "the value must not leak"
3643 );
3644 assert!(
3645 drawn.iter().any(|t| t.chars().all(|c| c == '•')),
3646 "dots are drawn"
3647 );
3648 assert_eq!(Widget::<Msg>::text_value(&field), Some("secret"));
3650 }
3651
3652 #[test]
3653 fn prefix_icon_draws_a_path_and_shifts_the_hit_test() {
3654 let theme = Theme::default();
3657 let with_icon = input("hello world").prefix_icon(Icons::STAR);
3658 let mut scene = Scene::new();
3659 Widget::<Msg>::paint(
3660 &with_icon,
3661 Rect::new(0.0, 0.0, 220.0, 30.0),
3662 Status::default(),
3663 &theme,
3664 &mut scene,
3665 );
3666 assert!(
3667 scene
3668 .primitives()
3669 .iter()
3670 .any(|p| matches!(p, frus_core::Primitive::Path { .. })),
3671 "the prefix icon draws a path"
3672 );
3673 let plain = input("hello world");
3674 let at_plain =
3675 Widget::<Msg>::cursor_at(&plain, 60.0, FIELD_OUTLINED_PADDING_TOP, 220.0, 0).unwrap();
3676 let at_icon =
3677 Widget::<Msg>::cursor_at(&with_icon, 60.0, FIELD_OUTLINED_PADDING_TOP, 220.0, 0)
3678 .unwrap();
3679 assert!(
3680 at_icon < at_plain,
3681 "the prefix offsets the content ({at_plain} → {at_icon})"
3682 );
3683 }
3684
3685 #[test]
3686 fn multiline_enter_inserts_a_newline_instead_of_submitting() {
3687 let inp = TextField::<Msg>::new("ab")
3689 .on_input(Msg::Changed)
3690 .multiline();
3691 let mut edit = Edit {
3692 cursor: 2,
3693 anchor: None,
3694 composing: None,
3695 };
3696 assert_eq!(
3697 inp.on_edit(&mut edit, &Key::Enter),
3698 Some(Msg::Changed("ab\n".to_string()))
3699 );
3700 assert_eq!(edit.cursor, 3);
3701 let single = input("ab").on_submit(Msg::Submitted);
3703 let mut edit = Edit {
3704 cursor: 2,
3705 anchor: None,
3706 composing: None,
3707 };
3708 assert_eq!(single.on_edit(&mut edit, &Key::Enter), Some(Msg::Submitted));
3709 }
3710
3711 #[test]
3712 fn multiline_wraps_long_lines_to_the_width() {
3713 let long = "word ".repeat(30); let inp = TextField::<Msg>::new(long.trim_end())
3718 .on_input(Msg::Changed)
3719 .rows(4)
3720 .width(160.0);
3721 let line_h = inp.text_style().line_height();
3722 let top = Widget::<Msg>::cursor_at(
3723 &inp,
3724 FIELD_PADDING_X + 2.0,
3725 FIELD_OUTLINED_PADDING_TOP + 1.0,
3726 160.0,
3727 0,
3728 )
3729 .unwrap();
3730 let wrapped = Widget::<Msg>::cursor_at(
3731 &inp,
3732 FIELD_PADDING_X + 2.0,
3733 FIELD_OUTLINED_PADDING_TOP + line_h * 2.0 + 1.0,
3734 160.0,
3735 0,
3736 )
3737 .unwrap();
3738 assert!(top < 10, "1st line: {top}");
3739 assert!(
3740 wrapped > top,
3741 "a wrapped line further down → an index further on ({top} → {wrapped})"
3742 );
3743 }
3744
3745 #[test]
3746 fn multiline_reports_overflow_and_scrolls_content() {
3747 let inp = TextField::<Msg>::new("l1\nl2\nl3\nl4\nl5")
3749 .on_input(Msg::Changed)
3750 .rows(2)
3751 .width(200.0);
3752 let (content_h, visible_h, _, _) =
3753 Widget::<Msg>::text_metrics(&inp, 200.0, 0).expect("a multi-line field");
3754 assert!(
3755 content_h > visible_h + 1.0,
3756 "5 lines > a box of 2 ({content_h} vs {visible_h})"
3757 );
3758
3759 let text_top = |scroll: f32| {
3761 let mut scene = Scene::new();
3762 let status = Status {
3763 focused: true,
3764 cursor: Some(0),
3765 scroll_y: scroll,
3766 ..Default::default()
3767 };
3768 Widget::<Msg>::paint(
3769 &inp,
3770 Rect::new(0.0, 0.0, 200.0, 80.0),
3771 status,
3772 &Theme::default(),
3773 &mut scene,
3774 );
3775 scene
3776 .primitives()
3777 .iter()
3778 .find_map(|p| match p {
3779 frus_core::Primitive::Text { text, position, .. } if text.contains("l1") => {
3780 Some(position.y)
3781 }
3782 _ => None,
3783 })
3784 .expect("the field's text")
3785 };
3786 assert!(
3787 text_top(20.0) < text_top(0.0),
3788 "scrolling raises the content"
3789 );
3790 }
3791
3792 #[test]
3793 fn multiline_arrows_move_the_caret_between_lines() {
3794 let inp = TextField::<Msg>::new("abc\ndefg\nhi")
3796 .on_input(Msg::Changed)
3797 .rows(3)
3798 .width(200.0);
3799 let (down, _) = Widget::<Msg>::caret_vertical(&inp, 200.0, 1, true, false, None).unwrap();
3801 assert!(
3802 (4..=8).contains(&down),
3803 "moves down to the 2nd line: {down}"
3804 );
3805 assert_eq!(
3807 Widget::<Msg>::caret_vertical(&inp, 200.0, 1, false, false, None),
3808 None
3809 );
3810 assert_eq!(
3812 Widget::<Msg>::caret_vertical(&inp, 200.0, 10, true, false, None),
3813 None
3814 );
3815 let (up, _) = Widget::<Msg>::caret_vertical(&inp, 200.0, 5, false, false, None).unwrap();
3817 assert!(up <= 3, "moves up to the 1st line: {up}");
3818 assert_eq!(
3820 Widget::<Msg>::caret_vertical(&input("abc"), 200.0, 1, true, false, None),
3821 None
3822 );
3823 }
3824
3825 #[test]
3826 fn multiline_goal_column_survives_a_short_line() {
3827 let inp = TextField::<Msg>::new("hello\nhi\nworld")
3832 .on_input(Msg::Changed)
3833 .rows(3)
3834 .width(200.0);
3835 let (mid, goal) = Widget::<Msg>::caret_vertical(&inp, 200.0, 5, true, false, None).unwrap();
3836 assert!(
3837 (6..=8).contains(&mid),
3838 "a short line, clamped to its end: {mid}"
3839 );
3840 let (low, _) =
3844 Widget::<Msg>::caret_vertical(&inp, 200.0, mid, true, false, Some(goal)).unwrap();
3845 assert!(
3846 (12..=14).contains(&low),
3847 "goal column preserved into \"world\": {low}"
3848 );
3849 }
3850
3851 #[test]
3852 fn multiline_page_jump_is_clamped_to_the_field() {
3853 let inp = TextField::<Msg>::new("a\nb\nc\nd\ne")
3856 .on_input(Msg::Changed)
3857 .rows(2)
3858 .width(200.0);
3859 let (bottom, _) = Widget::<Msg>::caret_vertical(&inp, 200.0, 8, true, true, None).unwrap();
3861 assert!(
3862 bottom >= 7,
3863 "PgDn clamped to the bottom of the field: {bottom}"
3864 );
3865 let (top, _) = Widget::<Msg>::caret_vertical(&inp, 200.0, 0, false, true, None).unwrap();
3867 assert!(top <= 1, "PgUp clamped to the top of the field: {top}");
3868 }
3869
3870 #[test]
3871 fn multiline_reserves_rows_of_height() {
3872 let one = match Widget::<Msg>::style(&input("x")).height {
3874 Dimension::Length(h) => h,
3875 _ => panic!("a fixed height"),
3876 };
3877 let four = match Widget::<Msg>::style(&TextField::<Msg>::new("x").rows(4)).height {
3878 Dimension::Length(h) => h,
3879 _ => panic!("a fixed height"),
3880 };
3881 assert!(
3882 four > one * 2.0,
3883 "4 lines much taller than one ({one} → {four})"
3884 );
3885 }
3886
3887 #[test]
3888 fn multiline_hit_test_uses_the_click_line() {
3889 let inp = TextField::<Msg>::new("ab\ncd")
3892 .on_input(Msg::Changed)
3893 .rows(3);
3894 let line_h = inp.text_style().line_height();
3895 let top = Widget::<Msg>::cursor_at(
3897 &inp,
3898 FIELD_PADDING_X + 1.0,
3899 FIELD_OUTLINED_PADDING_TOP + 1.0,
3900 220.0,
3901 0,
3902 )
3903 .unwrap();
3904 let below = Widget::<Msg>::cursor_at(
3906 &inp,
3907 FIELD_PADDING_X + 1.0,
3908 FIELD_OUTLINED_PADDING_TOP + line_h + 1.0,
3909 220.0,
3910 0,
3911 )
3912 .unwrap();
3913 assert!(top <= 2, "1st line: {top}");
3914 assert!(below >= 3, "2nd line: {below}");
3915 }
3916
3917 #[test]
3918 fn floating_label_rests_in_box_then_floats_up() {
3919 let theme = Theme::default();
3922 let field = TextField::<Msg>::new("").label("Name");
3923 let label_geo = |status: Status| -> (f32, f32) {
3924 let mut scene = Scene::new();
3925 Widget::<Msg>::paint(
3926 &field,
3927 Rect::new(0.0, 30.0, 220.0, 60.0),
3928 status,
3929 &theme,
3930 &mut scene,
3931 );
3932 scene
3933 .primitives()
3934 .iter()
3935 .find_map(|p| match p {
3936 frus_core::Primitive::Text {
3937 text,
3938 size,
3939 position,
3940 ..
3941 } if text == "Name" => Some((*size, position.y)),
3942 _ => None,
3943 })
3944 .expect("a painted label")
3945 };
3946 let (rest_size, rest_y) = label_geo(Status::default());
3947 let (float_size, float_y) = label_geo(Status {
3948 focused: true,
3949 focus_progress: 1.0,
3950 ..Default::default()
3951 });
3952 assert!(
3953 rest_size > float_size,
3954 "at rest the label is larger ({rest_size} → {float_size})"
3955 );
3956 assert!(
3957 float_y < rest_y,
3958 "once focused the label rises ({rest_y} → {float_y})"
3959 );
3960 }
3961
3962 #[test]
3973 fn an_errored_field_deepens_under_the_pointer() {
3974 let theme = Theme::default();
3975 let field = input("x").label("Name").error("Required").outlined();
3976 let painted = |status: Status| {
3977 let mut scene = Scene::new();
3978 Widget::<Msg>::paint(
3979 &field,
3980 Rect::new(0.0, 0.0, 220.0, 90.0),
3981 status,
3982 &theme,
3983 &mut scene,
3984 );
3985 scene
3986 };
3987 let border = |status: Status| {
3989 painted(status)
3990 .primitives()
3991 .iter()
3992 .find_map(|p| match p {
3993 frus_core::Primitive::Rect {
3994 border_color,
3995 border_width,
3996 ..
3997 } if *border_width > 0.0 => Some(*border_color),
3998 _ => None,
3999 })
4000 .expect("an outlined field draws a border")
4001 };
4002 let message = |status: Status| {
4003 painted(status)
4004 .primitives()
4005 .iter()
4006 .find_map(|p| match p {
4007 frus_core::Primitive::Text { text, color, .. } if text == "Required" => {
4008 Some(*color)
4009 }
4010 _ => None,
4011 })
4012 .expect("the message is painted")
4013 };
4014 let rest = Status {
4015 opacity: 1.0,
4016 ..Default::default()
4017 };
4018 let hovered = Status {
4019 hover_progress: 1.0,
4020 ..rest
4021 };
4022 let both = Status {
4023 focused: true,
4024 focus_progress: 1.0,
4025 ..hovered
4026 };
4027
4028 assert_eq!(border(rest), theme.scheme.error);
4029 assert_eq!(
4030 border(hovered),
4031 theme.scheme.on_error_container,
4032 "it deepens under the pointer"
4033 );
4034 assert_eq!(
4035 border(both),
4036 theme.scheme.error,
4037 "and comes back once focused, which the reference tests first"
4038 );
4039 assert_ne!(
4040 theme.scheme.error, theme.scheme.on_error_container,
4041 "the two have to differ for any of the above to mean anything"
4042 );
4043
4044 for status in [rest, hovered, both] {
4045 assert_eq!(
4046 message(status),
4047 theme.scheme.error,
4048 "the message is a sentence, not a control"
4049 );
4050 }
4051 }
4052
4053 #[test]
4054 fn decoration_grows_height_for_label_and_error() {
4055 let bare_h = match Widget::<Msg>::style(&input("x")).height {
4058 Dimension::Length(h) => h,
4059 _ => panic!("a fixed height was expected"),
4060 };
4061 let decorated = input("x").label("Email").error("Required");
4062 let deco_h = match Widget::<Msg>::style(&decorated).height {
4063 Dimension::Length(h) => h,
4064 _ => panic!("a fixed height was expected"),
4065 };
4066 assert!(
4067 deco_h > bare_h,
4068 "label + error grow the field ({bare_h} → {deco_h})"
4069 );
4070 }
4071
4072 #[test]
4073 fn error_paints_the_border_in_the_error_color() {
4074 let theme = Theme::default();
4076 let field = input("x").error("bad");
4077 let mut scene = Scene::new();
4078 Widget::<Msg>::paint(
4079 &field,
4080 Rect::new(0.0, 0.0, 220.0, 60.0),
4081 Status::default(),
4082 &theme,
4083 &mut scene,
4084 );
4085 let has_error_border = scene.primitives().iter().any(|p| {
4086 matches!(
4087 p,
4088 frus_core::Primitive::Rect { border_color, border_width, .. }
4089 if *border_width > 0.0 && *border_color == theme.error
4090 )
4091 });
4092 assert!(has_error_border, "the border must be in the error colour");
4093 }
4094
4095 #[test]
4096 fn placeholder_shows_only_when_empty() {
4097 let theme = Theme::default();
4099 let count_texts = |value: &str| {
4100 let field = TextField::<Msg>::new(value).placeholder("Type here");
4101 let mut scene = Scene::new();
4102 Widget::<Msg>::paint(
4103 &field,
4104 Rect::new(0.0, 0.0, 220.0, 30.0),
4105 Status::default(),
4106 &theme,
4107 &mut scene,
4108 );
4109 scene
4110 .primitives()
4111 .iter()
4112 .filter(
4113 |p| matches!(p, frus_core::Primitive::Text { text, .. } if text == "Type here"),
4114 )
4115 .count()
4116 };
4117 assert_eq!(count_texts(""), 1, "empty: the hint shows");
4118 assert_eq!(count_texts("hi"), 0, "rempli : pas d'indice");
4119 }
4120
4121 #[test]
4122 fn clickable_suffix_emits_and_blocks_caret() {
4123 let field = TextField::new("hello")
4124 .suffix_icon(Icons::CLOSE)
4125 .on_suffix(Msg::Submitted)
4126 .width(220.0);
4127 let (w, y) = (220.0, 12.0);
4128 let x_suffix = w - 8.0; assert_eq!(
4132 Widget::<Msg>::positional_click(&field, x_suffix, y, w, 40.0),
4133 Some(Msg::Submitted)
4134 );
4135 assert_eq!(Widget::<Msg>::cursor_at(&field, x_suffix, y, w, 0), None);
4136 assert_eq!(
4138 Widget::<Msg>::positional_click(&field, 20.0, y, w, 40.0),
4139 None
4140 );
4141 assert!(Widget::<Msg>::cursor_at(&field, 20.0, y, w, 0).is_some());
4142 let deco = TextField::<Msg>::new("hello")
4144 .suffix_icon(Icons::CLOSE)
4145 .width(220.0);
4146 assert_eq!(
4147 Widget::<Msg>::positional_click(&deco, x_suffix, y, w, 40.0),
4148 None
4149 );
4150 }
4151
4152 #[test]
4153 fn hovering_active_suffix_paints_a_halo() {
4154 let field = TextField::new("hello")
4155 .suffix_icon(Icons::CLOSE)
4156 .on_suffix(Msg::Submitted)
4157 .width(220.0);
4158 let bounds = Rect::new(0.0, 0.0, 220.0, 40.0);
4159 let halos = |status: Status| {
4161 let mut scene = Scene::new();
4162 Widget::<Msg>::paint(&field, bounds, status, &Theme::default(), &mut scene);
4163 scene
4164 .primitives()
4165 .iter()
4166 .filter(|p| match p {
4167 frus_core::Primitive::Rect { rect, .. } => {
4168 (rect.width - (FIELD_ICON_SIZE + 8.0)).abs() < 0.5
4169 && (rect.height - (FIELD_ICON_SIZE + 8.0)).abs() < 0.5
4170 }
4171 _ => false,
4172 })
4173 .count()
4174 };
4175 let over_suffix = Status {
4177 hover_cursor: Some(Point::new(212.0, 20.0)),
4178 ..Default::default()
4179 };
4180 let over_body = Status {
4181 hover_cursor: Some(Point::new(20.0, 20.0)),
4182 ..Default::default()
4183 };
4184 assert_eq!(halos(over_suffix), 1, "a halo on the hovered suffix");
4185 assert_eq!(halos(over_body), 0, "no halo in the body");
4186 assert_eq!(halos(Status::default()), 0, "no halo without hover");
4187 }
4188
4189 #[test]
4190 fn cursor_icon_is_pointer_over_active_suffix() {
4191 use crate::interaction::Cursor;
4192 let field = TextField::new("hello")
4193 .suffix_icon(Icons::CLOSE)
4194 .on_suffix(Msg::Submitted)
4195 .width(220.0);
4196 let (w, h, y) = (220.0, 40.0, 12.0);
4197 assert_eq!(
4199 Widget::<Msg>::cursor_icon(&field, w - 8.0, y, w, h),
4200 Some(Cursor::Pointer)
4201 );
4202 assert_eq!(Widget::<Msg>::cursor_icon(&field, 20.0, y, w, h), None);
4203 let deco = TextField::<Msg>::new("hello")
4205 .suffix_icon(Icons::CLOSE)
4206 .width(220.0);
4207 assert_eq!(Widget::<Msg>::cursor_icon(&deco, w - 8.0, y, w, h), None);
4208 }
4209
4210 #[test]
4216 fn each_handle_stands_for_its_own_end_of_the_selection() {
4217 let width = 220.0;
4218 let fields = [
4219 TextField::<Msg>::new("hello world").width(width),
4220 TextField::<Msg>::new("hello world")
4221 .label("Name")
4222 .prefix_icon(Icons::CLOSE)
4223 .width(width),
4224 TextField::<Msg>::new("hello world")
4226 .label("Name")
4227 .outlined()
4228 .width(width),
4229 TextField::<Msg>::new("a sentence long enough to be scrolled along its box")
4231 .width(width),
4232 ];
4233 for field in &fields {
4234 let len = field.value.chars().count();
4235 let edit = Edit {
4236 cursor: len,
4237 anchor: Some(len - 5),
4238 composing: None,
4239 };
4240 let [start, end] = Widget::<Msg>::selection_handles(field, width, &edit, 0.0)
4241 .expect("a selection has handles");
4242 for (handle, index) in [(start, len - 5), (end, len)] {
4243 let at = handle.line_center;
4244 assert_eq!(
4245 Widget::<Msg>::cursor_at(field, at.x, at.y, width, edit.cursor),
4246 Some(index),
4247 "{:?}",
4248 field.value
4249 );
4250 }
4251 assert_eq!(start.rect.x + start.rect.width, start.line_center.x);
4254 assert_eq!(end.rect.x, end.line_center.x);
4255 assert!(start.rect.y > start.line_center.y && end.rect.y > end.line_center.y);
4256 let mut scene = Scene::new();
4260 let status = Status {
4261 focused: true,
4262 cursor: Some(len),
4263 selection: Some((len - 5, len)),
4264 ..Status::default()
4265 };
4266 Widget::<Msg>::paint(
4267 field,
4268 Rect::new(0.0, 0.0, width, 120.0),
4269 status,
4270 &Theme::default(),
4271 &mut scene,
4272 );
4273 let top = scene
4274 .primitives()
4275 .iter()
4276 .find_map(|p| match p {
4277 frus_core::Primitive::Text { position, text, .. } if *text == field.value => {
4278 Some(position.y)
4279 }
4280 _ => None,
4281 })
4282 .expect("the value is painted");
4283 let half = start.line_center.y - top;
4284 assert!(
4285 half > 0.0 && (start.rect.y - top - 2.0 * half).abs() < 0.01,
4286 "{:?}: text at {top}, line centre at {}, handle at {}",
4287 field.value,
4288 start.line_center.y,
4289 start.rect.y
4290 );
4291 }
4292 }
4293
4294 #[test]
4297 fn a_handle_rises_with_the_lines_a_field_scrolls_away() {
4298 let width = 200.0;
4299 let field = TextField::<Msg>::new("one\ntwo\nthree\nfour\nfive\nsix")
4300 .multiline()
4301 .rows(3)
4302 .width(width);
4303 let edit = Edit {
4305 cursor: 23,
4306 anchor: Some(19),
4307 composing: None,
4308 };
4309 let scroll = 20.0;
4310 let still = Widget::<Msg>::selection_handles(&field, width, &edit, 0.0).unwrap();
4311 let [start, end] = Widget::<Msg>::selection_handles(&field, width, &edit, scroll).unwrap();
4312 assert!((still[0].line_center.y - start.line_center.y - scroll).abs() < 0.01);
4313 for (handle, index) in [(start, 19), (end, 23)] {
4314 let at = handle.line_center;
4315 assert_eq!(
4316 Widget::<Msg>::cursor_at(&field, at.x, at.y + scroll, width, edit.cursor),
4317 Some(index)
4318 );
4319 }
4320 }
4321
4322 #[test]
4326 fn the_handles_are_painted_for_a_touch_selection_only() {
4327 let theme = Theme::default();
4328 let fills = |field: &TextField<Msg>, handles: bool, focused: bool| {
4329 let mut scene = Scene::new();
4330 let status = Status {
4331 focused,
4332 cursor: Some(11),
4333 selection: Some((6, 11)),
4334 handles,
4335 ..Status::default()
4336 };
4337 Widget::<Msg>::paint(
4338 field,
4339 Rect::new(10.0, 20.0, 220.0, 56.0),
4340 status,
4341 &theme,
4342 &mut scene,
4343 );
4344 scene
4345 .primitives()
4346 .iter()
4347 .filter_map(|p| match p {
4348 frus_core::Primitive::Path { fill: Some(c), .. } => Some(*c),
4349 _ => None,
4350 })
4351 .collect::<Vec<_>>()
4352 };
4353 let field = TextField::<Msg>::new("hello world").width(220.0);
4354 assert_eq!(fills(&field, true, true), vec![theme.scheme.primary; 2]);
4355 assert!(fills(&field, false, true).is_empty(), "a mouse selection");
4356 assert!(fills(&field, true, false).is_empty(), "a field left behind");
4357 let styled = TextField::<Msg>::new("hello world")
4358 .width(220.0)
4359 .style(TextFieldStyle {
4360 handle_color: Some(frus_core::Color::WHITE),
4361 ..TextFieldStyle::default()
4362 });
4363 assert_eq!(fills(&styled, true, true), vec![frus_core::Color::WHITE; 2]);
4364 }
4365
4366 #[test]
4368 fn a_hidden_caret_is_not_painted() {
4369 let theme = Theme::default();
4370 let carets = |hidden: bool| {
4371 let mut scene = Scene::new();
4372 let status = Status {
4373 focused: true,
4374 cursor: Some(2),
4375 caret_hidden: hidden,
4376 ..Status::default()
4377 };
4378 Widget::<Msg>::paint(
4379 &TextField::<Msg>::new("hello").width(220.0),
4380 Rect::new(0.0, 0.0, 220.0, 56.0),
4381 status,
4382 &theme,
4383 &mut scene,
4384 );
4385 scene
4386 .primitives()
4387 .iter()
4388 .filter(
4389 |p| matches!(p, frus_core::Primitive::Rect { rect, .. } if rect.width == 2.0),
4390 )
4391 .count()
4392 };
4393 assert_eq!(carets(false), 1, "shown");
4394 assert_eq!(carets(true), 0, "hidden");
4395 }
4396
4397 #[test]
4398 fn word_at_finds_word_bounds() {
4399 let inp = input("hello there world");
4400 assert_eq!(inp.word_at(2), Some((0, 5))); assert_eq!(inp.word_at(13), Some((12, 17))); }
4404}
4405
4406#[cfg(test)]
4407mod limit_tests {
4408 use super::*;
4409 use crate::runtime::Edit;
4410
4411 #[derive(Clone, Debug, PartialEq)]
4412 enum Msg {
4413 Changed(String),
4414 }
4415
4416 fn field(value: &str) -> TextField<Msg> {
4417 TextField::new(value).on_input(Msg::Changed)
4418 }
4419
4420 fn typed(field: &TextField<Msg>, text: &str) -> (Option<Msg>, Edit) {
4422 let end = field.value.chars().count();
4423 let mut edit = Edit {
4424 cursor: end,
4425 anchor: None,
4426 ..Default::default()
4427 };
4428 let msg = Widget::on_edit(field, &mut edit, &Key::Text(text.to_string()));
4429 (msg, edit)
4430 }
4431
4432 fn value_of(msg: Option<Msg>) -> Option<String> {
4433 msg.map(|Msg::Changed(v)| v)
4434 }
4435
4436 #[test]
4438 fn a_keystroke_past_the_limit_goes_nowhere() {
4439 let full = field("abcde").max_length(5);
4440 assert_eq!(value_of(typed(&full, "f").0), Some("abcde".into()));
4441 let room = field("abcd").max_length(5);
4442 assert_eq!(value_of(typed(&room, "e").0), Some("abcde".into()));
4443 }
4444
4445 #[test]
4448 fn a_paste_that_crosses_the_limit_lands_what_fits() {
4449 let f = field("ab").max_length(5);
4450 let (msg, edit) = typed(&f, "cdefgh");
4451 assert_eq!(value_of(msg), Some("abcde".into()));
4452 assert_eq!(edit.cursor, 5, "the caret comes to rest at the end");
4453 }
4454
4455 #[test]
4457 fn the_limit_counts_characters_not_bytes() {
4458 let f = field("").max_length(3);
4459 assert_eq!(value_of(typed(&f, "éàü").0), Some("éàü".into()));
4460 let full = field("éàü").max_length(3);
4461 assert_eq!(value_of(typed(&full, "e").0), Some("éàü".into()));
4462 }
4463
4464 #[test]
4467 fn a_value_the_caller_set_over_the_limit_is_left_alone() {
4468 let over = field("abcdefg").max_length(5);
4469 assert_eq!(over.counter().as_deref(), Some("7/5"));
4470 assert_eq!(over.value, "abcdefg");
4471 }
4472
4473 #[test]
4475 fn the_counter_reserves_the_line_it_sits_on() {
4476 assert_eq!(
4477 field("ab").sub_block(),
4478 0.0,
4479 "nothing to say, no room taken"
4480 );
4481 assert!(field("ab").max_length(5).sub_block() > 0.0);
4482 assert_eq!(field("ab").max_length(5).counter().as_deref(), Some("2/5"));
4483 }
4484
4485 #[test]
4487 fn read_only_refuses_the_change_and_keeps_the_caret() {
4488 let f = field("hello").read_only();
4489 assert_eq!(typed(&f, "x").0, None, "typing goes nowhere");
4490
4491 let mut edit = Edit {
4492 cursor: 5,
4493 anchor: None,
4494 ..Default::default()
4495 };
4496 assert_eq!(
4497 Widget::on_edit(
4498 &f,
4499 &mut edit,
4500 &Key::Left {
4501 shift: false,
4502 word: false
4503 }
4504 ),
4505 None
4506 );
4507 assert_eq!(edit.cursor, 4, "but the caret still moves");
4508
4509 let mut edit = Edit {
4510 cursor: 5,
4511 anchor: None,
4512 ..Default::default()
4513 };
4514 Widget::on_edit(
4515 &f,
4516 &mut edit,
4517 &Key::Home {
4518 shift: true,
4519 doc: false,
4520 },
4521 );
4522 assert_eq!(
4523 (edit.cursor, edit.anchor),
4524 (0, Some(5)),
4525 "and a selection can still be made, so it can be copied"
4526 );
4527 }
4528
4529 #[test]
4531 fn read_only_refuses_a_deletion() {
4532 let f = field("hello").read_only();
4533 let mut edit = Edit {
4534 cursor: 5,
4535 anchor: None,
4536 ..Default::default()
4537 };
4538 assert_eq!(Widget::on_edit(&f, &mut edit, &Key::Backspace), None);
4539 assert_eq!(edit.cursor, 5);
4540 }
4541
4542 #[test]
4544 fn read_only_is_not_disabled() {
4545 let read = field("REF-4417").read_only();
4546 assert!(Widget::<Msg>::focusable(&read), "still in the tab order");
4547 assert!(
4548 Widget::<Msg>::cursor_at(&read, 20.0, 10.0, 200.0, 0).is_some(),
4549 "and it still takes a caret, so a reference can be selected and copied"
4550 );
4551
4552 let dead = field("REF-4417").enabled(false);
4553 assert!(!Widget::<Msg>::focusable(&dead));
4554 assert!(Widget::<Msg>::cursor_at(&dead, 20.0, 10.0, 200.0, 0).is_none());
4555 }
4556}
4557
4558#[cfg(test)]
4560mod decoration_tests {
4561 use super::*;
4562 use crate::Icons;
4563
4564 const W: f32 = 300.0;
4565
4566 fn texts(field: &TextField<()>, status: Status) -> Vec<(String, Point, Color)> {
4568 let mut scene = Scene::new();
4569 Widget::<()>::paint(
4570 field,
4571 Rect::new(0.0, 0.0, W, 80.0),
4572 status,
4573 &Theme::default(),
4574 &mut scene,
4575 );
4576 scene
4577 .primitives()
4578 .iter()
4579 .filter_map(|p| match p {
4580 frus_core::Primitive::Text {
4581 text,
4582 position,
4583 color,
4584 ..
4585 } => Some((text.clone(), *position, *color)),
4586 _ => None,
4587 })
4588 .collect()
4589 }
4590
4591 fn find(texts: &[(String, Point, Color)], what: &str) -> Option<(Point, Color)> {
4592 texts
4593 .iter()
4594 .find(|(text, ..)| text == what)
4595 .map(|(_, at, color)| (*at, *color))
4596 }
4597
4598 fn focused() -> Status {
4599 Status {
4600 focused: true,
4601 focus_progress: 1.0,
4602 ..Default::default()
4603 }
4604 }
4605
4606 #[test]
4610 fn the_label_floats_as_it_is_told() {
4611 let label_y =
4612 |field: &TextField<()>, status| find(&texts(field, status), "Name").map(|(at, _)| at.y);
4613 let auto = TextField::<()>::new("").label("Name");
4614 let rest = label_y(&auto, Status::default()).unwrap();
4615 let floated = label_y(&TextField::new("Ada").label("Name"), Status::default()).unwrap();
4616 assert!(
4617 floated < rest,
4618 "auto floats once filled: {floated} < {rest}"
4619 );
4620
4621 let always = TextField::<()>::new("")
4622 .label("Name")
4623 .placeholder("e.g. Ada")
4624 .floating_label_behavior(FloatingLabelBehavior::Always);
4625 let shown = texts(&always, Status::default());
4626 assert_eq!(
4627 find(&shown, "Name").unwrap().0.y,
4628 floated,
4629 "floated at rest"
4630 );
4631 assert!(find(&shown, "e.g. Ada").is_some(), "and the hint shows");
4632
4633 let never = |value: &str| {
4634 TextField::<()>::new(value)
4635 .label("Name")
4636 .placeholder("e.g. Ada")
4637 .floating_label_behavior(FloatingLabelBehavior::Never)
4638 };
4639 assert_eq!(
4640 label_y(&never(""), focused()),
4641 Some(rest),
4642 "inside, even focused"
4643 );
4644 assert!(
4645 find(&texts(&never(""), focused()), "e.g. Ada").is_none(),
4646 "no hint"
4647 );
4648 assert_eq!(
4649 label_y(&never("Ada"), Status::default()),
4650 None,
4651 "gone once filled"
4652 );
4653
4654 let mut theme = Theme::default();
4655 theme.widgets.text_field.floating_label_behavior = Some(FloatingLabelBehavior::Always);
4656 let mut scene = Scene::new();
4657 Widget::<()>::paint(
4658 &TextField::<()>::new("").label("Name"),
4659 Rect::new(0.0, 0.0, W, 80.0),
4660 Status::default(),
4661 &theme,
4662 &mut scene,
4663 );
4664 let themed = scene.primitives().iter().find_map(|p| match p {
4665 frus_core::Primitive::Text { text, position, .. } if text == "Name" => Some(position.y),
4666 _ => None,
4667 });
4668 assert_eq!(themed, Some(floated), "the theme's word");
4669 }
4670
4671 #[test]
4673 fn a_centred_label_floats_to_the_middle() {
4674 let x = |value: &str| {
4675 let field = TextField::<()>::new(value)
4676 .label("Name")
4677 .floating_label_alignment(FloatingLabelAlignment::Center);
4678 find(&texts(&field, Status::default()), "Name").unwrap().0.x
4679 };
4680 let width = frus_text::measure_resolved(
4681 "Name",
4682 &ResolvedTextStyle::exact(FIELD_TEXT_SIZE * FIELD_LABEL_SCALE),
4683 )
4684 .width;
4685 assert!(((x("Ada") + width / 2.0) - W / 2.0).abs() < 0.5, "centred");
4686 let rest = find(
4687 &texts(&TextField::<()>::new("").label("Name"), Status::default()),
4688 "Name",
4689 )
4690 .unwrap()
4691 .0
4692 .x;
4693 assert_eq!(x(""), rest, "at rest, where the value starts");
4694 }
4695
4696 #[test]
4699 fn the_affixes_sit_either_side_of_the_value() {
4700 let theme = Theme::default();
4701 let plain = TextField::<()>::new("42");
4702 let priced = TextField::<()>::new("42")
4703 .prefix_text("$")
4704 .suffix_text("kg");
4705 let shown = texts(&priced, Status::default());
4706 let (dollar, colour) = find(&shown, "$").expect("the prefix");
4707 let (value, _) = find(&shown, "42").unwrap();
4708 let (unit, _) = find(&shown, "kg").expect("the suffix");
4709 assert_eq!(colour, theme.muted, "the hint's colour");
4710 let plain_x = find(&texts(&plain, Status::default()), "42").unwrap().0.x;
4711 let style = TextStyle::new(FIELD_TEXT_SIZE).resolved();
4712 let dollar_w = frus_text::measure_resolved("$", &style).width;
4713 assert_eq!(dollar.x, plain_x, "the prefix where the value was");
4714 assert!(
4715 (value.x - (plain_x + dollar_w)).abs() < 1e-3,
4716 "the value after it"
4717 );
4718 let unit_w = frus_text::measure_resolved("kg", &style).width;
4719 assert!(
4720 unit.x + unit_w <= W && unit.x > value.x,
4721 "the suffix at the end"
4722 );
4723
4724 let at = Widget::<()>::cursor_at(&priced, value.x + 0.5, 30.0, W, 0);
4726 assert_eq!(at, Some(0));
4727
4728 let labelled = TextField::<()>::new("").label("Price").prefix_text("$");
4730 assert!(find(&texts(&labelled, Status::default()), "$").is_none());
4731 assert!(find(&texts(&labelled, focused()), "$").is_some());
4732 let without = TextField::<()>::new("").label("Price");
4735 assert_eq!(
4736 find(&texts(&labelled, Status::default()), "Price")
4737 .unwrap()
4738 .0
4739 .x,
4740 find(&texts(&without, Status::default()), "Price")
4741 .unwrap()
4742 .0
4743 .x
4744 );
4745 let bare = TextField::<()>::new("").prefix_text("$");
4746 assert!(
4747 find(&texts(&bare, Status::default()), "$").is_none(),
4748 "no label: focus"
4749 );
4750 assert!(find(&texts(&bare, focused()), "$").is_some());
4751 }
4752
4753 #[test]
4756 fn the_outer_icon_moves_the_box_over() {
4757 let plain = TextField::<()>::new("42").helper("Help");
4758 let iconed = TextField::<()>::new("42").helper("Help").icon(Icons::STAR);
4759 let before = texts(&plain, Status::default());
4760 let after = texts(&iconed, Status::default());
4761 for what in ["42", "Help"] {
4762 let shift = find(&after, what).unwrap().0.x - find(&before, what).unwrap().0.x;
4763 assert_eq!(shift, 40.0, "{what} moves over by 24 and 16");
4764 }
4765 let dense = TextField::<()>::new("42").dense(true).icon(Icons::STAR);
4766 let dense_plain = TextField::<()>::new("42").dense(true);
4767 let shift = find(&texts(&dense, Status::default()), "42").unwrap().0.x
4768 - find(&texts(&dense_plain, Status::default()), "42")
4769 .unwrap()
4770 .0
4771 .x;
4772 assert_eq!(shift, 34.0, "18 and 16, dense");
4773 let x = find(&after, "42").unwrap().0.x;
4774 assert_eq!(
4775 Widget::<()>::cursor_at(&iconed, x + 0.5, 30.0, W, 0),
4776 Some(0)
4777 );
4778 let boxes: Vec<Rect> = {
4780 let mut scene = Scene::new();
4781 Widget::<()>::paint(
4782 &iconed,
4783 Rect::new(0.0, 0.0, W, 80.0),
4784 Status::default(),
4785 &Theme::default(),
4786 &mut scene,
4787 );
4788 scene
4789 .primitives()
4790 .iter()
4791 .filter_map(|p| match p {
4792 frus_core::Primitive::Rect { rect, .. } => Some(*rect),
4793 _ => None,
4794 })
4795 .collect()
4796 };
4797 assert_eq!(boxes[0].x, 40.0);
4798 assert_eq!(boxes[0].width, W - 40.0);
4799 }
4800
4801 #[test]
4803 fn the_counter_says_what_it_is_told() {
4804 let counted = TextField::<()>::new("abc").max_length(10);
4805 assert!(find(&texts(&counted, Status::default()), "3/10").is_some());
4806 let told = TextField::<()>::new("abc")
4807 .max_length(10)
4808 .counter_text("3 of 10");
4809 let shown = texts(&told, Status::default());
4810 assert!(find(&shown, "3 of 10").is_some() && find(&shown, "3/10").is_none());
4811 let hidden = TextField::<()>::new("abc").max_length(10).counter_text("");
4812 assert!(find(&texts(&hidden, Status::default()), "3/10").is_none());
4813 assert_eq!(hidden.sub_block(), 0.0, "and its line is not kept");
4814 let alone = TextField::<()>::new("abc").counter_text("Optional");
4815 assert!(find(&texts(&alone, Status::default()), "Optional").is_some());
4816 assert!(alone.sub_block() > 0.0);
4817 }
4818}
4819
4820#[cfg(test)]
4822mod lines_tests {
4823 use super::*;
4824
4825 const W: f32 = 300.0;
4826
4827 fn words(n: usize) -> String {
4829 vec!["word"; n].join(" ")
4830 }
4831
4832 fn lines(field: &TextField<()>) -> f32 {
4833 field.lines_at(W)
4834 }
4835
4836 #[test]
4839 fn a_box_grows_from_one_line_to_the_most() {
4840 let empty = TextField::<()>::new("").max_lines(4);
4841 assert!(empty.grows());
4842 assert_eq!(lines(&empty), 1.0, "one line, empty");
4843 let short = TextField::<()>::new(words(2)).max_lines(4);
4844 assert_eq!(lines(&short), 1.0);
4845 let wrapped = TextField::<()>::new(words(40)).max_lines(4);
4846 assert_eq!(lines(&wrapped), 4.0, "held at the most");
4847 let some = TextField::<()>::new(words(14)).max_lines(4);
4848 let n = lines(&some);
4849 assert!(n > 1.0 && n < 4.0, "in between: {n}");
4850 let unbounded = TextField::<()>::new(words(160)).max_lines(None);
4851 assert!(lines(&unbounded) > 12.0, "no most: {}", lines(&unbounded));
4852 }
4853
4854 #[test]
4856 fn the_fewest_lines_hold_the_box_open() {
4857 let held = TextField::<()>::new("").min_lines(2).max_lines(5);
4858 assert_eq!(lines(&held), 2.0);
4859 let either_order = TextField::<()>::new("").max_lines(5).min_lines(2);
4860 assert_eq!(lines(&either_order), 2.0, "said after the most");
4861 let inverted = TextField::<()>::new(words(40)).min_lines(6).max_lines(3);
4862 assert_eq!(
4863 lines(&inverted),
4864 6.0,
4865 "a most below the fewest is the fewest"
4866 );
4867 assert!(!inverted.grows());
4868 let raised = TextField::<()>::new(words(40)).max_lines(3).min_lines(6);
4869 assert_eq!(lines(&raised), 6.0, "the fewest raise a most said first");
4870 assert!(!raised.grows());
4871 }
4872
4873 #[test]
4875 fn one_line_and_fixed_rows_do_not_grow() {
4876 let single = TextField::<()>::new(words(40)).max_lines(1);
4877 assert!(!single.multiline && !single.grows());
4878 assert_eq!(lines(&single), 1.0);
4879 let fixed = TextField::<()>::new(words(40)).rows(3);
4880 assert!(!fixed.grows());
4881 assert_eq!(lines(&fixed), 3.0);
4882 assert!(Widget::<()>::measure(&fixed, &Theme::default()).is_none());
4883 assert!(matches!(
4884 Widget::<()>::style(&fixed).height,
4885 Dimension::Length(_)
4886 ));
4887 }
4888
4889 #[test]
4893 fn the_layout_and_the_paint_agree() {
4894 let field = TextField::<()>::new(words(14))
4895 .max_lines(6)
4896 .label("Notes")
4897 .suffix_icon(crate::Icons::CLOSE)
4898 .on_suffix(());
4899 let style = Widget::<()>::style(&field);
4900 assert_eq!(style.height, Dimension::Auto);
4901 let theme = Theme::default();
4902 let measure = Widget::<()>::measure(&field, &theme).expect("measured");
4903 let size = measure(Some(W), None);
4904 let expected =
4905 frus_core::fits(field.label_block() + field.field_height_at(W) + field.sub_block());
4906 assert_eq!(size.height, expected, "in whole pixels");
4907 assert_eq!(size.height.fract(), 0.0);
4908 assert!(
4909 field.field_height_at(W) > field.field_height(),
4910 "taller than one line"
4911 );
4912 let narrow = measure(Some(200.0), None);
4913 assert!(narrow.height > size.height, "narrower wraps more");
4914
4915 let mut scene = Scene::new();
4916 Widget::<()>::paint(
4917 &field,
4918 Rect::new(0.0, 0.0, W, size.height),
4919 Status::default(),
4920 &theme,
4921 &mut scene,
4922 );
4923 let tallest = scene
4924 .primitives()
4925 .iter()
4926 .filter_map(|p| match p {
4927 frus_core::Primitive::Rect { rect, .. } => Some(rect.height),
4928 _ => None,
4929 })
4930 .fold(0.0, f32::max);
4931 assert_eq!(tallest, field.field_height_at(W), "the box is that tall");
4932 let low = field.label_block() + field.field_height_at(W) - 4.0;
4933 assert!(
4934 field.suffix_hit(W - 10.0, low, W),
4935 "the suffix answers low in the box"
4936 );
4937
4938 let key = |f: &TextField<()>| Widget::<()>::measure_key(f, &theme);
4939 assert_ne!(
4940 key(&TextField::<()>::new(words(14)).max_lines(6).label("Notes")),
4941 key(&TextField::<()>::new(words(30)).max_lines(6).label("Notes")),
4942 "another value, and nothing else, another measurement"
4943 );
4944 assert_ne!(
4945 key(&TextField::<()>::new(words(14)).max_lines(6)),
4946 key(&TextField::<()>::new(words(14))
4947 .max_lines(6)
4948 .prefix_text("$")),
4949 "a prefix narrows the wrap"
4950 );
4951 }
4952}
4953
4954#[cfg(test)]
4956mod cursor_tests {
4957 use super::*;
4958 use frus_core::{BorderRadius, Primitive, TargetPlatform};
4959
4960 fn rects(field: &TextField<()>, theme: &Theme) -> Vec<(Rect, Color, BorderRadius)> {
4962 let mut scene = Scene::new();
4963 Widget::<()>::paint(
4964 field,
4965 Rect::new(0.0, 0.0, 300.0, 80.0),
4966 Status {
4967 focused: true,
4968 focus_progress: 1.0,
4969 cursor: Some(2),
4970 ..Default::default()
4971 },
4972 theme,
4973 &mut scene,
4974 );
4975 scene
4976 .primitives()
4977 .iter()
4978 .filter_map(|p| match p {
4979 Primitive::Rect {
4980 rect,
4981 color,
4982 radius,
4983 ..
4984 } => Some((*rect, *color, *radius)),
4985 _ => None,
4986 })
4987 .collect()
4988 }
4989
4990 fn caret(field: &TextField<()>, theme: &Theme, color: Color) -> Option<(Rect, BorderRadius)> {
4992 rects(field, theme)
4993 .into_iter()
4994 .find(|(_, c, _)| *c == color)
4995 .map(|(r, _, radius)| (r, radius))
4996 }
4997
4998 #[test]
5002 fn the_caret_takes_the_reference_s_colours() {
5003 let theme = Theme::default();
5004 let field = TextField::<()>::new("ab");
5005 let (rect, _) = caret(&field, &theme, theme.scheme.primary).expect("in primary");
5006 assert_eq!(rect.width, 2.0, "the reference's two pixels");
5007
5008 let errored = TextField::<()>::new("ab").error("Wrong");
5009 assert!(
5010 caret(&errored, &theme, theme.scheme.error).is_some(),
5011 "in error"
5012 );
5013 assert!(caret(&errored, &theme, theme.scheme.primary).is_none());
5014
5015 let red = Color::rgb(1.0, 0.0, 0.0);
5016 let told = TextField::<()>::new("ab").cursor_color(red);
5017 assert!(caret(&told, &theme, red).is_some(), "the field's");
5018 let blue = Color::rgb(0.0, 0.0, 1.0);
5019 let told_error = TextField::<()>::new("ab")
5020 .error("Wrong")
5021 .cursor_error_color(blue);
5022 assert!(
5023 caret(&told_error, &theme, blue).is_some(),
5024 "the field's error colour"
5025 );
5026
5027 let mut selection = Theme::default();
5028 selection.widgets.text_selection.cursor_color = Some(red);
5029 assert!(
5030 caret(&field, &selection, red).is_some(),
5031 "the text selection's"
5032 );
5033 let mut both = selection.clone();
5034 both.widgets.text_field.cursor_color = Some(blue);
5035 assert!(
5036 caret(&field, &both, blue).is_some(),
5037 "the field theme's over it"
5038 );
5039 }
5040
5041 #[test]
5044 fn the_caret_takes_its_shape() {
5045 let theme = Theme::default().with_platform(TargetPlatform::Android);
5046 let primary = theme.scheme.primary;
5047 let (line, square) = caret(&TextField::new("ab"), &theme, primary).unwrap();
5048 assert_eq!(square, BorderRadius::uniform(0.0), "square on Android");
5049
5050 let shaped = TextField::<()>::new("ab")
5051 .cursor_width(3.0)
5052 .cursor_height(10.0);
5053 let (rect, _) = caret(&shaped, &theme, primary).unwrap();
5054 assert_eq!((rect.width, rect.height), (3.0, 10.0));
5055 let centre = |r: Rect| r.y + r.height / 2.0;
5056 assert!(
5057 (centre(rect) - centre(line)).abs() < 1e-3,
5058 "centred on the line"
5059 );
5060
5061 let ios = Theme::default().with_platform(TargetPlatform::Ios);
5062 let (_, round) = caret(&TextField::new("ab"), &ios, ios.scheme.primary).unwrap();
5063 assert_eq!(round, BorderRadius::uniform(2.0), "rounded on iOS");
5064 let told = TextField::<()>::new("ab").cursor_radius(1.0);
5065 assert_eq!(
5066 caret(&told, &theme, primary).unwrap().1,
5067 BorderRadius::uniform(1.0)
5068 );
5069
5070 let hidden = TextField::<()>::new("ab").show_cursor(false);
5071 assert!(caret(&hidden, &theme, primary).is_none(), "not shown");
5072 }
5073
5074 #[test]
5076 fn a_masked_value_shows_the_character_told() {
5077 assert_eq!(TextField::<()>::new("abc").obscure(true).display(), "•••");
5078 let starred = TextField::<()>::new("abc")
5079 .obscure(true)
5080 .obscuring_character('*');
5081 assert_eq!(starred.display(), "***");
5082 assert_eq!(
5083 TextField::<()>::new("abc")
5084 .obscuring_character('*')
5085 .display(),
5086 "abc",
5087 "only a masked field"
5088 );
5089 }
5090
5091 #[test]
5093 fn a_field_asks_for_the_focus_when_told() {
5094 assert!(!Widget::<()>::autofocus(&TextField::new("")));
5095 assert!(Widget::<()>::autofocus(&TextField::new("").autofocus(true)));
5096 assert!(!Widget::<()>::autofocus(
5097 &TextField::new("").autofocus(true).enabled(false)
5098 ));
5099 }
5100}