1use frus_core::{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::theme::Theme;
18use crate::widget::Widget;
19
20pub const FIELD_PADDING_X: f32 = 12.0;
22pub const FIELD_PADDING_Y: f32 = 8.0;
29pub const FIELD_OUTLINED_PADDING_TOP: f32 = 20.0;
31pub const FIELD_OUTLINED_PADDING_BOTTOM: f32 = 12.0;
33
34pub const FIELD_DENSE_PADDING_Y: f32 = 4.0;
37pub const FIELD_DENSE_OUTLINED_PADDING_TOP: f32 = 16.0;
41pub const FIELD_DENSE_OUTLINED_PADDING_BOTTOM: f32 = 8.0;
43
44pub const FIELD_TEXT_SIZE: f32 = 16.0;
46pub const FIELD_LABEL_SCALE: f32 = 0.75;
49pub const FIELD_SUB_SIZE: f32 = 12.0;
51pub const FIELD_GAP: f32 = 4.0;
53pub const FIELD_NOTCH_GAP: f32 = 4.0;
56
57pub const FIELD_RADIUS: f32 = 4.0;
59pub const FIELD_BORDER_WIDTH: f32 = 1.0;
62pub const FIELD_FOCUSED_BORDER_WIDTH: f32 = 2.0;
64
65pub const FIELD_ICON_SIZE: f32 = 24.0;
67const ICON_PAD: f32 = 6.0;
68pub const FIELD_DISABLED_OPACITY: f32 = DISABLED_CONTENT_OPACITY;
74const OBSCURE_CHAR: char = '•';
76
77#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
79pub enum TextFieldVariant {
80 #[default]
83 Outlined,
84 Filled,
87 None,
100}
101
102#[derive(Clone, Copy, Debug, Default)]
105pub struct TextFieldStyle {
106 pub fill: Option<frus_core::Color>,
109 pub border_color: Option<frus_core::Color>,
111 pub focused_border_color: Option<frus_core::Color>,
113 pub error_color: Option<frus_core::Color>,
115 pub error_hover_color: Option<frus_core::Color>,
118 pub text_color: Option<frus_core::Color>,
120 pub label_color: Option<frus_core::Color>,
122 pub focused_label_color: Option<frus_core::Color>,
124 pub helper_color: Option<frus_core::Color>,
126 pub icon_color: Option<frus_core::Color>,
128 pub handle_color: Option<frus_core::Color>,
131 pub radius: Option<f32>,
133 pub border_width: Option<f32>,
135 pub focused_border_width: Option<f32>,
137 pub icon_size: Option<f32>,
139 pub padding_x: Option<f32>,
141}
142
143pub struct TextField<Msg = crate::callback::Callback> {
149 value: String,
150 size: f32,
151 width: Dimension,
152 on_input: Option<Box<dyn Fn(String) -> Msg>>,
153 on_submit: Option<Msg>,
154 announce: Option<String>,
156 label: Option<String>,
158 placeholder: Option<String>,
160 helper: Option<String>,
162 error: Option<String>,
165 obscure: bool,
168 text_align: TextAlign,
171 keyboard: Option<KeyboardType>,
174 action: Option<TextInputAction>,
176 prefix: Option<IconData>,
178 suffix: Option<IconData>,
180 suffix_action: Option<Msg>,
183 multiline: bool,
186 rows: u16,
188 variant: TextFieldVariant,
190 enabled: bool,
193 dense: bool,
196 max_length: Option<usize>,
198 input_filter: Option<Box<dyn Fn(char) -> Option<char>>>,
201 capitalization: Capitalization,
204 read_only: bool,
207 style: TextFieldStyle,
209 autofill: Vec<AutofillHint>,
212}
213
214impl TextField<crate::callback::Callback> {
215 pub fn controller(mut self, controller: &crate::TextEditingController) -> Self {
221 self.value = controller.text();
222 let write = controller.clone();
223 self.on_input(move |text| write.set_text(text))
224 }
225}
226
227fn is_word(c: char) -> bool {
229 c.is_alphanumeric() || c == '_'
230}
231
232fn word_boundary_left(chars: &[char], cursor: usize) -> usize {
235 let mut i = cursor;
236 while i > 0 && !is_word(chars[i - 1]) {
237 i -= 1;
238 }
239 while i > 0 && is_word(chars[i - 1]) {
240 i -= 1;
241 }
242 i
243}
244
245fn word_boundary_right(chars: &[char], cursor: usize) -> usize {
248 let len = chars.len();
249 let mut i = cursor;
250 while i < len && !is_word(chars[i]) {
251 i += 1;
252 }
253 while i < len && is_word(chars[i]) {
254 i += 1;
255 }
256 i
257}
258
259fn line_start(chars: &[char], cursor: usize) -> usize {
261 let mut i = cursor;
262 while i > 0 && chars[i - 1] != '\n' {
263 i -= 1;
264 }
265 i
266}
267
268fn line_end(chars: &[char], cursor: usize) -> usize {
270 let len = chars.len();
271 let mut i = cursor;
272 while i < len && chars[i] != '\n' {
273 i += 1;
274 }
275 i
276}
277
278const HANDLE_SIZE: f32 = 22.0;
280
281fn handle_path(rect: Rect, start: bool) -> frus_core::Path {
285 use std::f32::consts::{FRAC_PI_2, PI};
286 let r = rect.width * 0.5;
287 let centre = Point::new(rect.x + r, rect.y + r);
288 if start {
289 frus_core::Path::new()
292 .move_to(Point::new(rect.x + rect.width, rect.y))
293 .line_to(Point::new(rect.x + rect.width, centre.y))
294 .arc_to(centre, r, 0.0, 3.0 * FRAC_PI_2)
295 .close()
296 } else {
297 frus_core::Path::new()
300 .move_to(Point::new(rect.x, rect.y))
301 .line_to(Point::new(centre.x, rect.y))
302 .arc_to(centre, r, -FRAC_PI_2, PI)
303 .close()
304 }
305}
306
307fn move_cursor(cursor: &mut usize, anchor: &mut Option<usize>, target: usize, shift: bool) {
309 if shift {
310 if anchor.is_none() {
311 *anchor = Some(*cursor);
312 }
313 } else {
314 *anchor = None;
315 }
316 *cursor = target;
317}
318
319impl<Msg> TextField<Msg> {
320 pub fn new(value: impl Into<String>) -> Self {
322 Self {
323 value: value.into(),
324 size: FIELD_TEXT_SIZE,
325 width: Dimension::Length(220.0),
326 on_input: None,
327 on_submit: None,
328 announce: None,
329 label: None,
330 placeholder: None,
331 helper: None,
332 error: None,
333 obscure: false,
334 text_align: TextAlign::Start,
335 keyboard: None,
336 action: None,
337 prefix: None,
338 suffix: None,
339 suffix_action: None,
340 multiline: false,
341 rows: 3,
342 variant: TextFieldVariant::Outlined,
343 enabled: true,
344 dense: false,
345 max_length: None,
346 input_filter: None,
347 capitalization: Capitalization::Auto,
348 read_only: false,
349 style: TextFieldStyle::default(),
350 autofill: Vec::new(),
351 }
352 }
353
354 pub fn outlined(mut self) -> Self {
357 self.variant = TextFieldVariant::Outlined;
358 self
359 }
360
361 pub fn borderless(mut self) -> Self {
367 self.variant = TextFieldVariant::None;
368 self
369 }
370
371 pub fn filled(mut self) -> Self {
374 self.variant = TextFieldVariant::Filled;
375 self
376 }
377
378 pub fn variant(mut self, variant: TextFieldVariant) -> Self {
380 self.variant = variant;
381 self
382 }
383
384 pub fn enabled(mut self, enabled: bool) -> Self {
388 self.enabled = enabled;
389 self
390 }
391
392 pub fn style(mut self, style: TextFieldStyle) -> Self {
396 self.style = style;
397 self
398 }
399
400 pub fn dense(mut self, dense: bool) -> Self {
404 self.dense = dense;
405 self
406 }
407
408 fn is_outlined(&self) -> bool {
410 self.variant == TextFieldVariant::Outlined
411 }
412
413 fn is_borderless(&self) -> bool {
415 self.variant == TextFieldVariant::None
416 }
417
418 fn pad_x(&self) -> f32 {
420 self.style.padding_x.unwrap_or(FIELD_PADDING_X)
421 }
422
423 fn pad_top(&self) -> f32 {
426 match (self.is_outlined(), self.dense) {
427 (true, false) => FIELD_OUTLINED_PADDING_TOP,
428 (true, true) => FIELD_DENSE_OUTLINED_PADDING_TOP,
429 (false, false) => FIELD_PADDING_Y,
430 (false, true) => FIELD_DENSE_PADDING_Y,
431 }
432 }
433
434 fn pad_bottom(&self) -> f32 {
436 match (self.is_outlined(), self.dense) {
437 (true, false) => FIELD_OUTLINED_PADDING_BOTTOM,
438 (true, true) => FIELD_DENSE_OUTLINED_PADDING_BOTTOM,
439 (false, false) => FIELD_PADDING_Y,
440 (false, true) => FIELD_DENSE_PADDING_Y,
441 }
442 }
443
444 fn text_style(&self) -> ResolvedTextStyle {
451 TextStyle::new(self.size).resolved()
452 }
453
454 fn sub_style(&self) -> ResolvedTextStyle {
456 TextStyle::new(FIELD_SUB_SIZE).resolved()
457 }
458
459 fn label_size(&self) -> f32 {
462 self.text_style().size * FIELD_LABEL_SCALE
463 }
464
465 fn icon_size(&self) -> f32 {
467 self.style.icon_size.unwrap_or(FIELD_ICON_SIZE)
468 }
469
470 fn text_top(&self) -> f32 {
473 self.pad_top() + self.floating_label_height()
474 }
475
476 fn floating_label_height(&self) -> f32 {
480 if self.is_outlined() || self.label.is_none() {
481 0.0
482 } else {
483 FIELD_GAP + self.label_size()
484 }
485 }
486
487 fn resolved(&self, theme: &Theme) -> TextFieldStyle {
489 let t = theme.widgets.text_field;
490 let pick = |a: Option<frus_core::Color>,
491 b: Option<frus_core::Color>,
492 c: frus_core::Color| { Some(a.or(b).unwrap_or(c)) };
493 TextFieldStyle {
494 fill: pick(
495 self.style.fill,
496 t.fill,
497 if self.is_outlined() || self.is_borderless() {
498 frus_core::Color::TRANSPARENT
499 } else {
500 theme.scheme.surface_container_highest
503 },
504 ),
505 border_color: pick(
506 self.style.border_color,
507 t.border_color,
508 if self.is_outlined() {
509 theme.scheme.outline
510 } else {
511 theme.scheme.on_surface_variant
512 },
513 ),
514 focused_border_color: pick(
515 self.style.focused_border_color,
516 t.focused_border_color,
517 theme.scheme.primary,
518 ),
519 error_color: pick(self.style.error_color, t.error_color, theme.scheme.error),
520 error_hover_color: pick(
521 self.style.error_hover_color,
522 t.error_hover_color,
523 theme.scheme.on_error_container,
524 ),
525 text_color: pick(self.style.text_color, t.text_color, theme.scheme.on_surface),
526 label_color: pick(
527 self.style.label_color,
528 t.label_color,
529 theme.scheme.on_surface_variant,
530 ),
531 focused_label_color: pick(
532 self.style.focused_label_color,
533 t.focused_label_color,
534 theme.scheme.primary,
535 ),
536 helper_color: pick(
537 self.style.helper_color,
538 t.helper_color,
539 theme.scheme.on_surface_variant,
540 ),
541 icon_color: pick(
542 self.style.icon_color,
543 t.icon_color,
544 theme.scheme.on_surface_variant,
545 ),
546 handle_color: pick(
547 self.style.handle_color,
548 t.handle_color,
549 theme.scheme.primary,
550 ),
551 radius: Some(self.style.radius.or(t.radius).unwrap_or(FIELD_RADIUS)),
552 border_width: Some(
553 self.style
554 .border_width
555 .or(t.border_width)
556 .unwrap_or(FIELD_BORDER_WIDTH),
557 ),
558 focused_border_width: Some(
559 self.style
560 .focused_border_width
561 .or(t.focused_border_width)
562 .unwrap_or(FIELD_FOCUSED_BORDER_WIDTH),
563 ),
564 icon_size: Some(self.icon_size()),
565 padding_x: Some(self.pad_x()),
566 }
567 }
568
569 pub fn autofill(mut self, hints: impl IntoIterator<Item = AutofillHint>) -> Self {
582 self.autofill = hints.into_iter().collect();
583 self
584 }
585
586 pub fn multiline(mut self) -> Self {
589 self.multiline = true;
590 self
591 }
592
593 pub fn rows(mut self, rows: u16) -> Self {
596 self.multiline = true;
597 self.rows = rows.max(1);
598 self
599 }
600
601 pub fn obscure(mut self, obscure: bool) -> Self {
609 self.obscure = obscure;
610 self
611 }
612
613 pub fn text_align(mut self, align: TextAlign) -> Self {
627 self.text_align = align;
628 self
629 }
630
631 fn align_offset(&self, content_w: f32, text_w: f32) -> f32 {
654 if self.multiline {
655 return 0.0;
656 }
657 let slack = (content_w - text_w).max(0.0);
658 match self.text_align {
659 TextAlign::Start | TextAlign::Left | TextAlign::Justify => 0.0,
663 TextAlign::End | TextAlign::Right => slack,
664 TextAlign::Center => slack / 2.0,
665 }
666 }
667
668 pub fn keyboard_type(mut self, keyboard: KeyboardType) -> Self {
674 self.keyboard = Some(keyboard);
675 self
676 }
677
678 pub fn action(mut self, action: TextInputAction) -> Self {
683 self.action = Some(action);
684 self
685 }
686
687 fn ime_options(&self) -> Ime {
689 let keyboard = self.keyboard.unwrap_or({
690 if self.obscure {
694 KeyboardType::Password
695 } else if self.multiline {
696 KeyboardType::Multiline
697 } else {
698 KeyboardType::Text
699 }
700 });
701 let action = self.action.unwrap_or(match keyboard {
702 KeyboardType::Multiline => TextInputAction::Newline,
705 _ => TextInputAction::Done,
706 });
707 Ime {
708 keyboard,
709 action,
710 capitalization: self.capitalization,
711 }
712 }
713
714 pub fn prefix_icon(mut self, icon: IconData) -> Self {
716 self.prefix = Some(icon);
717 self
718 }
719
720 pub fn suffix_icon(mut self, icon: IconData) -> Self {
722 self.suffix = Some(icon);
723 self
724 }
725
726 pub fn on_suffix(mut self, message: impl Into<Msg>) -> Self {
730 self.suffix_action = Some(message.into());
731 self
732 }
733
734 fn suffix_hit(&self, local_x: f32, local_y: f32, width: f32) -> bool {
737 if self.suffix.is_none() {
738 return false;
739 }
740 let zone_left = width - (self.icon_size() + ICON_PAD * 2.0);
741 let top = self.label_block();
742 let bottom = top + self.field_height();
743 local_x >= zone_left && local_y >= top && local_y <= bottom
744 }
745
746 pub fn label(mut self, label: impl Into<String>) -> Self {
748 self.label = Some(label.into());
749 self
750 }
751
752 pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
754 self.placeholder = Some(placeholder.into());
755 self
756 }
757
758 pub fn helper(mut self, helper: impl Into<String>) -> Self {
760 self.helper = Some(helper.into());
761 self
762 }
763
764 pub fn error(mut self, message: impl Into<String>) -> Self {
767 self.error = Some(message.into());
768 self
769 }
770
771 pub fn width(mut self, width: f32) -> Self {
773 self.width = Dimension::Length(width);
774 self
775 }
776
777 pub fn size(mut self, size: f32) -> Self {
779 self.size = size;
780 self
781 }
782
783 pub fn max_length(mut self, max: usize) -> Self {
797 self.max_length = Some(max);
798 self
799 }
800
801 pub fn input_filter(mut self, filter: impl Fn(char) -> Option<char> + 'static) -> Self {
825 self.input_filter = Some(Box::new(filter));
826 self
827 }
828
829 pub fn digits_only(mut self) -> Self {
836 self.input_filter = Some(Box::new(|c| c.is_ascii_digit().then_some(c)));
837 self.keyboard = self.keyboard.or(Some(KeyboardType::Number));
838 self
839 }
840
841 pub fn capitalization(mut self, capitalization: Capitalization) -> Self {
848 self.capitalization = capitalization;
849 self
850 }
851
852 pub fn read_only(mut self) -> Self {
861 self.read_only = true;
862 self
863 }
864
865 pub fn on_input<R: crate::callback::IntoMsg<Msg>>(
867 mut self,
868 on_input: impl Fn(String) -> R + 'static,
869 ) -> Self {
870 self.on_input = Some(Box::new(crate::callback::handler1(on_input)));
871 self
872 }
873
874 pub fn on_submit(mut self, message: impl Into<Msg>) -> Self {
876 self.on_submit = Some(message.into());
877 self
878 }
879
880 pub fn announce(mut self, message: impl Into<String>) -> Self {
888 self.announce = Some(message.into());
889 self
890 }
891
892 fn display(&self) -> String {
896 if self.obscure {
897 OBSCURE_CHAR.to_string().repeat(self.value.chars().count())
898 } else {
899 self.value.clone()
900 }
901 }
902
903 fn layout(&self, wrap_width: Option<f32>) -> TextLayout {
907 let style = self.text_style();
908 TextLayout::wrapped(
909 &self.display(),
910 style.size,
911 style.weight,
912 style.italic,
913 wrap_width,
914 )
915 }
916
917 fn prefix_w(&self) -> f32 {
919 if self.prefix.is_some() {
920 self.icon_size() + ICON_PAD
921 } else {
922 0.0
923 }
924 }
925
926 fn suffix_w(&self) -> f32 {
928 if self.suffix.is_some() {
929 self.icon_size() + ICON_PAD
930 } else {
931 0.0
932 }
933 }
934
935 fn handles(
941 &self,
942 width: f32,
943 edit: &Edit,
944 scroll_y: f32,
945 ) -> Option<[crate::SelectionHandle; 2]> {
946 let len = self.value.chars().count();
947 let (start, end) = edit.selection_range()?;
948 let (start, end) = (start.min(len), end.min(len));
949 if start >= end {
950 return None;
951 }
952 let content_w = self.content_width(width);
953 let layout = self.layout(self.multiline.then_some(content_w));
954 let scroll = (layout.caret_rect(edit.cursor.min(len)).x - content_w).max(0.0);
955 let align = self.align_offset(content_w, layout.size().width);
956 let vscroll = if self.multiline {
957 let visible = self.field_height() - self.text_top() - self.pad_bottom();
958 scroll_y.clamp(0.0, (layout.size().height - visible).max(0.0))
959 } else {
960 0.0
961 };
962 let origin_x = self.pad_x() + self.prefix_w() + align - scroll;
963 let origin_y = self.label_block() + self.text_top() - vscroll;
964 let at = |index: usize, start: bool| {
965 let caret = layout.caret_rect(index);
966 let x = origin_x + caret.x;
967 let top = origin_y + caret.y;
968 crate::SelectionHandle {
969 rect: Rect::new(
973 if start { x - HANDLE_SIZE } else { x },
974 top + caret.height,
975 HANDLE_SIZE,
976 HANDLE_SIZE,
977 ),
978 line_center: Point::new(x, top + caret.height * 0.5),
979 }
980 };
981 Some([at(start, true), at(end, false)])
982 }
983
984 fn content_width(&self, width: f32) -> f32 {
985 (width - (self.pad_x() + self.prefix_w()) - self.pad_x() - self.suffix_w()).max(0.0)
986 }
987
988 fn label_block(&self) -> f32 {
992 if self.label.is_some() {
993 if self.is_outlined() {
994 (frus_text::line_height(self.label_size()) * 0.5).ceil()
995 } else {
996 frus_text::line_height(self.label_size()) + FIELD_GAP
997 }
998 } else {
999 0.0
1000 }
1001 }
1002
1003 fn counter(&self) -> Option<String> {
1005 self.max_length
1006 .map(|max| format!("{}/{}", self.value.chars().count(), max))
1007 }
1008
1009 fn sub_block(&self) -> f32 {
1011 if self.error.is_some() || self.helper.is_some() || self.max_length.is_some() {
1012 self.sub_style().line_height() + FIELD_GAP
1016 } else {
1017 0.0
1018 }
1019 }
1020
1021 fn field_height(&self) -> f32 {
1024 let lines = if self.multiline {
1025 self.rows.max(1) as f32
1026 } else {
1027 1.0
1028 };
1029 (self.text_style().line_height() * lines + self.text_top() + self.pad_bottom()).ceil()
1030 }
1031}
1032
1033impl<Msg: Clone> Widget<Msg> for TextField<Msg> {
1034 fn style(&self) -> Style {
1035 let height = self.label_block() + self.field_height() + self.sub_block();
1036 Style {
1037 width: self.width,
1038 height: Dimension::Length(height),
1039 ..Default::default()
1040 }
1041 }
1042
1043 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
1044 &[]
1045 }
1046
1047 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
1048 let s = self.resolved(theme);
1049 let o = if self.enabled {
1053 status.opacity
1054 } else {
1055 status.opacity * FIELD_DISABLED_OPACITY
1056 };
1057 let has_error = self.error.is_some() && self.enabled;
1058 let fp = if self.enabled {
1059 status.focus_progress.clamp(0.0, 1.0)
1060 } else {
1061 0.0
1062 };
1063 let error_ink = s.error_color.unwrap().lerp(s.error_hover_color.unwrap(), {
1071 let hover = if self.enabled {
1072 status.hover_progress.clamp(0.0, 1.0)
1073 } else {
1074 0.0
1075 };
1076 hover * (1.0 - fp)
1077 });
1078
1079 let label_block = self.label_block();
1082 let field = Rect::new(
1083 bounds.x,
1084 bounds.y + label_block,
1085 bounds.width,
1086 self.field_height(),
1087 );
1088
1089 let float_t = if self.value.is_empty() { fp } else { 1.0 };
1095 let content_left = field.x + self.pad_x() + self.prefix_w();
1096 let label_geom = self.label.as_ref().map(|label| {
1100 let rest = Point::new(content_left, field.y + self.text_top());
1101 let (fx, fy) = if self.is_outlined() {
1102 (
1104 field.x + self.pad_x(),
1105 field.y - frus_text::line_height(self.label_size()) * 0.5,
1106 )
1107 } else {
1108 (content_left, field.y + self.pad_top())
1111 };
1112 let x = rest.x + (fx - rest.x) * float_t;
1113 let y = rest.y + (fy - rest.y) * float_t;
1114 let resolved = self.text_style().size;
1115 let size = resolved + (self.label_size() - resolved) * float_t;
1116 let color = if has_error {
1117 error_ink
1118 } else {
1119 s.label_color
1120 .unwrap()
1121 .lerp(s.focused_label_color.unwrap(), fp)
1122 };
1123 (label.clone(), x, y, size, color)
1124 });
1125 let sub = self.error.as_ref().or(self.helper.as_ref());
1127 if let Some(sub) = sub {
1128 let color = if has_error {
1133 s.error_color.unwrap()
1134 } else {
1135 s.helper_color.unwrap()
1136 };
1137 scene.text(
1138 Point::new(bounds.x, field.y + field.height + FIELD_GAP),
1139 sub.clone(),
1140 &self.sub_style(),
1141 color.fade(o),
1142 );
1143 }
1144 if let Some(counter) = self.counter() {
1149 let width = frus_text::measure_resolved(&counter, &self.sub_style()).width;
1150 scene.text(
1151 Point::new(
1152 bounds.x + bounds.width - width,
1153 field.y + field.height + FIELD_GAP,
1154 ),
1155 counter,
1156 &self.sub_style(),
1157 s.helper_color.unwrap().fade(o),
1158 );
1159 }
1160
1161 let border_color = if has_error {
1166 error_ink
1167 } else {
1168 s.border_color
1169 .unwrap()
1170 .lerp(s.focused_border_color.unwrap(), fp)
1171 }
1172 .fade(o);
1173 let rest_w = s.border_width.unwrap();
1174 let border_width = rest_w + (s.focused_border_width.unwrap() - rest_w) * fp;
1175 let radius = s.radius.unwrap();
1176 let fill = s.fill.unwrap();
1177 if self.is_borderless() {
1178 } else if self.is_outlined() {
1180 scene.draw_rect(field, fill.fade(o), radius, border_width, border_color);
1181 } else {
1182 scene.draw_rect(
1185 field,
1186 fill.fade(o),
1187 frus_core::BorderRadius {
1188 top_left: radius,
1189 top_right: radius,
1190 bottom_right: 0.0,
1191 bottom_left: 0.0,
1192 },
1193 0.0,
1194 frus_core::Color::TRANSPARENT,
1195 );
1196 scene.fill_rect(
1197 Rect::new(
1198 field.x,
1199 field.y + field.height - border_width,
1200 field.width,
1201 border_width,
1202 ),
1203 border_color,
1204 );
1205 }
1206
1207 if !self.is_outlined() {
1213 if let Some((label, x, y, size, color)) = &label_geom {
1214 let style = ResolvedTextStyle::exact(*size);
1217 scene.text(Point::new(*x, *y), label.clone(), &style, color.fade(o));
1218 }
1219 }
1220
1221 if self.is_outlined() {
1225 if let Some((label, x, y, size, color)) = &label_geom {
1226 if float_t > 0.01 {
1227 let label_w = frus_text::measure(label, self.label_size()).width;
1228 let notch = Rect::new(
1229 *x - FIELD_NOTCH_GAP,
1230 field.y - (border_width + FIELD_NOTCH_GAP) * 0.5,
1231 label_w + FIELD_NOTCH_GAP * 2.0,
1232 border_width + FIELD_NOTCH_GAP,
1233 );
1234 scene.fill_rect(notch, theme.surface.fade(o * float_t));
1235 }
1236 scene.text(
1237 Point::new(*x, *y),
1238 label.clone(),
1239 &ResolvedTextStyle::exact(*size),
1240 color.fade(o),
1241 );
1242 }
1243 }
1244
1245 let icon_color = s.icon_color.unwrap().fade(o);
1247 let icon_y = field.y + (field.height - self.icon_size()) * 0.5;
1248 if let Some(prefix) = self.prefix {
1249 let path = prefix.placed(
1250 self.icon_size(),
1251 field.x + ICON_PAD,
1252 icon_y,
1253 theme.direction,
1254 );
1255 scene.fill_path(&path, icon_color);
1256 }
1257 if let Some(suffix) = self.suffix {
1258 let x = field.x + field.width - self.icon_size() - ICON_PAD;
1259 if self.suffix_action.is_some() {
1263 if let Some(hc) = status.hover_cursor {
1264 if self.suffix_hit(hc.x - bounds.x, hc.y - bounds.y, bounds.width) {
1265 let halo = Rect::new(
1266 x - 4.0,
1267 icon_y - 4.0,
1268 self.icon_size() + 8.0,
1269 self.icon_size() + 8.0,
1270 );
1271 scene.draw_rect(
1272 halo,
1273 theme.muted.fade(o * 0.18),
1274 halo.height * 0.5,
1275 0.0,
1276 frus_core::Color::TRANSPARENT,
1277 );
1278 }
1279 }
1280 }
1281 let path = suffix.placed(self.icon_size(), x, icon_y, theme.direction);
1282 scene.fill_path(&path, icon_color);
1283 }
1284
1285 let len = self.value.chars().count();
1286 let left = self.pad_x() + self.prefix_w();
1288 let content_x = field.x + left;
1289 let content_w = self.content_width(field.width);
1290 let text_y = field.y + self.text_top();
1291 let wrap = if self.multiline {
1294 Some(content_w)
1295 } else {
1296 None
1297 };
1298 let layout = self.layout(wrap);
1299 let align = self.align_offset(content_w, layout.size().width);
1303
1304 if self.value.is_empty() {
1308 if let Some(placeholder) = &self.placeholder {
1309 let ph_alpha = if self.label.is_some() { o * fp } else { o };
1310 if ph_alpha > 0.01 {
1311 let style = self.text_style();
1314 let hint_w = frus_text::measure_resolved(placeholder, &style).width;
1315 let hint_align = self.align_offset(content_w, hint_w);
1316 scene.text(
1317 Point::new(content_x + hint_align, text_y),
1318 placeholder.clone(),
1319 &style,
1320 theme.muted.fade(ph_alpha),
1321 );
1322 }
1323 }
1324 }
1325
1326 let cursor = status.cursor.unwrap_or(len).min(len);
1329 let caret = layout.caret_rect(cursor);
1330 let scroll = if status.focused {
1331 (caret.x - content_w).max(0.0)
1332 } else {
1333 0.0
1334 };
1335 let text_x = content_x + align - scroll;
1336 let vscroll = if self.multiline {
1339 let overflow = (layout.size().height
1340 - (field.height - self.text_top() - self.pad_bottom()))
1341 .max(0.0);
1342 status.scroll_y.clamp(0.0, overflow)
1343 } else {
1344 0.0
1345 };
1346 let text_top = text_y - vscroll;
1348
1349 let outer_clip = scene.current_clip();
1356 let content_clip = outer_clip.intersect(Rect::new(
1357 content_x,
1358 field.y + self.text_top(),
1359 content_w,
1360 (field.height - self.text_top() - self.pad_bottom()).max(0.0),
1361 ));
1362 scene.set_clip(content_clip);
1363
1364 if status.focused {
1366 if let Some((start, end)) = status.selection {
1367 for r in layout.selection_rects(start.min(len), end.min(len)) {
1368 scene.fill_rect(
1369 Rect::new(text_x + r.x, text_top + r.y, r.width, r.height),
1370 theme.selection.fade(o),
1371 );
1372 }
1373 }
1374 }
1375
1376 if !self.value.is_empty() {
1377 let pos = Point::new(text_x, text_top);
1378 let color = theme.on_surface.fade(o);
1379 match wrap {
1380 Some(max_w) => {
1382 scene.text_wrapped(pos, self.display(), &self.text_style(), color, max_w)
1383 }
1384 None => scene.text(pos, self.display(), &self.text_style(), color),
1385 }
1386 }
1387
1388 if status.focused {
1391 if let Some((start, end)) = status.composing {
1392 let (start, end) = (start.min(len), end.min(len));
1393 for r in layout.selection_rects(start, end) {
1394 scene.fill_rect(
1395 Rect::new(text_x + r.x, text_top + r.y + r.height - 1.5, r.width, 1.5),
1396 theme.on_surface.fade(o * 0.7),
1397 );
1398 }
1399 }
1400 }
1401
1402 if status.focused && !status.caret_hidden {
1404 scene.fill_rect(
1405 Rect::new(text_x + caret.x, text_top + caret.y, 2.0, caret.height),
1406 theme.on_surface.fade(o),
1407 );
1408 }
1409
1410 if status.focused && status.handles {
1413 scene.set_clip(outer_clip);
1414 let edit = Edit {
1415 cursor,
1416 anchor: status
1417 .selection
1418 .map(|(a, b)| if cursor == a { b } else { a }),
1419 composing: None,
1420 };
1421 if let Some([start, end]) = self.handles(bounds.width, &edit, status.scroll_y) {
1422 let color = s.handle_color.unwrap().fade(o);
1423 let place = |r: Rect| r.translate(bounds.x, bounds.y);
1424 scene.fill_path(&handle_path(place(start.rect), true), color);
1425 scene.fill_path(&handle_path(place(end.rect), false), color);
1426 }
1427 }
1428 }
1429
1430 fn on_click(&self) -> Option<Msg> {
1431 None
1432 }
1433
1434 fn positional_click(
1435 &self,
1436 local_x: f32,
1437 local_y: f32,
1438 width: f32,
1439 _height: f32,
1440 ) -> Option<Msg> {
1441 if !self.enabled {
1442 return None;
1443 }
1444 if self.suffix_action.is_some() && self.suffix_hit(local_x, local_y, width) {
1445 self.suffix_action.clone()
1446 } else {
1447 None
1448 }
1449 }
1450
1451 fn cursor_icon(
1452 &self,
1453 local_x: f32,
1454 local_y: f32,
1455 width: f32,
1456 _height: f32,
1457 ) -> Option<crate::interaction::Cursor> {
1458 if self.suffix_action.is_some() && self.suffix_hit(local_x, local_y, width) {
1460 Some(crate::interaction::Cursor::Pointer)
1461 } else {
1462 None
1463 }
1464 }
1465
1466 fn on_edit(&self, edit: &mut Edit, key: &Key) -> Option<Msg> {
1467 if !self.enabled {
1471 return None;
1472 }
1473 let mut chars: Vec<char> = self.value.chars().collect();
1474 let len = chars.len();
1475 let mut cursor = edit.cursor.min(len);
1476 let mut anchor = edit.anchor.map(|a| a.min(len));
1477 let selection = anchor
1478 .map(|a| (a.min(cursor), a.max(cursor)))
1479 .filter(|(s, e)| s < e);
1480
1481 let mut changed = false;
1482
1483 match key {
1484 Key::Text(text) => {
1485 let inserted: Vec<char> = match &self.input_filter {
1489 Some(filter) => text.chars().filter_map(filter).collect(),
1490 None => text.chars().collect(),
1491 };
1492 if selection.is_some() || !inserted.is_empty() {
1497 if let Some((s, e)) = selection {
1498 chars.drain(s..e);
1499 cursor = s;
1500 }
1501 let n = inserted.len();
1502 chars.splice(cursor..cursor, inserted);
1503 cursor += n;
1504 anchor = None;
1505 changed = true;
1506 }
1507 }
1508 Key::Backspace => {
1509 if let Some((s, e)) = selection {
1510 chars.drain(s..e);
1511 cursor = s;
1512 changed = true;
1513 } else if cursor > 0 {
1514 chars.remove(cursor - 1);
1515 cursor -= 1;
1516 changed = true;
1517 }
1518 anchor = None;
1519 }
1520 Key::Delete => {
1521 if let Some((s, e)) = selection {
1522 chars.drain(s..e);
1523 cursor = s;
1524 changed = true;
1525 } else if cursor < len {
1526 chars.remove(cursor);
1527 changed = true;
1528 }
1529 anchor = None;
1530 }
1531 Key::Left { shift, word } => {
1532 let target = if *word {
1533 word_boundary_left(&chars, cursor)
1534 } else {
1535 cursor.saturating_sub(1)
1536 };
1537 move_cursor(&mut cursor, &mut anchor, target, *shift);
1538 }
1539 Key::Right { shift, word } => {
1540 let target = if *word {
1541 word_boundary_right(&chars, cursor)
1542 } else {
1543 (cursor + 1).min(len)
1544 };
1545 move_cursor(&mut cursor, &mut anchor, target, *shift);
1546 }
1547 Key::Home { shift, doc } => {
1550 let target = if *doc { 0 } else { line_start(&chars, cursor) };
1551 move_cursor(&mut cursor, &mut anchor, target, *shift);
1552 }
1553 Key::End { shift, doc } => {
1554 let target = if *doc { len } else { line_end(&chars, cursor) };
1555 move_cursor(&mut cursor, &mut anchor, target, *shift);
1556 }
1557 Key::Escape => {}
1559 Key::Enter if self.multiline => {
1560 if let Some((s, e)) = selection {
1562 chars.drain(s..e);
1563 cursor = s;
1564 }
1565 chars.insert(cursor, '\n');
1566 cursor += 1;
1567 anchor = None;
1568 changed = true;
1569 }
1570 Key::Enter => {
1571 edit.cursor = cursor;
1573 edit.anchor = anchor;
1574 return self.on_submit.clone();
1575 }
1576 }
1577
1578 if self.read_only && changed {
1583 return None;
1584 }
1585
1586 if changed {
1595 if let Some(max) = self.max_length {
1596 if chars.len() > max {
1597 chars.truncate(max);
1598 cursor = cursor.min(max);
1599 anchor = anchor.map(|a| a.min(max));
1600 }
1601 }
1602 }
1603
1604 edit.cursor = cursor;
1605 edit.anchor = anchor;
1606
1607 if changed {
1608 let new_value: String = chars.into_iter().collect();
1609 self.on_input.as_ref().map(|make| make(new_value))
1610 } else {
1611 None
1612 }
1613 }
1614
1615 fn ime(&self) -> Ime {
1616 self.ime_options()
1617 }
1618
1619 fn cursor_at(
1620 &self,
1621 local_x: f32,
1622 local_y: f32,
1623 width: f32,
1624 scroll_cursor: usize,
1625 ) -> Option<usize> {
1626 if !self.enabled {
1629 return None;
1630 }
1631 if self.suffix_action.is_some() && self.suffix_hit(local_x, local_y, width) {
1634 return None;
1635 }
1636 let left = self.pad_x() + self.prefix_w();
1640 let content_w = self.content_width(width);
1641 let layout = self.layout(if self.multiline {
1642 Some(content_w)
1643 } else {
1644 None
1645 });
1646 let scroll = (layout.caret_rect(scroll_cursor).x - content_w).max(0.0);
1647 let align = self.align_offset(content_w, layout.size().width);
1652 let target_x = local_x - left - align + scroll;
1655 let target_y = local_y - self.label_block() - self.text_top();
1656 Some(layout.hit_test(Point::new(target_x, target_y)))
1657 }
1658
1659 fn text_metrics(&self, width: f32, cursor: usize) -> Option<(f32, f32, f32, f32)> {
1660 if !self.multiline {
1661 return None;
1662 }
1663 let layout = self.layout(Some(self.content_width(width)));
1664 let caret = layout.caret_rect(cursor);
1665 let visible = self.field_height() - self.text_top() - self.pad_bottom();
1666 Some((layout.size().height, visible, caret.y, caret.height))
1667 }
1668
1669 fn text_viewport(&self, rect: Rect) -> Option<Rect> {
1670 if !self.multiline {
1671 return None;
1672 }
1673 Some(Rect::new(
1675 rect.x,
1676 rect.y + self.label_block(),
1677 rect.width,
1678 self.field_height(),
1679 ))
1680 }
1681
1682 fn caret_vertical(
1683 &self,
1684 width: f32,
1685 cursor: usize,
1686 down: bool,
1687 page: bool,
1688 goal_x: Option<f32>,
1689 ) -> Option<(usize, f32)> {
1690 if !self.multiline {
1691 return None;
1692 }
1693 let layout = self.layout(Some(self.content_width(width)));
1694 let caret = layout.caret_rect(cursor);
1695 let line_h = caret.height;
1696 let x = goal_x.unwrap_or(caret.x);
1698 let step = if page {
1700 (self.field_height() - self.text_top() - self.pad_bottom()).max(line_h)
1701 } else {
1702 line_h
1703 };
1704 let center = caret.y + line_h * 0.5;
1705 let target_y = if down { center + step } else { center - step };
1706 let full = layout.size().height;
1707 if page {
1708 let clamped = target_y.clamp(0.0, (full - line_h * 0.5).max(0.0));
1710 Some((layout.hit_test(Point::new(x, clamped)), x))
1711 } else {
1712 if target_y < 0.0 || target_y >= full {
1714 return None;
1715 }
1716 Some((layout.hit_test(Point::new(x, target_y)), x))
1717 }
1718 }
1719
1720 fn text_value(&self) -> Option<&str> {
1721 Some(&self.value)
1722 }
1723
1724 fn replace_value(&self, value: String) -> Option<Msg> {
1725 if !self.enabled || self.read_only {
1731 return None;
1732 }
1733 self.on_input.as_ref().map(|make| make(value))
1734 }
1735
1736 fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
1737 let mut s = frus_core::SemanticsProperties::new(frus_core::Role::TextInput)
1738 .value(self.value.clone());
1739 let label = match (&self.label, &self.error) {
1741 (Some(l), Some(e)) => Some(format!("{l}, {e}")),
1742 (Some(l), None) => Some(l.clone()),
1743 (None, Some(e)) => Some(e.clone()),
1744 (None, None) => None,
1745 };
1746 if let Some(label) = label {
1747 s = s.label(label);
1748 }
1749 if !self.enabled {
1750 s = s.disabled(true);
1751 }
1752 Some(s)
1753 }
1754
1755 fn selected_text(&self, edit: &Edit) -> Option<String> {
1756 let chars: Vec<char> = self.value.chars().collect();
1757 let len = chars.len();
1758 let cursor = edit.cursor.min(len);
1759 let anchor = edit.anchor?.min(len);
1760 let (start, end) = (anchor.min(cursor), anchor.max(cursor));
1761 if start < end {
1762 Some(chars[start..end].iter().collect())
1763 } else {
1764 None
1765 }
1766 }
1767
1768 fn word_at(&self, index: usize) -> Option<(usize, usize)> {
1769 let chars: Vec<char> = self.value.chars().collect();
1770 let len = chars.len();
1771 if len == 0 {
1772 return Some((0, 0));
1773 }
1774 let i = index.min(len - 1);
1775 let is_word = |c: char| c.is_alphanumeric() || c == '_';
1776 if !is_word(chars[i]) {
1777 return Some((i, (i + 1).min(len)));
1778 }
1779 let mut start = i;
1780 while start > 0 && is_word(chars[start - 1]) {
1781 start -= 1;
1782 }
1783 let mut end = i + 1;
1784 while end < len && is_word(chars[end]) {
1785 end += 1;
1786 }
1787 Some((start, end))
1788 }
1789
1790 fn selection_handles(
1791 &self,
1792 width: f32,
1793 edit: &Edit,
1794 scroll_y: f32,
1795 ) -> Option<[crate::SelectionHandle; 2]> {
1796 self.handles(width, edit, scroll_y)
1797 }
1798
1799 fn autofill_hints(&self) -> &[AutofillHint] {
1800 &self.autofill
1801 }
1802
1803 fn focusable(&self) -> bool {
1804 self.enabled
1805 }
1806
1807 fn draws_own_focus(&self) -> bool {
1808 true
1809 }
1810
1811 fn announce(&self) -> Option<String> {
1812 self.announce.clone()
1813 }
1814}
1815
1816#[cfg(test)]
1817mod tests {
1818 use super::*;
1819 use crate::icons::Icons;
1820
1821 #[derive(Clone, Debug, PartialEq)]
1822 enum Msg {
1823 Changed(String),
1824 Submitted,
1825 }
1826
1827 fn input(value: &str) -> TextField<Msg> {
1828 TextField::new(value).on_input(Msg::Changed)
1829 }
1830
1831 fn rects(field: &TextField<Msg>, theme: &Theme, w: f32, h: f32) -> Vec<frus_core::Primitive> {
1833 let mut scene = Scene::new();
1834 Widget::<Msg>::paint(
1835 field,
1836 Rect::new(0.0, 0.0, w, h),
1837 Status::default(),
1838 theme,
1839 &mut scene,
1840 );
1841 scene
1842 .primitives()
1843 .iter()
1844 .filter(|p| matches!(p, frus_core::Primitive::Rect { .. }))
1845 .cloned()
1846 .collect()
1847 }
1848
1849 #[test]
1854 fn a_field_is_the_references_size() {
1855 let line = input("x").text_style().line_height();
1856 let outlined = input("x");
1857 assert_eq!(
1858 outlined.field_height(),
1859 (line + FIELD_OUTLINED_PADDING_TOP + FIELD_OUTLINED_PADDING_BOTTOM).ceil()
1860 );
1861 let filled = input("x").filled();
1864 assert_eq!(filled.field_height(), (line + FIELD_PADDING_Y * 2.0).ceil());
1865 let labelled = input("x").filled().label("Name");
1866 assert_eq!(
1867 labelled.field_height() - filled.field_height(),
1868 (FIELD_GAP + FIELD_TEXT_SIZE * FIELD_LABEL_SCALE).ceil()
1869 );
1870 assert_eq!(input("x").size(32.0).label_size(), 24.0);
1873 }
1874
1875 #[test]
1879 fn a_dense_field_gives_back_its_padding() {
1880 let line = input("x").text_style().line_height();
1881 assert_eq!(
1882 input("x").dense(true).field_height(),
1883 (line + FIELD_DENSE_OUTLINED_PADDING_TOP + FIELD_DENSE_OUTLINED_PADDING_BOTTOM).ceil()
1884 );
1885 assert_eq!(
1886 input("x").filled().dense(true).field_height(),
1887 (line + FIELD_DENSE_PADDING_Y * 2.0).ceil()
1888 );
1889 for dense in [input("x").dense(true), input("x").filled().dense(true)] {
1892 let roomy = if dense.is_outlined() {
1893 input("x")
1894 } else {
1895 input("x").filled()
1896 };
1897 assert!(dense.field_height() < roomy.field_height());
1898 }
1899 }
1900
1901 #[test]
1905 fn a_filled_field_has_a_line_not_a_box() {
1906 let theme = Theme::default();
1907 let stroked = |field: &TextField<Msg>| {
1908 rects(field, &theme, 220.0, 80.0)
1909 .iter()
1910 .filter(|p| {
1911 matches!(p, frus_core::Primitive::Rect { border_width, .. } if *border_width > 0.0)
1912 })
1913 .count()
1914 };
1915 assert_eq!(
1916 stroked(&input("x").outlined()),
1917 1,
1918 "outlined: one stroked box"
1919 );
1920 assert_eq!(stroked(&input("x").filled()), 0, "filled: nothing stroked");
1921 let filled = input("x").filled();
1923 let height = filled.field_height();
1924 let has_underline = rects(&filled, &theme, 220.0, 80.0).iter().any(|p| {
1925 matches!(
1926 p,
1927 frus_core::Primitive::Rect { rect, border_width, .. }
1928 if *border_width == 0.0
1929 && (rect.width - 220.0).abs() < 0.5
1930 && (rect.height - FIELD_BORDER_WIDTH).abs() < 0.5
1931 && (rect.y + rect.height - height).abs() < 0.5
1932 )
1933 });
1934 assert!(has_underline, "a filled field is underlined");
1935 }
1936
1937 #[test]
1940 fn a_disabled_field_dims_and_never_looks_focused() {
1941 let theme = Theme::default();
1942 let focused = Status {
1943 focus_progress: 1.0,
1944 ..Default::default()
1945 };
1946 let border = |field: &TextField<Msg>, status: Status| {
1947 let mut scene = Scene::new();
1948 Widget::<Msg>::paint(
1949 field,
1950 Rect::new(0.0, 0.0, 220.0, 80.0),
1951 status,
1952 &theme,
1953 &mut scene,
1954 );
1955 scene.primitives().iter().find_map(|p| match p {
1956 frus_core::Primitive::Rect {
1957 border_width,
1958 border_color,
1959 ..
1960 } if *border_width > 0.0 => Some((*border_width, *border_color)),
1961 _ => None,
1962 })
1963 };
1964 let (live_w, live_c) = border(&input("x"), focused).unwrap();
1965 assert_eq!(
1966 live_w, FIELD_FOCUSED_BORDER_WIDTH,
1967 "focus widens the border"
1968 );
1969 assert_eq!(live_c.a, 1.0, "and it is fully opaque");
1970 let (dead_w, dead_c) = border(&input("x").enabled(false), focused).unwrap();
1971 assert_eq!(dead_w, FIELD_BORDER_WIDTH, "disabled: no focus weight");
1972 assert!(
1973 (dead_c.a - FIELD_DISABLED_OPACITY).abs() < 0.01,
1974 "disabled: dimmed to the reference's 38%, got {}",
1975 dead_c.a
1976 );
1977 }
1978
1979 #[test]
1983 fn scrolled_text_is_clipped_below_the_label() {
1984 let theme = Theme::default();
1985 let field = input("one\ntwo\nthree\nfour\nfive\nsix")
1986 .rows(3)
1987 .label("Notes");
1988 let mut scene = Scene::new();
1989 Widget::<Msg>::paint(
1990 &field,
1991 Rect::new(0.0, 0.0, 220.0, 140.0),
1992 Status {
1993 scroll_y: 40.0,
1994 ..Default::default()
1995 },
1996 &theme,
1997 &mut scene,
1998 );
1999 let box_top = field.label_block();
2000 let clip = scene
2001 .primitives()
2002 .iter()
2003 .find_map(|p| match p {
2004 frus_core::Primitive::Text { clip, .. } if clip.y > -1.0e6 => Some(*clip),
2007 _ => None,
2008 })
2009 .expect("the value is clipped");
2010 assert!(
2011 clip.y >= box_top + field.text_top() - 0.5,
2012 "the clip starts below the padding, not at the border: {} < {}",
2013 clip.y,
2014 box_top + field.text_top()
2015 );
2016 }
2017
2018 #[test]
2021 fn a_disabled_field_is_inert() {
2022 let live = input("hello");
2023 let dead = input("hello").enabled(false);
2024 assert!(Widget::<Msg>::focusable(&live));
2025 assert!(!Widget::<Msg>::focusable(&dead), "out of the tab order");
2026 assert!(
2027 Widget::<Msg>::cursor_at(&live, 40.0, 30.0, 220.0, 0).is_some(),
2028 "a live field takes a caret"
2029 );
2030 assert_eq!(
2031 Widget::<Msg>::cursor_at(&dead, 40.0, 30.0, 220.0, 0),
2032 None,
2033 "a disabled field takes none"
2034 );
2035 let mut edit = Edit::default();
2036 assert!(
2037 Widget::on_edit(&dead, &mut edit, &Key::Text("x".into())).is_none(),
2038 "and no key edits it"
2039 );
2040 let semantics = Widget::<Msg>::semantics(&dead).unwrap();
2041 assert!(semantics.disabled, "a reader is told it is disabled");
2042 }
2043
2044 #[test]
2046 fn the_caller_outranks_the_theme_which_outranks_the_framework() {
2047 let radius = |field: &TextField<Msg>, theme: &Theme| {
2048 rects(field, theme, 220.0, 80.0)
2049 .iter()
2050 .find_map(|p| match p {
2051 frus_core::Primitive::Rect {
2052 border_width,
2053 radius,
2054 ..
2055 } if *border_width > 0.0 => Some(radius.top_left),
2056 _ => None,
2057 })
2058 .unwrap()
2059 };
2060 let plain = Theme::default();
2061 assert_eq!(radius(&input("x"), &plain), FIELD_RADIUS);
2062 let mut themed = Theme::default();
2063 themed.widgets.text_field.radius = Some(10.0);
2064 assert_eq!(radius(&input("x"), &themed), 10.0);
2065 let overridden = input("x").style(TextFieldStyle {
2066 radius: Some(2.0),
2067 ..Default::default()
2068 });
2069 assert_eq!(radius(&overridden, &themed), 2.0);
2070 }
2071
2072 #[test]
2073 fn text_is_clipped_to_content_box() {
2074 let inp = input("a very long value that overflows the field width").width(100.0);
2077 let mut scene = Scene::new();
2078 Widget::<Msg>::paint(
2079 &inp,
2080 Rect::new(0.0, 0.0, 100.0, 30.0),
2081 Status::default(),
2082 &Theme::default(),
2083 &mut scene,
2084 );
2085 let clip = scene
2086 .primitives()
2087 .iter()
2088 .find_map(|p| match p {
2089 frus_core::Primitive::Text { clip, .. } => Some(*clip),
2090 _ => None,
2091 })
2092 .expect("text primitive");
2093 assert!(
2094 (clip.width - (100.0 - FIELD_PADDING_X * 2.0)).abs() < 0.5,
2095 "clip = {clip:?}"
2096 );
2097 }
2098
2099 #[test]
2100 fn composing_region_draws_an_underline() {
2101 let inp = input("konnichiwa");
2104 let base = Status {
2105 focused: true,
2106 cursor: Some(5),
2107 ..Default::default()
2108 };
2109 let composing = Status {
2110 composing: Some((0, 5)),
2111 ..base
2112 };
2113
2114 let count_thin_rects = |status: Status| {
2115 let mut scene = Scene::new();
2116 Widget::<Msg>::paint(
2117 &inp,
2118 Rect::new(0.0, 0.0, 220.0, 30.0),
2119 status,
2120 &Theme::default(),
2121 &mut scene,
2122 );
2123 scene
2124 .primitives()
2125 .iter()
2126 .filter(|p| matches!(p, frus_core::Primitive::Rect { rect, .. } if rect.height <= 2.0 && rect.width > 2.0))
2127 .count()
2128 };
2129 let plain = count_thin_rects(base);
2130 let underlined = count_thin_rects(composing);
2131 assert!(
2132 underlined > plain,
2133 "the composition must underline ({plain} → {underlined})"
2134 );
2135 }
2136
2137 #[test]
2138 fn text_value_exposes_the_field_content() {
2139 let inp = input("hello");
2141 assert_eq!(Widget::<Msg>::text_value(&inp), Some("hello"));
2142 }
2143
2144 #[test]
2145 fn cursor_at_accounts_for_scroll() {
2146 let inp = input("0123456789 abcdefghij");
2148 assert_eq!(
2150 Widget::<Msg>::cursor_at(&inp, FIELD_PADDING_X, FIELD_OUTLINED_PADDING_TOP, 80.0, 0),
2151 Some(0)
2152 );
2153 let end = inp.value.chars().count();
2156 let at_left =
2157 Widget::<Msg>::cursor_at(&inp, FIELD_PADDING_X, FIELD_OUTLINED_PADDING_TOP, 80.0, end)
2158 .unwrap();
2159 let at_right =
2160 Widget::<Msg>::cursor_at(&inp, 76.0, FIELD_OUTLINED_PADDING_TOP, 80.0, end).unwrap();
2161 assert!(at_left > 0, "scrolled: the left edge is no longer index 0");
2162 assert!(at_right > at_left, "a click on the right → a larger index");
2163 }
2164
2165 #[test]
2166 fn insert_at_cursor() {
2167 let inp = input("ac");
2168 let mut edit = Edit {
2169 cursor: 1,
2170 anchor: None,
2171 composing: None,
2172 };
2173 assert_eq!(
2174 inp.on_edit(&mut edit, &Key::Text("b".to_string())),
2175 Some(Msg::Changed("abc".to_string()))
2176 );
2177 assert_eq!(edit.cursor, 2);
2178 }
2179
2180 #[test]
2181 fn shift_arrow_selects_then_delete() {
2182 let inp = input("hello");
2183 let mut edit = Edit {
2185 cursor: 5,
2186 anchor: None,
2187 composing: None,
2188 };
2189 inp.on_edit(
2190 &mut edit,
2191 &Key::Left {
2192 shift: true,
2193 word: false,
2194 },
2195 );
2196 inp.on_edit(
2197 &mut edit,
2198 &Key::Left {
2199 shift: true,
2200 word: false,
2201 },
2202 );
2203 assert_eq!(edit.selection_range(), Some((3, 5)));
2204 assert_eq!(
2206 inp.on_edit(&mut edit, &Key::Backspace),
2207 Some(Msg::Changed("hel".to_string()))
2208 );
2209 assert_eq!(edit.cursor, 3);
2210 assert_eq!(edit.anchor, None);
2211 }
2212
2213 #[test]
2214 fn home_end_bounds() {
2215 let inp = input("abc");
2216 let mut edit = Edit {
2217 cursor: 1,
2218 anchor: None,
2219 composing: None,
2220 };
2221 inp.on_edit(
2222 &mut edit,
2223 &Key::End {
2224 shift: false,
2225 doc: false,
2226 },
2227 );
2228 assert_eq!(edit.cursor, 3);
2229 inp.on_edit(
2230 &mut edit,
2231 &Key::Home {
2232 shift: false,
2233 doc: false,
2234 },
2235 );
2236 assert_eq!(edit.cursor, 0);
2237 }
2238
2239 #[test]
2240 fn ctrl_arrow_jumps_by_word() {
2241 let inp = input("foo bar baz");
2242 let mut edit = Edit {
2244 cursor: 11,
2245 anchor: None,
2246 composing: None,
2247 };
2248 inp.on_edit(
2249 &mut edit,
2250 &Key::Left {
2251 shift: false,
2252 word: true,
2253 },
2254 );
2255 assert_eq!(edit.cursor, 8, "start of \"baz\"");
2256 inp.on_edit(
2257 &mut edit,
2258 &Key::Left {
2259 shift: false,
2260 word: true,
2261 },
2262 );
2263 assert_eq!(edit.cursor, 4, "start of \"bar\"");
2264 inp.on_edit(
2266 &mut edit,
2267 &Key::Right {
2268 shift: false,
2269 word: true,
2270 },
2271 );
2272 assert_eq!(edit.cursor, 7, "fin de \"bar\"");
2273 inp.on_edit(
2274 &mut edit,
2275 &Key::Right {
2276 shift: false,
2277 word: true,
2278 },
2279 );
2280 assert_eq!(edit.cursor, 11, "fin de \"baz\"");
2281 }
2282
2283 #[test]
2284 fn home_end_are_line_relative_but_ctrl_spans_the_field() {
2285 let inp = TextField::<Msg>::new("ab\ncd\nef")
2287 .on_input(Msg::Changed)
2288 .rows(3);
2289 let mut edit = Edit {
2290 cursor: 4,
2291 anchor: None,
2292 composing: None,
2293 };
2294 inp.on_edit(
2296 &mut edit,
2297 &Key::Home {
2298 shift: false,
2299 doc: false,
2300 },
2301 );
2302 assert_eq!(edit.cursor, 3, "start of the 2nd line");
2303 inp.on_edit(
2305 &mut edit,
2306 &Key::End {
2307 shift: false,
2308 doc: false,
2309 },
2310 );
2311 assert_eq!(edit.cursor, 5, "end of the 2nd line");
2312 inp.on_edit(
2314 &mut edit,
2315 &Key::Home {
2316 shift: false,
2317 doc: true,
2318 },
2319 );
2320 assert_eq!(edit.cursor, 0, "start of the field");
2321 inp.on_edit(
2322 &mut edit,
2323 &Key::End {
2324 shift: false,
2325 doc: true,
2326 },
2327 );
2328 assert_eq!(edit.cursor, 8, "end of the field");
2329 }
2330
2331 #[test]
2332 fn selected_text_reads_range() {
2333 let inp = input("hello");
2334 let edit = Edit {
2335 cursor: 5,
2336 anchor: Some(2),
2337 composing: None,
2338 };
2339 assert_eq!(inp.selected_text(&edit), Some("llo".to_string()));
2340 }
2341
2342 #[test]
2343 fn enter_submits_without_changing_value() {
2344 let inp = input("acheter du lait").on_submit(Msg::Submitted);
2345 let mut edit = Edit {
2346 cursor: 3,
2347 anchor: None,
2348 composing: None,
2349 };
2350 assert_eq!(inp.on_edit(&mut edit, &Key::Enter), Some(Msg::Submitted));
2352 assert_eq!(edit.cursor, 3); }
2354
2355 #[test]
2356 fn enter_without_submit_is_noop() {
2357 let inp = input("x");
2358 let mut edit = Edit {
2359 cursor: 1,
2360 anchor: None,
2361 composing: None,
2362 };
2363 assert_eq!(inp.on_edit(&mut edit, &Key::Enter), None);
2364 }
2365
2366 fn drawn_text_x(field: &TextField<()>, width: f32) -> f32 {
2368 let mut scene = Scene::new();
2369 Widget::<()>::paint(
2370 field,
2371 Rect::new(0.0, 0.0, width, 56.0),
2372 Status {
2373 focused: false,
2374 ..Default::default()
2375 },
2376 &Theme::default(),
2377 &mut scene,
2378 );
2379 scene
2380 .primitives()
2381 .iter()
2382 .find_map(|p| match p {
2383 frus_core::Primitive::Text { position, text, .. } if text == "42" => {
2384 Some(position.x)
2385 }
2386 _ => None,
2387 })
2388 .expect("the value is drawn")
2389 }
2390
2391 #[test]
2393 fn a_field_that_says_nothing_starts_at_the_left() {
2394 let plain: TextField<()> = TextField::new("42");
2395 let started: TextField<()> = TextField::new("42").text_align(TextAlign::Start);
2396 assert_eq!(drawn_text_x(&plain, 300.0), drawn_text_x(&started, 300.0));
2397 }
2398
2399 #[test]
2401 fn the_text_sits_where_it_was_told_to() {
2402 let left = drawn_text_x(&TextField::<()>::new("42"), 300.0);
2403 let centre = drawn_text_x(
2404 &TextField::<()>::new("42").text_align(TextAlign::Center),
2405 300.0,
2406 );
2407 let right = drawn_text_x(
2408 &TextField::<()>::new("42").text_align(TextAlign::Right),
2409 300.0,
2410 );
2411 assert!(left < centre, "centred is further right than left-aligned");
2412 assert!(centre < right, "right-aligned is further right again");
2413 assert!(
2415 ((centre - left) - (right - centre)).abs() < 0.5,
2416 "halfway: {left} {centre} {right}"
2417 );
2418 }
2419
2420 #[test]
2424 fn a_click_lands_where_the_glyphs_are() {
2425 let width = 300.0;
2426 for align in [TextAlign::Left, TextAlign::Center, TextAlign::Right] {
2427 let field: TextField<()> = TextField::new("42").text_align(align);
2428 let x = drawn_text_x(&field, width);
2429 let before = Widget::<()>::cursor_at(&field, x - 1.0, 20.0, width, 0);
2431 assert_eq!(before, Some(0), "{align:?}: before the first character");
2432 let text_w = frus_text::measure("42", field.size).width;
2434 let after = Widget::<()>::cursor_at(&field, x + text_w + 1.0, 20.0, width, 0);
2435 assert_eq!(after, Some(2), "{align:?}: after the last character");
2436 }
2437 }
2438
2439 #[test]
2443 fn text_wider_than_the_field_is_not_pushed_off_it() {
2444 let long: TextField<()> = TextField::new("42").text_align(TextAlign::Right);
2445 assert_eq!(long.align_offset(4.0, 40.0), 0.0);
2447 assert_eq!(long.align_offset(40.0, 40.0), 0.0);
2448 }
2449
2450 #[test]
2455 fn a_multiline_field_is_not_aligned() {
2456 let note: TextField<()> = TextField::new("42")
2457 .multiline()
2458 .text_align(TextAlign::Center);
2459 assert_eq!(note.align_offset(300.0, 40.0), 0.0);
2460 let one_line: TextField<()> = TextField::new("42").text_align(TextAlign::Center);
2462 assert_eq!(one_line.align_offset(300.0, 40.0), 130.0);
2463 }
2464
2465 #[test]
2468 fn the_placeholder_is_aligned_like_the_value() {
2469 let hint_x = |align: TextAlign| {
2470 let field: TextField<()> = TextField::new("").placeholder("Amount").text_align(align);
2471 let mut scene = Scene::new();
2472 Widget::<()>::paint(
2473 &field,
2474 Rect::new(0.0, 0.0, 300.0, 56.0),
2475 Status::default(),
2476 &Theme::default(),
2477 &mut scene,
2478 );
2479 scene
2480 .primitives()
2481 .iter()
2482 .find_map(|p| match p {
2483 frus_core::Primitive::Text { position, text, .. } if text == "Amount" => {
2484 Some(position.x)
2485 }
2486 _ => None,
2487 })
2488 .expect("the hint is drawn")
2489 };
2490 assert!(hint_x(TextAlign::Right) > hint_x(TextAlign::Center));
2491 assert!(hint_x(TextAlign::Center) > hint_x(TextAlign::Left));
2492 }
2493
2494 #[test]
2496 fn a_filter_drops_what_it_will_not_take() {
2497 let field: TextField<String> = TextField::new("").digits_only().on_input(|v| v);
2498 let mut edit = Edit::default();
2499 let out = Widget::on_edit(&field, &mut edit, &Key::Text("12 34-56".into()));
2501 assert_eq!(out.as_deref(), Some("123456"));
2502 assert_eq!(edit.cursor, 6, "the caret is past what actually arrived");
2503 }
2504
2505 #[test]
2508 fn a_refused_keystroke_says_nothing() {
2509 let field: TextField<String> = TextField::new("42").digits_only().on_input(|v| v);
2510 let mut edit = Edit {
2511 cursor: 2,
2512 ..Default::default()
2513 };
2514 assert_eq!(
2515 Widget::on_edit(&field, &mut edit, &Key::Text("x".into())),
2516 None
2517 );
2518 assert_eq!(edit.cursor, 2, "and the caret has not moved");
2519 }
2520
2521 #[test]
2523 fn a_refused_keystroke_over_a_selection_still_clears_it() {
2524 let field: TextField<String> = TextField::new("1234").digits_only().on_input(|v| v);
2525 let mut edit = Edit {
2526 cursor: 3,
2527 anchor: Some(1),
2528 ..Default::default()
2529 };
2530 let out = Widget::on_edit(&field, &mut edit, &Key::Text("x".into()));
2531 assert_eq!(out.as_deref(), Some("14"));
2532 assert_eq!(edit.cursor, 1);
2533 }
2534
2535 #[test]
2538 fn a_filter_can_substitute() {
2539 let field: TextField<String> = TextField::new("")
2540 .input_filter(|c| c.to_uppercase().next())
2541 .on_input(|v| v);
2542 let mut edit = Edit::default();
2543 let out = Widget::on_edit(&field, &mut edit, &Key::Text("ab".into()));
2544 assert_eq!(out.as_deref(), Some("AB"));
2545 assert_eq!(edit.cursor, 2);
2546 }
2547
2548 #[test]
2551 fn a_filter_does_not_rewrite_what_the_caller_gave() {
2552 let field: TextField<String> = TextField::new("a1b2").digits_only().on_input(|v| v);
2553 let mut edit = Edit {
2554 cursor: 4,
2555 ..Default::default()
2556 };
2557 let out = Widget::on_edit(&field, &mut edit, &Key::Text("3".into()));
2558 assert_eq!(
2559 out.as_deref(),
2560 Some("a1b23"),
2561 "only the keystroke is filtered"
2562 );
2563 }
2564
2565 #[test]
2567 fn digits_only_asks_for_a_keypad_without_overruling_one() {
2568 let plain: TextField<()> = TextField::new("").digits_only();
2569 assert_eq!(Widget::<()>::ime(&plain).keyboard, KeyboardType::Number);
2570 let before: TextField<()> = TextField::new("")
2571 .keyboard_type(KeyboardType::Phone)
2572 .digits_only();
2573 assert_eq!(Widget::<()>::ime(&before).keyboard, KeyboardType::Phone);
2574 let after: TextField<()> = TextField::new("")
2575 .digits_only()
2576 .keyboard_type(KeyboardType::Phone);
2577 assert_eq!(Widget::<()>::ime(&after).keyboard, KeyboardType::Phone);
2578 }
2579
2580 #[test]
2582 fn the_capitalisation_travels_with_the_field() {
2583 let field: TextField<()> = TextField::new("").capitalization(Capitalization::Characters);
2584 let ime = Widget::<()>::ime(&field);
2585 assert_eq!(ime.capitalization, Capitalization::Characters);
2586 assert_eq!(ime.android_input_type() & 0x0000_7000, 0x0000_1000);
2587 }
2588
2589 #[test]
2592 fn a_plain_field_asks_for_the_plain_keyboard() {
2593 let field: TextField<()> = TextField::new("hello");
2594 assert_eq!(
2595 Widget::<()>::ime(&field),
2596 Ime {
2597 keyboard: KeyboardType::Text,
2598 action: TextInputAction::Done,
2599 capitalization: Capitalization::Auto,
2600 }
2601 );
2602 }
2603
2604 #[test]
2609 fn an_obscured_field_tells_the_keyboard_it_is_a_secret() {
2610 let field: TextField<()> = TextField::new("hunter2").obscure(true);
2611 let ime = Widget::<()>::ime(&field);
2612 assert_eq!(ime.keyboard, KeyboardType::Password);
2613 assert!(ime.keyboard.is_secret());
2614 assert_eq!(ime.keyboard.android_input_type() & 0x0000_0ff0, 0x80);
2616 }
2617
2618 #[test]
2621 fn a_multiline_field_takes_a_newline_key() {
2622 let field: TextField<()> = TextField::new("a note").multiline();
2623 let ime = Widget::<()>::ime(&field);
2624 assert_eq!(ime.keyboard, KeyboardType::Multiline);
2625 assert_eq!(ime.action, TextInputAction::Newline);
2626 let rowed: TextField<()> = TextField::new("a note").rows(4);
2628 assert_eq!(Widget::<()>::ime(&rowed).keyboard, KeyboardType::Multiline);
2629 }
2630
2631 #[test]
2633 fn what_the_caller_says_wins() {
2634 let field: TextField<()> = TextField::new("")
2635 .obscure(true)
2636 .keyboard_type(KeyboardType::VisiblePassword);
2637 assert_eq!(
2638 Widget::<()>::ime(&field).keyboard,
2639 KeyboardType::VisiblePassword
2640 );
2641
2642 let query: TextField<()> = TextField::new("").action(TextInputAction::Search);
2644 let ime = Widget::<()>::ime(&query);
2645 assert_eq!(ime.keyboard, KeyboardType::Text);
2646 assert_eq!(ime.action, TextInputAction::Search);
2647
2648 let note: TextField<()> = TextField::new("").multiline().action(TextInputAction::Done);
2650 assert_eq!(Widget::<()>::ime(¬e).action, TextInputAction::Done);
2651 }
2652
2653 #[test]
2656 fn a_typed_field_gets_the_keyboard_for_it() {
2657 let phone: TextField<()> = TextField::new("").keyboard_type(KeyboardType::Phone);
2658 assert_eq!(Widget::<()>::ime(&phone).keyboard.android_input_type(), 3);
2660
2661 let email: TextField<()> = TextField::new("").keyboard_type(KeyboardType::Email);
2662 let bits = Widget::<()>::ime(&email).keyboard.android_input_type();
2663 assert_eq!(bits, 1 | 0x20);
2666 }
2667
2668 #[test]
2669 fn obscure_masks_the_displayed_text_but_not_the_value() {
2670 let theme = Theme::default();
2673 let field = TextField::<Msg>::new("secret").obscure(true);
2674 let mut scene = Scene::new();
2675 let status = Status {
2676 focused: true,
2677 cursor: Some(6),
2678 ..Default::default()
2679 };
2680 Widget::<Msg>::paint(
2681 &field,
2682 Rect::new(0.0, 0.0, 220.0, 30.0),
2683 status,
2684 &theme,
2685 &mut scene,
2686 );
2687 let drawn: Vec<&str> = scene
2688 .primitives()
2689 .iter()
2690 .filter_map(|p| match p {
2691 frus_core::Primitive::Text { text, .. } => Some(text.as_str()),
2692 _ => None,
2693 })
2694 .collect();
2695 assert!(
2696 drawn.iter().all(|t| !t.contains("secret")),
2697 "the value must not leak"
2698 );
2699 assert!(
2700 drawn.iter().any(|t| t.chars().all(|c| c == '•')),
2701 "dots are drawn"
2702 );
2703 assert_eq!(Widget::<Msg>::text_value(&field), Some("secret"));
2705 }
2706
2707 #[test]
2708 fn prefix_icon_draws_a_path_and_shifts_the_hit_test() {
2709 let theme = Theme::default();
2712 let with_icon = input("hello world").prefix_icon(Icons::STAR);
2713 let mut scene = Scene::new();
2714 Widget::<Msg>::paint(
2715 &with_icon,
2716 Rect::new(0.0, 0.0, 220.0, 30.0),
2717 Status::default(),
2718 &theme,
2719 &mut scene,
2720 );
2721 assert!(
2722 scene
2723 .primitives()
2724 .iter()
2725 .any(|p| matches!(p, frus_core::Primitive::Path { .. })),
2726 "the prefix icon draws a path"
2727 );
2728 let plain = input("hello world");
2729 let at_plain =
2730 Widget::<Msg>::cursor_at(&plain, 60.0, FIELD_OUTLINED_PADDING_TOP, 220.0, 0).unwrap();
2731 let at_icon =
2732 Widget::<Msg>::cursor_at(&with_icon, 60.0, FIELD_OUTLINED_PADDING_TOP, 220.0, 0)
2733 .unwrap();
2734 assert!(
2735 at_icon < at_plain,
2736 "the prefix offsets the content ({at_plain} → {at_icon})"
2737 );
2738 }
2739
2740 #[test]
2741 fn multiline_enter_inserts_a_newline_instead_of_submitting() {
2742 let inp = TextField::<Msg>::new("ab")
2744 .on_input(Msg::Changed)
2745 .multiline();
2746 let mut edit = Edit {
2747 cursor: 2,
2748 anchor: None,
2749 composing: None,
2750 };
2751 assert_eq!(
2752 inp.on_edit(&mut edit, &Key::Enter),
2753 Some(Msg::Changed("ab\n".to_string()))
2754 );
2755 assert_eq!(edit.cursor, 3);
2756 let single = input("ab").on_submit(Msg::Submitted);
2758 let mut edit = Edit {
2759 cursor: 2,
2760 anchor: None,
2761 composing: None,
2762 };
2763 assert_eq!(single.on_edit(&mut edit, &Key::Enter), Some(Msg::Submitted));
2764 }
2765
2766 #[test]
2767 fn multiline_wraps_long_lines_to_the_width() {
2768 let long = "word ".repeat(30); let inp = TextField::<Msg>::new(long.trim_end())
2773 .on_input(Msg::Changed)
2774 .rows(4)
2775 .width(160.0);
2776 let line_h = inp.text_style().line_height();
2777 let top = Widget::<Msg>::cursor_at(
2778 &inp,
2779 FIELD_PADDING_X + 2.0,
2780 FIELD_OUTLINED_PADDING_TOP + 1.0,
2781 160.0,
2782 0,
2783 )
2784 .unwrap();
2785 let wrapped = Widget::<Msg>::cursor_at(
2786 &inp,
2787 FIELD_PADDING_X + 2.0,
2788 FIELD_OUTLINED_PADDING_TOP + line_h * 2.0 + 1.0,
2789 160.0,
2790 0,
2791 )
2792 .unwrap();
2793 assert!(top < 10, "1st line: {top}");
2794 assert!(
2795 wrapped > top,
2796 "a wrapped line further down → an index further on ({top} → {wrapped})"
2797 );
2798 }
2799
2800 #[test]
2801 fn multiline_reports_overflow_and_scrolls_content() {
2802 let inp = TextField::<Msg>::new("l1\nl2\nl3\nl4\nl5")
2804 .on_input(Msg::Changed)
2805 .rows(2)
2806 .width(200.0);
2807 let (content_h, visible_h, _, _) =
2808 Widget::<Msg>::text_metrics(&inp, 200.0, 0).expect("a multi-line field");
2809 assert!(
2810 content_h > visible_h + 1.0,
2811 "5 lines > a box of 2 ({content_h} vs {visible_h})"
2812 );
2813
2814 let text_top = |scroll: f32| {
2816 let mut scene = Scene::new();
2817 let status = Status {
2818 focused: true,
2819 cursor: Some(0),
2820 scroll_y: scroll,
2821 ..Default::default()
2822 };
2823 Widget::<Msg>::paint(
2824 &inp,
2825 Rect::new(0.0, 0.0, 200.0, 80.0),
2826 status,
2827 &Theme::default(),
2828 &mut scene,
2829 );
2830 scene
2831 .primitives()
2832 .iter()
2833 .find_map(|p| match p {
2834 frus_core::Primitive::Text { text, position, .. } if text.contains("l1") => {
2835 Some(position.y)
2836 }
2837 _ => None,
2838 })
2839 .expect("the field's text")
2840 };
2841 assert!(
2842 text_top(20.0) < text_top(0.0),
2843 "scrolling raises the content"
2844 );
2845 }
2846
2847 #[test]
2848 fn multiline_arrows_move_the_caret_between_lines() {
2849 let inp = TextField::<Msg>::new("abc\ndefg\nhi")
2851 .on_input(Msg::Changed)
2852 .rows(3)
2853 .width(200.0);
2854 let (down, _) = Widget::<Msg>::caret_vertical(&inp, 200.0, 1, true, false, None).unwrap();
2856 assert!(
2857 (4..=8).contains(&down),
2858 "moves down to the 2nd line: {down}"
2859 );
2860 assert_eq!(
2862 Widget::<Msg>::caret_vertical(&inp, 200.0, 1, false, false, None),
2863 None
2864 );
2865 assert_eq!(
2867 Widget::<Msg>::caret_vertical(&inp, 200.0, 10, true, false, None),
2868 None
2869 );
2870 let (up, _) = Widget::<Msg>::caret_vertical(&inp, 200.0, 5, false, false, None).unwrap();
2872 assert!(up <= 3, "moves up to the 1st line: {up}");
2873 assert_eq!(
2875 Widget::<Msg>::caret_vertical(&input("abc"), 200.0, 1, true, false, None),
2876 None
2877 );
2878 }
2879
2880 #[test]
2881 fn multiline_goal_column_survives_a_short_line() {
2882 let inp = TextField::<Msg>::new("hello\nhi\nworld")
2887 .on_input(Msg::Changed)
2888 .rows(3)
2889 .width(200.0);
2890 let (mid, goal) = Widget::<Msg>::caret_vertical(&inp, 200.0, 5, true, false, None).unwrap();
2891 assert!(
2892 (6..=8).contains(&mid),
2893 "a short line, clamped to its end: {mid}"
2894 );
2895 let (low, _) =
2899 Widget::<Msg>::caret_vertical(&inp, 200.0, mid, true, false, Some(goal)).unwrap();
2900 assert!(
2901 (12..=14).contains(&low),
2902 "goal column preserved into \"world\": {low}"
2903 );
2904 }
2905
2906 #[test]
2907 fn multiline_page_jump_is_clamped_to_the_field() {
2908 let inp = TextField::<Msg>::new("a\nb\nc\nd\ne")
2911 .on_input(Msg::Changed)
2912 .rows(2)
2913 .width(200.0);
2914 let (bottom, _) = Widget::<Msg>::caret_vertical(&inp, 200.0, 8, true, true, None).unwrap();
2916 assert!(
2917 bottom >= 7,
2918 "PgDn clamped to the bottom of the field: {bottom}"
2919 );
2920 let (top, _) = Widget::<Msg>::caret_vertical(&inp, 200.0, 0, false, true, None).unwrap();
2922 assert!(top <= 1, "PgUp clamped to the top of the field: {top}");
2923 }
2924
2925 #[test]
2926 fn multiline_reserves_rows_of_height() {
2927 let one = match Widget::<Msg>::style(&input("x")).height {
2929 Dimension::Length(h) => h,
2930 _ => panic!("a fixed height"),
2931 };
2932 let four = match Widget::<Msg>::style(&TextField::<Msg>::new("x").rows(4)).height {
2933 Dimension::Length(h) => h,
2934 _ => panic!("a fixed height"),
2935 };
2936 assert!(
2937 four > one * 2.0,
2938 "4 lines much taller than one ({one} → {four})"
2939 );
2940 }
2941
2942 #[test]
2943 fn multiline_hit_test_uses_the_click_line() {
2944 let inp = TextField::<Msg>::new("ab\ncd")
2947 .on_input(Msg::Changed)
2948 .rows(3);
2949 let line_h = inp.text_style().line_height();
2950 let top = Widget::<Msg>::cursor_at(
2952 &inp,
2953 FIELD_PADDING_X + 1.0,
2954 FIELD_OUTLINED_PADDING_TOP + 1.0,
2955 220.0,
2956 0,
2957 )
2958 .unwrap();
2959 let below = Widget::<Msg>::cursor_at(
2961 &inp,
2962 FIELD_PADDING_X + 1.0,
2963 FIELD_OUTLINED_PADDING_TOP + line_h + 1.0,
2964 220.0,
2965 0,
2966 )
2967 .unwrap();
2968 assert!(top <= 2, "1st line: {top}");
2969 assert!(below >= 3, "2nd line: {below}");
2970 }
2971
2972 #[test]
2973 fn floating_label_rests_in_box_then_floats_up() {
2974 let theme = Theme::default();
2977 let field = TextField::<Msg>::new("").label("Name");
2978 let label_geo = |status: Status| -> (f32, f32) {
2979 let mut scene = Scene::new();
2980 Widget::<Msg>::paint(
2981 &field,
2982 Rect::new(0.0, 30.0, 220.0, 60.0),
2983 status,
2984 &theme,
2985 &mut scene,
2986 );
2987 scene
2988 .primitives()
2989 .iter()
2990 .find_map(|p| match p {
2991 frus_core::Primitive::Text {
2992 text,
2993 size,
2994 position,
2995 ..
2996 } if text == "Name" => Some((*size, position.y)),
2997 _ => None,
2998 })
2999 .expect("a painted label")
3000 };
3001 let (rest_size, rest_y) = label_geo(Status::default());
3002 let (float_size, float_y) = label_geo(Status {
3003 focused: true,
3004 focus_progress: 1.0,
3005 ..Default::default()
3006 });
3007 assert!(
3008 rest_size > float_size,
3009 "at rest the label is larger ({rest_size} → {float_size})"
3010 );
3011 assert!(
3012 float_y < rest_y,
3013 "once focused the label rises ({rest_y} → {float_y})"
3014 );
3015 }
3016
3017 #[test]
3028 fn an_errored_field_deepens_under_the_pointer() {
3029 let theme = Theme::default();
3030 let field = input("x").label("Name").error("Required").outlined();
3031 let painted = |status: Status| {
3032 let mut scene = Scene::new();
3033 Widget::<Msg>::paint(
3034 &field,
3035 Rect::new(0.0, 0.0, 220.0, 90.0),
3036 status,
3037 &theme,
3038 &mut scene,
3039 );
3040 scene
3041 };
3042 let border = |status: Status| {
3044 painted(status)
3045 .primitives()
3046 .iter()
3047 .find_map(|p| match p {
3048 frus_core::Primitive::Rect {
3049 border_color,
3050 border_width,
3051 ..
3052 } if *border_width > 0.0 => Some(*border_color),
3053 _ => None,
3054 })
3055 .expect("an outlined field draws a border")
3056 };
3057 let message = |status: Status| {
3058 painted(status)
3059 .primitives()
3060 .iter()
3061 .find_map(|p| match p {
3062 frus_core::Primitive::Text { text, color, .. } if text == "Required" => {
3063 Some(*color)
3064 }
3065 _ => None,
3066 })
3067 .expect("the message is painted")
3068 };
3069 let rest = Status {
3070 opacity: 1.0,
3071 ..Default::default()
3072 };
3073 let hovered = Status {
3074 hover_progress: 1.0,
3075 ..rest
3076 };
3077 let both = Status {
3078 focused: true,
3079 focus_progress: 1.0,
3080 ..hovered
3081 };
3082
3083 assert_eq!(border(rest), theme.scheme.error);
3084 assert_eq!(
3085 border(hovered),
3086 theme.scheme.on_error_container,
3087 "it deepens under the pointer"
3088 );
3089 assert_eq!(
3090 border(both),
3091 theme.scheme.error,
3092 "and comes back once focused, which the reference tests first"
3093 );
3094 assert_ne!(
3095 theme.scheme.error, theme.scheme.on_error_container,
3096 "the two have to differ for any of the above to mean anything"
3097 );
3098
3099 for status in [rest, hovered, both] {
3100 assert_eq!(
3101 message(status),
3102 theme.scheme.error,
3103 "the message is a sentence, not a control"
3104 );
3105 }
3106 }
3107
3108 #[test]
3109 fn decoration_grows_height_for_label_and_error() {
3110 let bare_h = match Widget::<Msg>::style(&input("x")).height {
3113 Dimension::Length(h) => h,
3114 _ => panic!("a fixed height was expected"),
3115 };
3116 let decorated = input("x").label("Email").error("Required");
3117 let deco_h = match Widget::<Msg>::style(&decorated).height {
3118 Dimension::Length(h) => h,
3119 _ => panic!("a fixed height was expected"),
3120 };
3121 assert!(
3122 deco_h > bare_h,
3123 "label + error grow the field ({bare_h} → {deco_h})"
3124 );
3125 }
3126
3127 #[test]
3128 fn error_paints_the_border_in_the_error_color() {
3129 let theme = Theme::default();
3131 let field = input("x").error("bad");
3132 let mut scene = Scene::new();
3133 Widget::<Msg>::paint(
3134 &field,
3135 Rect::new(0.0, 0.0, 220.0, 60.0),
3136 Status::default(),
3137 &theme,
3138 &mut scene,
3139 );
3140 let has_error_border = scene.primitives().iter().any(|p| {
3141 matches!(
3142 p,
3143 frus_core::Primitive::Rect { border_color, border_width, .. }
3144 if *border_width > 0.0 && *border_color == theme.error
3145 )
3146 });
3147 assert!(has_error_border, "the border must be in the error colour");
3148 }
3149
3150 #[test]
3151 fn placeholder_shows_only_when_empty() {
3152 let theme = Theme::default();
3154 let count_texts = |value: &str| {
3155 let field = TextField::<Msg>::new(value).placeholder("Type here");
3156 let mut scene = Scene::new();
3157 Widget::<Msg>::paint(
3158 &field,
3159 Rect::new(0.0, 0.0, 220.0, 30.0),
3160 Status::default(),
3161 &theme,
3162 &mut scene,
3163 );
3164 scene
3165 .primitives()
3166 .iter()
3167 .filter(
3168 |p| matches!(p, frus_core::Primitive::Text { text, .. } if text == "Type here"),
3169 )
3170 .count()
3171 };
3172 assert_eq!(count_texts(""), 1, "empty: the hint shows");
3173 assert_eq!(count_texts("hi"), 0, "rempli : pas d'indice");
3174 }
3175
3176 #[test]
3177 fn clickable_suffix_emits_and_blocks_caret() {
3178 let field = TextField::new("hello")
3179 .suffix_icon(Icons::CLOSE)
3180 .on_suffix(Msg::Submitted)
3181 .width(220.0);
3182 let (w, y) = (220.0, 12.0);
3183 let x_suffix = w - 8.0; assert_eq!(
3187 Widget::<Msg>::positional_click(&field, x_suffix, y, w, 40.0),
3188 Some(Msg::Submitted)
3189 );
3190 assert_eq!(Widget::<Msg>::cursor_at(&field, x_suffix, y, w, 0), None);
3191 assert_eq!(
3193 Widget::<Msg>::positional_click(&field, 20.0, y, w, 40.0),
3194 None
3195 );
3196 assert!(Widget::<Msg>::cursor_at(&field, 20.0, y, w, 0).is_some());
3197 let deco = TextField::<Msg>::new("hello")
3199 .suffix_icon(Icons::CLOSE)
3200 .width(220.0);
3201 assert_eq!(
3202 Widget::<Msg>::positional_click(&deco, x_suffix, y, w, 40.0),
3203 None
3204 );
3205 }
3206
3207 #[test]
3208 fn hovering_active_suffix_paints_a_halo() {
3209 let field = TextField::new("hello")
3210 .suffix_icon(Icons::CLOSE)
3211 .on_suffix(Msg::Submitted)
3212 .width(220.0);
3213 let bounds = Rect::new(0.0, 0.0, 220.0, 40.0);
3214 let halos = |status: Status| {
3216 let mut scene = Scene::new();
3217 Widget::<Msg>::paint(&field, bounds, status, &Theme::default(), &mut scene);
3218 scene
3219 .primitives()
3220 .iter()
3221 .filter(|p| match p {
3222 frus_core::Primitive::Rect { rect, .. } => {
3223 (rect.width - (FIELD_ICON_SIZE + 8.0)).abs() < 0.5
3224 && (rect.height - (FIELD_ICON_SIZE + 8.0)).abs() < 0.5
3225 }
3226 _ => false,
3227 })
3228 .count()
3229 };
3230 let over_suffix = Status {
3232 hover_cursor: Some(Point::new(212.0, 20.0)),
3233 ..Default::default()
3234 };
3235 let over_body = Status {
3236 hover_cursor: Some(Point::new(20.0, 20.0)),
3237 ..Default::default()
3238 };
3239 assert_eq!(halos(over_suffix), 1, "a halo on the hovered suffix");
3240 assert_eq!(halos(over_body), 0, "no halo in the body");
3241 assert_eq!(halos(Status::default()), 0, "no halo without hover");
3242 }
3243
3244 #[test]
3245 fn cursor_icon_is_pointer_over_active_suffix() {
3246 use crate::interaction::Cursor;
3247 let field = TextField::new("hello")
3248 .suffix_icon(Icons::CLOSE)
3249 .on_suffix(Msg::Submitted)
3250 .width(220.0);
3251 let (w, h, y) = (220.0, 40.0, 12.0);
3252 assert_eq!(
3254 Widget::<Msg>::cursor_icon(&field, w - 8.0, y, w, h),
3255 Some(Cursor::Pointer)
3256 );
3257 assert_eq!(Widget::<Msg>::cursor_icon(&field, 20.0, y, w, h), None);
3258 let deco = TextField::<Msg>::new("hello")
3260 .suffix_icon(Icons::CLOSE)
3261 .width(220.0);
3262 assert_eq!(Widget::<Msg>::cursor_icon(&deco, w - 8.0, y, w, h), None);
3263 }
3264
3265 #[test]
3271 fn each_handle_stands_for_its_own_end_of_the_selection() {
3272 let width = 220.0;
3273 let fields = [
3274 TextField::<Msg>::new("hello world").width(width),
3275 TextField::<Msg>::new("hello world")
3276 .label("Name")
3277 .prefix_icon(Icons::CLOSE)
3278 .width(width),
3279 TextField::<Msg>::new("hello world")
3281 .label("Name")
3282 .outlined()
3283 .width(width),
3284 TextField::<Msg>::new("a sentence long enough to be scrolled along its box")
3286 .width(width),
3287 ];
3288 for field in &fields {
3289 let len = field.value.chars().count();
3290 let edit = Edit {
3291 cursor: len,
3292 anchor: Some(len - 5),
3293 composing: None,
3294 };
3295 let [start, end] = Widget::<Msg>::selection_handles(field, width, &edit, 0.0)
3296 .expect("a selection has handles");
3297 for (handle, index) in [(start, len - 5), (end, len)] {
3298 let at = handle.line_center;
3299 assert_eq!(
3300 Widget::<Msg>::cursor_at(field, at.x, at.y, width, edit.cursor),
3301 Some(index),
3302 "{:?}",
3303 field.value
3304 );
3305 }
3306 assert_eq!(start.rect.x + start.rect.width, start.line_center.x);
3309 assert_eq!(end.rect.x, end.line_center.x);
3310 assert!(start.rect.y > start.line_center.y && end.rect.y > end.line_center.y);
3311 let mut scene = Scene::new();
3315 let status = Status {
3316 focused: true,
3317 cursor: Some(len),
3318 selection: Some((len - 5, len)),
3319 ..Status::default()
3320 };
3321 Widget::<Msg>::paint(
3322 field,
3323 Rect::new(0.0, 0.0, width, 120.0),
3324 status,
3325 &Theme::default(),
3326 &mut scene,
3327 );
3328 let top = scene
3329 .primitives()
3330 .iter()
3331 .find_map(|p| match p {
3332 frus_core::Primitive::Text { position, text, .. } if *text == field.value => {
3333 Some(position.y)
3334 }
3335 _ => None,
3336 })
3337 .expect("the value is painted");
3338 let half = start.line_center.y - top;
3339 assert!(
3340 half > 0.0 && (start.rect.y - top - 2.0 * half).abs() < 0.01,
3341 "{:?}: text at {top}, line centre at {}, handle at {}",
3342 field.value,
3343 start.line_center.y,
3344 start.rect.y
3345 );
3346 }
3347 }
3348
3349 #[test]
3352 fn a_handle_rises_with_the_lines_a_field_scrolls_away() {
3353 let width = 200.0;
3354 let field = TextField::<Msg>::new("one\ntwo\nthree\nfour\nfive\nsix")
3355 .multiline()
3356 .rows(3)
3357 .width(width);
3358 let edit = Edit {
3360 cursor: 23,
3361 anchor: Some(19),
3362 composing: None,
3363 };
3364 let scroll = 20.0;
3365 let still = Widget::<Msg>::selection_handles(&field, width, &edit, 0.0).unwrap();
3366 let [start, end] = Widget::<Msg>::selection_handles(&field, width, &edit, scroll).unwrap();
3367 assert!((still[0].line_center.y - start.line_center.y - scroll).abs() < 0.01);
3368 for (handle, index) in [(start, 19), (end, 23)] {
3369 let at = handle.line_center;
3370 assert_eq!(
3371 Widget::<Msg>::cursor_at(&field, at.x, at.y + scroll, width, edit.cursor),
3372 Some(index)
3373 );
3374 }
3375 }
3376
3377 #[test]
3381 fn the_handles_are_painted_for_a_touch_selection_only() {
3382 let theme = Theme::default();
3383 let fills = |field: &TextField<Msg>, handles: bool, focused: bool| {
3384 let mut scene = Scene::new();
3385 let status = Status {
3386 focused,
3387 cursor: Some(11),
3388 selection: Some((6, 11)),
3389 handles,
3390 ..Status::default()
3391 };
3392 Widget::<Msg>::paint(
3393 field,
3394 Rect::new(10.0, 20.0, 220.0, 56.0),
3395 status,
3396 &theme,
3397 &mut scene,
3398 );
3399 scene
3400 .primitives()
3401 .iter()
3402 .filter_map(|p| match p {
3403 frus_core::Primitive::Path { fill: Some(c), .. } => Some(*c),
3404 _ => None,
3405 })
3406 .collect::<Vec<_>>()
3407 };
3408 let field = TextField::<Msg>::new("hello world").width(220.0);
3409 assert_eq!(fills(&field, true, true), vec![theme.scheme.primary; 2]);
3410 assert!(fills(&field, false, true).is_empty(), "a mouse selection");
3411 assert!(fills(&field, true, false).is_empty(), "a field left behind");
3412 let styled = TextField::<Msg>::new("hello world")
3413 .width(220.0)
3414 .style(TextFieldStyle {
3415 handle_color: Some(frus_core::Color::WHITE),
3416 ..TextFieldStyle::default()
3417 });
3418 assert_eq!(fills(&styled, true, true), vec![frus_core::Color::WHITE; 2]);
3419 }
3420
3421 #[test]
3423 fn a_hidden_caret_is_not_painted() {
3424 let theme = Theme::default();
3425 let carets = |hidden: bool| {
3426 let mut scene = Scene::new();
3427 let status = Status {
3428 focused: true,
3429 cursor: Some(2),
3430 caret_hidden: hidden,
3431 ..Status::default()
3432 };
3433 Widget::<Msg>::paint(
3434 &TextField::<Msg>::new("hello").width(220.0),
3435 Rect::new(0.0, 0.0, 220.0, 56.0),
3436 status,
3437 &theme,
3438 &mut scene,
3439 );
3440 scene
3441 .primitives()
3442 .iter()
3443 .filter(
3444 |p| matches!(p, frus_core::Primitive::Rect { rect, .. } if rect.width == 2.0),
3445 )
3446 .count()
3447 };
3448 assert_eq!(carets(false), 1, "shown");
3449 assert_eq!(carets(true), 0, "hidden");
3450 }
3451
3452 #[test]
3453 fn word_at_finds_word_bounds() {
3454 let inp = input("hello there world");
3455 assert_eq!(inp.word_at(2), Some((0, 5))); assert_eq!(inp.word_at(13), Some((12, 17))); }
3459}
3460
3461#[cfg(test)]
3462mod limit_tests {
3463 use super::*;
3464 use crate::runtime::Edit;
3465
3466 #[derive(Clone, Debug, PartialEq)]
3467 enum Msg {
3468 Changed(String),
3469 }
3470
3471 fn field(value: &str) -> TextField<Msg> {
3472 TextField::new(value).on_input(Msg::Changed)
3473 }
3474
3475 fn typed(field: &TextField<Msg>, text: &str) -> (Option<Msg>, Edit) {
3477 let end = field.value.chars().count();
3478 let mut edit = Edit {
3479 cursor: end,
3480 anchor: None,
3481 ..Default::default()
3482 };
3483 let msg = Widget::on_edit(field, &mut edit, &Key::Text(text.to_string()));
3484 (msg, edit)
3485 }
3486
3487 fn value_of(msg: Option<Msg>) -> Option<String> {
3488 msg.map(|Msg::Changed(v)| v)
3489 }
3490
3491 #[test]
3493 fn a_keystroke_past_the_limit_goes_nowhere() {
3494 let full = field("abcde").max_length(5);
3495 assert_eq!(value_of(typed(&full, "f").0), Some("abcde".into()));
3496 let room = field("abcd").max_length(5);
3497 assert_eq!(value_of(typed(&room, "e").0), Some("abcde".into()));
3498 }
3499
3500 #[test]
3503 fn a_paste_that_crosses_the_limit_lands_what_fits() {
3504 let f = field("ab").max_length(5);
3505 let (msg, edit) = typed(&f, "cdefgh");
3506 assert_eq!(value_of(msg), Some("abcde".into()));
3507 assert_eq!(edit.cursor, 5, "the caret comes to rest at the end");
3508 }
3509
3510 #[test]
3512 fn the_limit_counts_characters_not_bytes() {
3513 let f = field("").max_length(3);
3514 assert_eq!(value_of(typed(&f, "éàü").0), Some("éàü".into()));
3515 let full = field("éàü").max_length(3);
3516 assert_eq!(value_of(typed(&full, "e").0), Some("éàü".into()));
3517 }
3518
3519 #[test]
3522 fn a_value_the_caller_set_over_the_limit_is_left_alone() {
3523 let over = field("abcdefg").max_length(5);
3524 assert_eq!(over.counter().as_deref(), Some("7/5"));
3525 assert_eq!(over.value, "abcdefg");
3526 }
3527
3528 #[test]
3530 fn the_counter_reserves_the_line_it_sits_on() {
3531 assert_eq!(
3532 field("ab").sub_block(),
3533 0.0,
3534 "nothing to say, no room taken"
3535 );
3536 assert!(field("ab").max_length(5).sub_block() > 0.0);
3537 assert_eq!(field("ab").max_length(5).counter().as_deref(), Some("2/5"));
3538 }
3539
3540 #[test]
3542 fn read_only_refuses_the_change_and_keeps_the_caret() {
3543 let f = field("hello").read_only();
3544 assert_eq!(typed(&f, "x").0, None, "typing goes nowhere");
3545
3546 let mut edit = Edit {
3547 cursor: 5,
3548 anchor: None,
3549 ..Default::default()
3550 };
3551 assert_eq!(
3552 Widget::on_edit(
3553 &f,
3554 &mut edit,
3555 &Key::Left {
3556 shift: false,
3557 word: false
3558 }
3559 ),
3560 None
3561 );
3562 assert_eq!(edit.cursor, 4, "but the caret still moves");
3563
3564 let mut edit = Edit {
3565 cursor: 5,
3566 anchor: None,
3567 ..Default::default()
3568 };
3569 Widget::on_edit(
3570 &f,
3571 &mut edit,
3572 &Key::Home {
3573 shift: true,
3574 doc: false,
3575 },
3576 );
3577 assert_eq!(
3578 (edit.cursor, edit.anchor),
3579 (0, Some(5)),
3580 "and a selection can still be made, so it can be copied"
3581 );
3582 }
3583
3584 #[test]
3586 fn read_only_refuses_a_deletion() {
3587 let f = field("hello").read_only();
3588 let mut edit = Edit {
3589 cursor: 5,
3590 anchor: None,
3591 ..Default::default()
3592 };
3593 assert_eq!(Widget::on_edit(&f, &mut edit, &Key::Backspace), None);
3594 assert_eq!(edit.cursor, 5);
3595 }
3596
3597 #[test]
3599 fn read_only_is_not_disabled() {
3600 let read = field("REF-4417").read_only();
3601 assert!(Widget::<Msg>::focusable(&read), "still in the tab order");
3602 assert!(
3603 Widget::<Msg>::cursor_at(&read, 20.0, 10.0, 200.0, 0).is_some(),
3604 "and it still takes a caret, so a reference can be selected and copied"
3605 );
3606
3607 let dead = field("REF-4417").enabled(false);
3608 assert!(!Widget::<Msg>::focusable(&dead));
3609 assert!(Widget::<Msg>::cursor_at(&dead, 20.0, 10.0, 200.0, 0).is_none());
3610 }
3611}