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)]
80pub enum TextFieldVariant {
81 #[default]
84 Outlined,
85 Filled,
88 None,
101}
102
103#[derive(Clone, Copy, Debug, Default)]
106pub struct TextFieldStyle {
107 pub fill: Option<frus_core::Color>,
110 pub border_color: Option<frus_core::Color>,
112 pub focused_border_color: Option<frus_core::Color>,
114 pub error_color: Option<frus_core::Color>,
116 pub error_hover_color: Option<frus_core::Color>,
119 pub text_color: Option<frus_core::Color>,
121 pub label_color: Option<frus_core::Color>,
123 pub focused_label_color: Option<frus_core::Color>,
125 pub helper_color: Option<frus_core::Color>,
127 pub icon_color: Option<frus_core::Color>,
129 pub handle_color: Option<frus_core::Color>,
132 pub radius: Option<f32>,
134 pub border_width: Option<f32>,
136 pub focused_border_width: Option<f32>,
138 pub icon_size: Option<f32>,
140 pub padding_x: Option<f32>,
142}
143
144pub struct TextField<Msg = crate::callback::Callback> {
150 value: String,
151 size: f32,
152 width: Dimension,
153 sized: bool,
156 on_input: Option<Box<dyn Fn(String) -> Msg>>,
157 on_submit: Option<Msg>,
158 announce: Option<String>,
160 label: Option<String>,
162 placeholder: Option<String>,
164 helper: Option<String>,
166 error: Option<String>,
169 obscure: bool,
172 text_align: TextAlign,
175 keyboard: Option<KeyboardType>,
178 action: Option<TextInputAction>,
180 prefix: Option<IconData>,
182 suffix: Option<IconData>,
184 suffix_action: Option<Msg>,
187 multiline: bool,
190 rows: u16,
192 variant: TextFieldVariant,
194 enabled: bool,
197 dense: bool,
200 max_length: Option<usize>,
202 input_filter: Option<Box<dyn Fn(char) -> Option<char>>>,
205 capitalization: Capitalization,
208 read_only: bool,
211 style: TextFieldStyle,
213 autofill: Vec<AutofillHint>,
216 toolbar_build: Option<ToolbarBuild<Msg>>,
219 toolbars:
223 [std::cell::OnceCell<Option<Box<dyn Widget<Msg>>>>; ToolbarContext::VARIANTS as usize],
224}
225
226pub(crate) type ToolbarBuild<Msg> =
229 Box<dyn Fn(&ToolbarContext, Vec<ToolbarItem<Msg>>) -> Vec<ToolbarItem<Msg>>>;
230
231impl TextField<crate::callback::Callback> {
232 pub fn controller(mut self, controller: &crate::TextEditingController) -> Self {
238 self.value = controller.text();
239 let write = controller.clone();
240 self.on_input(move |text| write.set_text(text))
241 }
242}
243
244fn is_word(c: char) -> bool {
246 c.is_alphanumeric() || c == '_'
247}
248
249pub(crate) fn word_boundary_left(chars: &[char], cursor: usize) -> usize {
252 let mut i = cursor;
253 while i > 0 && !is_word(chars[i - 1]) {
254 i -= 1;
255 }
256 while i > 0 && is_word(chars[i - 1]) {
257 i -= 1;
258 }
259 i
260}
261
262pub(crate) fn word_boundary_right(chars: &[char], cursor: usize) -> usize {
265 let len = chars.len();
266 let mut i = cursor;
267 while i < len && !is_word(chars[i]) {
268 i += 1;
269 }
270 while i < len && is_word(chars[i]) {
271 i += 1;
272 }
273 i
274}
275
276pub(crate) fn line_start(chars: &[char], cursor: usize) -> usize {
278 let mut i = cursor;
279 while i > 0 && chars[i - 1] != '\n' {
280 i -= 1;
281 }
282 i
283}
284
285pub(crate) fn line_end(chars: &[char], cursor: usize) -> usize {
287 let len = chars.len();
288 let mut i = cursor;
289 while i < len && chars[i] != '\n' {
290 i += 1;
291 }
292 i
293}
294
295pub(crate) const HANDLE_SIZE: f32 = 22.0;
297
298pub(crate) fn handles_in(
303 layout: &TextLayout,
304 origin: Point,
305 len: usize,
306 edit: &Edit,
307) -> Option<[crate::SelectionHandle; 2]> {
308 let (start, end) = edit.selection_range()?;
309 let (start, end) = (start.min(len), end.min(len));
310 if start >= end {
311 return None;
312 }
313 Some([
314 handle_at(layout, origin, start, true),
315 handle_at(layout, origin, end, false),
316 ])
317}
318
319pub(crate) fn handle_at(
322 layout: &TextLayout,
323 origin: Point,
324 index: usize,
325 start: bool,
326) -> crate::SelectionHandle {
327 let caret = layout.caret_rect(index);
328 let x = origin.x + caret.x;
329 let top = origin.y + caret.y;
330 crate::SelectionHandle {
331 rect: Rect::new(
334 if start { x - HANDLE_SIZE } else { x },
335 top + caret.height,
336 HANDLE_SIZE,
337 HANDLE_SIZE,
338 ),
339 line_center: Point::new(x, top + caret.height * 0.5),
340 }
341}
342
343pub(crate) fn paint_handle(
346 scene: &mut Scene,
347 handle: crate::SelectionHandle,
348 offset: Point,
349 color: Color,
350 start: bool,
351) {
352 scene.fill_path(
353 &handle_path(handle.rect.translate(offset.x, offset.y), start),
354 color,
355 );
356}
357
358pub(crate) fn selection_box_in(
362 layout: &TextLayout,
363 origin: Point,
364 len: usize,
365 edit: &Edit,
366) -> Rect {
367 let selected = edit
368 .selection_range()
369 .map(|(start, end)| layout.selection_rects(start.min(len), end.min(len)))
370 .unwrap_or_default();
371 let mut boxed = selected.first().copied();
372 for rect in selected.iter().skip(1) {
373 boxed = boxed.map(|so_far| so_far.union(*rect));
374 }
375 let boxed = boxed.unwrap_or_else(|| layout.caret_rect(edit.cursor.min(len)));
376 boxed.translate(origin.x, origin.y)
377}
378
379pub(crate) fn paint_handles(
382 scene: &mut Scene,
383 handles: [crate::SelectionHandle; 2],
384 offset: Point,
385 color: Color,
386) {
387 let [start, end] = handles;
388 let place = |r: Rect| r.translate(offset.x, offset.y);
389 scene.fill_path(&handle_path(place(start.rect), true), color);
390 scene.fill_path(&handle_path(place(end.rect), false), color);
391}
392
393pub(crate) fn word_range(chars: &[char], index: usize) -> Option<(usize, usize)> {
396 let len = chars.len();
397 if len == 0 {
398 return Some((0, 0));
399 }
400 let i = index.min(len - 1);
401 if !is_word(chars[i]) {
402 return Some((i, (i + 1).min(len)));
403 }
404 let mut start = i;
405 while start > 0 && is_word(chars[start - 1]) {
406 start -= 1;
407 }
408 let mut end = i + 1;
409 while end < len && is_word(chars[end]) {
410 end += 1;
411 }
412 Some((start, end))
413}
414
415pub fn word_bounds(text: &str, index: usize) -> (usize, usize) {
418 let chars: Vec<char> = text.chars().collect();
419 word_range(&chars, index).unwrap_or((0, 0))
420}
421
422pub(crate) fn move_caret(
427 chars: &[char],
428 cursor: &mut usize,
429 anchor: &mut Option<usize>,
430 key: &Key,
431) -> bool {
432 let len = chars.len();
433 let target = match key {
434 Key::Left { word, .. } => {
435 if *word {
436 word_boundary_left(chars, *cursor)
437 } else {
438 cursor.saturating_sub(1)
439 }
440 }
441 Key::Right { word, .. } => {
442 if *word {
443 word_boundary_right(chars, *cursor)
444 } else {
445 (*cursor + 1).min(len)
446 }
447 }
448 Key::Home { doc, .. } => {
450 if *doc {
451 0
452 } else {
453 line_start(chars, *cursor)
454 }
455 }
456 Key::End { doc, .. } => {
457 if *doc {
458 len
459 } else {
460 line_end(chars, *cursor)
461 }
462 }
463 _ => return false,
464 };
465 let shift = matches!(
466 key,
467 Key::Left { shift: true, .. }
468 | Key::Right { shift: true, .. }
469 | Key::Home { shift: true, .. }
470 | Key::End { shift: true, .. }
471 );
472 move_cursor(cursor, anchor, target, shift);
473 true
474}
475
476fn handle_path(rect: Rect, start: bool) -> frus_core::Path {
480 use std::f32::consts::{FRAC_PI_2, PI};
481 let r = rect.width * 0.5;
482 let centre = Point::new(rect.x + r, rect.y + r);
483 if start {
484 frus_core::Path::new()
487 .move_to(Point::new(rect.x + rect.width, rect.y))
488 .line_to(Point::new(rect.x + rect.width, centre.y))
489 .arc_to(centre, r, 0.0, 3.0 * FRAC_PI_2)
490 .close()
491 } else {
492 frus_core::Path::new()
495 .move_to(Point::new(rect.x, rect.y))
496 .line_to(Point::new(centre.x, rect.y))
497 .arc_to(centre, r, -FRAC_PI_2, PI)
498 .close()
499 }
500}
501
502fn move_cursor(cursor: &mut usize, anchor: &mut Option<usize>, target: usize, shift: bool) {
504 if shift {
505 if anchor.is_none() {
506 *anchor = Some(*cursor);
507 }
508 } else {
509 *anchor = None;
510 }
511 *cursor = target;
512}
513
514impl<Msg> TextField<Msg> {
515 pub fn new(value: impl Into<String>) -> Self {
517 Self {
518 value: value.into(),
519 size: FIELD_TEXT_SIZE,
520 width: Dimension::Length(220.0),
521 sized: false,
522 on_input: None,
523 on_submit: None,
524 announce: None,
525 label: None,
526 placeholder: None,
527 helper: None,
528 error: None,
529 obscure: false,
530 text_align: TextAlign::Start,
531 keyboard: None,
532 action: None,
533 prefix: None,
534 suffix: None,
535 suffix_action: None,
536 multiline: false,
537 rows: 3,
538 variant: TextFieldVariant::Outlined,
539 enabled: true,
540 dense: false,
541 max_length: None,
542 input_filter: None,
543 capitalization: Capitalization::Auto,
544 read_only: false,
545 style: TextFieldStyle::default(),
546 autofill: Vec::new(),
547 toolbar_build: None,
548 toolbars: Default::default(),
549 }
550 }
551
552 pub fn outlined(mut self) -> Self {
555 self.variant = TextFieldVariant::Outlined;
556 self
557 }
558
559 pub fn borderless(mut self) -> Self {
565 self.variant = TextFieldVariant::None;
566 self
567 }
568
569 pub fn filled(mut self) -> Self {
572 self.variant = TextFieldVariant::Filled;
573 self
574 }
575
576 pub fn variant(mut self, variant: TextFieldVariant) -> Self {
578 self.variant = variant;
579 self
580 }
581
582 pub fn enabled(mut self, enabled: bool) -> Self {
586 self.enabled = enabled;
587 self
588 }
589
590 pub fn style(mut self, style: TextFieldStyle) -> Self {
594 self.style = style;
595 self
596 }
597
598 pub fn dense(mut self, dense: bool) -> Self {
602 self.dense = dense;
603 self
604 }
605
606 fn is_outlined(&self) -> bool {
608 self.variant == TextFieldVariant::Outlined
609 }
610
611 fn is_borderless(&self) -> bool {
613 self.variant == TextFieldVariant::None
614 }
615
616 fn pad_x(&self) -> f32 {
618 self.style.padding_x.unwrap_or(FIELD_PADDING_X)
619 }
620
621 fn pad_top(&self) -> f32 {
624 match (self.is_outlined(), self.dense) {
625 (true, false) => FIELD_OUTLINED_PADDING_TOP,
626 (true, true) => FIELD_DENSE_OUTLINED_PADDING_TOP,
627 (false, false) => FIELD_PADDING_Y,
628 (false, true) => FIELD_DENSE_PADDING_Y,
629 }
630 }
631
632 fn pad_bottom(&self) -> f32 {
634 match (self.is_outlined(), self.dense) {
635 (true, false) => FIELD_OUTLINED_PADDING_BOTTOM,
636 (true, true) => FIELD_DENSE_OUTLINED_PADDING_BOTTOM,
637 (false, false) => FIELD_PADDING_Y,
638 (false, true) => FIELD_DENSE_PADDING_Y,
639 }
640 }
641
642 fn text_style(&self) -> ResolvedTextStyle {
649 TextStyle::new(self.size).resolved()
650 }
651
652 fn sub_style(&self) -> ResolvedTextStyle {
654 TextStyle::new(FIELD_SUB_SIZE).resolved()
655 }
656
657 fn label_size(&self) -> f32 {
660 self.text_style().size * FIELD_LABEL_SCALE
661 }
662
663 fn icon_size(&self) -> f32 {
665 self.style.icon_size.unwrap_or(FIELD_ICON_SIZE)
666 }
667
668 fn text_top(&self) -> f32 {
671 self.pad_top() + self.floating_label_height()
672 }
673
674 fn floating_label_height(&self) -> f32 {
678 if self.is_outlined() || self.label.is_none() {
679 0.0
680 } else {
681 FIELD_GAP + self.label_size()
682 }
683 }
684
685 fn resolved(&self, theme: &Theme) -> TextFieldStyle {
687 let t = theme.widgets.text_field;
688 let pick = |a: Option<frus_core::Color>,
689 b: Option<frus_core::Color>,
690 c: frus_core::Color| { Some(a.or(b).unwrap_or(c)) };
691 TextFieldStyle {
692 fill: pick(
693 self.style.fill,
694 t.fill,
695 if self.is_outlined() || self.is_borderless() {
696 frus_core::Color::TRANSPARENT
697 } else {
698 theme.scheme.surface_container_highest
701 },
702 ),
703 border_color: pick(
704 self.style.border_color,
705 t.border_color,
706 if self.is_outlined() {
707 theme.scheme.outline
708 } else {
709 theme.scheme.on_surface_variant
710 },
711 ),
712 focused_border_color: pick(
713 self.style.focused_border_color,
714 t.focused_border_color,
715 theme.scheme.primary,
716 ),
717 error_color: pick(self.style.error_color, t.error_color, theme.scheme.error),
718 error_hover_color: pick(
719 self.style.error_hover_color,
720 t.error_hover_color,
721 theme.scheme.on_error_container,
722 ),
723 text_color: pick(self.style.text_color, t.text_color, theme.scheme.on_surface),
724 label_color: pick(
725 self.style.label_color,
726 t.label_color,
727 theme.scheme.on_surface_variant,
728 ),
729 focused_label_color: pick(
730 self.style.focused_label_color,
731 t.focused_label_color,
732 theme.scheme.primary,
733 ),
734 helper_color: pick(
735 self.style.helper_color,
736 t.helper_color,
737 theme.scheme.on_surface_variant,
738 ),
739 icon_color: pick(
740 self.style.icon_color,
741 t.icon_color,
742 theme.scheme.on_surface_variant,
743 ),
744 handle_color: pick(
745 self.style.handle_color,
746 t.handle_color,
747 theme.scheme.primary,
748 ),
749 radius: Some(self.style.radius.or(t.radius).unwrap_or(FIELD_RADIUS)),
750 border_width: Some(
751 self.style
752 .border_width
753 .or(t.border_width)
754 .unwrap_or(FIELD_BORDER_WIDTH),
755 ),
756 focused_border_width: Some(
757 self.style
758 .focused_border_width
759 .or(t.focused_border_width)
760 .unwrap_or(FIELD_FOCUSED_BORDER_WIDTH),
761 ),
762 icon_size: Some(self.icon_size()),
763 padding_x: Some(self.pad_x()),
764 }
765 }
766
767 pub fn autofill(mut self, hints: impl IntoIterator<Item = AutofillHint>) -> Self {
780 self.autofill = hints.into_iter().collect();
781 self
782 }
783
784 pub fn multiline(mut self) -> Self {
787 self.multiline = true;
788 self
789 }
790
791 pub fn rows(mut self, rows: u16) -> Self {
794 self.multiline = true;
795 self.rows = rows.max(1);
796 self
797 }
798
799 pub fn obscure(mut self, obscure: bool) -> Self {
807 self.obscure = obscure;
808 self
809 }
810
811 pub fn text_align(mut self, align: TextAlign) -> Self {
825 self.text_align = align;
826 self
827 }
828
829 fn align_offset(&self, content_w: f32, text_w: f32) -> f32 {
852 if self.multiline {
853 return 0.0;
854 }
855 let slack = (content_w - text_w).max(0.0);
856 match self.text_align {
857 TextAlign::Start | TextAlign::Left | TextAlign::Justify => 0.0,
861 TextAlign::End | TextAlign::Right => slack,
862 TextAlign::Center => slack / 2.0,
863 }
864 }
865
866 pub fn keyboard_type(mut self, keyboard: KeyboardType) -> Self {
872 self.keyboard = Some(keyboard);
873 self
874 }
875
876 pub fn action(mut self, action: TextInputAction) -> Self {
881 self.action = Some(action);
882 self
883 }
884
885 fn ime_options(&self) -> Ime {
887 let keyboard = self.keyboard.unwrap_or({
888 if self.obscure {
892 KeyboardType::Password
893 } else if self.multiline {
894 KeyboardType::Multiline
895 } else {
896 KeyboardType::Text
897 }
898 });
899 let action = self.action.unwrap_or(match keyboard {
900 KeyboardType::Multiline => TextInputAction::Newline,
903 _ => TextInputAction::Done,
904 });
905 Ime {
906 keyboard,
907 action,
908 capitalization: self.capitalization,
909 }
910 }
911
912 pub fn prefix_icon(mut self, icon: IconData) -> Self {
914 self.prefix = Some(icon);
915 self
916 }
917
918 pub fn suffix_icon(mut self, icon: IconData) -> Self {
920 self.suffix = Some(icon);
921 self
922 }
923
924 pub fn on_suffix(mut self, message: impl Into<Msg>) -> Self {
928 self.suffix_action = Some(message.into());
929 self
930 }
931
932 fn suffix_hit(&self, local_x: f32, local_y: f32, width: f32) -> bool {
935 if self.suffix.is_none() {
936 return false;
937 }
938 let zone_left = width - (self.icon_size() + ICON_PAD * 2.0);
939 let top = self.label_block();
940 let bottom = top + self.field_height();
941 local_x >= zone_left && local_y >= top && local_y <= bottom
942 }
943
944 pub fn label(mut self, label: impl Into<String>) -> Self {
946 self.label = Some(label.into());
947 self
948 }
949
950 pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
952 self.placeholder = Some(placeholder.into());
953 self
954 }
955
956 pub fn helper(mut self, helper: impl Into<String>) -> Self {
958 self.helper = Some(helper.into());
959 self
960 }
961
962 pub fn error(mut self, message: impl Into<String>) -> Self {
965 self.error = Some(message.into());
966 self
967 }
968
969 pub fn width(mut self, width: f32) -> Self {
971 self.width = Dimension::Length(width);
972 self.sized = true;
973 self
974 }
975
976 pub fn size(mut self, size: f32) -> Self {
978 self.size = size;
979 self
980 }
981
982 pub fn max_length(mut self, max: usize) -> Self {
996 self.max_length = Some(max);
997 self
998 }
999
1000 pub fn input_filter(mut self, filter: impl Fn(char) -> Option<char> + 'static) -> Self {
1024 self.input_filter = Some(Box::new(filter));
1025 self
1026 }
1027
1028 pub fn digits_only(mut self) -> Self {
1035 self.input_filter = Some(Box::new(|c| c.is_ascii_digit().then_some(c)));
1036 self.keyboard = self.keyboard.or(Some(KeyboardType::Number));
1037 self
1038 }
1039
1040 pub fn capitalization(mut self, capitalization: Capitalization) -> Self {
1047 self.capitalization = capitalization;
1048 self
1049 }
1050
1051 pub fn read_only(mut self) -> Self {
1060 self.read_only = true;
1061 self
1062 }
1063
1064 pub fn selection_toolbar(
1095 mut self,
1096 build: impl Fn(&ToolbarContext, Vec<ToolbarItem<Msg>>) -> Vec<ToolbarItem<Msg>> + 'static,
1097 ) -> Self {
1098 self.toolbar_build = Some(Box::new(build));
1099 self.toolbars = Default::default();
1101 self
1102 }
1103
1104 pub fn on_input<R: crate::callback::IntoMsg<Msg>>(
1106 mut self,
1107 on_input: impl Fn(String) -> R + 'static,
1108 ) -> Self {
1109 self.on_input = Some(Box::new(crate::callback::handler1(on_input)));
1110 self
1111 }
1112
1113 pub fn on_submit(mut self, message: impl Into<Msg>) -> Self {
1115 self.on_submit = Some(message.into());
1116 self
1117 }
1118
1119 pub fn announce(mut self, message: impl Into<String>) -> Self {
1127 self.announce = Some(message.into());
1128 self
1129 }
1130
1131 fn display(&self) -> String {
1135 if self.obscure {
1136 OBSCURE_CHAR.to_string().repeat(self.value.chars().count())
1137 } else {
1138 self.value.clone()
1139 }
1140 }
1141
1142 fn layout(&self, wrap_width: Option<f32>) -> TextLayout {
1146 let style = self.text_style();
1147 TextLayout::wrapped(
1148 &self.display(),
1149 style.size,
1150 style.weight,
1151 style.italic,
1152 wrap_width,
1153 )
1154 }
1155
1156 fn prefix_w(&self) -> f32 {
1158 if self.prefix.is_some() {
1159 self.icon_size() + ICON_PAD
1160 } else {
1161 0.0
1162 }
1163 }
1164
1165 fn suffix_w(&self) -> f32 {
1167 if self.suffix.is_some() {
1168 self.icon_size() + ICON_PAD
1169 } else {
1170 0.0
1171 }
1172 }
1173
1174 fn text_frame(&self, width: f32, edit: &Edit, scroll_y: f32) -> (TextLayout, f32, f32) {
1181 let len = self.value.chars().count();
1182 let content_w = self.content_width(width);
1183 let layout = self.layout(self.multiline.then_some(content_w));
1184 let scroll = (layout.caret_rect(edit.cursor.min(len)).x - content_w).max(0.0);
1185 let align = self.align_offset(content_w, layout.size().width);
1186 let vscroll = if self.multiline {
1187 let visible = self.field_height() - self.text_top() - self.pad_bottom();
1188 scroll_y.clamp(0.0, (layout.size().height - visible).max(0.0))
1189 } else {
1190 0.0
1191 };
1192 let origin_x = self.pad_x() + self.prefix_w() + align - scroll;
1193 let origin_y = self.label_block() + self.text_top() - vscroll;
1194 (layout, origin_x, origin_y)
1195 }
1196
1197 fn handles(
1200 &self,
1201 width: f32,
1202 edit: &Edit,
1203 scroll_y: f32,
1204 ) -> Option<[crate::SelectionHandle; 2]> {
1205 let len = self.value.chars().count();
1206 edit.selection_range()?;
1207 let (layout, origin_x, origin_y) = self.text_frame(width, edit, scroll_y);
1208 handles_in(&layout, Point::new(origin_x, origin_y), len, edit)
1209 }
1210
1211 fn selection_box(&self, width: f32, edit: &Edit, scroll_y: f32) -> Rect {
1214 let len = self.value.chars().count();
1215 let (layout, origin_x, origin_y) = self.text_frame(width, edit, scroll_y);
1216 selection_box_in(&layout, Point::new(origin_x, origin_y), len, edit)
1217 }
1218
1219 fn default_toolbar_items(&self, context: &ToolbarContext) -> Vec<ToolbarItem<Msg>> {
1224 let editable = self.enabled && !self.read_only;
1225 let mut items = Vec::new();
1226 if context.has_selection && editable && !self.obscure {
1227 items.push(ToolbarItem::cut());
1228 }
1229 if context.has_selection && !self.obscure {
1230 items.push(ToolbarItem::copy());
1231 }
1232 if context.can_paste && editable {
1233 items.push(ToolbarItem::paste());
1234 }
1235 if !context.all_selected {
1236 items.push(ToolbarItem::select_all());
1237 }
1238 items
1239 }
1240
1241 fn content_width(&self, width: f32) -> f32 {
1242 (width - (self.pad_x() + self.prefix_w()) - self.pad_x() - self.suffix_w()).max(0.0)
1243 }
1244
1245 fn label_block(&self) -> f32 {
1249 if self.label.is_some() {
1250 if self.is_outlined() {
1251 (frus_text::line_height(self.label_size()) * 0.5).ceil()
1252 } else {
1253 frus_text::line_height(self.label_size()) + FIELD_GAP
1254 }
1255 } else {
1256 0.0
1257 }
1258 }
1259
1260 fn counter(&self) -> Option<String> {
1262 self.max_length
1263 .map(|max| format!("{}/{}", self.value.chars().count(), max))
1264 }
1265
1266 fn sub_block(&self) -> f32 {
1268 if self.error.is_some() || self.helper.is_some() || self.max_length.is_some() {
1269 self.sub_style().line_height() + FIELD_GAP
1273 } else {
1274 0.0
1275 }
1276 }
1277
1278 fn field_height(&self) -> f32 {
1281 let lines = if self.multiline {
1282 self.rows.max(1) as f32
1283 } else {
1284 1.0
1285 };
1286 (self.text_style().line_height() * lines + self.text_top() + self.pad_bottom()).ceil()
1287 }
1288}
1289
1290impl<Msg: Clone + 'static> Widget<Msg> for TextField<Msg> {
1291 fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
1294 if false {
1295 return crate::widget::FillAxes::NONE;
1296 }
1297 crate::widget::FillAxes {
1298 horizontal: !self.sized,
1299 vertical: false,
1300 }
1301 }
1302
1303 fn soft_extent(&self) -> crate::widget::FillAxes {
1306 crate::widget::FillAxes {
1307 horizontal: !self.sized,
1308 vertical: false,
1309 }
1310 }
1311
1312 fn style(&self) -> Style {
1313 let height = self.label_block() + self.field_height() + self.sub_block();
1314 Style {
1315 width: self.width,
1316 height: Dimension::Length(height),
1317 ..Default::default()
1318 }
1319 }
1320
1321 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
1322 &[]
1323 }
1324
1325 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
1326 let s = self.resolved(theme);
1327 let o = if self.enabled {
1331 status.opacity
1332 } else {
1333 status.opacity * FIELD_DISABLED_OPACITY
1334 };
1335 let has_error = self.error.is_some() && self.enabled;
1336 let fp = if self.enabled {
1337 status.focus_progress.clamp(0.0, 1.0)
1338 } else {
1339 0.0
1340 };
1341 let error_ink = s.error_color.unwrap().lerp(s.error_hover_color.unwrap(), {
1349 let hover = if self.enabled {
1350 status.hover_progress.clamp(0.0, 1.0)
1351 } else {
1352 0.0
1353 };
1354 hover * (1.0 - fp)
1355 });
1356
1357 let label_block = self.label_block();
1360 let field = Rect::new(
1361 bounds.x,
1362 bounds.y + label_block,
1363 bounds.width,
1364 self.field_height(),
1365 );
1366
1367 let float_t = if self.value.is_empty() { fp } else { 1.0 };
1373 let content_left = field.x + self.pad_x() + self.prefix_w();
1374 let label_geom = self.label.as_ref().map(|label| {
1378 let rest = Point::new(content_left, field.y + self.text_top());
1379 let (fx, fy) = if self.is_outlined() {
1380 (
1382 field.x + self.pad_x(),
1383 field.y - frus_text::line_height(self.label_size()) * 0.5,
1384 )
1385 } else {
1386 (content_left, field.y + self.pad_top())
1389 };
1390 let x = rest.x + (fx - rest.x) * float_t;
1391 let y = rest.y + (fy - rest.y) * float_t;
1392 let resolved = self.text_style().size;
1393 let size = resolved + (self.label_size() - resolved) * float_t;
1394 let color = if has_error {
1395 error_ink
1396 } else {
1397 s.label_color
1398 .unwrap()
1399 .lerp(s.focused_label_color.unwrap(), fp)
1400 };
1401 (label.clone(), x, y, size, color)
1402 });
1403 let sub = self.error.as_ref().or(self.helper.as_ref());
1405 if let Some(sub) = sub {
1406 let color = if has_error {
1411 s.error_color.unwrap()
1412 } else {
1413 s.helper_color.unwrap()
1414 };
1415 scene.text(
1416 Point::new(bounds.x, field.y + field.height + FIELD_GAP),
1417 sub.clone(),
1418 &self.sub_style(),
1419 color.fade(o),
1420 );
1421 }
1422 if let Some(counter) = self.counter() {
1427 let width = frus_text::measure_resolved(&counter, &self.sub_style()).width;
1428 scene.text(
1429 Point::new(
1430 bounds.x + bounds.width - width,
1431 field.y + field.height + FIELD_GAP,
1432 ),
1433 counter,
1434 &self.sub_style(),
1435 s.helper_color.unwrap().fade(o),
1436 );
1437 }
1438
1439 let border_color = if has_error {
1444 error_ink
1445 } else {
1446 s.border_color
1447 .unwrap()
1448 .lerp(s.focused_border_color.unwrap(), fp)
1449 }
1450 .fade(o);
1451 let rest_w = s.border_width.unwrap();
1452 let border_width = rest_w + (s.focused_border_width.unwrap() - rest_w) * fp;
1453 let radius = s.radius.unwrap();
1454 let fill = s.fill.unwrap();
1455 if self.is_borderless() {
1456 } else if self.is_outlined() {
1458 scene.draw_rect(field, fill.fade(o), radius, border_width, border_color);
1459 } else {
1460 scene.draw_rect(
1463 field,
1464 fill.fade(o),
1465 frus_core::BorderRadius {
1466 top_left: radius,
1467 top_right: radius,
1468 bottom_right: 0.0,
1469 bottom_left: 0.0,
1470 },
1471 0.0,
1472 frus_core::Color::TRANSPARENT,
1473 );
1474 scene.fill_rect(
1475 Rect::new(
1476 field.x,
1477 field.y + field.height - border_width,
1478 field.width,
1479 border_width,
1480 ),
1481 border_color,
1482 );
1483 }
1484
1485 if !self.is_outlined() {
1491 if let Some((label, x, y, size, color)) = &label_geom {
1492 let style = ResolvedTextStyle::exact(*size);
1495 scene.text(Point::new(*x, *y), label.clone(), &style, color.fade(o));
1496 }
1497 }
1498
1499 if self.is_outlined() {
1503 if let Some((label, x, y, size, color)) = &label_geom {
1504 if float_t > 0.01 {
1505 let label_w = frus_text::measure(label, self.label_size()).width;
1506 let notch = Rect::new(
1507 *x - FIELD_NOTCH_GAP,
1508 field.y - (border_width + FIELD_NOTCH_GAP) * 0.5,
1509 label_w + FIELD_NOTCH_GAP * 2.0,
1510 border_width + FIELD_NOTCH_GAP,
1511 );
1512 scene.fill_rect(notch, theme.surface.fade(o * float_t));
1513 }
1514 scene.text(
1515 Point::new(*x, *y),
1516 label.clone(),
1517 &ResolvedTextStyle::exact(*size),
1518 color.fade(o),
1519 );
1520 }
1521 }
1522
1523 let icon_color = s.icon_color.unwrap().fade(o);
1525 let icon_y = field.y + (field.height - self.icon_size()) * 0.5;
1526 if let Some(prefix) = self.prefix {
1527 let path = prefix.placed(
1528 self.icon_size(),
1529 field.x + ICON_PAD,
1530 icon_y,
1531 theme.direction,
1532 );
1533 scene.fill_path(&path, icon_color);
1534 }
1535 if let Some(suffix) = self.suffix {
1536 let x = field.x + field.width - self.icon_size() - ICON_PAD;
1537 if self.suffix_action.is_some() {
1541 if let Some(hc) = status.hover_cursor {
1542 if self.suffix_hit(hc.x - bounds.x, hc.y - bounds.y, bounds.width) {
1543 let halo = Rect::new(
1544 x - 4.0,
1545 icon_y - 4.0,
1546 self.icon_size() + 8.0,
1547 self.icon_size() + 8.0,
1548 );
1549 scene.draw_rect(
1550 halo,
1551 theme.muted.fade(o * 0.18),
1552 halo.height * 0.5,
1553 0.0,
1554 frus_core::Color::TRANSPARENT,
1555 );
1556 }
1557 }
1558 }
1559 let path = suffix.placed(self.icon_size(), x, icon_y, theme.direction);
1560 scene.fill_path(&path, icon_color);
1561 }
1562
1563 let len = self.value.chars().count();
1564 let left = self.pad_x() + self.prefix_w();
1566 let content_x = field.x + left;
1567 let content_w = self.content_width(field.width);
1568 let text_y = field.y + self.text_top();
1569 let wrap = if self.multiline {
1572 Some(content_w)
1573 } else {
1574 None
1575 };
1576 let layout = self.layout(wrap);
1577 let align = self.align_offset(content_w, layout.size().width);
1581
1582 if self.value.is_empty() {
1586 if let Some(placeholder) = &self.placeholder {
1587 let ph_alpha = if self.label.is_some() { o * fp } else { o };
1588 if ph_alpha > 0.01 {
1589 let style = self.text_style();
1592 let hint_w = frus_text::measure_resolved(placeholder, &style).width;
1593 let hint_align = self.align_offset(content_w, hint_w);
1594 scene.text(
1595 Point::new(content_x + hint_align, text_y),
1596 placeholder.clone(),
1597 &style,
1598 theme.muted.fade(ph_alpha),
1599 );
1600 }
1601 }
1602 }
1603
1604 let cursor = status.cursor.unwrap_or(len).min(len);
1607 let caret = layout.caret_rect(cursor);
1608 let scroll = if status.focused {
1609 (caret.x - content_w).max(0.0)
1610 } else {
1611 0.0
1612 };
1613 let text_x = content_x + align - scroll;
1614 let vscroll = if self.multiline {
1617 let overflow = (layout.size().height
1618 - (field.height - self.text_top() - self.pad_bottom()))
1619 .max(0.0);
1620 status.scroll_y.clamp(0.0, overflow)
1621 } else {
1622 0.0
1623 };
1624 let text_top = text_y - vscroll;
1626
1627 let outer_clip = scene.current_clip();
1634 let content_clip = outer_clip.intersect(Rect::new(
1635 content_x,
1636 field.y + self.text_top(),
1637 content_w,
1638 (field.height - self.text_top() - self.pad_bottom()).max(0.0),
1639 ));
1640 scene.set_clip(content_clip);
1641
1642 if status.focused {
1644 if let Some((start, end)) = status.selection {
1645 for r in layout.selection_rects(start.min(len), end.min(len)) {
1646 scene.fill_rect(
1647 Rect::new(text_x + r.x, text_top + r.y, r.width, r.height),
1648 theme.selection.fade(o),
1649 );
1650 }
1651 }
1652 }
1653
1654 if !self.value.is_empty() {
1655 let pos = Point::new(text_x, text_top);
1656 let color = theme.on_surface.fade(o);
1657 match wrap {
1658 Some(max_w) => {
1660 scene.text_wrapped(pos, self.display(), &self.text_style(), color, max_w)
1661 }
1662 None => scene.text(pos, self.display(), &self.text_style(), color),
1663 }
1664 }
1665
1666 if status.focused {
1669 if let Some((start, end)) = status.composing {
1670 let (start, end) = (start.min(len), end.min(len));
1671 for r in layout.selection_rects(start, end) {
1672 scene.fill_rect(
1673 Rect::new(text_x + r.x, text_top + r.y + r.height - 1.5, r.width, 1.5),
1674 theme.on_surface.fade(o * 0.7),
1675 );
1676 }
1677 }
1678 }
1679
1680 if status.focused && !status.caret_hidden {
1682 scene.fill_rect(
1683 Rect::new(text_x + caret.x, text_top + caret.y, 2.0, caret.height),
1684 theme.on_surface.fade(o),
1685 );
1686 }
1687
1688 if status.focused && status.handles {
1691 scene.set_clip(outer_clip);
1692 let edit = Edit {
1693 cursor,
1694 anchor: status
1695 .selection
1696 .map(|(a, b)| if cursor == a { b } else { a }),
1697 composing: None,
1698 };
1699 if let Some(handles) = self.handles(bounds.width, &edit, status.scroll_y) {
1700 let color = s.handle_color.unwrap().fade(o);
1701 paint_handles(scene, handles, Point::new(bounds.x, bounds.y), color);
1702 }
1703 }
1704 }
1705
1706 fn on_click(&self) -> Option<Msg> {
1707 None
1708 }
1709
1710 fn positional_click(
1711 &self,
1712 local_x: f32,
1713 local_y: f32,
1714 width: f32,
1715 _height: f32,
1716 ) -> Option<Msg> {
1717 if !self.enabled {
1718 return None;
1719 }
1720 if self.suffix_action.is_some() && self.suffix_hit(local_x, local_y, width) {
1721 self.suffix_action.clone()
1722 } else {
1723 None
1724 }
1725 }
1726
1727 fn cursor_icon(
1728 &self,
1729 local_x: f32,
1730 local_y: f32,
1731 width: f32,
1732 _height: f32,
1733 ) -> Option<crate::interaction::Cursor> {
1734 if self.suffix_action.is_some() && self.suffix_hit(local_x, local_y, width) {
1736 Some(crate::interaction::Cursor::Pointer)
1737 } else {
1738 None
1739 }
1740 }
1741
1742 fn on_edit(&self, edit: &mut Edit, key: &Key) -> Option<Msg> {
1743 if !self.enabled {
1747 return None;
1748 }
1749 let mut chars: Vec<char> = self.value.chars().collect();
1750 let len = chars.len();
1751 let mut cursor = edit.cursor.min(len);
1752 let mut anchor = edit.anchor.map(|a| a.min(len));
1753 let selection = anchor
1754 .map(|a| (a.min(cursor), a.max(cursor)))
1755 .filter(|(s, e)| s < e);
1756
1757 let mut changed = false;
1758
1759 match key {
1760 Key::Text(text) => {
1761 let inserted: Vec<char> = match &self.input_filter {
1765 Some(filter) => text.chars().filter_map(filter).collect(),
1766 None => text.chars().collect(),
1767 };
1768 if selection.is_some() || !inserted.is_empty() {
1773 if let Some((s, e)) = selection {
1774 chars.drain(s..e);
1775 cursor = s;
1776 }
1777 let n = inserted.len();
1778 chars.splice(cursor..cursor, inserted);
1779 cursor += n;
1780 anchor = None;
1781 changed = true;
1782 }
1783 }
1784 Key::Backspace => {
1785 if let Some((s, e)) = selection {
1786 chars.drain(s..e);
1787 cursor = s;
1788 changed = true;
1789 } else if cursor > 0 {
1790 chars.remove(cursor - 1);
1791 cursor -= 1;
1792 changed = true;
1793 }
1794 anchor = None;
1795 }
1796 Key::Delete => {
1797 if let Some((s, e)) = selection {
1798 chars.drain(s..e);
1799 cursor = s;
1800 changed = true;
1801 } else if cursor < len {
1802 chars.remove(cursor);
1803 changed = true;
1804 }
1805 anchor = None;
1806 }
1807 Key::Left { .. } | Key::Right { .. } | Key::Home { .. } | Key::End { .. } => {
1810 move_caret(&chars, &mut cursor, &mut anchor, key);
1811 }
1812 Key::Escape => {}
1814 Key::Enter if self.multiline => {
1815 if let Some((s, e)) = selection {
1817 chars.drain(s..e);
1818 cursor = s;
1819 }
1820 chars.insert(cursor, '\n');
1821 cursor += 1;
1822 anchor = None;
1823 changed = true;
1824 }
1825 Key::Enter => {
1826 edit.cursor = cursor;
1828 edit.anchor = anchor;
1829 return self.on_submit.clone();
1830 }
1831 }
1832
1833 if self.read_only && changed {
1838 return None;
1839 }
1840
1841 if changed {
1850 if let Some(max) = self.max_length {
1851 if chars.len() > max {
1852 chars.truncate(max);
1853 cursor = cursor.min(max);
1854 anchor = anchor.map(|a| a.min(max));
1855 }
1856 }
1857 }
1858
1859 edit.cursor = cursor;
1860 edit.anchor = anchor;
1861
1862 if changed {
1863 let new_value: String = chars.into_iter().collect();
1864 self.on_input.as_ref().map(|make| make(new_value))
1865 } else {
1866 None
1867 }
1868 }
1869
1870 fn ime(&self) -> Ime {
1871 self.ime_options()
1872 }
1873
1874 fn cursor_at(
1875 &self,
1876 local_x: f32,
1877 local_y: f32,
1878 width: f32,
1879 scroll_cursor: usize,
1880 ) -> Option<usize> {
1881 if !self.enabled {
1884 return None;
1885 }
1886 if self.suffix_action.is_some() && self.suffix_hit(local_x, local_y, width) {
1889 return None;
1890 }
1891 let left = self.pad_x() + self.prefix_w();
1895 let content_w = self.content_width(width);
1896 let layout = self.layout(if self.multiline {
1897 Some(content_w)
1898 } else {
1899 None
1900 });
1901 let scroll = (layout.caret_rect(scroll_cursor).x - content_w).max(0.0);
1902 let align = self.align_offset(content_w, layout.size().width);
1907 let target_x = local_x - left - align + scroll;
1910 let target_y = local_y - self.label_block() - self.text_top();
1911 Some(layout.hit_test(Point::new(target_x, target_y)))
1912 }
1913
1914 fn text_metrics(&self, width: f32, cursor: usize) -> Option<(f32, f32, f32, f32)> {
1915 if !self.multiline {
1916 return None;
1917 }
1918 let layout = self.layout(Some(self.content_width(width)));
1919 let caret = layout.caret_rect(cursor);
1920 let visible = self.field_height() - self.text_top() - self.pad_bottom();
1921 Some((layout.size().height, visible, caret.y, caret.height))
1922 }
1923
1924 fn text_viewport(&self, rect: Rect) -> Option<Rect> {
1925 if !self.multiline {
1926 return None;
1927 }
1928 Some(Rect::new(
1930 rect.x,
1931 rect.y + self.label_block(),
1932 rect.width,
1933 self.field_height(),
1934 ))
1935 }
1936
1937 fn caret_vertical(
1938 &self,
1939 width: f32,
1940 cursor: usize,
1941 down: bool,
1942 page: bool,
1943 goal_x: Option<f32>,
1944 ) -> Option<(usize, f32)> {
1945 if !self.multiline {
1946 return None;
1947 }
1948 let layout = self.layout(Some(self.content_width(width)));
1949 let caret = layout.caret_rect(cursor);
1950 let line_h = caret.height;
1951 let x = goal_x.unwrap_or(caret.x);
1953 let step = if page {
1955 (self.field_height() - self.text_top() - self.pad_bottom()).max(line_h)
1956 } else {
1957 line_h
1958 };
1959 let center = caret.y + line_h * 0.5;
1960 let target_y = if down { center + step } else { center - step };
1961 let full = layout.size().height;
1962 if page {
1963 let clamped = target_y.clamp(0.0, (full - line_h * 0.5).max(0.0));
1965 Some((layout.hit_test(Point::new(x, clamped)), x))
1966 } else {
1967 if target_y < 0.0 || target_y >= full {
1969 return None;
1970 }
1971 Some((layout.hit_test(Point::new(x, target_y)), x))
1972 }
1973 }
1974
1975 fn text_value(&self) -> Option<&str> {
1976 Some(&self.value)
1977 }
1978
1979 fn takes_typing(&self) -> bool {
1981 self.enabled && !self.read_only
1982 }
1983
1984 fn replace_value(&self, value: String) -> Option<Msg> {
1985 if !self.enabled || self.read_only {
1991 return None;
1992 }
1993 self.on_input.as_ref().map(|make| make(value))
1994 }
1995
1996 fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
1997 let mut s = frus_core::SemanticsProperties::new(frus_core::Role::TextInput)
1998 .value(self.value.clone());
1999 let label = match (&self.label, &self.error) {
2001 (Some(l), Some(e)) => Some(format!("{l}, {e}")),
2002 (Some(l), None) => Some(l.clone()),
2003 (None, Some(e)) => Some(e.clone()),
2004 (None, None) => None,
2005 };
2006 if let Some(label) = label {
2007 s = s.label(label);
2008 }
2009 if !self.enabled {
2010 s = s.disabled(true);
2011 }
2012 Some(s)
2013 }
2014
2015 fn selected_text(&self, edit: &Edit) -> Option<String> {
2016 if self.obscure {
2019 return None;
2020 }
2021 let chars: Vec<char> = self.value.chars().collect();
2022 let len = chars.len();
2023 let cursor = edit.cursor.min(len);
2024 let anchor = edit.anchor?.min(len);
2025 let (start, end) = (anchor.min(cursor), anchor.max(cursor));
2026 if start < end {
2027 Some(chars[start..end].iter().collect())
2028 } else {
2029 None
2030 }
2031 }
2032
2033 fn word_at(&self, index: usize) -> Option<(usize, usize)> {
2034 let chars: Vec<char> = self.value.chars().collect();
2035 word_range(&chars, index)
2036 }
2037
2038 fn selection_handles(
2039 &self,
2040 width: f32,
2041 edit: &Edit,
2042 scroll_y: f32,
2043 ) -> Option<[crate::SelectionHandle; 2]> {
2044 self.handles(width, edit, scroll_y)
2045 }
2046
2047 fn selection_toolbar(&self, context: ToolbarContext) -> Option<&dyn Widget<Msg>> {
2048 self.toolbars[usize::from(context.variant())]
2049 .get_or_init(|| {
2050 let defaults = self.default_toolbar_items(&context);
2051 let items = match &self.toolbar_build {
2052 Some(build) => build(&context, defaults),
2053 None => defaults,
2054 };
2055 (!items.is_empty()).then(|| {
2056 let bar: Box<dyn Widget<Msg>> = Box::new(SelectionToolbar::new(items));
2057 bar
2058 })
2059 })
2060 .as_deref()
2061 }
2062
2063 fn selection_anchor(&self, width: f32, edit: &Edit, scroll_y: f32) -> Option<Rect> {
2064 Some(self.selection_box(width, edit, scroll_y))
2065 }
2066
2067 fn autofill_hints(&self) -> &[AutofillHint] {
2068 &self.autofill
2069 }
2070
2071 fn focusable(&self) -> bool {
2072 self.enabled
2073 }
2074
2075 fn draws_own_focus(&self) -> bool {
2076 true
2077 }
2078
2079 fn announce(&self) -> Option<String> {
2080 self.announce.clone()
2081 }
2082}
2083
2084#[cfg(test)]
2085mod tests {
2086 use super::*;
2087 use crate::icons::Icons;
2088
2089 #[derive(Clone, Debug, PartialEq)]
2090 enum Msg {
2091 Changed(String),
2092 Submitted,
2093 }
2094
2095 fn input(value: &str) -> TextField<Msg> {
2096 TextField::new(value).on_input(Msg::Changed)
2097 }
2098
2099 fn rects(field: &TextField<Msg>, theme: &Theme, w: f32, h: f32) -> Vec<frus_core::Primitive> {
2101 let mut scene = Scene::new();
2102 Widget::<Msg>::paint(
2103 field,
2104 Rect::new(0.0, 0.0, w, h),
2105 Status::default(),
2106 theme,
2107 &mut scene,
2108 );
2109 scene
2110 .primitives()
2111 .iter()
2112 .filter(|p| matches!(p, frus_core::Primitive::Rect { .. }))
2113 .cloned()
2114 .collect()
2115 }
2116
2117 #[test]
2122 fn a_field_is_the_references_size() {
2123 let line = input("x").text_style().line_height();
2124 let outlined = input("x");
2125 assert_eq!(
2126 outlined.field_height(),
2127 (line + FIELD_OUTLINED_PADDING_TOP + FIELD_OUTLINED_PADDING_BOTTOM).ceil()
2128 );
2129 let filled = input("x").filled();
2132 assert_eq!(filled.field_height(), (line + FIELD_PADDING_Y * 2.0).ceil());
2133 let labelled = input("x").filled().label("Name");
2134 assert_eq!(
2135 labelled.field_height() - filled.field_height(),
2136 (FIELD_GAP + FIELD_TEXT_SIZE * FIELD_LABEL_SCALE).ceil()
2137 );
2138 assert_eq!(input("x").size(32.0).label_size(), 24.0);
2141 }
2142
2143 #[test]
2147 fn a_dense_field_gives_back_its_padding() {
2148 let line = input("x").text_style().line_height();
2149 assert_eq!(
2150 input("x").dense(true).field_height(),
2151 (line + FIELD_DENSE_OUTLINED_PADDING_TOP + FIELD_DENSE_OUTLINED_PADDING_BOTTOM).ceil()
2152 );
2153 assert_eq!(
2154 input("x").filled().dense(true).field_height(),
2155 (line + FIELD_DENSE_PADDING_Y * 2.0).ceil()
2156 );
2157 for dense in [input("x").dense(true), input("x").filled().dense(true)] {
2160 let roomy = if dense.is_outlined() {
2161 input("x")
2162 } else {
2163 input("x").filled()
2164 };
2165 assert!(dense.field_height() < roomy.field_height());
2166 }
2167 }
2168
2169 #[test]
2173 fn a_filled_field_has_a_line_not_a_box() {
2174 let theme = Theme::default();
2175 let stroked = |field: &TextField<Msg>| {
2176 rects(field, &theme, 220.0, 80.0)
2177 .iter()
2178 .filter(|p| {
2179 matches!(p, frus_core::Primitive::Rect { border_width, .. } if *border_width > 0.0)
2180 })
2181 .count()
2182 };
2183 assert_eq!(
2184 stroked(&input("x").outlined()),
2185 1,
2186 "outlined: one stroked box"
2187 );
2188 assert_eq!(stroked(&input("x").filled()), 0, "filled: nothing stroked");
2189 let filled = input("x").filled();
2191 let height = filled.field_height();
2192 let has_underline = rects(&filled, &theme, 220.0, 80.0).iter().any(|p| {
2193 matches!(
2194 p,
2195 frus_core::Primitive::Rect { rect, border_width, .. }
2196 if *border_width == 0.0
2197 && (rect.width - 220.0).abs() < 0.5
2198 && (rect.height - FIELD_BORDER_WIDTH).abs() < 0.5
2199 && (rect.y + rect.height - height).abs() < 0.5
2200 )
2201 });
2202 assert!(has_underline, "a filled field is underlined");
2203 }
2204
2205 #[test]
2208 fn a_disabled_field_dims_and_never_looks_focused() {
2209 let theme = Theme::default();
2210 let focused = Status {
2211 focus_progress: 1.0,
2212 ..Default::default()
2213 };
2214 let border = |field: &TextField<Msg>, status: Status| {
2215 let mut scene = Scene::new();
2216 Widget::<Msg>::paint(
2217 field,
2218 Rect::new(0.0, 0.0, 220.0, 80.0),
2219 status,
2220 &theme,
2221 &mut scene,
2222 );
2223 scene.primitives().iter().find_map(|p| match p {
2224 frus_core::Primitive::Rect {
2225 border_width,
2226 border_color,
2227 ..
2228 } if *border_width > 0.0 => Some((*border_width, *border_color)),
2229 _ => None,
2230 })
2231 };
2232 let (live_w, live_c) = border(&input("x"), focused).unwrap();
2233 assert_eq!(
2234 live_w, FIELD_FOCUSED_BORDER_WIDTH,
2235 "focus widens the border"
2236 );
2237 assert_eq!(live_c.a, 1.0, "and it is fully opaque");
2238 let (dead_w, dead_c) = border(&input("x").enabled(false), focused).unwrap();
2239 assert_eq!(dead_w, FIELD_BORDER_WIDTH, "disabled: no focus weight");
2240 assert!(
2241 (dead_c.a - FIELD_DISABLED_OPACITY).abs() < 0.01,
2242 "disabled: dimmed to the reference's 38%, got {}",
2243 dead_c.a
2244 );
2245 }
2246
2247 #[test]
2251 fn scrolled_text_is_clipped_below_the_label() {
2252 let theme = Theme::default();
2253 let field = input("one\ntwo\nthree\nfour\nfive\nsix")
2254 .rows(3)
2255 .label("Notes");
2256 let mut scene = Scene::new();
2257 Widget::<Msg>::paint(
2258 &field,
2259 Rect::new(0.0, 0.0, 220.0, 140.0),
2260 Status {
2261 scroll_y: 40.0,
2262 ..Default::default()
2263 },
2264 &theme,
2265 &mut scene,
2266 );
2267 let box_top = field.label_block();
2268 let clip = scene
2269 .primitives()
2270 .iter()
2271 .find_map(|p| match p {
2272 frus_core::Primitive::Text { clip, .. } if clip.y > -1.0e6 => Some(*clip),
2275 _ => None,
2276 })
2277 .expect("the value is clipped");
2278 assert!(
2279 clip.y >= box_top + field.text_top() - 0.5,
2280 "the clip starts below the padding, not at the border: {} < {}",
2281 clip.y,
2282 box_top + field.text_top()
2283 );
2284 }
2285
2286 #[test]
2289 fn a_disabled_field_is_inert() {
2290 let live = input("hello");
2291 let dead = input("hello").enabled(false);
2292 assert!(Widget::<Msg>::focusable(&live));
2293 assert!(!Widget::<Msg>::focusable(&dead), "out of the tab order");
2294 assert!(
2295 Widget::<Msg>::cursor_at(&live, 40.0, 30.0, 220.0, 0).is_some(),
2296 "a live field takes a caret"
2297 );
2298 assert_eq!(
2299 Widget::<Msg>::cursor_at(&dead, 40.0, 30.0, 220.0, 0),
2300 None,
2301 "a disabled field takes none"
2302 );
2303 let mut edit = Edit::default();
2304 assert!(
2305 Widget::on_edit(&dead, &mut edit, &Key::Text("x".into())).is_none(),
2306 "and no key edits it"
2307 );
2308 let semantics = Widget::<Msg>::semantics(&dead).unwrap();
2309 assert!(semantics.disabled, "a reader is told it is disabled");
2310 }
2311
2312 #[test]
2314 fn the_caller_outranks_the_theme_which_outranks_the_framework() {
2315 let radius = |field: &TextField<Msg>, theme: &Theme| {
2316 rects(field, theme, 220.0, 80.0)
2317 .iter()
2318 .find_map(|p| match p {
2319 frus_core::Primitive::Rect {
2320 border_width,
2321 radius,
2322 ..
2323 } if *border_width > 0.0 => Some(radius.top_left),
2324 _ => None,
2325 })
2326 .unwrap()
2327 };
2328 let plain = Theme::default();
2329 assert_eq!(radius(&input("x"), &plain), FIELD_RADIUS);
2330 let mut themed = Theme::default();
2331 themed.widgets.text_field.radius = Some(10.0);
2332 assert_eq!(radius(&input("x"), &themed), 10.0);
2333 let overridden = input("x").style(TextFieldStyle {
2334 radius: Some(2.0),
2335 ..Default::default()
2336 });
2337 assert_eq!(radius(&overridden, &themed), 2.0);
2338 }
2339
2340 #[test]
2341 fn text_is_clipped_to_content_box() {
2342 let inp = input("a very long value that overflows the field width").width(100.0);
2345 let mut scene = Scene::new();
2346 Widget::<Msg>::paint(
2347 &inp,
2348 Rect::new(0.0, 0.0, 100.0, 30.0),
2349 Status::default(),
2350 &Theme::default(),
2351 &mut scene,
2352 );
2353 let clip = scene
2354 .primitives()
2355 .iter()
2356 .find_map(|p| match p {
2357 frus_core::Primitive::Text { clip, .. } => Some(*clip),
2358 _ => None,
2359 })
2360 .expect("text primitive");
2361 assert!(
2362 (clip.width - (100.0 - FIELD_PADDING_X * 2.0)).abs() < 0.5,
2363 "clip = {clip:?}"
2364 );
2365 }
2366
2367 #[test]
2368 fn composing_region_draws_an_underline() {
2369 let inp = input("konnichiwa");
2372 let base = Status {
2373 focused: true,
2374 cursor: Some(5),
2375 ..Default::default()
2376 };
2377 let composing = Status {
2378 composing: Some((0, 5)),
2379 ..base
2380 };
2381
2382 let count_thin_rects = |status: Status| {
2383 let mut scene = Scene::new();
2384 Widget::<Msg>::paint(
2385 &inp,
2386 Rect::new(0.0, 0.0, 220.0, 30.0),
2387 status,
2388 &Theme::default(),
2389 &mut scene,
2390 );
2391 scene
2392 .primitives()
2393 .iter()
2394 .filter(|p| matches!(p, frus_core::Primitive::Rect { rect, .. } if rect.height <= 2.0 && rect.width > 2.0))
2395 .count()
2396 };
2397 let plain = count_thin_rects(base);
2398 let underlined = count_thin_rects(composing);
2399 assert!(
2400 underlined > plain,
2401 "the composition must underline ({plain} → {underlined})"
2402 );
2403 }
2404
2405 #[test]
2406 fn text_value_exposes_the_field_content() {
2407 let inp = input("hello");
2409 assert_eq!(Widget::<Msg>::text_value(&inp), Some("hello"));
2410 }
2411
2412 #[test]
2413 fn cursor_at_accounts_for_scroll() {
2414 let inp = input("0123456789 abcdefghij");
2416 assert_eq!(
2418 Widget::<Msg>::cursor_at(&inp, FIELD_PADDING_X, FIELD_OUTLINED_PADDING_TOP, 80.0, 0),
2419 Some(0)
2420 );
2421 let end = inp.value.chars().count();
2424 let at_left =
2425 Widget::<Msg>::cursor_at(&inp, FIELD_PADDING_X, FIELD_OUTLINED_PADDING_TOP, 80.0, end)
2426 .unwrap();
2427 let at_right =
2428 Widget::<Msg>::cursor_at(&inp, 76.0, FIELD_OUTLINED_PADDING_TOP, 80.0, end).unwrap();
2429 assert!(at_left > 0, "scrolled: the left edge is no longer index 0");
2430 assert!(at_right > at_left, "a click on the right → a larger index");
2431 }
2432
2433 #[test]
2434 fn insert_at_cursor() {
2435 let inp = input("ac");
2436 let mut edit = Edit {
2437 cursor: 1,
2438 anchor: None,
2439 composing: None,
2440 };
2441 assert_eq!(
2442 inp.on_edit(&mut edit, &Key::Text("b".to_string())),
2443 Some(Msg::Changed("abc".to_string()))
2444 );
2445 assert_eq!(edit.cursor, 2);
2446 }
2447
2448 #[test]
2449 fn shift_arrow_selects_then_delete() {
2450 let inp = input("hello");
2451 let mut edit = Edit {
2453 cursor: 5,
2454 anchor: None,
2455 composing: None,
2456 };
2457 inp.on_edit(
2458 &mut edit,
2459 &Key::Left {
2460 shift: true,
2461 word: false,
2462 },
2463 );
2464 inp.on_edit(
2465 &mut edit,
2466 &Key::Left {
2467 shift: true,
2468 word: false,
2469 },
2470 );
2471 assert_eq!(edit.selection_range(), Some((3, 5)));
2472 assert_eq!(
2474 inp.on_edit(&mut edit, &Key::Backspace),
2475 Some(Msg::Changed("hel".to_string()))
2476 );
2477 assert_eq!(edit.cursor, 3);
2478 assert_eq!(edit.anchor, None);
2479 }
2480
2481 #[test]
2482 fn home_end_bounds() {
2483 let inp = input("abc");
2484 let mut edit = Edit {
2485 cursor: 1,
2486 anchor: None,
2487 composing: None,
2488 };
2489 inp.on_edit(
2490 &mut edit,
2491 &Key::End {
2492 shift: false,
2493 doc: false,
2494 },
2495 );
2496 assert_eq!(edit.cursor, 3);
2497 inp.on_edit(
2498 &mut edit,
2499 &Key::Home {
2500 shift: false,
2501 doc: false,
2502 },
2503 );
2504 assert_eq!(edit.cursor, 0);
2505 }
2506
2507 #[test]
2508 fn ctrl_arrow_jumps_by_word() {
2509 let inp = input("foo bar baz");
2510 let mut edit = Edit {
2512 cursor: 11,
2513 anchor: None,
2514 composing: None,
2515 };
2516 inp.on_edit(
2517 &mut edit,
2518 &Key::Left {
2519 shift: false,
2520 word: true,
2521 },
2522 );
2523 assert_eq!(edit.cursor, 8, "start of \"baz\"");
2524 inp.on_edit(
2525 &mut edit,
2526 &Key::Left {
2527 shift: false,
2528 word: true,
2529 },
2530 );
2531 assert_eq!(edit.cursor, 4, "start of \"bar\"");
2532 inp.on_edit(
2534 &mut edit,
2535 &Key::Right {
2536 shift: false,
2537 word: true,
2538 },
2539 );
2540 assert_eq!(edit.cursor, 7, "fin de \"bar\"");
2541 inp.on_edit(
2542 &mut edit,
2543 &Key::Right {
2544 shift: false,
2545 word: true,
2546 },
2547 );
2548 assert_eq!(edit.cursor, 11, "fin de \"baz\"");
2549 }
2550
2551 #[test]
2552 fn home_end_are_line_relative_but_ctrl_spans_the_field() {
2553 let inp = TextField::<Msg>::new("ab\ncd\nef")
2555 .on_input(Msg::Changed)
2556 .rows(3);
2557 let mut edit = Edit {
2558 cursor: 4,
2559 anchor: None,
2560 composing: None,
2561 };
2562 inp.on_edit(
2564 &mut edit,
2565 &Key::Home {
2566 shift: false,
2567 doc: false,
2568 },
2569 );
2570 assert_eq!(edit.cursor, 3, "start of the 2nd line");
2571 inp.on_edit(
2573 &mut edit,
2574 &Key::End {
2575 shift: false,
2576 doc: false,
2577 },
2578 );
2579 assert_eq!(edit.cursor, 5, "end of the 2nd line");
2580 inp.on_edit(
2582 &mut edit,
2583 &Key::Home {
2584 shift: false,
2585 doc: true,
2586 },
2587 );
2588 assert_eq!(edit.cursor, 0, "start of the field");
2589 inp.on_edit(
2590 &mut edit,
2591 &Key::End {
2592 shift: false,
2593 doc: true,
2594 },
2595 );
2596 assert_eq!(edit.cursor, 8, "end of the field");
2597 }
2598
2599 #[test]
2600 fn selected_text_reads_range() {
2601 let inp = input("hello");
2602 let edit = Edit {
2603 cursor: 5,
2604 anchor: Some(2),
2605 composing: None,
2606 };
2607 assert_eq!(inp.selected_text(&edit), Some("llo".to_string()));
2608 }
2609
2610 fn bar_words(field: &TextField<Msg>, context: ToolbarContext) -> Option<Vec<String>> {
2613 let bar = Widget::<Msg>::selection_toolbar(field, context)?;
2614 Some(
2615 bar.children()
2616 .iter()
2617 .map(|button| {
2618 button
2619 .semantics()
2620 .and_then(|semantics| semantics.label)
2621 .unwrap_or_default()
2622 })
2623 .collect(),
2624 )
2625 }
2626
2627 fn context(has_selection: bool, can_paste: bool, all_selected: bool) -> ToolbarContext {
2628 ToolbarContext {
2629 has_selection,
2630 can_paste,
2631 all_selected,
2632 }
2633 }
2634
2635 #[test]
2639 fn the_default_bar_follows_the_three_conditions() {
2640 let field = input("hello world");
2641 let words = |ctx| bar_words(&field, ctx);
2642 assert_eq!(
2643 words(context(true, true, false)),
2644 Some(vec![
2645 "Cut".into(),
2646 "Copy".into(),
2647 "Paste".into(),
2648 "Select all".into()
2649 ])
2650 );
2651 assert_eq!(
2652 words(context(false, true, false)),
2653 Some(vec!["Paste".into(), "Select all".into()])
2654 );
2655 assert_eq!(
2656 words(context(true, false, true)),
2657 Some(vec!["Cut".into(), "Copy".into()])
2658 );
2659 assert_eq!(words(context(false, false, true)), None, "nothing to offer");
2660 }
2661
2662 #[test]
2666 fn a_masked_field_offers_neither_cut_nor_copy_and_copies_nothing() {
2667 let field = input("hunter2").obscure(true);
2668 assert_eq!(
2669 bar_words(&field, context(true, true, false)),
2670 Some(vec!["Paste".into(), "Select all".into()])
2671 );
2672 let selected = Edit {
2673 cursor: 7,
2674 anchor: Some(0),
2675 composing: None,
2676 };
2677 assert_eq!(field.selected_text(&selected), None);
2678 assert_eq!(
2680 input("hunter2").selected_text(&selected),
2681 Some("hunter2".to_string())
2682 );
2683 }
2684
2685 #[test]
2688 fn a_read_only_field_offers_copy_and_select_all_only() {
2689 let field = input("REF-0042").read_only();
2690 assert_eq!(
2691 bar_words(&field, context(true, true, false)),
2692 Some(vec!["Copy".into(), "Select all".into()])
2693 );
2694 }
2695
2696 #[test]
2700 fn the_application_rewrites_the_list() {
2701 let field = input("hello").selection_toolbar(|context, mut items| {
2702 items.retain(|item| item.label() != "Paste");
2703 if context.has_selection {
2704 items.push(ToolbarItem::custom("Translate", Msg::Submitted));
2705 }
2706 items
2707 });
2708 assert_eq!(
2709 bar_words(&field, context(true, true, false)),
2710 Some(vec![
2711 "Cut".into(),
2712 "Copy".into(),
2713 "Select all".into(),
2714 "Translate".into()
2715 ])
2716 );
2717 assert_eq!(
2718 bar_words(&field, context(false, true, false)),
2719 Some(vec!["Select all".into()]),
2720 "the custom item only for a selection"
2721 );
2722 let none = input("hello").selection_toolbar(|_, _| Vec::new());
2723 assert_eq!(bar_words(&none, context(true, true, false)), None);
2724 }
2725
2726 #[test]
2729 fn an_application_item_sends_its_message_and_a_built_in_one_does_not() {
2730 let field = input("hello").selection_toolbar(|_, mut items| {
2731 items.push(ToolbarItem::custom("Translate", Msg::Submitted));
2732 items
2733 });
2734 let bar =
2735 Widget::<Msg>::selection_toolbar(&field, context(true, false, false)).expect("a bar");
2736 let buttons = bar.children();
2737 let clicks: Vec<Option<Msg>> = buttons.iter().map(|b| b.on_click()).collect();
2738 let actions: Vec<Option<crate::EditAction>> =
2739 buttons.iter().map(|b| b.edit_action()).collect();
2740 assert_eq!(clicks, vec![None, None, None, Some(Msg::Submitted)]);
2741 assert_eq!(
2742 actions,
2743 vec![
2744 Some(crate::EditAction::Cut),
2745 Some(crate::EditAction::Copy),
2746 Some(crate::EditAction::SelectAll),
2747 None
2748 ]
2749 );
2750 }
2751
2752 #[test]
2755 fn the_bar_is_anchored_on_the_selection_or_the_caret() {
2756 let field = input("hello world");
2757 let word = Edit {
2758 cursor: 5,
2759 anchor: Some(0),
2760 composing: None,
2761 };
2762 let all = Edit {
2763 cursor: 11,
2764 anchor: Some(0),
2765 composing: None,
2766 };
2767 let caret = Edit {
2768 cursor: 5,
2769 anchor: None,
2770 composing: None,
2771 };
2772 let anchor =
2773 |edit: &Edit| Widget::<Msg>::selection_anchor(&field, 200.0, edit, 0.0).unwrap();
2774 assert!(anchor(&all).width > anchor(&word).width);
2775 assert_eq!(anchor(&caret).width, 0.0);
2776 assert!(anchor(&caret).height > 0.0);
2777 let handles = Widget::<Msg>::selection_handles(&field, 200.0, &word, 0.0).unwrap();
2779 let word_box = anchor(&word);
2780 assert!((word_box.x - handles[0].line_center.x).abs() < 0.5);
2781 assert!((word_box.x + word_box.width - handles[1].line_center.x).abs() < 0.5);
2782 }
2783
2784 #[test]
2785 fn enter_submits_without_changing_value() {
2786 let inp = input("acheter du lait").on_submit(Msg::Submitted);
2787 let mut edit = Edit {
2788 cursor: 3,
2789 anchor: None,
2790 composing: None,
2791 };
2792 assert_eq!(inp.on_edit(&mut edit, &Key::Enter), Some(Msg::Submitted));
2794 assert_eq!(edit.cursor, 3); }
2796
2797 #[test]
2798 fn enter_without_submit_is_noop() {
2799 let inp = input("x");
2800 let mut edit = Edit {
2801 cursor: 1,
2802 anchor: None,
2803 composing: None,
2804 };
2805 assert_eq!(inp.on_edit(&mut edit, &Key::Enter), None);
2806 }
2807
2808 fn drawn_text_x(field: &TextField<()>, width: f32) -> f32 {
2810 let mut scene = Scene::new();
2811 Widget::<()>::paint(
2812 field,
2813 Rect::new(0.0, 0.0, width, 56.0),
2814 Status {
2815 focused: false,
2816 ..Default::default()
2817 },
2818 &Theme::default(),
2819 &mut scene,
2820 );
2821 scene
2822 .primitives()
2823 .iter()
2824 .find_map(|p| match p {
2825 frus_core::Primitive::Text { position, text, .. } if text == "42" => {
2826 Some(position.x)
2827 }
2828 _ => None,
2829 })
2830 .expect("the value is drawn")
2831 }
2832
2833 #[test]
2835 fn a_field_that_says_nothing_starts_at_the_left() {
2836 let plain: TextField<()> = TextField::new("42");
2837 let started: TextField<()> = TextField::new("42").text_align(TextAlign::Start);
2838 assert_eq!(drawn_text_x(&plain, 300.0), drawn_text_x(&started, 300.0));
2839 }
2840
2841 #[test]
2843 fn the_text_sits_where_it_was_told_to() {
2844 let left = drawn_text_x(&TextField::<()>::new("42"), 300.0);
2845 let centre = drawn_text_x(
2846 &TextField::<()>::new("42").text_align(TextAlign::Center),
2847 300.0,
2848 );
2849 let right = drawn_text_x(
2850 &TextField::<()>::new("42").text_align(TextAlign::Right),
2851 300.0,
2852 );
2853 assert!(left < centre, "centred is further right than left-aligned");
2854 assert!(centre < right, "right-aligned is further right again");
2855 assert!(
2857 ((centre - left) - (right - centre)).abs() < 0.5,
2858 "halfway: {left} {centre} {right}"
2859 );
2860 }
2861
2862 #[test]
2866 fn a_click_lands_where_the_glyphs_are() {
2867 let width = 300.0;
2868 for align in [TextAlign::Left, TextAlign::Center, TextAlign::Right] {
2869 let field: TextField<()> = TextField::new("42").text_align(align);
2870 let x = drawn_text_x(&field, width);
2871 let before = Widget::<()>::cursor_at(&field, x - 1.0, 20.0, width, 0);
2873 assert_eq!(before, Some(0), "{align:?}: before the first character");
2874 let text_w = frus_text::measure("42", field.size).width;
2876 let after = Widget::<()>::cursor_at(&field, x + text_w + 1.0, 20.0, width, 0);
2877 assert_eq!(after, Some(2), "{align:?}: after the last character");
2878 }
2879 }
2880
2881 #[test]
2885 fn text_wider_than_the_field_is_not_pushed_off_it() {
2886 let long: TextField<()> = TextField::new("42").text_align(TextAlign::Right);
2887 assert_eq!(long.align_offset(4.0, 40.0), 0.0);
2889 assert_eq!(long.align_offset(40.0, 40.0), 0.0);
2890 }
2891
2892 #[test]
2897 fn a_multiline_field_is_not_aligned() {
2898 let note: TextField<()> = TextField::new("42")
2899 .multiline()
2900 .text_align(TextAlign::Center);
2901 assert_eq!(note.align_offset(300.0, 40.0), 0.0);
2902 let one_line: TextField<()> = TextField::new("42").text_align(TextAlign::Center);
2904 assert_eq!(one_line.align_offset(300.0, 40.0), 130.0);
2905 }
2906
2907 #[test]
2910 fn the_placeholder_is_aligned_like_the_value() {
2911 let hint_x = |align: TextAlign| {
2912 let field: TextField<()> = TextField::new("").placeholder("Amount").text_align(align);
2913 let mut scene = Scene::new();
2914 Widget::<()>::paint(
2915 &field,
2916 Rect::new(0.0, 0.0, 300.0, 56.0),
2917 Status::default(),
2918 &Theme::default(),
2919 &mut scene,
2920 );
2921 scene
2922 .primitives()
2923 .iter()
2924 .find_map(|p| match p {
2925 frus_core::Primitive::Text { position, text, .. } if text == "Amount" => {
2926 Some(position.x)
2927 }
2928 _ => None,
2929 })
2930 .expect("the hint is drawn")
2931 };
2932 assert!(hint_x(TextAlign::Right) > hint_x(TextAlign::Center));
2933 assert!(hint_x(TextAlign::Center) > hint_x(TextAlign::Left));
2934 }
2935
2936 #[test]
2938 fn a_filter_drops_what_it_will_not_take() {
2939 let field: TextField<String> = TextField::new("").digits_only().on_input(|v| v);
2940 let mut edit = Edit::default();
2941 let out = Widget::on_edit(&field, &mut edit, &Key::Text("12 34-56".into()));
2943 assert_eq!(out.as_deref(), Some("123456"));
2944 assert_eq!(edit.cursor, 6, "the caret is past what actually arrived");
2945 }
2946
2947 #[test]
2950 fn a_refused_keystroke_says_nothing() {
2951 let field: TextField<String> = TextField::new("42").digits_only().on_input(|v| v);
2952 let mut edit = Edit {
2953 cursor: 2,
2954 ..Default::default()
2955 };
2956 assert_eq!(
2957 Widget::on_edit(&field, &mut edit, &Key::Text("x".into())),
2958 None
2959 );
2960 assert_eq!(edit.cursor, 2, "and the caret has not moved");
2961 }
2962
2963 #[test]
2965 fn a_refused_keystroke_over_a_selection_still_clears_it() {
2966 let field: TextField<String> = TextField::new("1234").digits_only().on_input(|v| v);
2967 let mut edit = Edit {
2968 cursor: 3,
2969 anchor: Some(1),
2970 ..Default::default()
2971 };
2972 let out = Widget::on_edit(&field, &mut edit, &Key::Text("x".into()));
2973 assert_eq!(out.as_deref(), Some("14"));
2974 assert_eq!(edit.cursor, 1);
2975 }
2976
2977 #[test]
2980 fn a_filter_can_substitute() {
2981 let field: TextField<String> = TextField::new("")
2982 .input_filter(|c| c.to_uppercase().next())
2983 .on_input(|v| v);
2984 let mut edit = Edit::default();
2985 let out = Widget::on_edit(&field, &mut edit, &Key::Text("ab".into()));
2986 assert_eq!(out.as_deref(), Some("AB"));
2987 assert_eq!(edit.cursor, 2);
2988 }
2989
2990 #[test]
2993 fn a_filter_does_not_rewrite_what_the_caller_gave() {
2994 let field: TextField<String> = TextField::new("a1b2").digits_only().on_input(|v| v);
2995 let mut edit = Edit {
2996 cursor: 4,
2997 ..Default::default()
2998 };
2999 let out = Widget::on_edit(&field, &mut edit, &Key::Text("3".into()));
3000 assert_eq!(
3001 out.as_deref(),
3002 Some("a1b23"),
3003 "only the keystroke is filtered"
3004 );
3005 }
3006
3007 #[test]
3009 fn digits_only_asks_for_a_keypad_without_overruling_one() {
3010 let plain: TextField<()> = TextField::new("").digits_only();
3011 assert_eq!(Widget::<()>::ime(&plain).keyboard, KeyboardType::Number);
3012 let before: TextField<()> = TextField::new("")
3013 .keyboard_type(KeyboardType::Phone)
3014 .digits_only();
3015 assert_eq!(Widget::<()>::ime(&before).keyboard, KeyboardType::Phone);
3016 let after: TextField<()> = TextField::new("")
3017 .digits_only()
3018 .keyboard_type(KeyboardType::Phone);
3019 assert_eq!(Widget::<()>::ime(&after).keyboard, KeyboardType::Phone);
3020 }
3021
3022 #[test]
3024 fn the_capitalisation_travels_with_the_field() {
3025 let field: TextField<()> = TextField::new("").capitalization(Capitalization::Characters);
3026 let ime = Widget::<()>::ime(&field);
3027 assert_eq!(ime.capitalization, Capitalization::Characters);
3028 assert_eq!(ime.android_input_type() & 0x0000_7000, 0x0000_1000);
3029 }
3030
3031 #[test]
3034 fn a_plain_field_asks_for_the_plain_keyboard() {
3035 let field: TextField<()> = TextField::new("hello");
3036 assert_eq!(
3037 Widget::<()>::ime(&field),
3038 Ime {
3039 keyboard: KeyboardType::Text,
3040 action: TextInputAction::Done,
3041 capitalization: Capitalization::Auto,
3042 }
3043 );
3044 }
3045
3046 #[test]
3051 fn an_obscured_field_tells_the_keyboard_it_is_a_secret() {
3052 let field: TextField<()> = TextField::new("hunter2").obscure(true);
3053 let ime = Widget::<()>::ime(&field);
3054 assert_eq!(ime.keyboard, KeyboardType::Password);
3055 assert!(ime.keyboard.is_secret());
3056 assert_eq!(ime.keyboard.android_input_type() & 0x0000_0ff0, 0x80);
3058 }
3059
3060 #[test]
3063 fn a_multiline_field_takes_a_newline_key() {
3064 let field: TextField<()> = TextField::new("a note").multiline();
3065 let ime = Widget::<()>::ime(&field);
3066 assert_eq!(ime.keyboard, KeyboardType::Multiline);
3067 assert_eq!(ime.action, TextInputAction::Newline);
3068 let rowed: TextField<()> = TextField::new("a note").rows(4);
3070 assert_eq!(Widget::<()>::ime(&rowed).keyboard, KeyboardType::Multiline);
3071 }
3072
3073 #[test]
3075 fn what_the_caller_says_wins() {
3076 let field: TextField<()> = TextField::new("")
3077 .obscure(true)
3078 .keyboard_type(KeyboardType::VisiblePassword);
3079 assert_eq!(
3080 Widget::<()>::ime(&field).keyboard,
3081 KeyboardType::VisiblePassword
3082 );
3083
3084 let query: TextField<()> = TextField::new("").action(TextInputAction::Search);
3086 let ime = Widget::<()>::ime(&query);
3087 assert_eq!(ime.keyboard, KeyboardType::Text);
3088 assert_eq!(ime.action, TextInputAction::Search);
3089
3090 let note: TextField<()> = TextField::new("").multiline().action(TextInputAction::Done);
3092 assert_eq!(Widget::<()>::ime(¬e).action, TextInputAction::Done);
3093 }
3094
3095 #[test]
3098 fn a_typed_field_gets_the_keyboard_for_it() {
3099 let phone: TextField<()> = TextField::new("").keyboard_type(KeyboardType::Phone);
3100 assert_eq!(Widget::<()>::ime(&phone).keyboard.android_input_type(), 3);
3102
3103 let email: TextField<()> = TextField::new("").keyboard_type(KeyboardType::Email);
3104 let bits = Widget::<()>::ime(&email).keyboard.android_input_type();
3105 assert_eq!(bits, 1 | 0x20);
3108 }
3109
3110 #[test]
3111 fn obscure_masks_the_displayed_text_but_not_the_value() {
3112 let theme = Theme::default();
3115 let field = TextField::<Msg>::new("secret").obscure(true);
3116 let mut scene = Scene::new();
3117 let status = Status {
3118 focused: true,
3119 cursor: Some(6),
3120 ..Default::default()
3121 };
3122 Widget::<Msg>::paint(
3123 &field,
3124 Rect::new(0.0, 0.0, 220.0, 30.0),
3125 status,
3126 &theme,
3127 &mut scene,
3128 );
3129 let drawn: Vec<&str> = scene
3130 .primitives()
3131 .iter()
3132 .filter_map(|p| match p {
3133 frus_core::Primitive::Text { text, .. } => Some(text.as_str()),
3134 _ => None,
3135 })
3136 .collect();
3137 assert!(
3138 drawn.iter().all(|t| !t.contains("secret")),
3139 "the value must not leak"
3140 );
3141 assert!(
3142 drawn.iter().any(|t| t.chars().all(|c| c == '•')),
3143 "dots are drawn"
3144 );
3145 assert_eq!(Widget::<Msg>::text_value(&field), Some("secret"));
3147 }
3148
3149 #[test]
3150 fn prefix_icon_draws_a_path_and_shifts_the_hit_test() {
3151 let theme = Theme::default();
3154 let with_icon = input("hello world").prefix_icon(Icons::STAR);
3155 let mut scene = Scene::new();
3156 Widget::<Msg>::paint(
3157 &with_icon,
3158 Rect::new(0.0, 0.0, 220.0, 30.0),
3159 Status::default(),
3160 &theme,
3161 &mut scene,
3162 );
3163 assert!(
3164 scene
3165 .primitives()
3166 .iter()
3167 .any(|p| matches!(p, frus_core::Primitive::Path { .. })),
3168 "the prefix icon draws a path"
3169 );
3170 let plain = input("hello world");
3171 let at_plain =
3172 Widget::<Msg>::cursor_at(&plain, 60.0, FIELD_OUTLINED_PADDING_TOP, 220.0, 0).unwrap();
3173 let at_icon =
3174 Widget::<Msg>::cursor_at(&with_icon, 60.0, FIELD_OUTLINED_PADDING_TOP, 220.0, 0)
3175 .unwrap();
3176 assert!(
3177 at_icon < at_plain,
3178 "the prefix offsets the content ({at_plain} → {at_icon})"
3179 );
3180 }
3181
3182 #[test]
3183 fn multiline_enter_inserts_a_newline_instead_of_submitting() {
3184 let inp = TextField::<Msg>::new("ab")
3186 .on_input(Msg::Changed)
3187 .multiline();
3188 let mut edit = Edit {
3189 cursor: 2,
3190 anchor: None,
3191 composing: None,
3192 };
3193 assert_eq!(
3194 inp.on_edit(&mut edit, &Key::Enter),
3195 Some(Msg::Changed("ab\n".to_string()))
3196 );
3197 assert_eq!(edit.cursor, 3);
3198 let single = input("ab").on_submit(Msg::Submitted);
3200 let mut edit = Edit {
3201 cursor: 2,
3202 anchor: None,
3203 composing: None,
3204 };
3205 assert_eq!(single.on_edit(&mut edit, &Key::Enter), Some(Msg::Submitted));
3206 }
3207
3208 #[test]
3209 fn multiline_wraps_long_lines_to_the_width() {
3210 let long = "word ".repeat(30); let inp = TextField::<Msg>::new(long.trim_end())
3215 .on_input(Msg::Changed)
3216 .rows(4)
3217 .width(160.0);
3218 let line_h = inp.text_style().line_height();
3219 let top = Widget::<Msg>::cursor_at(
3220 &inp,
3221 FIELD_PADDING_X + 2.0,
3222 FIELD_OUTLINED_PADDING_TOP + 1.0,
3223 160.0,
3224 0,
3225 )
3226 .unwrap();
3227 let wrapped = Widget::<Msg>::cursor_at(
3228 &inp,
3229 FIELD_PADDING_X + 2.0,
3230 FIELD_OUTLINED_PADDING_TOP + line_h * 2.0 + 1.0,
3231 160.0,
3232 0,
3233 )
3234 .unwrap();
3235 assert!(top < 10, "1st line: {top}");
3236 assert!(
3237 wrapped > top,
3238 "a wrapped line further down → an index further on ({top} → {wrapped})"
3239 );
3240 }
3241
3242 #[test]
3243 fn multiline_reports_overflow_and_scrolls_content() {
3244 let inp = TextField::<Msg>::new("l1\nl2\nl3\nl4\nl5")
3246 .on_input(Msg::Changed)
3247 .rows(2)
3248 .width(200.0);
3249 let (content_h, visible_h, _, _) =
3250 Widget::<Msg>::text_metrics(&inp, 200.0, 0).expect("a multi-line field");
3251 assert!(
3252 content_h > visible_h + 1.0,
3253 "5 lines > a box of 2 ({content_h} vs {visible_h})"
3254 );
3255
3256 let text_top = |scroll: f32| {
3258 let mut scene = Scene::new();
3259 let status = Status {
3260 focused: true,
3261 cursor: Some(0),
3262 scroll_y: scroll,
3263 ..Default::default()
3264 };
3265 Widget::<Msg>::paint(
3266 &inp,
3267 Rect::new(0.0, 0.0, 200.0, 80.0),
3268 status,
3269 &Theme::default(),
3270 &mut scene,
3271 );
3272 scene
3273 .primitives()
3274 .iter()
3275 .find_map(|p| match p {
3276 frus_core::Primitive::Text { text, position, .. } if text.contains("l1") => {
3277 Some(position.y)
3278 }
3279 _ => None,
3280 })
3281 .expect("the field's text")
3282 };
3283 assert!(
3284 text_top(20.0) < text_top(0.0),
3285 "scrolling raises the content"
3286 );
3287 }
3288
3289 #[test]
3290 fn multiline_arrows_move_the_caret_between_lines() {
3291 let inp = TextField::<Msg>::new("abc\ndefg\nhi")
3293 .on_input(Msg::Changed)
3294 .rows(3)
3295 .width(200.0);
3296 let (down, _) = Widget::<Msg>::caret_vertical(&inp, 200.0, 1, true, false, None).unwrap();
3298 assert!(
3299 (4..=8).contains(&down),
3300 "moves down to the 2nd line: {down}"
3301 );
3302 assert_eq!(
3304 Widget::<Msg>::caret_vertical(&inp, 200.0, 1, false, false, None),
3305 None
3306 );
3307 assert_eq!(
3309 Widget::<Msg>::caret_vertical(&inp, 200.0, 10, true, false, None),
3310 None
3311 );
3312 let (up, _) = Widget::<Msg>::caret_vertical(&inp, 200.0, 5, false, false, None).unwrap();
3314 assert!(up <= 3, "moves up to the 1st line: {up}");
3315 assert_eq!(
3317 Widget::<Msg>::caret_vertical(&input("abc"), 200.0, 1, true, false, None),
3318 None
3319 );
3320 }
3321
3322 #[test]
3323 fn multiline_goal_column_survives_a_short_line() {
3324 let inp = TextField::<Msg>::new("hello\nhi\nworld")
3329 .on_input(Msg::Changed)
3330 .rows(3)
3331 .width(200.0);
3332 let (mid, goal) = Widget::<Msg>::caret_vertical(&inp, 200.0, 5, true, false, None).unwrap();
3333 assert!(
3334 (6..=8).contains(&mid),
3335 "a short line, clamped to its end: {mid}"
3336 );
3337 let (low, _) =
3341 Widget::<Msg>::caret_vertical(&inp, 200.0, mid, true, false, Some(goal)).unwrap();
3342 assert!(
3343 (12..=14).contains(&low),
3344 "goal column preserved into \"world\": {low}"
3345 );
3346 }
3347
3348 #[test]
3349 fn multiline_page_jump_is_clamped_to_the_field() {
3350 let inp = TextField::<Msg>::new("a\nb\nc\nd\ne")
3353 .on_input(Msg::Changed)
3354 .rows(2)
3355 .width(200.0);
3356 let (bottom, _) = Widget::<Msg>::caret_vertical(&inp, 200.0, 8, true, true, None).unwrap();
3358 assert!(
3359 bottom >= 7,
3360 "PgDn clamped to the bottom of the field: {bottom}"
3361 );
3362 let (top, _) = Widget::<Msg>::caret_vertical(&inp, 200.0, 0, false, true, None).unwrap();
3364 assert!(top <= 1, "PgUp clamped to the top of the field: {top}");
3365 }
3366
3367 #[test]
3368 fn multiline_reserves_rows_of_height() {
3369 let one = match Widget::<Msg>::style(&input("x")).height {
3371 Dimension::Length(h) => h,
3372 _ => panic!("a fixed height"),
3373 };
3374 let four = match Widget::<Msg>::style(&TextField::<Msg>::new("x").rows(4)).height {
3375 Dimension::Length(h) => h,
3376 _ => panic!("a fixed height"),
3377 };
3378 assert!(
3379 four > one * 2.0,
3380 "4 lines much taller than one ({one} → {four})"
3381 );
3382 }
3383
3384 #[test]
3385 fn multiline_hit_test_uses_the_click_line() {
3386 let inp = TextField::<Msg>::new("ab\ncd")
3389 .on_input(Msg::Changed)
3390 .rows(3);
3391 let line_h = inp.text_style().line_height();
3392 let top = Widget::<Msg>::cursor_at(
3394 &inp,
3395 FIELD_PADDING_X + 1.0,
3396 FIELD_OUTLINED_PADDING_TOP + 1.0,
3397 220.0,
3398 0,
3399 )
3400 .unwrap();
3401 let below = Widget::<Msg>::cursor_at(
3403 &inp,
3404 FIELD_PADDING_X + 1.0,
3405 FIELD_OUTLINED_PADDING_TOP + line_h + 1.0,
3406 220.0,
3407 0,
3408 )
3409 .unwrap();
3410 assert!(top <= 2, "1st line: {top}");
3411 assert!(below >= 3, "2nd line: {below}");
3412 }
3413
3414 #[test]
3415 fn floating_label_rests_in_box_then_floats_up() {
3416 let theme = Theme::default();
3419 let field = TextField::<Msg>::new("").label("Name");
3420 let label_geo = |status: Status| -> (f32, f32) {
3421 let mut scene = Scene::new();
3422 Widget::<Msg>::paint(
3423 &field,
3424 Rect::new(0.0, 30.0, 220.0, 60.0),
3425 status,
3426 &theme,
3427 &mut scene,
3428 );
3429 scene
3430 .primitives()
3431 .iter()
3432 .find_map(|p| match p {
3433 frus_core::Primitive::Text {
3434 text,
3435 size,
3436 position,
3437 ..
3438 } if text == "Name" => Some((*size, position.y)),
3439 _ => None,
3440 })
3441 .expect("a painted label")
3442 };
3443 let (rest_size, rest_y) = label_geo(Status::default());
3444 let (float_size, float_y) = label_geo(Status {
3445 focused: true,
3446 focus_progress: 1.0,
3447 ..Default::default()
3448 });
3449 assert!(
3450 rest_size > float_size,
3451 "at rest the label is larger ({rest_size} → {float_size})"
3452 );
3453 assert!(
3454 float_y < rest_y,
3455 "once focused the label rises ({rest_y} → {float_y})"
3456 );
3457 }
3458
3459 #[test]
3470 fn an_errored_field_deepens_under_the_pointer() {
3471 let theme = Theme::default();
3472 let field = input("x").label("Name").error("Required").outlined();
3473 let painted = |status: Status| {
3474 let mut scene = Scene::new();
3475 Widget::<Msg>::paint(
3476 &field,
3477 Rect::new(0.0, 0.0, 220.0, 90.0),
3478 status,
3479 &theme,
3480 &mut scene,
3481 );
3482 scene
3483 };
3484 let border = |status: Status| {
3486 painted(status)
3487 .primitives()
3488 .iter()
3489 .find_map(|p| match p {
3490 frus_core::Primitive::Rect {
3491 border_color,
3492 border_width,
3493 ..
3494 } if *border_width > 0.0 => Some(*border_color),
3495 _ => None,
3496 })
3497 .expect("an outlined field draws a border")
3498 };
3499 let message = |status: Status| {
3500 painted(status)
3501 .primitives()
3502 .iter()
3503 .find_map(|p| match p {
3504 frus_core::Primitive::Text { text, color, .. } if text == "Required" => {
3505 Some(*color)
3506 }
3507 _ => None,
3508 })
3509 .expect("the message is painted")
3510 };
3511 let rest = Status {
3512 opacity: 1.0,
3513 ..Default::default()
3514 };
3515 let hovered = Status {
3516 hover_progress: 1.0,
3517 ..rest
3518 };
3519 let both = Status {
3520 focused: true,
3521 focus_progress: 1.0,
3522 ..hovered
3523 };
3524
3525 assert_eq!(border(rest), theme.scheme.error);
3526 assert_eq!(
3527 border(hovered),
3528 theme.scheme.on_error_container,
3529 "it deepens under the pointer"
3530 );
3531 assert_eq!(
3532 border(both),
3533 theme.scheme.error,
3534 "and comes back once focused, which the reference tests first"
3535 );
3536 assert_ne!(
3537 theme.scheme.error, theme.scheme.on_error_container,
3538 "the two have to differ for any of the above to mean anything"
3539 );
3540
3541 for status in [rest, hovered, both] {
3542 assert_eq!(
3543 message(status),
3544 theme.scheme.error,
3545 "the message is a sentence, not a control"
3546 );
3547 }
3548 }
3549
3550 #[test]
3551 fn decoration_grows_height_for_label_and_error() {
3552 let bare_h = match Widget::<Msg>::style(&input("x")).height {
3555 Dimension::Length(h) => h,
3556 _ => panic!("a fixed height was expected"),
3557 };
3558 let decorated = input("x").label("Email").error("Required");
3559 let deco_h = match Widget::<Msg>::style(&decorated).height {
3560 Dimension::Length(h) => h,
3561 _ => panic!("a fixed height was expected"),
3562 };
3563 assert!(
3564 deco_h > bare_h,
3565 "label + error grow the field ({bare_h} → {deco_h})"
3566 );
3567 }
3568
3569 #[test]
3570 fn error_paints_the_border_in_the_error_color() {
3571 let theme = Theme::default();
3573 let field = input("x").error("bad");
3574 let mut scene = Scene::new();
3575 Widget::<Msg>::paint(
3576 &field,
3577 Rect::new(0.0, 0.0, 220.0, 60.0),
3578 Status::default(),
3579 &theme,
3580 &mut scene,
3581 );
3582 let has_error_border = scene.primitives().iter().any(|p| {
3583 matches!(
3584 p,
3585 frus_core::Primitive::Rect { border_color, border_width, .. }
3586 if *border_width > 0.0 && *border_color == theme.error
3587 )
3588 });
3589 assert!(has_error_border, "the border must be in the error colour");
3590 }
3591
3592 #[test]
3593 fn placeholder_shows_only_when_empty() {
3594 let theme = Theme::default();
3596 let count_texts = |value: &str| {
3597 let field = TextField::<Msg>::new(value).placeholder("Type here");
3598 let mut scene = Scene::new();
3599 Widget::<Msg>::paint(
3600 &field,
3601 Rect::new(0.0, 0.0, 220.0, 30.0),
3602 Status::default(),
3603 &theme,
3604 &mut scene,
3605 );
3606 scene
3607 .primitives()
3608 .iter()
3609 .filter(
3610 |p| matches!(p, frus_core::Primitive::Text { text, .. } if text == "Type here"),
3611 )
3612 .count()
3613 };
3614 assert_eq!(count_texts(""), 1, "empty: the hint shows");
3615 assert_eq!(count_texts("hi"), 0, "rempli : pas d'indice");
3616 }
3617
3618 #[test]
3619 fn clickable_suffix_emits_and_blocks_caret() {
3620 let field = TextField::new("hello")
3621 .suffix_icon(Icons::CLOSE)
3622 .on_suffix(Msg::Submitted)
3623 .width(220.0);
3624 let (w, y) = (220.0, 12.0);
3625 let x_suffix = w - 8.0; assert_eq!(
3629 Widget::<Msg>::positional_click(&field, x_suffix, y, w, 40.0),
3630 Some(Msg::Submitted)
3631 );
3632 assert_eq!(Widget::<Msg>::cursor_at(&field, x_suffix, y, w, 0), None);
3633 assert_eq!(
3635 Widget::<Msg>::positional_click(&field, 20.0, y, w, 40.0),
3636 None
3637 );
3638 assert!(Widget::<Msg>::cursor_at(&field, 20.0, y, w, 0).is_some());
3639 let deco = TextField::<Msg>::new("hello")
3641 .suffix_icon(Icons::CLOSE)
3642 .width(220.0);
3643 assert_eq!(
3644 Widget::<Msg>::positional_click(&deco, x_suffix, y, w, 40.0),
3645 None
3646 );
3647 }
3648
3649 #[test]
3650 fn hovering_active_suffix_paints_a_halo() {
3651 let field = TextField::new("hello")
3652 .suffix_icon(Icons::CLOSE)
3653 .on_suffix(Msg::Submitted)
3654 .width(220.0);
3655 let bounds = Rect::new(0.0, 0.0, 220.0, 40.0);
3656 let halos = |status: Status| {
3658 let mut scene = Scene::new();
3659 Widget::<Msg>::paint(&field, bounds, status, &Theme::default(), &mut scene);
3660 scene
3661 .primitives()
3662 .iter()
3663 .filter(|p| match p {
3664 frus_core::Primitive::Rect { rect, .. } => {
3665 (rect.width - (FIELD_ICON_SIZE + 8.0)).abs() < 0.5
3666 && (rect.height - (FIELD_ICON_SIZE + 8.0)).abs() < 0.5
3667 }
3668 _ => false,
3669 })
3670 .count()
3671 };
3672 let over_suffix = Status {
3674 hover_cursor: Some(Point::new(212.0, 20.0)),
3675 ..Default::default()
3676 };
3677 let over_body = Status {
3678 hover_cursor: Some(Point::new(20.0, 20.0)),
3679 ..Default::default()
3680 };
3681 assert_eq!(halos(over_suffix), 1, "a halo on the hovered suffix");
3682 assert_eq!(halos(over_body), 0, "no halo in the body");
3683 assert_eq!(halos(Status::default()), 0, "no halo without hover");
3684 }
3685
3686 #[test]
3687 fn cursor_icon_is_pointer_over_active_suffix() {
3688 use crate::interaction::Cursor;
3689 let field = TextField::new("hello")
3690 .suffix_icon(Icons::CLOSE)
3691 .on_suffix(Msg::Submitted)
3692 .width(220.0);
3693 let (w, h, y) = (220.0, 40.0, 12.0);
3694 assert_eq!(
3696 Widget::<Msg>::cursor_icon(&field, w - 8.0, y, w, h),
3697 Some(Cursor::Pointer)
3698 );
3699 assert_eq!(Widget::<Msg>::cursor_icon(&field, 20.0, y, w, h), None);
3700 let deco = TextField::<Msg>::new("hello")
3702 .suffix_icon(Icons::CLOSE)
3703 .width(220.0);
3704 assert_eq!(Widget::<Msg>::cursor_icon(&deco, w - 8.0, y, w, h), None);
3705 }
3706
3707 #[test]
3713 fn each_handle_stands_for_its_own_end_of_the_selection() {
3714 let width = 220.0;
3715 let fields = [
3716 TextField::<Msg>::new("hello world").width(width),
3717 TextField::<Msg>::new("hello world")
3718 .label("Name")
3719 .prefix_icon(Icons::CLOSE)
3720 .width(width),
3721 TextField::<Msg>::new("hello world")
3723 .label("Name")
3724 .outlined()
3725 .width(width),
3726 TextField::<Msg>::new("a sentence long enough to be scrolled along its box")
3728 .width(width),
3729 ];
3730 for field in &fields {
3731 let len = field.value.chars().count();
3732 let edit = Edit {
3733 cursor: len,
3734 anchor: Some(len - 5),
3735 composing: None,
3736 };
3737 let [start, end] = Widget::<Msg>::selection_handles(field, width, &edit, 0.0)
3738 .expect("a selection has handles");
3739 for (handle, index) in [(start, len - 5), (end, len)] {
3740 let at = handle.line_center;
3741 assert_eq!(
3742 Widget::<Msg>::cursor_at(field, at.x, at.y, width, edit.cursor),
3743 Some(index),
3744 "{:?}",
3745 field.value
3746 );
3747 }
3748 assert_eq!(start.rect.x + start.rect.width, start.line_center.x);
3751 assert_eq!(end.rect.x, end.line_center.x);
3752 assert!(start.rect.y > start.line_center.y && end.rect.y > end.line_center.y);
3753 let mut scene = Scene::new();
3757 let status = Status {
3758 focused: true,
3759 cursor: Some(len),
3760 selection: Some((len - 5, len)),
3761 ..Status::default()
3762 };
3763 Widget::<Msg>::paint(
3764 field,
3765 Rect::new(0.0, 0.0, width, 120.0),
3766 status,
3767 &Theme::default(),
3768 &mut scene,
3769 );
3770 let top = scene
3771 .primitives()
3772 .iter()
3773 .find_map(|p| match p {
3774 frus_core::Primitive::Text { position, text, .. } if *text == field.value => {
3775 Some(position.y)
3776 }
3777 _ => None,
3778 })
3779 .expect("the value is painted");
3780 let half = start.line_center.y - top;
3781 assert!(
3782 half > 0.0 && (start.rect.y - top - 2.0 * half).abs() < 0.01,
3783 "{:?}: text at {top}, line centre at {}, handle at {}",
3784 field.value,
3785 start.line_center.y,
3786 start.rect.y
3787 );
3788 }
3789 }
3790
3791 #[test]
3794 fn a_handle_rises_with_the_lines_a_field_scrolls_away() {
3795 let width = 200.0;
3796 let field = TextField::<Msg>::new("one\ntwo\nthree\nfour\nfive\nsix")
3797 .multiline()
3798 .rows(3)
3799 .width(width);
3800 let edit = Edit {
3802 cursor: 23,
3803 anchor: Some(19),
3804 composing: None,
3805 };
3806 let scroll = 20.0;
3807 let still = Widget::<Msg>::selection_handles(&field, width, &edit, 0.0).unwrap();
3808 let [start, end] = Widget::<Msg>::selection_handles(&field, width, &edit, scroll).unwrap();
3809 assert!((still[0].line_center.y - start.line_center.y - scroll).abs() < 0.01);
3810 for (handle, index) in [(start, 19), (end, 23)] {
3811 let at = handle.line_center;
3812 assert_eq!(
3813 Widget::<Msg>::cursor_at(&field, at.x, at.y + scroll, width, edit.cursor),
3814 Some(index)
3815 );
3816 }
3817 }
3818
3819 #[test]
3823 fn the_handles_are_painted_for_a_touch_selection_only() {
3824 let theme = Theme::default();
3825 let fills = |field: &TextField<Msg>, handles: bool, focused: bool| {
3826 let mut scene = Scene::new();
3827 let status = Status {
3828 focused,
3829 cursor: Some(11),
3830 selection: Some((6, 11)),
3831 handles,
3832 ..Status::default()
3833 };
3834 Widget::<Msg>::paint(
3835 field,
3836 Rect::new(10.0, 20.0, 220.0, 56.0),
3837 status,
3838 &theme,
3839 &mut scene,
3840 );
3841 scene
3842 .primitives()
3843 .iter()
3844 .filter_map(|p| match p {
3845 frus_core::Primitive::Path { fill: Some(c), .. } => Some(*c),
3846 _ => None,
3847 })
3848 .collect::<Vec<_>>()
3849 };
3850 let field = TextField::<Msg>::new("hello world").width(220.0);
3851 assert_eq!(fills(&field, true, true), vec![theme.scheme.primary; 2]);
3852 assert!(fills(&field, false, true).is_empty(), "a mouse selection");
3853 assert!(fills(&field, true, false).is_empty(), "a field left behind");
3854 let styled = TextField::<Msg>::new("hello world")
3855 .width(220.0)
3856 .style(TextFieldStyle {
3857 handle_color: Some(frus_core::Color::WHITE),
3858 ..TextFieldStyle::default()
3859 });
3860 assert_eq!(fills(&styled, true, true), vec![frus_core::Color::WHITE; 2]);
3861 }
3862
3863 #[test]
3865 fn a_hidden_caret_is_not_painted() {
3866 let theme = Theme::default();
3867 let carets = |hidden: bool| {
3868 let mut scene = Scene::new();
3869 let status = Status {
3870 focused: true,
3871 cursor: Some(2),
3872 caret_hidden: hidden,
3873 ..Status::default()
3874 };
3875 Widget::<Msg>::paint(
3876 &TextField::<Msg>::new("hello").width(220.0),
3877 Rect::new(0.0, 0.0, 220.0, 56.0),
3878 status,
3879 &theme,
3880 &mut scene,
3881 );
3882 scene
3883 .primitives()
3884 .iter()
3885 .filter(
3886 |p| matches!(p, frus_core::Primitive::Rect { rect, .. } if rect.width == 2.0),
3887 )
3888 .count()
3889 };
3890 assert_eq!(carets(false), 1, "shown");
3891 assert_eq!(carets(true), 0, "hidden");
3892 }
3893
3894 #[test]
3895 fn word_at_finds_word_bounds() {
3896 let inp = input("hello there world");
3897 assert_eq!(inp.word_at(2), Some((0, 5))); assert_eq!(inp.word_at(13), Some((12, 17))); }
3901}
3902
3903#[cfg(test)]
3904mod limit_tests {
3905 use super::*;
3906 use crate::runtime::Edit;
3907
3908 #[derive(Clone, Debug, PartialEq)]
3909 enum Msg {
3910 Changed(String),
3911 }
3912
3913 fn field(value: &str) -> TextField<Msg> {
3914 TextField::new(value).on_input(Msg::Changed)
3915 }
3916
3917 fn typed(field: &TextField<Msg>, text: &str) -> (Option<Msg>, Edit) {
3919 let end = field.value.chars().count();
3920 let mut edit = Edit {
3921 cursor: end,
3922 anchor: None,
3923 ..Default::default()
3924 };
3925 let msg = Widget::on_edit(field, &mut edit, &Key::Text(text.to_string()));
3926 (msg, edit)
3927 }
3928
3929 fn value_of(msg: Option<Msg>) -> Option<String> {
3930 msg.map(|Msg::Changed(v)| v)
3931 }
3932
3933 #[test]
3935 fn a_keystroke_past_the_limit_goes_nowhere() {
3936 let full = field("abcde").max_length(5);
3937 assert_eq!(value_of(typed(&full, "f").0), Some("abcde".into()));
3938 let room = field("abcd").max_length(5);
3939 assert_eq!(value_of(typed(&room, "e").0), Some("abcde".into()));
3940 }
3941
3942 #[test]
3945 fn a_paste_that_crosses_the_limit_lands_what_fits() {
3946 let f = field("ab").max_length(5);
3947 let (msg, edit) = typed(&f, "cdefgh");
3948 assert_eq!(value_of(msg), Some("abcde".into()));
3949 assert_eq!(edit.cursor, 5, "the caret comes to rest at the end");
3950 }
3951
3952 #[test]
3954 fn the_limit_counts_characters_not_bytes() {
3955 let f = field("").max_length(3);
3956 assert_eq!(value_of(typed(&f, "éàü").0), Some("éàü".into()));
3957 let full = field("éàü").max_length(3);
3958 assert_eq!(value_of(typed(&full, "e").0), Some("éàü".into()));
3959 }
3960
3961 #[test]
3964 fn a_value_the_caller_set_over_the_limit_is_left_alone() {
3965 let over = field("abcdefg").max_length(5);
3966 assert_eq!(over.counter().as_deref(), Some("7/5"));
3967 assert_eq!(over.value, "abcdefg");
3968 }
3969
3970 #[test]
3972 fn the_counter_reserves_the_line_it_sits_on() {
3973 assert_eq!(
3974 field("ab").sub_block(),
3975 0.0,
3976 "nothing to say, no room taken"
3977 );
3978 assert!(field("ab").max_length(5).sub_block() > 0.0);
3979 assert_eq!(field("ab").max_length(5).counter().as_deref(), Some("2/5"));
3980 }
3981
3982 #[test]
3984 fn read_only_refuses_the_change_and_keeps_the_caret() {
3985 let f = field("hello").read_only();
3986 assert_eq!(typed(&f, "x").0, None, "typing goes nowhere");
3987
3988 let mut edit = Edit {
3989 cursor: 5,
3990 anchor: None,
3991 ..Default::default()
3992 };
3993 assert_eq!(
3994 Widget::on_edit(
3995 &f,
3996 &mut edit,
3997 &Key::Left {
3998 shift: false,
3999 word: false
4000 }
4001 ),
4002 None
4003 );
4004 assert_eq!(edit.cursor, 4, "but the caret still moves");
4005
4006 let mut edit = Edit {
4007 cursor: 5,
4008 anchor: None,
4009 ..Default::default()
4010 };
4011 Widget::on_edit(
4012 &f,
4013 &mut edit,
4014 &Key::Home {
4015 shift: true,
4016 doc: false,
4017 },
4018 );
4019 assert_eq!(
4020 (edit.cursor, edit.anchor),
4021 (0, Some(5)),
4022 "and a selection can still be made, so it can be copied"
4023 );
4024 }
4025
4026 #[test]
4028 fn read_only_refuses_a_deletion() {
4029 let f = field("hello").read_only();
4030 let mut edit = Edit {
4031 cursor: 5,
4032 anchor: None,
4033 ..Default::default()
4034 };
4035 assert_eq!(Widget::on_edit(&f, &mut edit, &Key::Backspace), None);
4036 assert_eq!(edit.cursor, 5);
4037 }
4038
4039 #[test]
4041 fn read_only_is_not_disabled() {
4042 let read = field("REF-4417").read_only();
4043 assert!(Widget::<Msg>::focusable(&read), "still in the tab order");
4044 assert!(
4045 Widget::<Msg>::cursor_at(&read, 20.0, 10.0, 200.0, 0).is_some(),
4046 "and it still takes a caret, so a reference can be selected and copied"
4047 );
4048
4049 let dead = field("REF-4417").enabled(false);
4050 assert!(!Widget::<Msg>::focusable(&dead));
4051 assert!(Widget::<Msg>::cursor_at(&dead, 20.0, 10.0, 200.0, 0).is_none());
4052 }
4053}