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 {
1202 self.placeholder = Some(placeholder.into());
1203 self
1204 }
1205
1206 pub fn helper(mut self, helper: impl Into<String>) -> Self {
1208 self.helper = Some(helper.into());
1209 self
1210 }
1211
1212 pub fn error(mut self, message: impl Into<String>) -> Self {
1215 self.error = Some(message.into());
1216 self
1217 }
1218
1219 pub fn width(mut self, width: f32) -> Self {
1221 self.width = Dimension::Length(width);
1222 self.sized = true;
1223 self
1224 }
1225
1226 pub fn size(mut self, size: f32) -> Self {
1228 self.size = size;
1229 self
1230 }
1231
1232 pub fn max_length(mut self, max: usize) -> Self {
1246 self.max_length = Some(max);
1247 self
1248 }
1249
1250 pub fn input_filter(mut self, filter: impl Fn(char) -> Option<char> + 'static) -> Self {
1274 self.input_filter = Some(Box::new(filter));
1275 self
1276 }
1277
1278 pub fn digits_only(mut self) -> Self {
1285 self.input_filter = Some(Box::new(|c| c.is_ascii_digit().then_some(c)));
1286 self.keyboard = self.keyboard.or(Some(KeyboardType::Number));
1287 self
1288 }
1289
1290 pub fn capitalization(mut self, capitalization: Capitalization) -> Self {
1297 self.capitalization = capitalization;
1298 self
1299 }
1300
1301 pub fn read_only(mut self) -> Self {
1310 self.read_only = true;
1311 self
1312 }
1313
1314 pub fn selection_toolbar(
1345 mut self,
1346 build: impl Fn(&ToolbarContext, Vec<ToolbarItem<Msg>>) -> Vec<ToolbarItem<Msg>> + 'static,
1347 ) -> Self {
1348 self.toolbar_build = Some(Box::new(build));
1349 self.toolbars = Default::default();
1351 self
1352 }
1353
1354 pub fn on_input<R: crate::callback::IntoMsg<Msg>>(
1356 mut self,
1357 on_input: impl Fn(String) -> R + 'static,
1358 ) -> Self {
1359 self.on_input = Some(Box::new(crate::callback::handler1(on_input)));
1360 self
1361 }
1362
1363 pub fn on_submit(mut self, message: impl Into<Msg>) -> Self {
1365 self.on_submit = Some(message.into());
1366 self
1367 }
1368
1369 pub fn announce(mut self, message: impl Into<String>) -> Self {
1377 self.announce = Some(message.into());
1378 self
1379 }
1380
1381 fn display(&self) -> String {
1385 if self.obscure {
1386 self.obscuring
1387 .to_string()
1388 .repeat(self.value.chars().count())
1389 } else {
1390 self.value.clone()
1391 }
1392 }
1393
1394 fn layout(&self, wrap_width: Option<f32>) -> TextLayout {
1398 let style = self.text_style();
1399 TextLayout::wrapped(
1400 &self.display(),
1401 style.size,
1402 style.weight,
1403 style.italic,
1404 wrap_width,
1405 )
1406 }
1407
1408 fn prefix_icon_w(&self) -> f32 {
1410 if self.prefix.is_some() {
1411 self.icon_size() + ICON_PAD
1412 } else {
1413 0.0
1414 }
1415 }
1416
1417 fn suffix_icon_w(&self) -> f32 {
1419 if self.suffix.is_some() {
1420 self.icon_size() + ICON_PAD
1421 } else {
1422 0.0
1423 }
1424 }
1425
1426 fn affix_w(&self, text: &Option<String>) -> f32 {
1428 text.as_ref().map_or(0.0, |text| {
1429 frus_text::measure_resolved(text, &self.text_style()).width
1430 })
1431 }
1432
1433 fn prefix_w(&self) -> f32 {
1435 self.prefix_icon_w() + self.affix_w(&self.prefix_text)
1436 }
1437
1438 fn suffix_w(&self) -> f32 {
1440 self.suffix_icon_w() + self.affix_w(&self.suffix_text)
1441 }
1442
1443 fn outer_icon_size(&self) -> f32 {
1445 if self.dense {
1446 18.0
1447 } else {
1448 24.0
1449 }
1450 }
1451
1452 fn gutter(&self) -> f32 {
1454 if self.icon.is_some() {
1455 self.outer_icon_size() + 16.0
1456 } else {
1457 0.0
1458 }
1459 }
1460
1461 fn lead(&self) -> f32 {
1464 self.gutter() + self.pad_x() + self.prefix_w()
1465 }
1466
1467 fn floating_behavior(&self, theme: &Theme) -> FloatingLabelBehavior {
1469 self.floating
1470 .or(theme.widgets.text_field.floating_label_behavior)
1471 .unwrap_or_default()
1472 }
1473
1474 fn float_progress(&self, fp: f32, theme: &Theme) -> f32 {
1476 match self.floating_behavior(theme) {
1477 FloatingLabelBehavior::Always => 1.0,
1478 FloatingLabelBehavior::Never => 0.0,
1479 FloatingLabelBehavior::Auto if self.value.is_empty() => fp,
1480 FloatingLabelBehavior::Auto => 1.0,
1481 }
1482 }
1483
1484 fn text_frame(&self, width: f32, edit: &Edit, scroll_y: f32) -> (TextLayout, f32, f32) {
1491 let len = self.value.chars().count();
1492 let content_w = self.content_width(width);
1493 let layout = self.layout(self.multiline.then_some(content_w));
1494 let scroll = (layout.caret_rect(edit.cursor.min(len)).x - content_w).max(0.0);
1495 let align = self.align_offset(content_w, layout.size().width);
1496 let vscroll = if self.multiline {
1497 let visible = self.field_height_at(width) - self.text_top() - self.pad_bottom();
1498 scroll_y.clamp(0.0, (layout.size().height - visible).max(0.0))
1499 } else {
1500 0.0
1501 };
1502 let origin_x = self.lead() + align - scroll;
1503 let origin_y = self.label_block() + self.text_top() - vscroll;
1504 (layout, origin_x, origin_y)
1505 }
1506
1507 fn handles(
1510 &self,
1511 width: f32,
1512 edit: &Edit,
1513 scroll_y: f32,
1514 ) -> Option<[crate::SelectionHandle; 2]> {
1515 let len = self.value.chars().count();
1516 edit.selection_range()?;
1517 let (layout, origin_x, origin_y) = self.text_frame(width, edit, scroll_y);
1518 handles_in(&layout, Point::new(origin_x, origin_y), len, edit)
1519 }
1520
1521 fn selection_box(&self, width: f32, edit: &Edit, scroll_y: f32) -> Rect {
1524 let len = self.value.chars().count();
1525 let (layout, origin_x, origin_y) = self.text_frame(width, edit, scroll_y);
1526 selection_box_in(&layout, Point::new(origin_x, origin_y), len, edit)
1527 }
1528
1529 fn default_toolbar_items(&self, context: &ToolbarContext) -> Vec<ToolbarItem<Msg>> {
1534 let editable = self.enabled && !self.read_only;
1535 let mut items = Vec::new();
1536 if context.has_selection && editable && !self.obscure {
1537 items.push(ToolbarItem::cut());
1538 }
1539 if context.has_selection && !self.obscure {
1540 items.push(ToolbarItem::copy());
1541 }
1542 if context.can_paste && editable {
1543 items.push(ToolbarItem::paste());
1544 }
1545 if !context.all_selected {
1546 items.push(ToolbarItem::select_all());
1547 }
1548 items
1549 }
1550
1551 fn content_width(&self, width: f32) -> f32 {
1553 (width - self.lead() - self.pad_x() - self.suffix_w()).max(0.0)
1554 }
1555
1556 fn label_block(&self) -> f32 {
1560 if self.label.is_some() {
1561 if self.is_outlined() {
1562 (frus_text::line_height(self.label_size()) * 0.5).ceil()
1563 } else {
1564 frus_text::line_height(self.label_size()) + FIELD_GAP
1565 }
1566 } else {
1567 0.0
1568 }
1569 }
1570
1571 fn counter(&self) -> Option<String> {
1573 match &self.counter_text {
1574 Some(text) if text.is_empty() => None,
1575 Some(text) => Some(text.clone()),
1576 None => self
1577 .max_length
1578 .map(|max| format!("{}/{}", self.value.chars().count(), max)),
1579 }
1580 }
1581
1582 fn sub_block(&self) -> f32 {
1584 if self.error.is_some() || self.helper.is_some() || self.counter().is_some() {
1585 self.sub_style().line_height() + FIELD_GAP
1589 } else {
1590 0.0
1591 }
1592 }
1593
1594 fn field_height(&self) -> f32 {
1597 let lines = if self.multiline {
1598 self.rows.max(1) as f32
1599 } else {
1600 1.0
1601 };
1602 self.box_height(lines)
1603 }
1604
1605 fn box_height(&self, lines: f32) -> f32 {
1607 (self.text_style().line_height() * lines + self.text_top() + self.pad_bottom()).ceil()
1608 }
1609
1610 fn grows(&self) -> bool {
1613 self.multiline && self.max_rows != Some(self.rows)
1614 }
1615
1616 fn lines_at(&self, width: f32) -> f32 {
1619 if !self.grows() {
1620 return if self.multiline {
1621 self.rows.max(1) as f32
1622 } else {
1623 1.0
1624 };
1625 }
1626 let layout = self.layout(Some(self.content_width(width)));
1627 let line_h = self.text_style().line_height();
1628 let wrapped = if line_h > 0.0 {
1629 (layout.size().height / line_h).round().max(1.0)
1630 } else {
1631 1.0
1632 };
1633 wrapped.clamp(
1634 self.rows.max(1) as f32,
1635 self.max_rows.map_or(f32::INFINITY, |max| max as f32),
1636 )
1637 }
1638
1639 fn field_height_at(&self, width: f32) -> f32 {
1641 self.box_height(self.lines_at(width))
1642 }
1643}
1644
1645impl<Msg: Clone + 'static> Widget<Msg> for TextField<Msg> {
1646 fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
1649 if false {
1650 return crate::widget::FillAxes::NONE;
1651 }
1652 crate::widget::FillAxes {
1653 horizontal: !self.sized,
1654 vertical: false,
1655 }
1656 }
1657
1658 fn soft_extent(&self) -> crate::widget::FillAxes {
1661 crate::widget::FillAxes {
1662 horizontal: !self.sized,
1663 vertical: false,
1664 }
1665 }
1666
1667 fn style(&self) -> Style {
1668 let height = if self.grows() {
1671 Dimension::Auto
1672 } else {
1673 Dimension::Length(self.label_block() + self.field_height() + self.sub_block())
1674 };
1675 Style {
1676 width: self.width,
1677 height,
1678 ..Default::default()
1679 }
1680 }
1681
1682 fn measure(&self, _theme: &Theme) -> Option<frus_layout::MeasureFn<'_>> {
1683 if !self.grows() {
1684 return None;
1685 }
1686 Some(Box::new(move |width, _| {
1687 let width = width.unwrap_or(match self.width {
1688 Dimension::Length(w) => w,
1689 _ => 220.0,
1690 });
1691 frus_core::Size::new(
1694 width,
1695 frus_core::fits(
1696 self.label_block() + self.field_height_at(width) + self.sub_block(),
1697 ),
1698 )
1699 }))
1700 }
1701
1702 fn measure_key(&self, _theme: &Theme) -> Option<u64> {
1703 if !self.grows() {
1704 return None;
1705 }
1706 use std::hash::{Hash, Hasher};
1707 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1708 self.display().hash(&mut hasher);
1709 self.rows.hash(&mut hasher);
1710 self.max_rows.hash(&mut hasher);
1711 self.size.to_bits().hash(&mut hasher);
1712 for part in [
1715 self.lead(),
1716 self.pad_x(),
1717 self.suffix_w(),
1718 self.text_top(),
1719 self.pad_bottom(),
1720 self.label_block(),
1721 self.sub_block(),
1722 ] {
1723 part.to_bits().hash(&mut hasher);
1724 }
1725 Some(hasher.finish())
1726 }
1727
1728 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
1729 &[]
1730 }
1731
1732 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
1733 let s = self.resolved(theme);
1734 let o = if self.enabled {
1738 status.opacity
1739 } else {
1740 status.opacity * FIELD_DISABLED_OPACITY
1741 };
1742 let has_error = self.error.is_some() && self.enabled;
1743 let fp = if self.enabled {
1744 status.focus_progress.clamp(0.0, 1.0)
1745 } else {
1746 0.0
1747 };
1748 let error_ink = s.error_color.unwrap().lerp(s.error_hover_color.unwrap(), {
1756 let hover = if self.enabled {
1757 status.hover_progress.clamp(0.0, 1.0)
1758 } else {
1759 0.0
1760 };
1761 hover * (1.0 - fp)
1762 });
1763
1764 let label_block = self.label_block();
1767 let gutter = self.gutter();
1768 let field = Rect::new(
1769 bounds.x + gutter,
1770 bounds.y + label_block,
1771 (bounds.width - gutter).max(0.0),
1772 self.field_height_at(bounds.width),
1773 );
1774 if let Some(icon) = self.icon {
1776 let side = self.outer_icon_size();
1777 let path = icon.placed(
1778 side,
1779 bounds.x,
1780 field.y + (field.height - side) * 0.5,
1781 theme.direction,
1782 );
1783 scene.fill_path(&path, s.icon_color.unwrap().fade(o));
1784 }
1785
1786 let float_t = self.float_progress(fp, theme);
1792 let label_shown =
1794 self.floating_behavior(theme) != FloatingLabelBehavior::Never || self.value.is_empty();
1795 let content_left = bounds.x + self.lead();
1796 let label_rest = field.x + self.pad_x() + self.prefix_icon_w();
1799 let centred = self
1800 .floating_alignment
1801 .or(theme.widgets.text_field.floating_label_alignment)
1802 .unwrap_or_default()
1803 == FloatingLabelAlignment::Center;
1804 let label_geom = self.label.as_ref().filter(|_| label_shown).map(|label| {
1808 let rest = Point::new(label_rest, field.y + self.text_top());
1809 let (fx, fy) = if self.is_outlined() {
1810 (
1812 field.x + self.pad_x(),
1813 field.y - frus_text::line_height(self.label_size()) * 0.5,
1814 )
1815 } else {
1816 (label_rest, field.y + self.pad_top())
1819 };
1820 let fx = if centred {
1822 let floated = frus_text::measure_resolved(
1823 label,
1824 &ResolvedTextStyle::exact(self.label_size()),
1825 )
1826 .width;
1827 field.x + (field.width - floated) * 0.5
1828 } else {
1829 fx
1830 };
1831 let x = rest.x + (fx - rest.x) * float_t;
1832 let y = rest.y + (fy - rest.y) * float_t;
1833 let resolved = self.text_style().size;
1834 let size = resolved + (self.label_size() - resolved) * float_t;
1835 let color = if has_error {
1836 error_ink
1837 } else {
1838 s.label_color
1839 .unwrap()
1840 .lerp(s.focused_label_color.unwrap(), fp)
1841 };
1842 (label.clone(), x, y, size, color)
1843 });
1844 let sub = self.error.as_ref().or(self.helper.as_ref());
1846 if let Some(sub) = sub {
1847 let color = if has_error {
1852 s.error_color.unwrap()
1853 } else {
1854 s.helper_color.unwrap()
1855 };
1856 scene.text(
1857 Point::new(field.x, field.y + field.height + FIELD_GAP),
1858 sub.clone(),
1859 &self.sub_style(),
1860 color.fade(o),
1861 );
1862 }
1863 if let Some(counter) = self.counter() {
1868 let width = frus_text::measure_resolved(&counter, &self.sub_style()).width;
1869 scene.text(
1870 Point::new(
1871 bounds.x + bounds.width - width,
1872 field.y + field.height + FIELD_GAP,
1873 ),
1874 counter,
1875 &self.sub_style(),
1876 s.helper_color.unwrap().fade(o),
1877 );
1878 }
1879
1880 let border_color = if has_error {
1885 error_ink
1886 } else {
1887 s.border_color
1888 .unwrap()
1889 .lerp(s.focused_border_color.unwrap(), fp)
1890 }
1891 .fade(o);
1892 let rest_w = s.border_width.unwrap();
1893 let border_width = rest_w + (s.focused_border_width.unwrap() - rest_w) * fp;
1894 let radius = s.radius.unwrap();
1895 let fill = s.fill.unwrap();
1896 if self.is_borderless() {
1897 } else if self.is_outlined() {
1899 scene.draw_rect(field, fill.fade(o), radius, border_width, border_color);
1900 } else {
1901 scene.draw_rect(
1904 field,
1905 fill.fade(o),
1906 frus_core::BorderRadius {
1907 top_left: radius,
1908 top_right: radius,
1909 bottom_right: 0.0,
1910 bottom_left: 0.0,
1911 },
1912 0.0,
1913 frus_core::Color::TRANSPARENT,
1914 );
1915 scene.fill_rect(
1916 Rect::new(
1917 field.x,
1918 field.y + field.height - border_width,
1919 field.width,
1920 border_width,
1921 ),
1922 border_color,
1923 );
1924 }
1925
1926 if !self.is_outlined() {
1932 if let Some((label, x, y, size, color)) = &label_geom {
1933 let style = ResolvedTextStyle::exact(*size);
1936 scene.text(Point::new(*x, *y), label.clone(), &style, color.fade(o));
1937 }
1938 }
1939
1940 if self.is_outlined() {
1944 if let Some((label, x, y, size, color)) = &label_geom {
1945 if float_t > 0.01 {
1946 let label_w = frus_text::measure(label, self.label_size()).width;
1947 let notch = Rect::new(
1948 *x - FIELD_NOTCH_GAP,
1949 field.y - (border_width + FIELD_NOTCH_GAP) * 0.5,
1950 label_w + FIELD_NOTCH_GAP * 2.0,
1951 border_width + FIELD_NOTCH_GAP,
1952 );
1953 scene.fill_rect(notch, theme.surface.fade(o * float_t));
1954 }
1955 scene.text(
1956 Point::new(*x, *y),
1957 label.clone(),
1958 &ResolvedTextStyle::exact(*size),
1959 color.fade(o),
1960 );
1961 }
1962 }
1963
1964 let icon_color = s.icon_color.unwrap().fade(o);
1966 let icon_y = field.y + (field.height - self.icon_size()) * 0.5;
1967 if let Some(prefix) = self.prefix {
1968 let path = prefix.placed(
1969 self.icon_size(),
1970 field.x + ICON_PAD,
1971 icon_y,
1972 theme.direction,
1973 );
1974 scene.fill_path(&path, icon_color);
1975 }
1976 if let Some(suffix) = self.suffix {
1977 let x = field.x + field.width - self.icon_size() - ICON_PAD;
1978 if self.suffix_action.is_some() {
1982 if let Some(hc) = status.hover_cursor {
1983 if self.suffix_hit(hc.x - bounds.x, hc.y - bounds.y, bounds.width) {
1984 let halo = Rect::new(
1985 x - 4.0,
1986 icon_y - 4.0,
1987 self.icon_size() + 8.0,
1988 self.icon_size() + 8.0,
1989 );
1990 scene.draw_rect(
1991 halo,
1992 theme.muted.fade(o * 0.18),
1993 halo.height * 0.5,
1994 0.0,
1995 frus_core::Color::TRANSPARENT,
1996 );
1997 }
1998 }
1999 }
2000 let path = suffix.placed(self.icon_size(), x, icon_y, theme.direction);
2001 scene.fill_path(&path, icon_color);
2002 }
2003
2004 let len = self.value.chars().count();
2005 let content_x = content_left;
2007 let content_w = self.content_width(bounds.width);
2008 let text_y = field.y + self.text_top();
2009 let wrap = if self.multiline {
2012 Some(content_w)
2013 } else {
2014 None
2015 };
2016 let layout = self.layout(wrap);
2017 let align = self.align_offset(content_w, layout.size().width);
2021
2022 if self.value.is_empty() {
2026 if let Some(placeholder) = &self.placeholder {
2027 let ph_alpha = if self.label.is_some() { o * float_t } else { o };
2028 if ph_alpha > 0.01 {
2029 let style = self.text_style();
2032 let hint_w = frus_text::measure_resolved(placeholder, &style).width;
2033 let hint_align = self.align_offset(content_w, hint_w);
2034 scene.text(
2035 Point::new(content_x + hint_align, text_y),
2036 placeholder.clone(),
2037 &style,
2038 theme.muted.fade(ph_alpha),
2039 );
2040 }
2041 }
2042 }
2043
2044 let affix_alpha = if self.label.is_some() {
2047 float_t
2048 } else if self.value.is_empty() {
2049 match self.floating_behavior(theme) {
2050 FloatingLabelBehavior::Always => 1.0,
2051 _ => fp,
2052 }
2053 } else {
2054 1.0
2055 } * o;
2056 if affix_alpha > 0.01 {
2057 let style = self.text_style();
2058 if let Some(prefix) = &self.prefix_text {
2059 scene.text(
2060 Point::new(field.x + self.pad_x() + self.prefix_icon_w(), text_y),
2061 prefix.clone(),
2062 &style,
2063 theme.muted.fade(affix_alpha),
2064 );
2065 }
2066 if let Some(suffix) = &self.suffix_text {
2067 let w = self.affix_w(&self.suffix_text);
2068 scene.text(
2069 Point::new(
2070 field.x + field.width - self.pad_x() - self.suffix_icon_w() - w,
2071 text_y,
2072 ),
2073 suffix.clone(),
2074 &style,
2075 theme.muted.fade(affix_alpha),
2076 );
2077 }
2078 }
2079
2080 let cursor = status.cursor.unwrap_or(len).min(len);
2083 let caret = layout.caret_rect(cursor);
2084 let scroll = if status.focused {
2085 (caret.x - content_w).max(0.0)
2086 } else {
2087 0.0
2088 };
2089 let text_x = content_x + align - scroll;
2090 let vscroll = if self.multiline {
2093 let overflow = (layout.size().height
2094 - (field.height - self.text_top() - self.pad_bottom()))
2095 .max(0.0);
2096 status.scroll_y.clamp(0.0, overflow)
2097 } else {
2098 0.0
2099 };
2100 let text_top = text_y - vscroll;
2102
2103 let outer_clip = scene.current_clip();
2110 let content_clip = outer_clip.intersect(Rect::new(
2111 content_x,
2112 field.y + self.text_top(),
2113 content_w,
2114 (field.height - self.text_top() - self.pad_bottom()).max(0.0),
2115 ));
2116 scene.set_clip(content_clip);
2117
2118 if status.focused {
2120 if let Some((start, end)) = status.selection {
2121 for r in layout.selection_rects(start.min(len), end.min(len)) {
2122 scene.fill_rect(
2123 Rect::new(text_x + r.x, text_top + r.y, r.width, r.height),
2124 theme.selection.fade(o),
2125 );
2126 }
2127 }
2128 }
2129
2130 if !self.value.is_empty() {
2131 let pos = Point::new(text_x, text_top);
2132 let color = theme.on_surface.fade(o);
2133 match wrap {
2134 Some(max_w) => {
2136 scene.text_wrapped(pos, self.display(), &self.text_style(), color, max_w)
2137 }
2138 None => scene.text(pos, self.display(), &self.text_style(), color),
2139 }
2140 }
2141
2142 if status.focused {
2145 if let Some((start, end)) = status.composing {
2146 let (start, end) = (start.min(len), end.min(len));
2147 for r in layout.selection_rects(start, end) {
2148 scene.fill_rect(
2149 Rect::new(text_x + r.x, text_top + r.y + r.height - 1.5, r.width, 1.5),
2150 theme.on_surface.fade(o * 0.7),
2151 );
2152 }
2153 }
2154 }
2155
2156 if status.focused && !status.caret_hidden && self.cursor.shown {
2160 let color = if has_error {
2161 s.cursor_error_color.unwrap()
2162 } else {
2163 s.cursor_color.unwrap()
2164 };
2165 let height = self.cursor.height.unwrap_or(caret.height);
2166 let radius =
2167 self.cursor
2168 .radius
2169 .unwrap_or(if theme.platform.is_apple() { 2.0 } else { 0.0 });
2170 scene.draw_rect(
2171 Rect::new(
2172 text_x + caret.x,
2173 text_top + caret.y + (caret.height - height) * 0.5,
2174 self.cursor.width,
2175 height,
2176 ),
2177 color.fade(o),
2178 radius,
2179 0.0,
2180 Color::TRANSPARENT,
2181 );
2182 }
2183
2184 if status.focused && status.handles {
2187 scene.set_clip(outer_clip);
2188 let edit = Edit {
2189 cursor,
2190 anchor: status
2191 .selection
2192 .map(|(a, b)| if cursor == a { b } else { a }),
2193 composing: None,
2194 };
2195 if let Some(handles) = self.handles(bounds.width, &edit, status.scroll_y) {
2196 let color = s.handle_color.unwrap().fade(o);
2197 paint_handles(scene, handles, Point::new(bounds.x, bounds.y), color);
2198 }
2199 }
2200 }
2201
2202 fn on_click(&self) -> Option<Msg> {
2203 None
2204 }
2205
2206 fn positional_click(
2207 &self,
2208 local_x: f32,
2209 local_y: f32,
2210 width: f32,
2211 _height: f32,
2212 ) -> Option<Msg> {
2213 if !self.enabled {
2214 return None;
2215 }
2216 if self.suffix_action.is_some() && self.suffix_hit(local_x, local_y, width) {
2217 self.suffix_action.clone()
2218 } else {
2219 None
2220 }
2221 }
2222
2223 fn cursor_icon(
2224 &self,
2225 local_x: f32,
2226 local_y: f32,
2227 width: f32,
2228 _height: f32,
2229 ) -> Option<crate::interaction::Cursor> {
2230 if self.suffix_action.is_some() && self.suffix_hit(local_x, local_y, width) {
2232 Some(crate::interaction::Cursor::Pointer)
2233 } else {
2234 None
2235 }
2236 }
2237
2238 fn on_edit(&self, edit: &mut Edit, key: &Key) -> Option<Msg> {
2239 if !self.enabled {
2243 return None;
2244 }
2245 let mut chars: Vec<char> = self.value.chars().collect();
2246 let len = chars.len();
2247 let mut cursor = edit.cursor.min(len);
2248 let mut anchor = edit.anchor.map(|a| a.min(len));
2249 let selection = anchor
2250 .map(|a| (a.min(cursor), a.max(cursor)))
2251 .filter(|(s, e)| s < e);
2252
2253 let mut changed = false;
2254
2255 match key {
2256 Key::Text(text) => {
2257 let inserted: Vec<char> = match &self.input_filter {
2261 Some(filter) => text.chars().filter_map(filter).collect(),
2262 None => text.chars().collect(),
2263 };
2264 if selection.is_some() || !inserted.is_empty() {
2269 if let Some((s, e)) = selection {
2270 chars.drain(s..e);
2271 cursor = s;
2272 }
2273 let n = inserted.len();
2274 chars.splice(cursor..cursor, inserted);
2275 cursor += n;
2276 anchor = None;
2277 changed = true;
2278 }
2279 }
2280 Key::Backspace => {
2281 if let Some((s, e)) = selection {
2282 chars.drain(s..e);
2283 cursor = s;
2284 changed = true;
2285 } else if cursor > 0 {
2286 chars.remove(cursor - 1);
2287 cursor -= 1;
2288 changed = true;
2289 }
2290 anchor = None;
2291 }
2292 Key::Delete => {
2293 if let Some((s, e)) = selection {
2294 chars.drain(s..e);
2295 cursor = s;
2296 changed = true;
2297 } else if cursor < len {
2298 chars.remove(cursor);
2299 changed = true;
2300 }
2301 anchor = None;
2302 }
2303 Key::Left { .. } | Key::Right { .. } | Key::Home { .. } | Key::End { .. } => {
2306 move_caret(&chars, &mut cursor, &mut anchor, key);
2307 }
2308 Key::Escape => {}
2310 Key::Enter if self.multiline => {
2311 if let Some((s, e)) = selection {
2313 chars.drain(s..e);
2314 cursor = s;
2315 }
2316 chars.insert(cursor, '\n');
2317 cursor += 1;
2318 anchor = None;
2319 changed = true;
2320 }
2321 Key::Enter => {
2322 edit.cursor = cursor;
2324 edit.anchor = anchor;
2325 return self.on_submit.clone();
2326 }
2327 }
2328
2329 if self.read_only && changed {
2334 return None;
2335 }
2336
2337 if changed {
2346 if let Some(max) = self.max_length {
2347 if chars.len() > max {
2348 chars.truncate(max);
2349 cursor = cursor.min(max);
2350 anchor = anchor.map(|a| a.min(max));
2351 }
2352 }
2353 }
2354
2355 edit.cursor = cursor;
2356 edit.anchor = anchor;
2357
2358 if changed {
2359 let new_value: String = chars.into_iter().collect();
2360 self.on_input.as_ref().map(|make| make(new_value))
2361 } else {
2362 None
2363 }
2364 }
2365
2366 fn ime(&self) -> Ime {
2367 self.ime_options()
2368 }
2369
2370 fn cursor_at(
2371 &self,
2372 local_x: f32,
2373 local_y: f32,
2374 width: f32,
2375 scroll_cursor: usize,
2376 ) -> Option<usize> {
2377 if !self.enabled {
2380 return None;
2381 }
2382 if self.suffix_action.is_some() && self.suffix_hit(local_x, local_y, width) {
2385 return None;
2386 }
2387 let left = self.lead();
2391 let content_w = self.content_width(width);
2392 let layout = self.layout(if self.multiline {
2393 Some(content_w)
2394 } else {
2395 None
2396 });
2397 let scroll = (layout.caret_rect(scroll_cursor).x - content_w).max(0.0);
2398 let align = self.align_offset(content_w, layout.size().width);
2403 let target_x = local_x - left - align + scroll;
2406 let target_y = local_y - self.label_block() - self.text_top();
2407 Some(layout.hit_test(Point::new(target_x, target_y)))
2408 }
2409
2410 fn text_metrics(&self, width: f32, cursor: usize) -> Option<(f32, f32, f32, f32)> {
2411 if !self.multiline {
2412 return None;
2413 }
2414 let layout = self.layout(Some(self.content_width(width)));
2415 let caret = layout.caret_rect(cursor);
2416 let visible = self.field_height_at(width) - self.text_top() - self.pad_bottom();
2417 Some((layout.size().height, visible, caret.y, caret.height))
2418 }
2419
2420 fn text_viewport(&self, rect: Rect) -> Option<Rect> {
2421 if !self.multiline {
2422 return None;
2423 }
2424 Some(Rect::new(
2426 rect.x,
2427 rect.y + self.label_block(),
2428 rect.width,
2429 self.field_height_at(rect.width),
2430 ))
2431 }
2432
2433 fn caret_vertical(
2434 &self,
2435 width: f32,
2436 cursor: usize,
2437 down: bool,
2438 page: bool,
2439 goal_x: Option<f32>,
2440 ) -> Option<(usize, f32)> {
2441 if !self.multiline {
2442 return None;
2443 }
2444 let layout = self.layout(Some(self.content_width(width)));
2445 let caret = layout.caret_rect(cursor);
2446 let line_h = caret.height;
2447 let x = goal_x.unwrap_or(caret.x);
2449 let step = if page {
2451 (self.field_height_at(width) - self.text_top() - self.pad_bottom()).max(line_h)
2452 } else {
2453 line_h
2454 };
2455 let center = caret.y + line_h * 0.5;
2456 let target_y = if down { center + step } else { center - step };
2457 let full = layout.size().height;
2458 if page {
2459 let clamped = target_y.clamp(0.0, (full - line_h * 0.5).max(0.0));
2461 Some((layout.hit_test(Point::new(x, clamped)), x))
2462 } else {
2463 if target_y < 0.0 || target_y >= full {
2465 return None;
2466 }
2467 Some((layout.hit_test(Point::new(x, target_y)), x))
2468 }
2469 }
2470
2471 fn text_value(&self) -> Option<&str> {
2472 Some(&self.value)
2473 }
2474
2475 fn takes_typing(&self) -> bool {
2477 self.enabled && !self.read_only
2478 }
2479
2480 fn replace_value(&self, value: String) -> Option<Msg> {
2481 if !self.enabled || self.read_only {
2487 return None;
2488 }
2489 self.on_input.as_ref().map(|make| make(value))
2490 }
2491
2492 fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
2493 let mut s = frus_core::SemanticsProperties::new(frus_core::Role::TextInput)
2494 .value(self.value.clone());
2495 let name = self.label.as_ref().or(self.placeholder.as_ref());
2499 let label = match (name, &self.error) {
2500 (Some(l), Some(e)) => Some(format!("{l}, {e}")),
2501 (Some(l), None) => Some(l.clone()),
2502 (None, Some(e)) => Some(e.clone()),
2503 (None, None) => None,
2504 };
2505 if let Some(label) = label {
2506 s = s.label(label);
2507 }
2508 if !self.enabled {
2509 s = s.disabled(true);
2510 }
2511 Some(s)
2512 }
2513
2514 fn selected_text(&self, edit: &Edit) -> Option<String> {
2515 if self.obscure {
2518 return None;
2519 }
2520 let chars: Vec<char> = self.value.chars().collect();
2521 let len = chars.len();
2522 let cursor = edit.cursor.min(len);
2523 let anchor = edit.anchor?.min(len);
2524 let (start, end) = (anchor.min(cursor), anchor.max(cursor));
2525 if start < end {
2526 Some(chars[start..end].iter().collect())
2527 } else {
2528 None
2529 }
2530 }
2531
2532 fn word_at(&self, index: usize) -> Option<(usize, usize)> {
2533 let chars: Vec<char> = self.value.chars().collect();
2534 word_range(&chars, index)
2535 }
2536
2537 fn selection_handles(
2538 &self,
2539 width: f32,
2540 edit: &Edit,
2541 scroll_y: f32,
2542 ) -> Option<[crate::SelectionHandle; 2]> {
2543 self.handles(width, edit, scroll_y)
2544 }
2545
2546 fn selection_toolbar(&self, context: ToolbarContext) -> Option<&dyn Widget<Msg>> {
2547 self.toolbars[usize::from(context.variant())]
2548 .get_or_init(|| {
2549 let defaults = self.default_toolbar_items(&context);
2550 let items = match &self.toolbar_build {
2551 Some(build) => build(&context, defaults),
2552 None => defaults,
2553 };
2554 (!items.is_empty()).then(|| {
2555 let bar: Box<dyn Widget<Msg>> = Box::new(SelectionToolbar::new(items));
2556 bar
2557 })
2558 })
2559 .as_deref()
2560 }
2561
2562 fn selection_anchor(&self, width: f32, edit: &Edit, scroll_y: f32) -> Option<Rect> {
2563 Some(self.selection_box(width, edit, scroll_y))
2564 }
2565
2566 fn autofill_hints(&self) -> &[AutofillHint] {
2567 &self.autofill
2568 }
2569
2570 fn autofocus(&self) -> bool {
2571 self.autofocus && self.enabled
2572 }
2573
2574 fn on_tap_outside(&self) -> Option<Msg> {
2575 self.on_tap_outside.clone()
2576 }
2577
2578 fn focusable(&self) -> bool {
2579 self.enabled
2580 }
2581
2582 fn draws_own_focus(&self) -> bool {
2583 true
2584 }
2585
2586 fn announce(&self) -> Option<String> {
2587 self.announce.clone()
2588 }
2589}
2590
2591#[cfg(test)]
2592mod tests {
2593 use super::*;
2594 use crate::icons::Icons;
2595
2596 #[derive(Clone, Debug, PartialEq)]
2597 enum Msg {
2598 Changed(String),
2599 Submitted,
2600 }
2601
2602 #[test]
2605 fn a_field_with_no_label_is_named_by_its_placeholder() {
2606 let name = |field: TextField<Msg>| Widget::<Msg>::semantics(&field).and_then(|s| s.label);
2607 assert_eq!(
2608 name(TextField::new("").placeholder("New task")).as_deref(),
2609 Some("New task")
2610 );
2611 assert_eq!(
2612 name(TextField::new("").placeholder("e.g. Ada").label("Name")).as_deref(),
2613 Some("Name")
2614 );
2615 assert_eq!(
2616 name(TextField::new("").placeholder("New task").error("Required")).as_deref(),
2617 Some("New task, Required")
2618 );
2619 assert_eq!(name(TextField::new("")), None);
2620 }
2621
2622 fn input(value: &str) -> TextField<Msg> {
2623 TextField::new(value).on_input(Msg::Changed)
2624 }
2625
2626 fn rects(field: &TextField<Msg>, theme: &Theme, w: f32, h: f32) -> Vec<frus_core::Primitive> {
2628 let mut scene = Scene::new();
2629 Widget::<Msg>::paint(
2630 field,
2631 Rect::new(0.0, 0.0, w, h),
2632 Status::default(),
2633 theme,
2634 &mut scene,
2635 );
2636 scene
2637 .primitives()
2638 .iter()
2639 .filter(|p| matches!(p, frus_core::Primitive::Rect { .. }))
2640 .cloned()
2641 .collect()
2642 }
2643
2644 #[test]
2649 fn a_field_is_the_references_size() {
2650 let line = input("x").text_style().line_height();
2651 let outlined = input("x");
2652 assert_eq!(
2653 outlined.field_height(),
2654 (line + FIELD_OUTLINED_PADDING_TOP + FIELD_OUTLINED_PADDING_BOTTOM).ceil()
2655 );
2656 let filled = input("x").filled();
2659 assert_eq!(filled.field_height(), (line + FIELD_PADDING_Y * 2.0).ceil());
2660 let labelled = input("x").filled().label("Name");
2661 assert_eq!(
2662 labelled.field_height() - filled.field_height(),
2663 (FIELD_GAP + FIELD_TEXT_SIZE * FIELD_LABEL_SCALE).ceil()
2664 );
2665 assert_eq!(input("x").size(32.0).label_size(), 24.0);
2668 }
2669
2670 #[test]
2674 fn a_dense_field_gives_back_its_padding() {
2675 let line = input("x").text_style().line_height();
2676 assert_eq!(
2677 input("x").dense(true).field_height(),
2678 (line + FIELD_DENSE_OUTLINED_PADDING_TOP + FIELD_DENSE_OUTLINED_PADDING_BOTTOM).ceil()
2679 );
2680 assert_eq!(
2681 input("x").filled().dense(true).field_height(),
2682 (line + FIELD_DENSE_PADDING_Y * 2.0).ceil()
2683 );
2684 for dense in [input("x").dense(true), input("x").filled().dense(true)] {
2687 let roomy = if dense.is_outlined() {
2688 input("x")
2689 } else {
2690 input("x").filled()
2691 };
2692 assert!(dense.field_height() < roomy.field_height());
2693 }
2694 }
2695
2696 #[test]
2700 fn a_filled_field_has_a_line_not_a_box() {
2701 let theme = Theme::default();
2702 let stroked = |field: &TextField<Msg>| {
2703 rects(field, &theme, 220.0, 80.0)
2704 .iter()
2705 .filter(|p| {
2706 matches!(p, frus_core::Primitive::Rect { border_width, .. } if *border_width > 0.0)
2707 })
2708 .count()
2709 };
2710 assert_eq!(
2711 stroked(&input("x").outlined()),
2712 1,
2713 "outlined: one stroked box"
2714 );
2715 assert_eq!(stroked(&input("x").filled()), 0, "filled: nothing stroked");
2716 let filled = input("x").filled();
2718 let height = filled.field_height();
2719 let has_underline = rects(&filled, &theme, 220.0, 80.0).iter().any(|p| {
2720 matches!(
2721 p,
2722 frus_core::Primitive::Rect { rect, border_width, .. }
2723 if *border_width == 0.0
2724 && (rect.width - 220.0).abs() < 0.5
2725 && (rect.height - FIELD_BORDER_WIDTH).abs() < 0.5
2726 && (rect.y + rect.height - height).abs() < 0.5
2727 )
2728 });
2729 assert!(has_underline, "a filled field is underlined");
2730 }
2731
2732 #[test]
2735 fn a_disabled_field_dims_and_never_looks_focused() {
2736 let theme = Theme::default();
2737 let focused = Status {
2738 focus_progress: 1.0,
2739 ..Default::default()
2740 };
2741 let border = |field: &TextField<Msg>, status: Status| {
2742 let mut scene = Scene::new();
2743 Widget::<Msg>::paint(
2744 field,
2745 Rect::new(0.0, 0.0, 220.0, 80.0),
2746 status,
2747 &theme,
2748 &mut scene,
2749 );
2750 scene.primitives().iter().find_map(|p| match p {
2751 frus_core::Primitive::Rect {
2752 border_width,
2753 border_color,
2754 ..
2755 } if *border_width > 0.0 => Some((*border_width, *border_color)),
2756 _ => None,
2757 })
2758 };
2759 let (live_w, live_c) = border(&input("x"), focused).unwrap();
2760 assert_eq!(
2761 live_w, FIELD_FOCUSED_BORDER_WIDTH,
2762 "focus widens the border"
2763 );
2764 assert_eq!(live_c.a, 1.0, "and it is fully opaque");
2765 let (dead_w, dead_c) = border(&input("x").enabled(false), focused).unwrap();
2766 assert_eq!(dead_w, FIELD_BORDER_WIDTH, "disabled: no focus weight");
2767 assert!(
2768 (dead_c.a - FIELD_DISABLED_OPACITY).abs() < 0.01,
2769 "disabled: dimmed to the reference's 38%, got {}",
2770 dead_c.a
2771 );
2772 }
2773
2774 #[test]
2778 fn scrolled_text_is_clipped_below_the_label() {
2779 let theme = Theme::default();
2780 let field = input("one\ntwo\nthree\nfour\nfive\nsix")
2781 .rows(3)
2782 .label("Notes");
2783 let mut scene = Scene::new();
2784 Widget::<Msg>::paint(
2785 &field,
2786 Rect::new(0.0, 0.0, 220.0, 140.0),
2787 Status {
2788 scroll_y: 40.0,
2789 ..Default::default()
2790 },
2791 &theme,
2792 &mut scene,
2793 );
2794 let box_top = field.label_block();
2795 let clip = scene
2796 .primitives()
2797 .iter()
2798 .find_map(|p| match p {
2799 frus_core::Primitive::Text { clip, .. } if clip.y > -1.0e6 => Some(*clip),
2802 _ => None,
2803 })
2804 .expect("the value is clipped");
2805 assert!(
2806 clip.y >= box_top + field.text_top() - 0.5,
2807 "the clip starts below the padding, not at the border: {} < {}",
2808 clip.y,
2809 box_top + field.text_top()
2810 );
2811 }
2812
2813 #[test]
2816 fn a_disabled_field_is_inert() {
2817 let live = input("hello");
2818 let dead = input("hello").enabled(false);
2819 assert!(Widget::<Msg>::focusable(&live));
2820 assert!(!Widget::<Msg>::focusable(&dead), "out of the tab order");
2821 assert!(
2822 Widget::<Msg>::cursor_at(&live, 40.0, 30.0, 220.0, 0).is_some(),
2823 "a live field takes a caret"
2824 );
2825 assert_eq!(
2826 Widget::<Msg>::cursor_at(&dead, 40.0, 30.0, 220.0, 0),
2827 None,
2828 "a disabled field takes none"
2829 );
2830 let mut edit = Edit::default();
2831 assert!(
2832 Widget::on_edit(&dead, &mut edit, &Key::Text("x".into())).is_none(),
2833 "and no key edits it"
2834 );
2835 let semantics = Widget::<Msg>::semantics(&dead).unwrap();
2836 assert!(semantics.disabled, "a reader is told it is disabled");
2837 }
2838
2839 #[test]
2841 fn the_caller_outranks_the_theme_which_outranks_the_framework() {
2842 let radius = |field: &TextField<Msg>, theme: &Theme| {
2843 rects(field, theme, 220.0, 80.0)
2844 .iter()
2845 .find_map(|p| match p {
2846 frus_core::Primitive::Rect {
2847 border_width,
2848 radius,
2849 ..
2850 } if *border_width > 0.0 => Some(radius.top_left),
2851 _ => None,
2852 })
2853 .unwrap()
2854 };
2855 let plain = Theme::default();
2856 assert_eq!(radius(&input("x"), &plain), FIELD_RADIUS);
2857 let mut themed = Theme::default();
2858 themed.widgets.text_field.radius = Some(10.0);
2859 assert_eq!(radius(&input("x"), &themed), 10.0);
2860 let overridden = input("x").style(TextFieldStyle {
2861 radius: Some(2.0),
2862 ..Default::default()
2863 });
2864 assert_eq!(radius(&overridden, &themed), 2.0);
2865 }
2866
2867 #[test]
2868 fn text_is_clipped_to_content_box() {
2869 let inp = input("a very long value that overflows the field width").width(100.0);
2872 let mut scene = Scene::new();
2873 Widget::<Msg>::paint(
2874 &inp,
2875 Rect::new(0.0, 0.0, 100.0, 30.0),
2876 Status::default(),
2877 &Theme::default(),
2878 &mut scene,
2879 );
2880 let clip = scene
2881 .primitives()
2882 .iter()
2883 .find_map(|p| match p {
2884 frus_core::Primitive::Text { clip, .. } => Some(*clip),
2885 _ => None,
2886 })
2887 .expect("text primitive");
2888 assert!(
2889 (clip.width - (100.0 - FIELD_PADDING_X * 2.0)).abs() < 0.5,
2890 "clip = {clip:?}"
2891 );
2892 }
2893
2894 #[test]
2895 fn composing_region_draws_an_underline() {
2896 let inp = input("konnichiwa");
2899 let base = Status {
2900 focused: true,
2901 cursor: Some(5),
2902 ..Default::default()
2903 };
2904 let composing = Status {
2905 composing: Some((0, 5)),
2906 ..base
2907 };
2908
2909 let count_thin_rects = |status: Status| {
2910 let mut scene = Scene::new();
2911 Widget::<Msg>::paint(
2912 &inp,
2913 Rect::new(0.0, 0.0, 220.0, 30.0),
2914 status,
2915 &Theme::default(),
2916 &mut scene,
2917 );
2918 scene
2919 .primitives()
2920 .iter()
2921 .filter(|p| matches!(p, frus_core::Primitive::Rect { rect, .. } if rect.height <= 2.0 && rect.width > 2.0))
2922 .count()
2923 };
2924 let plain = count_thin_rects(base);
2925 let underlined = count_thin_rects(composing);
2926 assert!(
2927 underlined > plain,
2928 "the composition must underline ({plain} → {underlined})"
2929 );
2930 }
2931
2932 #[test]
2933 fn text_value_exposes_the_field_content() {
2934 let inp = input("hello");
2936 assert_eq!(Widget::<Msg>::text_value(&inp), Some("hello"));
2937 }
2938
2939 #[test]
2940 fn cursor_at_accounts_for_scroll() {
2941 let inp = input("0123456789 abcdefghij");
2943 assert_eq!(
2945 Widget::<Msg>::cursor_at(&inp, FIELD_PADDING_X, FIELD_OUTLINED_PADDING_TOP, 80.0, 0),
2946 Some(0)
2947 );
2948 let end = inp.value.chars().count();
2951 let at_left =
2952 Widget::<Msg>::cursor_at(&inp, FIELD_PADDING_X, FIELD_OUTLINED_PADDING_TOP, 80.0, end)
2953 .unwrap();
2954 let at_right =
2955 Widget::<Msg>::cursor_at(&inp, 76.0, FIELD_OUTLINED_PADDING_TOP, 80.0, end).unwrap();
2956 assert!(at_left > 0, "scrolled: the left edge is no longer index 0");
2957 assert!(at_right > at_left, "a click on the right → a larger index");
2958 }
2959
2960 #[test]
2961 fn insert_at_cursor() {
2962 let inp = input("ac");
2963 let mut edit = Edit {
2964 cursor: 1,
2965 anchor: None,
2966 composing: None,
2967 };
2968 assert_eq!(
2969 inp.on_edit(&mut edit, &Key::Text("b".to_string())),
2970 Some(Msg::Changed("abc".to_string()))
2971 );
2972 assert_eq!(edit.cursor, 2);
2973 }
2974
2975 #[test]
2976 fn shift_arrow_selects_then_delete() {
2977 let inp = input("hello");
2978 let mut edit = Edit {
2980 cursor: 5,
2981 anchor: None,
2982 composing: None,
2983 };
2984 inp.on_edit(
2985 &mut edit,
2986 &Key::Left {
2987 shift: true,
2988 word: false,
2989 },
2990 );
2991 inp.on_edit(
2992 &mut edit,
2993 &Key::Left {
2994 shift: true,
2995 word: false,
2996 },
2997 );
2998 assert_eq!(edit.selection_range(), Some((3, 5)));
2999 assert_eq!(
3001 inp.on_edit(&mut edit, &Key::Backspace),
3002 Some(Msg::Changed("hel".to_string()))
3003 );
3004 assert_eq!(edit.cursor, 3);
3005 assert_eq!(edit.anchor, None);
3006 }
3007
3008 #[test]
3009 fn home_end_bounds() {
3010 let inp = input("abc");
3011 let mut edit = Edit {
3012 cursor: 1,
3013 anchor: None,
3014 composing: None,
3015 };
3016 inp.on_edit(
3017 &mut edit,
3018 &Key::End {
3019 shift: false,
3020 doc: false,
3021 },
3022 );
3023 assert_eq!(edit.cursor, 3);
3024 inp.on_edit(
3025 &mut edit,
3026 &Key::Home {
3027 shift: false,
3028 doc: false,
3029 },
3030 );
3031 assert_eq!(edit.cursor, 0);
3032 }
3033
3034 #[test]
3035 fn ctrl_arrow_jumps_by_word() {
3036 let inp = input("foo bar baz");
3037 let mut edit = Edit {
3039 cursor: 11,
3040 anchor: None,
3041 composing: None,
3042 };
3043 inp.on_edit(
3044 &mut edit,
3045 &Key::Left {
3046 shift: false,
3047 word: true,
3048 },
3049 );
3050 assert_eq!(edit.cursor, 8, "start of \"baz\"");
3051 inp.on_edit(
3052 &mut edit,
3053 &Key::Left {
3054 shift: false,
3055 word: true,
3056 },
3057 );
3058 assert_eq!(edit.cursor, 4, "start of \"bar\"");
3059 inp.on_edit(
3061 &mut edit,
3062 &Key::Right {
3063 shift: false,
3064 word: true,
3065 },
3066 );
3067 assert_eq!(edit.cursor, 7, "fin de \"bar\"");
3068 inp.on_edit(
3069 &mut edit,
3070 &Key::Right {
3071 shift: false,
3072 word: true,
3073 },
3074 );
3075 assert_eq!(edit.cursor, 11, "fin de \"baz\"");
3076 }
3077
3078 #[test]
3079 fn home_end_are_line_relative_but_ctrl_spans_the_field() {
3080 let inp = TextField::<Msg>::new("ab\ncd\nef")
3082 .on_input(Msg::Changed)
3083 .rows(3);
3084 let mut edit = Edit {
3085 cursor: 4,
3086 anchor: None,
3087 composing: None,
3088 };
3089 inp.on_edit(
3091 &mut edit,
3092 &Key::Home {
3093 shift: false,
3094 doc: false,
3095 },
3096 );
3097 assert_eq!(edit.cursor, 3, "start of the 2nd line");
3098 inp.on_edit(
3100 &mut edit,
3101 &Key::End {
3102 shift: false,
3103 doc: false,
3104 },
3105 );
3106 assert_eq!(edit.cursor, 5, "end of the 2nd line");
3107 inp.on_edit(
3109 &mut edit,
3110 &Key::Home {
3111 shift: false,
3112 doc: true,
3113 },
3114 );
3115 assert_eq!(edit.cursor, 0, "start of the field");
3116 inp.on_edit(
3117 &mut edit,
3118 &Key::End {
3119 shift: false,
3120 doc: true,
3121 },
3122 );
3123 assert_eq!(edit.cursor, 8, "end of the field");
3124 }
3125
3126 #[test]
3127 fn selected_text_reads_range() {
3128 let inp = input("hello");
3129 let edit = Edit {
3130 cursor: 5,
3131 anchor: Some(2),
3132 composing: None,
3133 };
3134 assert_eq!(inp.selected_text(&edit), Some("llo".to_string()));
3135 }
3136
3137 fn bar_words(field: &TextField<Msg>, context: ToolbarContext) -> Option<Vec<String>> {
3140 let bar = Widget::<Msg>::selection_toolbar(field, context)?;
3141 Some(
3142 bar.children()
3143 .iter()
3144 .map(|button| {
3145 button
3146 .semantics()
3147 .and_then(|semantics| semantics.label)
3148 .unwrap_or_default()
3149 })
3150 .collect(),
3151 )
3152 }
3153
3154 fn context(has_selection: bool, can_paste: bool, all_selected: bool) -> ToolbarContext {
3155 ToolbarContext {
3156 has_selection,
3157 can_paste,
3158 all_selected,
3159 }
3160 }
3161
3162 #[test]
3166 fn the_default_bar_follows_the_three_conditions() {
3167 let field = input("hello world");
3168 let words = |ctx| bar_words(&field, ctx);
3169 assert_eq!(
3170 words(context(true, true, false)),
3171 Some(vec![
3172 "Cut".into(),
3173 "Copy".into(),
3174 "Paste".into(),
3175 "Select all".into()
3176 ])
3177 );
3178 assert_eq!(
3179 words(context(false, true, false)),
3180 Some(vec!["Paste".into(), "Select all".into()])
3181 );
3182 assert_eq!(
3183 words(context(true, false, true)),
3184 Some(vec!["Cut".into(), "Copy".into()])
3185 );
3186 assert_eq!(words(context(false, false, true)), None, "nothing to offer");
3187 }
3188
3189 #[test]
3193 fn a_masked_field_offers_neither_cut_nor_copy_and_copies_nothing() {
3194 let field = input("hunter2").obscure(true);
3195 assert_eq!(
3196 bar_words(&field, context(true, true, false)),
3197 Some(vec!["Paste".into(), "Select all".into()])
3198 );
3199 let selected = Edit {
3200 cursor: 7,
3201 anchor: Some(0),
3202 composing: None,
3203 };
3204 assert_eq!(field.selected_text(&selected), None);
3205 assert_eq!(
3207 input("hunter2").selected_text(&selected),
3208 Some("hunter2".to_string())
3209 );
3210 }
3211
3212 #[test]
3215 fn a_read_only_field_offers_copy_and_select_all_only() {
3216 let field = input("REF-0042").read_only();
3217 assert_eq!(
3218 bar_words(&field, context(true, true, false)),
3219 Some(vec!["Copy".into(), "Select all".into()])
3220 );
3221 }
3222
3223 #[test]
3227 fn the_application_rewrites_the_list() {
3228 let field = input("hello").selection_toolbar(|context, mut items| {
3229 items.retain(|item| item.label() != "Paste");
3230 if context.has_selection {
3231 items.push(ToolbarItem::custom("Translate", Msg::Submitted));
3232 }
3233 items
3234 });
3235 assert_eq!(
3236 bar_words(&field, context(true, true, false)),
3237 Some(vec![
3238 "Cut".into(),
3239 "Copy".into(),
3240 "Select all".into(),
3241 "Translate".into()
3242 ])
3243 );
3244 assert_eq!(
3245 bar_words(&field, context(false, true, false)),
3246 Some(vec!["Select all".into()]),
3247 "the custom item only for a selection"
3248 );
3249 let none = input("hello").selection_toolbar(|_, _| Vec::new());
3250 assert_eq!(bar_words(&none, context(true, true, false)), None);
3251 }
3252
3253 #[test]
3256 fn an_application_item_sends_its_message_and_a_built_in_one_does_not() {
3257 let field = input("hello").selection_toolbar(|_, mut items| {
3258 items.push(ToolbarItem::custom("Translate", Msg::Submitted));
3259 items
3260 });
3261 let bar =
3262 Widget::<Msg>::selection_toolbar(&field, context(true, false, false)).expect("a bar");
3263 let buttons = bar.children();
3264 let clicks: Vec<Option<Msg>> = buttons.iter().map(|b| b.on_click()).collect();
3265 let actions: Vec<Option<crate::EditAction>> =
3266 buttons.iter().map(|b| b.edit_action()).collect();
3267 assert_eq!(clicks, vec![None, None, None, Some(Msg::Submitted)]);
3268 assert_eq!(
3269 actions,
3270 vec![
3271 Some(crate::EditAction::Cut),
3272 Some(crate::EditAction::Copy),
3273 Some(crate::EditAction::SelectAll),
3274 None
3275 ]
3276 );
3277 }
3278
3279 #[test]
3282 fn the_bar_is_anchored_on_the_selection_or_the_caret() {
3283 let field = input("hello world");
3284 let word = Edit {
3285 cursor: 5,
3286 anchor: Some(0),
3287 composing: None,
3288 };
3289 let all = Edit {
3290 cursor: 11,
3291 anchor: Some(0),
3292 composing: None,
3293 };
3294 let caret = Edit {
3295 cursor: 5,
3296 anchor: None,
3297 composing: None,
3298 };
3299 let anchor =
3300 |edit: &Edit| Widget::<Msg>::selection_anchor(&field, 200.0, edit, 0.0).unwrap();
3301 assert!(anchor(&all).width > anchor(&word).width);
3302 assert_eq!(anchor(&caret).width, 0.0);
3303 assert!(anchor(&caret).height > 0.0);
3304 let handles = Widget::<Msg>::selection_handles(&field, 200.0, &word, 0.0).unwrap();
3306 let word_box = anchor(&word);
3307 assert!((word_box.x - handles[0].line_center.x).abs() < 0.5);
3308 assert!((word_box.x + word_box.width - handles[1].line_center.x).abs() < 0.5);
3309 }
3310
3311 #[test]
3312 fn enter_submits_without_changing_value() {
3313 let inp = input("acheter du lait").on_submit(Msg::Submitted);
3314 let mut edit = Edit {
3315 cursor: 3,
3316 anchor: None,
3317 composing: None,
3318 };
3319 assert_eq!(inp.on_edit(&mut edit, &Key::Enter), Some(Msg::Submitted));
3321 assert_eq!(edit.cursor, 3); }
3323
3324 #[test]
3325 fn enter_without_submit_is_noop() {
3326 let inp = input("x");
3327 let mut edit = Edit {
3328 cursor: 1,
3329 anchor: None,
3330 composing: None,
3331 };
3332 assert_eq!(inp.on_edit(&mut edit, &Key::Enter), None);
3333 }
3334
3335 fn drawn_text_x(field: &TextField<()>, width: f32) -> f32 {
3337 let mut scene = Scene::new();
3338 Widget::<()>::paint(
3339 field,
3340 Rect::new(0.0, 0.0, width, 56.0),
3341 Status {
3342 focused: false,
3343 ..Default::default()
3344 },
3345 &Theme::default(),
3346 &mut scene,
3347 );
3348 scene
3349 .primitives()
3350 .iter()
3351 .find_map(|p| match p {
3352 frus_core::Primitive::Text { position, text, .. } if text == "42" => {
3353 Some(position.x)
3354 }
3355 _ => None,
3356 })
3357 .expect("the value is drawn")
3358 }
3359
3360 #[test]
3362 fn a_field_that_says_nothing_starts_at_the_left() {
3363 let plain: TextField<()> = TextField::new("42");
3364 let started: TextField<()> = TextField::new("42").text_align(TextAlign::Start);
3365 assert_eq!(drawn_text_x(&plain, 300.0), drawn_text_x(&started, 300.0));
3366 }
3367
3368 #[test]
3370 fn the_text_sits_where_it_was_told_to() {
3371 let left = drawn_text_x(&TextField::<()>::new("42"), 300.0);
3372 let centre = drawn_text_x(
3373 &TextField::<()>::new("42").text_align(TextAlign::Center),
3374 300.0,
3375 );
3376 let right = drawn_text_x(
3377 &TextField::<()>::new("42").text_align(TextAlign::Right),
3378 300.0,
3379 );
3380 assert!(left < centre, "centred is further right than left-aligned");
3381 assert!(centre < right, "right-aligned is further right again");
3382 assert!(
3384 ((centre - left) - (right - centre)).abs() < 0.5,
3385 "halfway: {left} {centre} {right}"
3386 );
3387 }
3388
3389 #[test]
3393 fn a_click_lands_where_the_glyphs_are() {
3394 let width = 300.0;
3395 for align in [TextAlign::Left, TextAlign::Center, TextAlign::Right] {
3396 let field: TextField<()> = TextField::new("42").text_align(align);
3397 let x = drawn_text_x(&field, width);
3398 let before = Widget::<()>::cursor_at(&field, x - 1.0, 20.0, width, 0);
3400 assert_eq!(before, Some(0), "{align:?}: before the first character");
3401 let text_w = frus_text::measure("42", field.size).width;
3403 let after = Widget::<()>::cursor_at(&field, x + text_w + 1.0, 20.0, width, 0);
3404 assert_eq!(after, Some(2), "{align:?}: after the last character");
3405 }
3406 }
3407
3408 #[test]
3412 fn text_wider_than_the_field_is_not_pushed_off_it() {
3413 let long: TextField<()> = TextField::new("42").text_align(TextAlign::Right);
3414 assert_eq!(long.align_offset(4.0, 40.0), 0.0);
3416 assert_eq!(long.align_offset(40.0, 40.0), 0.0);
3417 }
3418
3419 #[test]
3424 fn a_multiline_field_is_not_aligned() {
3425 let note: TextField<()> = TextField::new("42")
3426 .multiline()
3427 .text_align(TextAlign::Center);
3428 assert_eq!(note.align_offset(300.0, 40.0), 0.0);
3429 let one_line: TextField<()> = TextField::new("42").text_align(TextAlign::Center);
3431 assert_eq!(one_line.align_offset(300.0, 40.0), 130.0);
3432 }
3433
3434 #[test]
3437 fn the_placeholder_is_aligned_like_the_value() {
3438 let hint_x = |align: TextAlign| {
3439 let field: TextField<()> = TextField::new("").placeholder("Amount").text_align(align);
3440 let mut scene = Scene::new();
3441 Widget::<()>::paint(
3442 &field,
3443 Rect::new(0.0, 0.0, 300.0, 56.0),
3444 Status::default(),
3445 &Theme::default(),
3446 &mut scene,
3447 );
3448 scene
3449 .primitives()
3450 .iter()
3451 .find_map(|p| match p {
3452 frus_core::Primitive::Text { position, text, .. } if text == "Amount" => {
3453 Some(position.x)
3454 }
3455 _ => None,
3456 })
3457 .expect("the hint is drawn")
3458 };
3459 assert!(hint_x(TextAlign::Right) > hint_x(TextAlign::Center));
3460 assert!(hint_x(TextAlign::Center) > hint_x(TextAlign::Left));
3461 }
3462
3463 #[test]
3465 fn a_filter_drops_what_it_will_not_take() {
3466 let field: TextField<String> = TextField::new("").digits_only().on_input(|v| v);
3467 let mut edit = Edit::default();
3468 let out = Widget::on_edit(&field, &mut edit, &Key::Text("12 34-56".into()));
3470 assert_eq!(out.as_deref(), Some("123456"));
3471 assert_eq!(edit.cursor, 6, "the caret is past what actually arrived");
3472 }
3473
3474 #[test]
3477 fn a_refused_keystroke_says_nothing() {
3478 let field: TextField<String> = TextField::new("42").digits_only().on_input(|v| v);
3479 let mut edit = Edit {
3480 cursor: 2,
3481 ..Default::default()
3482 };
3483 assert_eq!(
3484 Widget::on_edit(&field, &mut edit, &Key::Text("x".into())),
3485 None
3486 );
3487 assert_eq!(edit.cursor, 2, "and the caret has not moved");
3488 }
3489
3490 #[test]
3492 fn a_refused_keystroke_over_a_selection_still_clears_it() {
3493 let field: TextField<String> = TextField::new("1234").digits_only().on_input(|v| v);
3494 let mut edit = Edit {
3495 cursor: 3,
3496 anchor: Some(1),
3497 ..Default::default()
3498 };
3499 let out = Widget::on_edit(&field, &mut edit, &Key::Text("x".into()));
3500 assert_eq!(out.as_deref(), Some("14"));
3501 assert_eq!(edit.cursor, 1);
3502 }
3503
3504 #[test]
3507 fn a_filter_can_substitute() {
3508 let field: TextField<String> = TextField::new("")
3509 .input_filter(|c| c.to_uppercase().next())
3510 .on_input(|v| v);
3511 let mut edit = Edit::default();
3512 let out = Widget::on_edit(&field, &mut edit, &Key::Text("ab".into()));
3513 assert_eq!(out.as_deref(), Some("AB"));
3514 assert_eq!(edit.cursor, 2);
3515 }
3516
3517 #[test]
3520 fn a_filter_does_not_rewrite_what_the_caller_gave() {
3521 let field: TextField<String> = TextField::new("a1b2").digits_only().on_input(|v| v);
3522 let mut edit = Edit {
3523 cursor: 4,
3524 ..Default::default()
3525 };
3526 let out = Widget::on_edit(&field, &mut edit, &Key::Text("3".into()));
3527 assert_eq!(
3528 out.as_deref(),
3529 Some("a1b23"),
3530 "only the keystroke is filtered"
3531 );
3532 }
3533
3534 #[test]
3536 fn digits_only_asks_for_a_keypad_without_overruling_one() {
3537 let plain: TextField<()> = TextField::new("").digits_only();
3538 assert_eq!(Widget::<()>::ime(&plain).keyboard, KeyboardType::Number);
3539 let before: TextField<()> = TextField::new("")
3540 .keyboard_type(KeyboardType::Phone)
3541 .digits_only();
3542 assert_eq!(Widget::<()>::ime(&before).keyboard, KeyboardType::Phone);
3543 let after: TextField<()> = TextField::new("")
3544 .digits_only()
3545 .keyboard_type(KeyboardType::Phone);
3546 assert_eq!(Widget::<()>::ime(&after).keyboard, KeyboardType::Phone);
3547 }
3548
3549 #[test]
3551 fn the_capitalisation_travels_with_the_field() {
3552 let field: TextField<()> = TextField::new("").capitalization(Capitalization::Characters);
3553 let ime = Widget::<()>::ime(&field);
3554 assert_eq!(ime.capitalization, Capitalization::Characters);
3555 assert_eq!(ime.android_input_type() & 0x0000_7000, 0x0000_1000);
3556 }
3557
3558 #[test]
3561 fn a_plain_field_asks_for_the_plain_keyboard() {
3562 let field: TextField<()> = TextField::new("hello");
3563 assert_eq!(
3564 Widget::<()>::ime(&field),
3565 Ime {
3566 keyboard: KeyboardType::Text,
3567 action: TextInputAction::Done,
3568 capitalization: Capitalization::Auto,
3569 }
3570 );
3571 }
3572
3573 #[test]
3578 fn an_obscured_field_tells_the_keyboard_it_is_a_secret() {
3579 let field: TextField<()> = TextField::new("hunter2").obscure(true);
3580 let ime = Widget::<()>::ime(&field);
3581 assert_eq!(ime.keyboard, KeyboardType::Password);
3582 assert!(ime.keyboard.is_secret());
3583 assert_eq!(ime.keyboard.android_input_type() & 0x0000_0ff0, 0x80);
3585 }
3586
3587 #[test]
3590 fn a_multiline_field_takes_a_newline_key() {
3591 let field: TextField<()> = TextField::new("a note").multiline();
3592 let ime = Widget::<()>::ime(&field);
3593 assert_eq!(ime.keyboard, KeyboardType::Multiline);
3594 assert_eq!(ime.action, TextInputAction::Newline);
3595 let rowed: TextField<()> = TextField::new("a note").rows(4);
3597 assert_eq!(Widget::<()>::ime(&rowed).keyboard, KeyboardType::Multiline);
3598 }
3599
3600 #[test]
3602 fn what_the_caller_says_wins() {
3603 let field: TextField<()> = TextField::new("")
3604 .obscure(true)
3605 .keyboard_type(KeyboardType::VisiblePassword);
3606 assert_eq!(
3607 Widget::<()>::ime(&field).keyboard,
3608 KeyboardType::VisiblePassword
3609 );
3610
3611 let query: TextField<()> = TextField::new("").action(TextInputAction::Search);
3613 let ime = Widget::<()>::ime(&query);
3614 assert_eq!(ime.keyboard, KeyboardType::Text);
3615 assert_eq!(ime.action, TextInputAction::Search);
3616
3617 let note: TextField<()> = TextField::new("").multiline().action(TextInputAction::Done);
3619 assert_eq!(Widget::<()>::ime(¬e).action, TextInputAction::Done);
3620 }
3621
3622 #[test]
3625 fn a_typed_field_gets_the_keyboard_for_it() {
3626 let phone: TextField<()> = TextField::new("").keyboard_type(KeyboardType::Phone);
3627 assert_eq!(Widget::<()>::ime(&phone).keyboard.android_input_type(), 3);
3629
3630 let email: TextField<()> = TextField::new("").keyboard_type(KeyboardType::Email);
3631 let bits = Widget::<()>::ime(&email).keyboard.android_input_type();
3632 assert_eq!(bits, 1 | 0x20);
3635 }
3636
3637 #[test]
3638 fn obscure_masks_the_displayed_text_but_not_the_value() {
3639 let theme = Theme::default();
3642 let field = TextField::<Msg>::new("secret").obscure(true);
3643 let mut scene = Scene::new();
3644 let status = Status {
3645 focused: true,
3646 cursor: Some(6),
3647 ..Default::default()
3648 };
3649 Widget::<Msg>::paint(
3650 &field,
3651 Rect::new(0.0, 0.0, 220.0, 30.0),
3652 status,
3653 &theme,
3654 &mut scene,
3655 );
3656 let drawn: Vec<&str> = scene
3657 .primitives()
3658 .iter()
3659 .filter_map(|p| match p {
3660 frus_core::Primitive::Text { text, .. } => Some(text.as_str()),
3661 _ => None,
3662 })
3663 .collect();
3664 assert!(
3665 drawn.iter().all(|t| !t.contains("secret")),
3666 "the value must not leak"
3667 );
3668 assert!(
3669 drawn.iter().any(|t| t.chars().all(|c| c == '•')),
3670 "dots are drawn"
3671 );
3672 assert_eq!(Widget::<Msg>::text_value(&field), Some("secret"));
3674 }
3675
3676 #[test]
3677 fn prefix_icon_draws_a_path_and_shifts_the_hit_test() {
3678 let theme = Theme::default();
3681 let with_icon = input("hello world").prefix_icon(Icons::STAR);
3682 let mut scene = Scene::new();
3683 Widget::<Msg>::paint(
3684 &with_icon,
3685 Rect::new(0.0, 0.0, 220.0, 30.0),
3686 Status::default(),
3687 &theme,
3688 &mut scene,
3689 );
3690 assert!(
3691 scene
3692 .primitives()
3693 .iter()
3694 .any(|p| matches!(p, frus_core::Primitive::Path { .. })),
3695 "the prefix icon draws a path"
3696 );
3697 let plain = input("hello world");
3698 let at_plain =
3699 Widget::<Msg>::cursor_at(&plain, 60.0, FIELD_OUTLINED_PADDING_TOP, 220.0, 0).unwrap();
3700 let at_icon =
3701 Widget::<Msg>::cursor_at(&with_icon, 60.0, FIELD_OUTLINED_PADDING_TOP, 220.0, 0)
3702 .unwrap();
3703 assert!(
3704 at_icon < at_plain,
3705 "the prefix offsets the content ({at_plain} → {at_icon})"
3706 );
3707 }
3708
3709 #[test]
3710 fn multiline_enter_inserts_a_newline_instead_of_submitting() {
3711 let inp = TextField::<Msg>::new("ab")
3713 .on_input(Msg::Changed)
3714 .multiline();
3715 let mut edit = Edit {
3716 cursor: 2,
3717 anchor: None,
3718 composing: None,
3719 };
3720 assert_eq!(
3721 inp.on_edit(&mut edit, &Key::Enter),
3722 Some(Msg::Changed("ab\n".to_string()))
3723 );
3724 assert_eq!(edit.cursor, 3);
3725 let single = input("ab").on_submit(Msg::Submitted);
3727 let mut edit = Edit {
3728 cursor: 2,
3729 anchor: None,
3730 composing: None,
3731 };
3732 assert_eq!(single.on_edit(&mut edit, &Key::Enter), Some(Msg::Submitted));
3733 }
3734
3735 #[test]
3736 fn multiline_wraps_long_lines_to_the_width() {
3737 let long = "word ".repeat(30); let inp = TextField::<Msg>::new(long.trim_end())
3742 .on_input(Msg::Changed)
3743 .rows(4)
3744 .width(160.0);
3745 let line_h = inp.text_style().line_height();
3746 let top = Widget::<Msg>::cursor_at(
3747 &inp,
3748 FIELD_PADDING_X + 2.0,
3749 FIELD_OUTLINED_PADDING_TOP + 1.0,
3750 160.0,
3751 0,
3752 )
3753 .unwrap();
3754 let wrapped = Widget::<Msg>::cursor_at(
3755 &inp,
3756 FIELD_PADDING_X + 2.0,
3757 FIELD_OUTLINED_PADDING_TOP + line_h * 2.0 + 1.0,
3758 160.0,
3759 0,
3760 )
3761 .unwrap();
3762 assert!(top < 10, "1st line: {top}");
3763 assert!(
3764 wrapped > top,
3765 "a wrapped line further down → an index further on ({top} → {wrapped})"
3766 );
3767 }
3768
3769 #[test]
3770 fn multiline_reports_overflow_and_scrolls_content() {
3771 let inp = TextField::<Msg>::new("l1\nl2\nl3\nl4\nl5")
3773 .on_input(Msg::Changed)
3774 .rows(2)
3775 .width(200.0);
3776 let (content_h, visible_h, _, _) =
3777 Widget::<Msg>::text_metrics(&inp, 200.0, 0).expect("a multi-line field");
3778 assert!(
3779 content_h > visible_h + 1.0,
3780 "5 lines > a box of 2 ({content_h} vs {visible_h})"
3781 );
3782
3783 let text_top = |scroll: f32| {
3785 let mut scene = Scene::new();
3786 let status = Status {
3787 focused: true,
3788 cursor: Some(0),
3789 scroll_y: scroll,
3790 ..Default::default()
3791 };
3792 Widget::<Msg>::paint(
3793 &inp,
3794 Rect::new(0.0, 0.0, 200.0, 80.0),
3795 status,
3796 &Theme::default(),
3797 &mut scene,
3798 );
3799 scene
3800 .primitives()
3801 .iter()
3802 .find_map(|p| match p {
3803 frus_core::Primitive::Text { text, position, .. } if text.contains("l1") => {
3804 Some(position.y)
3805 }
3806 _ => None,
3807 })
3808 .expect("the field's text")
3809 };
3810 assert!(
3811 text_top(20.0) < text_top(0.0),
3812 "scrolling raises the content"
3813 );
3814 }
3815
3816 #[test]
3817 fn multiline_arrows_move_the_caret_between_lines() {
3818 let inp = TextField::<Msg>::new("abc\ndefg\nhi")
3820 .on_input(Msg::Changed)
3821 .rows(3)
3822 .width(200.0);
3823 let (down, _) = Widget::<Msg>::caret_vertical(&inp, 200.0, 1, true, false, None).unwrap();
3825 assert!(
3826 (4..=8).contains(&down),
3827 "moves down to the 2nd line: {down}"
3828 );
3829 assert_eq!(
3831 Widget::<Msg>::caret_vertical(&inp, 200.0, 1, false, false, None),
3832 None
3833 );
3834 assert_eq!(
3836 Widget::<Msg>::caret_vertical(&inp, 200.0, 10, true, false, None),
3837 None
3838 );
3839 let (up, _) = Widget::<Msg>::caret_vertical(&inp, 200.0, 5, false, false, None).unwrap();
3841 assert!(up <= 3, "moves up to the 1st line: {up}");
3842 assert_eq!(
3844 Widget::<Msg>::caret_vertical(&input("abc"), 200.0, 1, true, false, None),
3845 None
3846 );
3847 }
3848
3849 #[test]
3850 fn multiline_goal_column_survives_a_short_line() {
3851 let inp = TextField::<Msg>::new("hello\nhi\nworld")
3856 .on_input(Msg::Changed)
3857 .rows(3)
3858 .width(200.0);
3859 let (mid, goal) = Widget::<Msg>::caret_vertical(&inp, 200.0, 5, true, false, None).unwrap();
3860 assert!(
3861 (6..=8).contains(&mid),
3862 "a short line, clamped to its end: {mid}"
3863 );
3864 let (low, _) =
3868 Widget::<Msg>::caret_vertical(&inp, 200.0, mid, true, false, Some(goal)).unwrap();
3869 assert!(
3870 (12..=14).contains(&low),
3871 "goal column preserved into \"world\": {low}"
3872 );
3873 }
3874
3875 #[test]
3876 fn multiline_page_jump_is_clamped_to_the_field() {
3877 let inp = TextField::<Msg>::new("a\nb\nc\nd\ne")
3880 .on_input(Msg::Changed)
3881 .rows(2)
3882 .width(200.0);
3883 let (bottom, _) = Widget::<Msg>::caret_vertical(&inp, 200.0, 8, true, true, None).unwrap();
3885 assert!(
3886 bottom >= 7,
3887 "PgDn clamped to the bottom of the field: {bottom}"
3888 );
3889 let (top, _) = Widget::<Msg>::caret_vertical(&inp, 200.0, 0, false, true, None).unwrap();
3891 assert!(top <= 1, "PgUp clamped to the top of the field: {top}");
3892 }
3893
3894 #[test]
3895 fn multiline_reserves_rows_of_height() {
3896 let one = match Widget::<Msg>::style(&input("x")).height {
3898 Dimension::Length(h) => h,
3899 _ => panic!("a fixed height"),
3900 };
3901 let four = match Widget::<Msg>::style(&TextField::<Msg>::new("x").rows(4)).height {
3902 Dimension::Length(h) => h,
3903 _ => panic!("a fixed height"),
3904 };
3905 assert!(
3906 four > one * 2.0,
3907 "4 lines much taller than one ({one} → {four})"
3908 );
3909 }
3910
3911 #[test]
3912 fn multiline_hit_test_uses_the_click_line() {
3913 let inp = TextField::<Msg>::new("ab\ncd")
3916 .on_input(Msg::Changed)
3917 .rows(3);
3918 let line_h = inp.text_style().line_height();
3919 let top = Widget::<Msg>::cursor_at(
3921 &inp,
3922 FIELD_PADDING_X + 1.0,
3923 FIELD_OUTLINED_PADDING_TOP + 1.0,
3924 220.0,
3925 0,
3926 )
3927 .unwrap();
3928 let below = Widget::<Msg>::cursor_at(
3930 &inp,
3931 FIELD_PADDING_X + 1.0,
3932 FIELD_OUTLINED_PADDING_TOP + line_h + 1.0,
3933 220.0,
3934 0,
3935 )
3936 .unwrap();
3937 assert!(top <= 2, "1st line: {top}");
3938 assert!(below >= 3, "2nd line: {below}");
3939 }
3940
3941 #[test]
3942 fn floating_label_rests_in_box_then_floats_up() {
3943 let theme = Theme::default();
3946 let field = TextField::<Msg>::new("").label("Name");
3947 let label_geo = |status: Status| -> (f32, f32) {
3948 let mut scene = Scene::new();
3949 Widget::<Msg>::paint(
3950 &field,
3951 Rect::new(0.0, 30.0, 220.0, 60.0),
3952 status,
3953 &theme,
3954 &mut scene,
3955 );
3956 scene
3957 .primitives()
3958 .iter()
3959 .find_map(|p| match p {
3960 frus_core::Primitive::Text {
3961 text,
3962 size,
3963 position,
3964 ..
3965 } if text == "Name" => Some((*size, position.y)),
3966 _ => None,
3967 })
3968 .expect("a painted label")
3969 };
3970 let (rest_size, rest_y) = label_geo(Status::default());
3971 let (float_size, float_y) = label_geo(Status {
3972 focused: true,
3973 focus_progress: 1.0,
3974 ..Default::default()
3975 });
3976 assert!(
3977 rest_size > float_size,
3978 "at rest the label is larger ({rest_size} → {float_size})"
3979 );
3980 assert!(
3981 float_y < rest_y,
3982 "once focused the label rises ({rest_y} → {float_y})"
3983 );
3984 }
3985
3986 #[test]
3997 fn an_errored_field_deepens_under_the_pointer() {
3998 let theme = Theme::default();
3999 let field = input("x").label("Name").error("Required").outlined();
4000 let painted = |status: Status| {
4001 let mut scene = Scene::new();
4002 Widget::<Msg>::paint(
4003 &field,
4004 Rect::new(0.0, 0.0, 220.0, 90.0),
4005 status,
4006 &theme,
4007 &mut scene,
4008 );
4009 scene
4010 };
4011 let border = |status: Status| {
4013 painted(status)
4014 .primitives()
4015 .iter()
4016 .find_map(|p| match p {
4017 frus_core::Primitive::Rect {
4018 border_color,
4019 border_width,
4020 ..
4021 } if *border_width > 0.0 => Some(*border_color),
4022 _ => None,
4023 })
4024 .expect("an outlined field draws a border")
4025 };
4026 let message = |status: Status| {
4027 painted(status)
4028 .primitives()
4029 .iter()
4030 .find_map(|p| match p {
4031 frus_core::Primitive::Text { text, color, .. } if text == "Required" => {
4032 Some(*color)
4033 }
4034 _ => None,
4035 })
4036 .expect("the message is painted")
4037 };
4038 let rest = Status {
4039 opacity: 1.0,
4040 ..Default::default()
4041 };
4042 let hovered = Status {
4043 hover_progress: 1.0,
4044 ..rest
4045 };
4046 let both = Status {
4047 focused: true,
4048 focus_progress: 1.0,
4049 ..hovered
4050 };
4051
4052 assert_eq!(border(rest), theme.scheme.error);
4053 assert_eq!(
4054 border(hovered),
4055 theme.scheme.on_error_container,
4056 "it deepens under the pointer"
4057 );
4058 assert_eq!(
4059 border(both),
4060 theme.scheme.error,
4061 "and comes back once focused, which the reference tests first"
4062 );
4063 assert_ne!(
4064 theme.scheme.error, theme.scheme.on_error_container,
4065 "the two have to differ for any of the above to mean anything"
4066 );
4067
4068 for status in [rest, hovered, both] {
4069 assert_eq!(
4070 message(status),
4071 theme.scheme.error,
4072 "the message is a sentence, not a control"
4073 );
4074 }
4075 }
4076
4077 #[test]
4078 fn decoration_grows_height_for_label_and_error() {
4079 let bare_h = match Widget::<Msg>::style(&input("x")).height {
4082 Dimension::Length(h) => h,
4083 _ => panic!("a fixed height was expected"),
4084 };
4085 let decorated = input("x").label("Email").error("Required");
4086 let deco_h = match Widget::<Msg>::style(&decorated).height {
4087 Dimension::Length(h) => h,
4088 _ => panic!("a fixed height was expected"),
4089 };
4090 assert!(
4091 deco_h > bare_h,
4092 "label + error grow the field ({bare_h} → {deco_h})"
4093 );
4094 }
4095
4096 #[test]
4097 fn error_paints_the_border_in_the_error_color() {
4098 let theme = Theme::default();
4100 let field = input("x").error("bad");
4101 let mut scene = Scene::new();
4102 Widget::<Msg>::paint(
4103 &field,
4104 Rect::new(0.0, 0.0, 220.0, 60.0),
4105 Status::default(),
4106 &theme,
4107 &mut scene,
4108 );
4109 let has_error_border = scene.primitives().iter().any(|p| {
4110 matches!(
4111 p,
4112 frus_core::Primitive::Rect { border_color, border_width, .. }
4113 if *border_width > 0.0 && *border_color == theme.error
4114 )
4115 });
4116 assert!(has_error_border, "the border must be in the error colour");
4117 }
4118
4119 #[test]
4120 fn placeholder_shows_only_when_empty() {
4121 let theme = Theme::default();
4123 let count_texts = |value: &str| {
4124 let field = TextField::<Msg>::new(value).placeholder("Type here");
4125 let mut scene = Scene::new();
4126 Widget::<Msg>::paint(
4127 &field,
4128 Rect::new(0.0, 0.0, 220.0, 30.0),
4129 Status::default(),
4130 &theme,
4131 &mut scene,
4132 );
4133 scene
4134 .primitives()
4135 .iter()
4136 .filter(
4137 |p| matches!(p, frus_core::Primitive::Text { text, .. } if text == "Type here"),
4138 )
4139 .count()
4140 };
4141 assert_eq!(count_texts(""), 1, "empty: the hint shows");
4142 assert_eq!(count_texts("hi"), 0, "rempli : pas d'indice");
4143 }
4144
4145 #[test]
4146 fn clickable_suffix_emits_and_blocks_caret() {
4147 let field = TextField::new("hello")
4148 .suffix_icon(Icons::CLOSE)
4149 .on_suffix(Msg::Submitted)
4150 .width(220.0);
4151 let (w, y) = (220.0, 12.0);
4152 let x_suffix = w - 8.0; assert_eq!(
4156 Widget::<Msg>::positional_click(&field, x_suffix, y, w, 40.0),
4157 Some(Msg::Submitted)
4158 );
4159 assert_eq!(Widget::<Msg>::cursor_at(&field, x_suffix, y, w, 0), None);
4160 assert_eq!(
4162 Widget::<Msg>::positional_click(&field, 20.0, y, w, 40.0),
4163 None
4164 );
4165 assert!(Widget::<Msg>::cursor_at(&field, 20.0, y, w, 0).is_some());
4166 let deco = TextField::<Msg>::new("hello")
4168 .suffix_icon(Icons::CLOSE)
4169 .width(220.0);
4170 assert_eq!(
4171 Widget::<Msg>::positional_click(&deco, x_suffix, y, w, 40.0),
4172 None
4173 );
4174 }
4175
4176 #[test]
4177 fn hovering_active_suffix_paints_a_halo() {
4178 let field = TextField::new("hello")
4179 .suffix_icon(Icons::CLOSE)
4180 .on_suffix(Msg::Submitted)
4181 .width(220.0);
4182 let bounds = Rect::new(0.0, 0.0, 220.0, 40.0);
4183 let halos = |status: Status| {
4185 let mut scene = Scene::new();
4186 Widget::<Msg>::paint(&field, bounds, status, &Theme::default(), &mut scene);
4187 scene
4188 .primitives()
4189 .iter()
4190 .filter(|p| match p {
4191 frus_core::Primitive::Rect { rect, .. } => {
4192 (rect.width - (FIELD_ICON_SIZE + 8.0)).abs() < 0.5
4193 && (rect.height - (FIELD_ICON_SIZE + 8.0)).abs() < 0.5
4194 }
4195 _ => false,
4196 })
4197 .count()
4198 };
4199 let over_suffix = Status {
4201 hover_cursor: Some(Point::new(212.0, 20.0)),
4202 ..Default::default()
4203 };
4204 let over_body = Status {
4205 hover_cursor: Some(Point::new(20.0, 20.0)),
4206 ..Default::default()
4207 };
4208 assert_eq!(halos(over_suffix), 1, "a halo on the hovered suffix");
4209 assert_eq!(halos(over_body), 0, "no halo in the body");
4210 assert_eq!(halos(Status::default()), 0, "no halo without hover");
4211 }
4212
4213 #[test]
4214 fn cursor_icon_is_pointer_over_active_suffix() {
4215 use crate::interaction::Cursor;
4216 let field = TextField::new("hello")
4217 .suffix_icon(Icons::CLOSE)
4218 .on_suffix(Msg::Submitted)
4219 .width(220.0);
4220 let (w, h, y) = (220.0, 40.0, 12.0);
4221 assert_eq!(
4223 Widget::<Msg>::cursor_icon(&field, w - 8.0, y, w, h),
4224 Some(Cursor::Pointer)
4225 );
4226 assert_eq!(Widget::<Msg>::cursor_icon(&field, 20.0, y, w, h), None);
4227 let deco = TextField::<Msg>::new("hello")
4229 .suffix_icon(Icons::CLOSE)
4230 .width(220.0);
4231 assert_eq!(Widget::<Msg>::cursor_icon(&deco, w - 8.0, y, w, h), None);
4232 }
4233
4234 #[test]
4240 fn each_handle_stands_for_its_own_end_of_the_selection() {
4241 let width = 220.0;
4242 let fields = [
4243 TextField::<Msg>::new("hello world").width(width),
4244 TextField::<Msg>::new("hello world")
4245 .label("Name")
4246 .prefix_icon(Icons::CLOSE)
4247 .width(width),
4248 TextField::<Msg>::new("hello world")
4250 .label("Name")
4251 .outlined()
4252 .width(width),
4253 TextField::<Msg>::new("a sentence long enough to be scrolled along its box")
4255 .width(width),
4256 ];
4257 for field in &fields {
4258 let len = field.value.chars().count();
4259 let edit = Edit {
4260 cursor: len,
4261 anchor: Some(len - 5),
4262 composing: None,
4263 };
4264 let [start, end] = Widget::<Msg>::selection_handles(field, width, &edit, 0.0)
4265 .expect("a selection has handles");
4266 for (handle, index) in [(start, len - 5), (end, len)] {
4267 let at = handle.line_center;
4268 assert_eq!(
4269 Widget::<Msg>::cursor_at(field, at.x, at.y, width, edit.cursor),
4270 Some(index),
4271 "{:?}",
4272 field.value
4273 );
4274 }
4275 assert_eq!(start.rect.x + start.rect.width, start.line_center.x);
4278 assert_eq!(end.rect.x, end.line_center.x);
4279 assert!(start.rect.y > start.line_center.y && end.rect.y > end.line_center.y);
4280 let mut scene = Scene::new();
4284 let status = Status {
4285 focused: true,
4286 cursor: Some(len),
4287 selection: Some((len - 5, len)),
4288 ..Status::default()
4289 };
4290 Widget::<Msg>::paint(
4291 field,
4292 Rect::new(0.0, 0.0, width, 120.0),
4293 status,
4294 &Theme::default(),
4295 &mut scene,
4296 );
4297 let top = scene
4298 .primitives()
4299 .iter()
4300 .find_map(|p| match p {
4301 frus_core::Primitive::Text { position, text, .. } if *text == field.value => {
4302 Some(position.y)
4303 }
4304 _ => None,
4305 })
4306 .expect("the value is painted");
4307 let half = start.line_center.y - top;
4308 assert!(
4309 half > 0.0 && (start.rect.y - top - 2.0 * half).abs() < 0.01,
4310 "{:?}: text at {top}, line centre at {}, handle at {}",
4311 field.value,
4312 start.line_center.y,
4313 start.rect.y
4314 );
4315 }
4316 }
4317
4318 #[test]
4321 fn a_handle_rises_with_the_lines_a_field_scrolls_away() {
4322 let width = 200.0;
4323 let field = TextField::<Msg>::new("one\ntwo\nthree\nfour\nfive\nsix")
4324 .multiline()
4325 .rows(3)
4326 .width(width);
4327 let edit = Edit {
4329 cursor: 23,
4330 anchor: Some(19),
4331 composing: None,
4332 };
4333 let scroll = 20.0;
4334 let still = Widget::<Msg>::selection_handles(&field, width, &edit, 0.0).unwrap();
4335 let [start, end] = Widget::<Msg>::selection_handles(&field, width, &edit, scroll).unwrap();
4336 assert!((still[0].line_center.y - start.line_center.y - scroll).abs() < 0.01);
4337 for (handle, index) in [(start, 19), (end, 23)] {
4338 let at = handle.line_center;
4339 assert_eq!(
4340 Widget::<Msg>::cursor_at(&field, at.x, at.y + scroll, width, edit.cursor),
4341 Some(index)
4342 );
4343 }
4344 }
4345
4346 #[test]
4350 fn the_handles_are_painted_for_a_touch_selection_only() {
4351 let theme = Theme::default();
4352 let fills = |field: &TextField<Msg>, handles: bool, focused: bool| {
4353 let mut scene = Scene::new();
4354 let status = Status {
4355 focused,
4356 cursor: Some(11),
4357 selection: Some((6, 11)),
4358 handles,
4359 ..Status::default()
4360 };
4361 Widget::<Msg>::paint(
4362 field,
4363 Rect::new(10.0, 20.0, 220.0, 56.0),
4364 status,
4365 &theme,
4366 &mut scene,
4367 );
4368 scene
4369 .primitives()
4370 .iter()
4371 .filter_map(|p| match p {
4372 frus_core::Primitive::Path { fill: Some(c), .. } => Some(*c),
4373 _ => None,
4374 })
4375 .collect::<Vec<_>>()
4376 };
4377 let field = TextField::<Msg>::new("hello world").width(220.0);
4378 assert_eq!(fills(&field, true, true), vec![theme.scheme.primary; 2]);
4379 assert!(fills(&field, false, true).is_empty(), "a mouse selection");
4380 assert!(fills(&field, true, false).is_empty(), "a field left behind");
4381 let styled = TextField::<Msg>::new("hello world")
4382 .width(220.0)
4383 .style(TextFieldStyle {
4384 handle_color: Some(frus_core::Color::WHITE),
4385 ..TextFieldStyle::default()
4386 });
4387 assert_eq!(fills(&styled, true, true), vec![frus_core::Color::WHITE; 2]);
4388 }
4389
4390 #[test]
4392 fn a_hidden_caret_is_not_painted() {
4393 let theme = Theme::default();
4394 let carets = |hidden: bool| {
4395 let mut scene = Scene::new();
4396 let status = Status {
4397 focused: true,
4398 cursor: Some(2),
4399 caret_hidden: hidden,
4400 ..Status::default()
4401 };
4402 Widget::<Msg>::paint(
4403 &TextField::<Msg>::new("hello").width(220.0),
4404 Rect::new(0.0, 0.0, 220.0, 56.0),
4405 status,
4406 &theme,
4407 &mut scene,
4408 );
4409 scene
4410 .primitives()
4411 .iter()
4412 .filter(
4413 |p| matches!(p, frus_core::Primitive::Rect { rect, .. } if rect.width == 2.0),
4414 )
4415 .count()
4416 };
4417 assert_eq!(carets(false), 1, "shown");
4418 assert_eq!(carets(true), 0, "hidden");
4419 }
4420
4421 #[test]
4422 fn word_at_finds_word_bounds() {
4423 let inp = input("hello there world");
4424 assert_eq!(inp.word_at(2), Some((0, 5))); assert_eq!(inp.word_at(13), Some((12, 17))); }
4428}
4429
4430#[cfg(test)]
4431mod limit_tests {
4432 use super::*;
4433 use crate::runtime::Edit;
4434
4435 #[derive(Clone, Debug, PartialEq)]
4436 enum Msg {
4437 Changed(String),
4438 }
4439
4440 fn field(value: &str) -> TextField<Msg> {
4441 TextField::new(value).on_input(Msg::Changed)
4442 }
4443
4444 fn typed(field: &TextField<Msg>, text: &str) -> (Option<Msg>, Edit) {
4446 let end = field.value.chars().count();
4447 let mut edit = Edit {
4448 cursor: end,
4449 anchor: None,
4450 ..Default::default()
4451 };
4452 let msg = Widget::on_edit(field, &mut edit, &Key::Text(text.to_string()));
4453 (msg, edit)
4454 }
4455
4456 fn value_of(msg: Option<Msg>) -> Option<String> {
4457 msg.map(|Msg::Changed(v)| v)
4458 }
4459
4460 #[test]
4462 fn a_keystroke_past_the_limit_goes_nowhere() {
4463 let full = field("abcde").max_length(5);
4464 assert_eq!(value_of(typed(&full, "f").0), Some("abcde".into()));
4465 let room = field("abcd").max_length(5);
4466 assert_eq!(value_of(typed(&room, "e").0), Some("abcde".into()));
4467 }
4468
4469 #[test]
4472 fn a_paste_that_crosses_the_limit_lands_what_fits() {
4473 let f = field("ab").max_length(5);
4474 let (msg, edit) = typed(&f, "cdefgh");
4475 assert_eq!(value_of(msg), Some("abcde".into()));
4476 assert_eq!(edit.cursor, 5, "the caret comes to rest at the end");
4477 }
4478
4479 #[test]
4481 fn the_limit_counts_characters_not_bytes() {
4482 let f = field("").max_length(3);
4483 assert_eq!(value_of(typed(&f, "éàü").0), Some("éàü".into()));
4484 let full = field("éàü").max_length(3);
4485 assert_eq!(value_of(typed(&full, "e").0), Some("éàü".into()));
4486 }
4487
4488 #[test]
4491 fn a_value_the_caller_set_over_the_limit_is_left_alone() {
4492 let over = field("abcdefg").max_length(5);
4493 assert_eq!(over.counter().as_deref(), Some("7/5"));
4494 assert_eq!(over.value, "abcdefg");
4495 }
4496
4497 #[test]
4499 fn the_counter_reserves_the_line_it_sits_on() {
4500 assert_eq!(
4501 field("ab").sub_block(),
4502 0.0,
4503 "nothing to say, no room taken"
4504 );
4505 assert!(field("ab").max_length(5).sub_block() > 0.0);
4506 assert_eq!(field("ab").max_length(5).counter().as_deref(), Some("2/5"));
4507 }
4508
4509 #[test]
4511 fn read_only_refuses_the_change_and_keeps_the_caret() {
4512 let f = field("hello").read_only();
4513 assert_eq!(typed(&f, "x").0, None, "typing goes nowhere");
4514
4515 let mut edit = Edit {
4516 cursor: 5,
4517 anchor: None,
4518 ..Default::default()
4519 };
4520 assert_eq!(
4521 Widget::on_edit(
4522 &f,
4523 &mut edit,
4524 &Key::Left {
4525 shift: false,
4526 word: false
4527 }
4528 ),
4529 None
4530 );
4531 assert_eq!(edit.cursor, 4, "but the caret still moves");
4532
4533 let mut edit = Edit {
4534 cursor: 5,
4535 anchor: None,
4536 ..Default::default()
4537 };
4538 Widget::on_edit(
4539 &f,
4540 &mut edit,
4541 &Key::Home {
4542 shift: true,
4543 doc: false,
4544 },
4545 );
4546 assert_eq!(
4547 (edit.cursor, edit.anchor),
4548 (0, Some(5)),
4549 "and a selection can still be made, so it can be copied"
4550 );
4551 }
4552
4553 #[test]
4555 fn read_only_refuses_a_deletion() {
4556 let f = field("hello").read_only();
4557 let mut edit = Edit {
4558 cursor: 5,
4559 anchor: None,
4560 ..Default::default()
4561 };
4562 assert_eq!(Widget::on_edit(&f, &mut edit, &Key::Backspace), None);
4563 assert_eq!(edit.cursor, 5);
4564 }
4565
4566 #[test]
4568 fn read_only_is_not_disabled() {
4569 let read = field("REF-4417").read_only();
4570 assert!(Widget::<Msg>::focusable(&read), "still in the tab order");
4571 assert!(
4572 Widget::<Msg>::cursor_at(&read, 20.0, 10.0, 200.0, 0).is_some(),
4573 "and it still takes a caret, so a reference can be selected and copied"
4574 );
4575
4576 let dead = field("REF-4417").enabled(false);
4577 assert!(!Widget::<Msg>::focusable(&dead));
4578 assert!(Widget::<Msg>::cursor_at(&dead, 20.0, 10.0, 200.0, 0).is_none());
4579 }
4580}
4581
4582#[cfg(test)]
4584mod decoration_tests {
4585 use super::*;
4586 use crate::Icons;
4587
4588 const W: f32 = 300.0;
4589
4590 fn texts(field: &TextField<()>, status: Status) -> Vec<(String, Point, Color)> {
4592 let mut scene = Scene::new();
4593 Widget::<()>::paint(
4594 field,
4595 Rect::new(0.0, 0.0, W, 80.0),
4596 status,
4597 &Theme::default(),
4598 &mut scene,
4599 );
4600 scene
4601 .primitives()
4602 .iter()
4603 .filter_map(|p| match p {
4604 frus_core::Primitive::Text {
4605 text,
4606 position,
4607 color,
4608 ..
4609 } => Some((text.clone(), *position, *color)),
4610 _ => None,
4611 })
4612 .collect()
4613 }
4614
4615 fn find(texts: &[(String, Point, Color)], what: &str) -> Option<(Point, Color)> {
4616 texts
4617 .iter()
4618 .find(|(text, ..)| text == what)
4619 .map(|(_, at, color)| (*at, *color))
4620 }
4621
4622 fn focused() -> Status {
4623 Status {
4624 focused: true,
4625 focus_progress: 1.0,
4626 ..Default::default()
4627 }
4628 }
4629
4630 #[test]
4634 fn the_label_floats_as_it_is_told() {
4635 let label_y =
4636 |field: &TextField<()>, status| find(&texts(field, status), "Name").map(|(at, _)| at.y);
4637 let auto = TextField::<()>::new("").label("Name");
4638 let rest = label_y(&auto, Status::default()).unwrap();
4639 let floated = label_y(&TextField::new("Ada").label("Name"), Status::default()).unwrap();
4640 assert!(
4641 floated < rest,
4642 "auto floats once filled: {floated} < {rest}"
4643 );
4644
4645 let always = TextField::<()>::new("")
4646 .label("Name")
4647 .placeholder("e.g. Ada")
4648 .floating_label_behavior(FloatingLabelBehavior::Always);
4649 let shown = texts(&always, Status::default());
4650 assert_eq!(
4651 find(&shown, "Name").unwrap().0.y,
4652 floated,
4653 "floated at rest"
4654 );
4655 assert!(find(&shown, "e.g. Ada").is_some(), "and the hint shows");
4656
4657 let never = |value: &str| {
4658 TextField::<()>::new(value)
4659 .label("Name")
4660 .placeholder("e.g. Ada")
4661 .floating_label_behavior(FloatingLabelBehavior::Never)
4662 };
4663 assert_eq!(
4664 label_y(&never(""), focused()),
4665 Some(rest),
4666 "inside, even focused"
4667 );
4668 assert!(
4669 find(&texts(&never(""), focused()), "e.g. Ada").is_none(),
4670 "no hint"
4671 );
4672 assert_eq!(
4673 label_y(&never("Ada"), Status::default()),
4674 None,
4675 "gone once filled"
4676 );
4677
4678 let mut theme = Theme::default();
4679 theme.widgets.text_field.floating_label_behavior = Some(FloatingLabelBehavior::Always);
4680 let mut scene = Scene::new();
4681 Widget::<()>::paint(
4682 &TextField::<()>::new("").label("Name"),
4683 Rect::new(0.0, 0.0, W, 80.0),
4684 Status::default(),
4685 &theme,
4686 &mut scene,
4687 );
4688 let themed = scene.primitives().iter().find_map(|p| match p {
4689 frus_core::Primitive::Text { text, position, .. } if text == "Name" => Some(position.y),
4690 _ => None,
4691 });
4692 assert_eq!(themed, Some(floated), "the theme's word");
4693 }
4694
4695 #[test]
4697 fn a_centred_label_floats_to_the_middle() {
4698 let x = |value: &str| {
4699 let field = TextField::<()>::new(value)
4700 .label("Name")
4701 .floating_label_alignment(FloatingLabelAlignment::Center);
4702 find(&texts(&field, Status::default()), "Name").unwrap().0.x
4703 };
4704 let width = frus_text::measure_resolved(
4705 "Name",
4706 &ResolvedTextStyle::exact(FIELD_TEXT_SIZE * FIELD_LABEL_SCALE),
4707 )
4708 .width;
4709 assert!(((x("Ada") + width / 2.0) - W / 2.0).abs() < 0.5, "centred");
4710 let rest = find(
4711 &texts(&TextField::<()>::new("").label("Name"), Status::default()),
4712 "Name",
4713 )
4714 .unwrap()
4715 .0
4716 .x;
4717 assert_eq!(x(""), rest, "at rest, where the value starts");
4718 }
4719
4720 #[test]
4723 fn the_affixes_sit_either_side_of_the_value() {
4724 let theme = Theme::default();
4725 let plain = TextField::<()>::new("42");
4726 let priced = TextField::<()>::new("42")
4727 .prefix_text("$")
4728 .suffix_text("kg");
4729 let shown = texts(&priced, Status::default());
4730 let (dollar, colour) = find(&shown, "$").expect("the prefix");
4731 let (value, _) = find(&shown, "42").unwrap();
4732 let (unit, _) = find(&shown, "kg").expect("the suffix");
4733 assert_eq!(colour, theme.muted, "the hint's colour");
4734 let plain_x = find(&texts(&plain, Status::default()), "42").unwrap().0.x;
4735 let style = TextStyle::new(FIELD_TEXT_SIZE).resolved();
4736 let dollar_w = frus_text::measure_resolved("$", &style).width;
4737 assert_eq!(dollar.x, plain_x, "the prefix where the value was");
4738 assert!(
4739 (value.x - (plain_x + dollar_w)).abs() < 1e-3,
4740 "the value after it"
4741 );
4742 let unit_w = frus_text::measure_resolved("kg", &style).width;
4743 assert!(
4744 unit.x + unit_w <= W && unit.x > value.x,
4745 "the suffix at the end"
4746 );
4747
4748 let at = Widget::<()>::cursor_at(&priced, value.x + 0.5, 30.0, W, 0);
4750 assert_eq!(at, Some(0));
4751
4752 let labelled = TextField::<()>::new("").label("Price").prefix_text("$");
4754 assert!(find(&texts(&labelled, Status::default()), "$").is_none());
4755 assert!(find(&texts(&labelled, focused()), "$").is_some());
4756 let without = TextField::<()>::new("").label("Price");
4759 assert_eq!(
4760 find(&texts(&labelled, Status::default()), "Price")
4761 .unwrap()
4762 .0
4763 .x,
4764 find(&texts(&without, Status::default()), "Price")
4765 .unwrap()
4766 .0
4767 .x
4768 );
4769 let bare = TextField::<()>::new("").prefix_text("$");
4770 assert!(
4771 find(&texts(&bare, Status::default()), "$").is_none(),
4772 "no label: focus"
4773 );
4774 assert!(find(&texts(&bare, focused()), "$").is_some());
4775 }
4776
4777 #[test]
4780 fn the_outer_icon_moves_the_box_over() {
4781 let plain = TextField::<()>::new("42").helper("Help");
4782 let iconed = TextField::<()>::new("42").helper("Help").icon(Icons::STAR);
4783 let before = texts(&plain, Status::default());
4784 let after = texts(&iconed, Status::default());
4785 for what in ["42", "Help"] {
4786 let shift = find(&after, what).unwrap().0.x - find(&before, what).unwrap().0.x;
4787 assert_eq!(shift, 40.0, "{what} moves over by 24 and 16");
4788 }
4789 let dense = TextField::<()>::new("42").dense(true).icon(Icons::STAR);
4790 let dense_plain = TextField::<()>::new("42").dense(true);
4791 let shift = find(&texts(&dense, Status::default()), "42").unwrap().0.x
4792 - find(&texts(&dense_plain, Status::default()), "42")
4793 .unwrap()
4794 .0
4795 .x;
4796 assert_eq!(shift, 34.0, "18 and 16, dense");
4797 let x = find(&after, "42").unwrap().0.x;
4798 assert_eq!(
4799 Widget::<()>::cursor_at(&iconed, x + 0.5, 30.0, W, 0),
4800 Some(0)
4801 );
4802 let boxes: Vec<Rect> = {
4804 let mut scene = Scene::new();
4805 Widget::<()>::paint(
4806 &iconed,
4807 Rect::new(0.0, 0.0, W, 80.0),
4808 Status::default(),
4809 &Theme::default(),
4810 &mut scene,
4811 );
4812 scene
4813 .primitives()
4814 .iter()
4815 .filter_map(|p| match p {
4816 frus_core::Primitive::Rect { rect, .. } => Some(*rect),
4817 _ => None,
4818 })
4819 .collect()
4820 };
4821 assert_eq!(boxes[0].x, 40.0);
4822 assert_eq!(boxes[0].width, W - 40.0);
4823 }
4824
4825 #[test]
4827 fn the_counter_says_what_it_is_told() {
4828 let counted = TextField::<()>::new("abc").max_length(10);
4829 assert!(find(&texts(&counted, Status::default()), "3/10").is_some());
4830 let told = TextField::<()>::new("abc")
4831 .max_length(10)
4832 .counter_text("3 of 10");
4833 let shown = texts(&told, Status::default());
4834 assert!(find(&shown, "3 of 10").is_some() && find(&shown, "3/10").is_none());
4835 let hidden = TextField::<()>::new("abc").max_length(10).counter_text("");
4836 assert!(find(&texts(&hidden, Status::default()), "3/10").is_none());
4837 assert_eq!(hidden.sub_block(), 0.0, "and its line is not kept");
4838 let alone = TextField::<()>::new("abc").counter_text("Optional");
4839 assert!(find(&texts(&alone, Status::default()), "Optional").is_some());
4840 assert!(alone.sub_block() > 0.0);
4841 }
4842}
4843
4844#[cfg(test)]
4846mod lines_tests {
4847 use super::*;
4848
4849 const W: f32 = 300.0;
4850
4851 fn words(n: usize) -> String {
4853 vec!["word"; n].join(" ")
4854 }
4855
4856 fn lines(field: &TextField<()>) -> f32 {
4857 field.lines_at(W)
4858 }
4859
4860 #[test]
4863 fn a_box_grows_from_one_line_to_the_most() {
4864 let empty = TextField::<()>::new("").max_lines(4);
4865 assert!(empty.grows());
4866 assert_eq!(lines(&empty), 1.0, "one line, empty");
4867 let short = TextField::<()>::new(words(2)).max_lines(4);
4868 assert_eq!(lines(&short), 1.0);
4869 let wrapped = TextField::<()>::new(words(40)).max_lines(4);
4870 assert_eq!(lines(&wrapped), 4.0, "held at the most");
4871 let some = TextField::<()>::new(words(14)).max_lines(4);
4872 let n = lines(&some);
4873 assert!(n > 1.0 && n < 4.0, "in between: {n}");
4874 let unbounded = TextField::<()>::new(words(160)).max_lines(None);
4875 assert!(lines(&unbounded) > 12.0, "no most: {}", lines(&unbounded));
4876 }
4877
4878 #[test]
4880 fn the_fewest_lines_hold_the_box_open() {
4881 let held = TextField::<()>::new("").min_lines(2).max_lines(5);
4882 assert_eq!(lines(&held), 2.0);
4883 let either_order = TextField::<()>::new("").max_lines(5).min_lines(2);
4884 assert_eq!(lines(&either_order), 2.0, "said after the most");
4885 let inverted = TextField::<()>::new(words(40)).min_lines(6).max_lines(3);
4886 assert_eq!(
4887 lines(&inverted),
4888 6.0,
4889 "a most below the fewest is the fewest"
4890 );
4891 assert!(!inverted.grows());
4892 let raised = TextField::<()>::new(words(40)).max_lines(3).min_lines(6);
4893 assert_eq!(lines(&raised), 6.0, "the fewest raise a most said first");
4894 assert!(!raised.grows());
4895 }
4896
4897 #[test]
4899 fn one_line_and_fixed_rows_do_not_grow() {
4900 let single = TextField::<()>::new(words(40)).max_lines(1);
4901 assert!(!single.multiline && !single.grows());
4902 assert_eq!(lines(&single), 1.0);
4903 let fixed = TextField::<()>::new(words(40)).rows(3);
4904 assert!(!fixed.grows());
4905 assert_eq!(lines(&fixed), 3.0);
4906 assert!(Widget::<()>::measure(&fixed, &Theme::default()).is_none());
4907 assert!(matches!(
4908 Widget::<()>::style(&fixed).height,
4909 Dimension::Length(_)
4910 ));
4911 }
4912
4913 #[test]
4917 fn the_layout_and_the_paint_agree() {
4918 let field = TextField::<()>::new(words(14))
4919 .max_lines(6)
4920 .label("Notes")
4921 .suffix_icon(crate::Icons::CLOSE)
4922 .on_suffix(());
4923 let style = Widget::<()>::style(&field);
4924 assert_eq!(style.height, Dimension::Auto);
4925 let theme = Theme::default();
4926 let measure = Widget::<()>::measure(&field, &theme).expect("measured");
4927 let size = measure(Some(W), None);
4928 let expected =
4929 frus_core::fits(field.label_block() + field.field_height_at(W) + field.sub_block());
4930 assert_eq!(size.height, expected, "in whole pixels");
4931 assert_eq!(size.height.fract(), 0.0);
4932 assert!(
4933 field.field_height_at(W) > field.field_height(),
4934 "taller than one line"
4935 );
4936 let narrow = measure(Some(200.0), None);
4937 assert!(narrow.height > size.height, "narrower wraps more");
4938
4939 let mut scene = Scene::new();
4940 Widget::<()>::paint(
4941 &field,
4942 Rect::new(0.0, 0.0, W, size.height),
4943 Status::default(),
4944 &theme,
4945 &mut scene,
4946 );
4947 let tallest = scene
4948 .primitives()
4949 .iter()
4950 .filter_map(|p| match p {
4951 frus_core::Primitive::Rect { rect, .. } => Some(rect.height),
4952 _ => None,
4953 })
4954 .fold(0.0, f32::max);
4955 assert_eq!(tallest, field.field_height_at(W), "the box is that tall");
4956 let low = field.label_block() + field.field_height_at(W) - 4.0;
4957 assert!(
4958 field.suffix_hit(W - 10.0, low, W),
4959 "the suffix answers low in the box"
4960 );
4961
4962 let key = |f: &TextField<()>| Widget::<()>::measure_key(f, &theme);
4963 assert_ne!(
4964 key(&TextField::<()>::new(words(14)).max_lines(6).label("Notes")),
4965 key(&TextField::<()>::new(words(30)).max_lines(6).label("Notes")),
4966 "another value, and nothing else, another measurement"
4967 );
4968 assert_ne!(
4969 key(&TextField::<()>::new(words(14)).max_lines(6)),
4970 key(&TextField::<()>::new(words(14))
4971 .max_lines(6)
4972 .prefix_text("$")),
4973 "a prefix narrows the wrap"
4974 );
4975 }
4976}
4977
4978#[cfg(test)]
4980mod cursor_tests {
4981 use super::*;
4982 use frus_core::{BorderRadius, Primitive, TargetPlatform};
4983
4984 fn rects(field: &TextField<()>, theme: &Theme) -> Vec<(Rect, Color, BorderRadius)> {
4986 let mut scene = Scene::new();
4987 Widget::<()>::paint(
4988 field,
4989 Rect::new(0.0, 0.0, 300.0, 80.0),
4990 Status {
4991 focused: true,
4992 focus_progress: 1.0,
4993 cursor: Some(2),
4994 ..Default::default()
4995 },
4996 theme,
4997 &mut scene,
4998 );
4999 scene
5000 .primitives()
5001 .iter()
5002 .filter_map(|p| match p {
5003 Primitive::Rect {
5004 rect,
5005 color,
5006 radius,
5007 ..
5008 } => Some((*rect, *color, *radius)),
5009 _ => None,
5010 })
5011 .collect()
5012 }
5013
5014 fn caret(field: &TextField<()>, theme: &Theme, color: Color) -> Option<(Rect, BorderRadius)> {
5016 rects(field, theme)
5017 .into_iter()
5018 .find(|(_, c, _)| *c == color)
5019 .map(|(r, _, radius)| (r, radius))
5020 }
5021
5022 #[test]
5026 fn the_caret_takes_the_reference_s_colours() {
5027 let theme = Theme::default();
5028 let field = TextField::<()>::new("ab");
5029 let (rect, _) = caret(&field, &theme, theme.scheme.primary).expect("in primary");
5030 assert_eq!(rect.width, 2.0, "the reference's two pixels");
5031
5032 let errored = TextField::<()>::new("ab").error("Wrong");
5033 assert!(
5034 caret(&errored, &theme, theme.scheme.error).is_some(),
5035 "in error"
5036 );
5037 assert!(caret(&errored, &theme, theme.scheme.primary).is_none());
5038
5039 let red = Color::rgb(1.0, 0.0, 0.0);
5040 let told = TextField::<()>::new("ab").cursor_color(red);
5041 assert!(caret(&told, &theme, red).is_some(), "the field's");
5042 let blue = Color::rgb(0.0, 0.0, 1.0);
5043 let told_error = TextField::<()>::new("ab")
5044 .error("Wrong")
5045 .cursor_error_color(blue);
5046 assert!(
5047 caret(&told_error, &theme, blue).is_some(),
5048 "the field's error colour"
5049 );
5050
5051 let mut selection = Theme::default();
5052 selection.widgets.text_selection.cursor_color = Some(red);
5053 assert!(
5054 caret(&field, &selection, red).is_some(),
5055 "the text selection's"
5056 );
5057 let mut both = selection.clone();
5058 both.widgets.text_field.cursor_color = Some(blue);
5059 assert!(
5060 caret(&field, &both, blue).is_some(),
5061 "the field theme's over it"
5062 );
5063 }
5064
5065 #[test]
5068 fn the_caret_takes_its_shape() {
5069 let theme = Theme::default().with_platform(TargetPlatform::Android);
5070 let primary = theme.scheme.primary;
5071 let (line, square) = caret(&TextField::new("ab"), &theme, primary).unwrap();
5072 assert_eq!(square, BorderRadius::uniform(0.0), "square on Android");
5073
5074 let shaped = TextField::<()>::new("ab")
5075 .cursor_width(3.0)
5076 .cursor_height(10.0);
5077 let (rect, _) = caret(&shaped, &theme, primary).unwrap();
5078 assert_eq!((rect.width, rect.height), (3.0, 10.0));
5079 let centre = |r: Rect| r.y + r.height / 2.0;
5080 assert!(
5081 (centre(rect) - centre(line)).abs() < 1e-3,
5082 "centred on the line"
5083 );
5084
5085 let ios = Theme::default().with_platform(TargetPlatform::Ios);
5086 let (_, round) = caret(&TextField::new("ab"), &ios, ios.scheme.primary).unwrap();
5087 assert_eq!(round, BorderRadius::uniform(2.0), "rounded on iOS");
5088 let told = TextField::<()>::new("ab").cursor_radius(1.0);
5089 assert_eq!(
5090 caret(&told, &theme, primary).unwrap().1,
5091 BorderRadius::uniform(1.0)
5092 );
5093
5094 let hidden = TextField::<()>::new("ab").show_cursor(false);
5095 assert!(caret(&hidden, &theme, primary).is_none(), "not shown");
5096 }
5097
5098 #[test]
5100 fn a_masked_value_shows_the_character_told() {
5101 assert_eq!(TextField::<()>::new("abc").obscure(true).display(), "•••");
5102 let starred = TextField::<()>::new("abc")
5103 .obscure(true)
5104 .obscuring_character('*');
5105 assert_eq!(starred.display(), "***");
5106 assert_eq!(
5107 TextField::<()>::new("abc")
5108 .obscuring_character('*')
5109 .display(),
5110 "abc",
5111 "only a masked field"
5112 );
5113 }
5114
5115 #[test]
5117 fn a_field_asks_for_the_focus_when_told() {
5118 assert!(!Widget::<()>::autofocus(&TextField::new("")));
5119 assert!(Widget::<()>::autofocus(&TextField::new("").autofocus(true)));
5120 assert!(!Widget::<()>::autofocus(
5121 &TextField::new("").autofocus(true).enabled(false)
5122 ));
5123 }
5124}