1#![warn(missing_docs)]
46
47use std::borrow::Cow;
48use std::panic::Location;
49
50use crate::cursor::Cursor;
51use crate::event::{LogicalKey, NamedKey, UiEvent, UiEventKind};
52use crate::metrics::MetricsRole;
53use crate::selection::{Selection, SelectionPoint, SelectionRange};
54use crate::style::StyleProfile;
55use crate::text::metrics::TextGeometry;
56use crate::tokens;
57use crate::tree::*;
58use crate::widgets::text::text;
59
60#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
69pub struct TextSelection {
70 pub anchor: usize,
72 pub head: usize,
74}
75
76#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
87pub enum MaskMode {
88 #[default]
90 None,
91 Password,
94}
95
96const MASK_CHAR: char = '•';
97
98#[derive(Clone, Copy, Debug, Default)]
110pub struct TextInputOpts<'a> {
111 pub placeholder: Option<&'a str>,
114 pub max_length: Option<usize>,
119 pub mask: MaskMode,
121 pub tabular_numerals: bool,
127}
128
129impl<'a> TextInputOpts<'a> {
130 pub fn placeholder(mut self, p: &'a str) -> Self {
133 self.placeholder = Some(p);
134 self
135 }
136
137 pub fn max_length(mut self, n: usize) -> Self {
140 self.max_length = Some(n);
141 self
142 }
143
144 pub fn password(mut self) -> Self {
147 self.mask = MaskMode::Password;
148 self
149 }
150
151 pub fn tabular_numerals(mut self) -> Self {
154 self.tabular_numerals = true;
155 self
156 }
157
158 fn is_masked(&self) -> bool {
159 !matches!(self.mask, MaskMode::None)
160 }
161}
162
163impl TextSelection {
164 pub const fn caret(head: usize) -> Self {
166 Self { anchor: head, head }
167 }
168
169 pub const fn range(anchor: usize, head: usize) -> Self {
172 Self { anchor, head }
173 }
174
175 pub fn ordered(self) -> (usize, usize) {
177 (self.anchor.min(self.head), self.anchor.max(self.head))
178 }
179
180 pub fn is_collapsed(self) -> bool {
182 self.anchor == self.head
183 }
184}
185
186#[track_caller]
221pub fn text_input(key: &str, value: &str, selection: &Selection) -> El {
222 text_input_with(key, value, selection, TextInputOpts::default())
223}
224
225#[track_caller]
230pub fn text_input_with(
231 key: &str,
232 value: &str,
233 selection: &Selection,
234 opts: TextInputOpts<'_>,
235) -> El {
236 build_text_input(value, selection.within(key), opts).key(key)
237}
238
239#[track_caller]
249fn build_text_input(value: &str, view: Option<TextSelection>, opts: TextInputOpts<'_>) -> El {
250 let selection = view.unwrap_or_default();
251 let head = clamp_to_char_boundary(value, selection.head.min(value.len()));
252 let anchor = clamp_to_char_boundary(value, selection.anchor.min(value.len()));
253 let lo = anchor.min(head);
254 let hi = anchor.max(head);
255 let line_h = line_height_px();
256
257 let display = display_str(value, opts.mask);
262
263 let geometry = single_line_geometry(&display, opts.tabular_numerals);
267 let to_display = |b: usize| original_to_display_byte(value, b, opts.mask);
268 let head_px = geometry.prefix_width(to_display(head));
269 let lo_px = geometry.prefix_width(to_display(lo));
270 let hi_px = geometry.prefix_width(to_display(hi));
271
272 let mut children: Vec<El> = Vec::with_capacity(4);
273
274 if lo < hi {
279 children.push(
280 El::new(Kind::Custom("text_input_selection"))
281 .style_profile(StyleProfile::Solid)
282 .fill(tokens::SELECTION_BG)
283 .dim_fill(tokens::SELECTION_BG_UNFOCUSED)
284 .radius(2.0)
285 .width(Size::Fixed(hi_px - lo_px))
286 .height(Size::Fixed(line_h))
287 .translate(lo_px, 0.0),
288 );
289 }
290
291 if value.is_empty()
295 && let Some(ph) = opts.placeholder
296 {
297 children.push(
298 text(ph)
299 .muted()
300 .width(Size::Hug)
301 .height(Size::Fixed(line_h)),
302 );
303 }
304
305 let mut value_leaf = text(display.into_owned())
308 .width(Size::Hug)
309 .height(Size::Fixed(line_h));
310 if opts.tabular_numerals {
311 value_leaf = value_leaf.tabular_numerals();
312 }
313 children.push(value_leaf);
314
315 if view.is_some() {
325 children.push(
326 caret_bar()
327 .translate(head_px, 0.0)
328 .alpha_follows_focused_ancestor()
329 .blink_when_focused(),
330 );
331 }
332
333 let inner = El::new(Kind::Group)
343 .clip()
344 .width(Size::Fill(1.0))
345 .height(Size::Fill(1.0))
346 .layout(move |ctx| {
347 let x_offset = (head_px - ctx.container.w).max(0.0);
355 ctx.children
356 .iter()
357 .map(|c| {
358 let (w, h) = (ctx.measure)(c);
359 let w = match c.width {
363 Size::Fixed(v) => v,
364 Size::Hug | Size::Ch(_) => w,
368 Size::Fill(_) => ctx.container.w,
369 Size::Aspect(r) => h * r,
370 };
371 let h = match c.height {
372 Size::Fixed(v) => v,
373 Size::Hug | Size::Ch(_) => h,
374 Size::Fill(_) => ctx.container.h,
375 Size::Aspect(r) => w * r,
376 };
377 let y = ctx.container.y + (ctx.container.h - h) * 0.5;
382 Rect::new(ctx.container.x - x_offset, y, w, h)
383 })
384 .collect()
385 })
386 .children(children);
387
388 El::new(Kind::Custom("text_input"))
389 .at_loc(Location::caller())
390 .style_profile(StyleProfile::Surface)
391 .metrics_role(MetricsRole::Input)
392 .surface_role(SurfaceRole::Input)
393 .focusable()
394 .always_show_focus_ring()
397 .capture_keys()
398 .paint_overflow(Sides::all(tokens::RING_WIDTH))
399 .hit_overflow(Sides::all(tokens::HIT_OVERFLOW))
400 .cursor(Cursor::Text)
401 .fill(tokens::MUTED)
402 .stroke(tokens::BORDER)
403 .default_radius(tokens::RADIUS_MD)
404 .axis(Axis::Overlay)
405 .align(Align::Start)
406 .justify(Justify::Center)
407 .default_width(Size::Fill(1.0))
408 .default_height(Size::Fixed(tokens::CONTROL_HEIGHT))
409 .default_padding(Sides::xy(tokens::SPACE_3, 0.0))
410 .child(inner)
411}
412
413fn caret_bar() -> El {
414 El::new(Kind::Custom("text_input_caret"))
415 .style_profile(StyleProfile::Solid)
416 .fill(tokens::FOREGROUND)
417 .width(Size::Fixed(2.0))
418 .height(Size::Fixed(line_height_px()))
419 .radius(1.0)
420}
421
422fn line_height_px() -> f32 {
423 tokens::TEXT_SM.line_height
424}
425
426fn single_line_geometry(value: &str, tabular: bool) -> TextGeometry<'_> {
427 let geometry = TextGeometry::new(
428 value,
429 tokens::TEXT_SM.size,
430 FontWeight::Regular,
431 false,
432 TextWrap::NoWrap,
433 None,
434 );
435 if tabular {
436 geometry.tabular_numerals()
437 } else {
438 geometry
439 }
440}
441
442pub fn apply_event(
474 value: &mut String,
475 selection: &mut Selection,
476 event: &UiEvent,
477 key: &str,
478) -> bool {
479 apply_event_with(value, selection, event, key, &TextInputOpts::default())
480}
481
482pub fn apply_event_with(
486 value: &mut String,
487 selection: &mut Selection,
488 event: &UiEvent,
489 key: &str,
490 opts: &TextInputOpts<'_>,
491) -> bool {
492 if matches!(
502 event.kind,
503 UiEventKind::PointerDown
504 | UiEventKind::Drag
505 | UiEventKind::MiddleClick
506 | UiEventKind::LongPress
507 ) && !event.is_route(key)
508 {
509 return false;
510 }
511 let mut local = selection.within(key).unwrap_or_default();
512 let changed = fold_event_local(value, &mut local, event, opts);
513 if changed {
514 selection.range = Some(SelectionRange {
515 anchor: SelectionPoint::new(key, local.anchor),
516 head: SelectionPoint::new(key, local.head),
517 });
518 }
519 changed
520}
521
522fn fold_event_local(
526 value: &mut String,
527 selection: &mut TextSelection,
528 event: &UiEvent,
529 opts: &TextInputOpts<'_>,
530) -> bool {
531 selection.anchor = clamp_to_char_boundary(value, selection.anchor.min(value.len()));
532 selection.head = clamp_to_char_boundary(value, selection.head.min(value.len()));
533 match event.kind {
534 UiEventKind::TextInput => {
535 let Some(insert) = event.text.as_deref() else {
536 return false;
537 };
538 if (event.modifiers.ctrl && !event.modifiers.alt) || event.modifiers.logo {
554 return false;
555 }
556 let filtered: String = insert.chars().filter(|c| !c.is_control()).collect();
557 if filtered.is_empty() {
558 return false;
559 }
560 let to_insert = clip_to_max_length(value, *selection, &filtered, opts.max_length);
561 if to_insert.is_empty() {
562 return false;
563 }
564 replace_selection(value, selection, &to_insert);
565 true
566 }
567 UiEventKind::MiddleClick => {
568 let Some(byte) = caret_byte_at(value, event, opts) else {
569 return false;
570 };
571 *selection = TextSelection::caret(byte);
572 if let Some(insert) = event.text.as_deref() {
573 replace_selection_with(value, selection, insert, opts);
574 }
575 true
576 }
577 UiEventKind::KeyDown => {
578 let Some(kp) = event.key_press.as_ref() else {
579 return false;
580 };
581 let mods = kp.modifiers;
582 if mods.ctrl
586 && !mods.alt
587 && !mods.logo
588 && let LogicalKey::Character(c) = &kp.logical
589 && c.eq_ignore_ascii_case("a")
590 {
591 let len = value.len();
592 if selection.anchor == 0 && selection.head == len {
593 return false;
594 }
595 *selection = TextSelection {
596 anchor: 0,
597 head: len,
598 };
599 return true;
600 }
601 if mods.ctrl
606 && !mods.alt
607 && !mods.logo
608 && !mods.shift
609 && let LogicalKey::Character(c) = &kp.logical
610 && c.eq_ignore_ascii_case("w")
611 {
612 return delete_word_backward(value, selection);
613 }
614 match kp.logical.named() {
615 Some(NamedKey::Escape) => {
616 if selection.is_collapsed() {
617 return false;
618 }
619 selection.anchor = selection.head;
620 true
621 }
622 Some(NamedKey::Backspace) => {
623 if !selection.is_collapsed() {
624 replace_selection(value, selection, "");
625 return true;
626 }
627 if selection.head == 0 {
628 return false;
629 }
630 if mods.ctrl && !mods.alt && !mods.logo {
631 return delete_word_backward(value, selection);
632 }
633 let prev = prev_char_boundary(value, selection.head);
634 value.replace_range(prev..selection.head, "");
635 selection.head = prev;
636 selection.anchor = prev;
637 true
638 }
639 Some(NamedKey::Delete) => {
640 if !selection.is_collapsed() {
641 replace_selection(value, selection, "");
642 return true;
643 }
644 if selection.head >= value.len() {
645 return false;
646 }
647 if mods.ctrl && !mods.alt && !mods.logo {
648 return delete_word_forward(value, selection);
649 }
650 let next = next_char_boundary(value, selection.head);
651 value.replace_range(selection.head..next, "");
652 true
653 }
654 Some(NamedKey::ArrowLeft) => {
655 let target = if selection.is_collapsed() || mods.shift {
656 if selection.head == 0 {
657 return false;
658 }
659 if mods.ctrl && !mods.alt && !mods.logo {
660 crate::selection::prev_word_boundary(value, selection.head)
661 } else {
662 prev_char_boundary(value, selection.head)
663 }
664 } else if mods.ctrl && !mods.alt && !mods.logo {
665 crate::selection::prev_word_boundary(value, selection.head)
668 } else {
669 selection.ordered().0
671 };
672 selection.head = target;
673 if !mods.shift {
674 selection.anchor = target;
675 }
676 true
677 }
678 Some(NamedKey::ArrowRight) => {
679 let target = if selection.is_collapsed() || mods.shift {
680 if selection.head >= value.len() {
681 return false;
682 }
683 if mods.ctrl && !mods.alt && !mods.logo {
684 crate::selection::next_word_boundary(value, selection.head)
685 } else {
686 next_char_boundary(value, selection.head)
687 }
688 } else if mods.ctrl && !mods.alt && !mods.logo {
689 crate::selection::next_word_boundary(value, selection.head)
690 } else {
691 selection.ordered().1
693 };
694 selection.head = target;
695 if !mods.shift {
696 selection.anchor = target;
697 }
698 true
699 }
700 Some(NamedKey::Home) => {
701 if selection.head == 0 && (mods.shift || selection.anchor == 0) {
702 return false;
703 }
704 selection.head = 0;
705 if !mods.shift {
706 selection.anchor = 0;
707 }
708 true
709 }
710 Some(NamedKey::End) => {
711 let end = value.len();
712 if selection.head == end && (mods.shift || selection.anchor == end) {
713 return false;
714 }
715 selection.head = end;
716 if !mods.shift {
717 selection.anchor = end;
718 }
719 true
720 }
721 _ => false,
722 }
723 }
724 UiEventKind::PointerDown => {
725 let (Some((px, _py)), Some(target)) = (event.pointer, event.target.as_ref()) else {
726 return false;
727 };
728 let viewport_w = (target.rect.w - 2.0 * tokens::SPACE_3).max(0.0);
734 let x_offset = current_x_offset(value, selection.head, viewport_w, opts);
735 let local_x = px - target.rect.x - tokens::SPACE_3 + x_offset;
736 let pos = caret_from_x(value, local_x, opts);
737 if !event.modifiers.shift {
744 match event.click_count {
745 2 => {
746 let (lo, hi) = crate::selection::word_range_at(value, pos);
747 selection.anchor = lo;
748 selection.head = hi;
749 return true;
750 }
751 n if n >= 3 => {
752 selection.anchor = 0;
753 selection.head = value.len();
754 return true;
755 }
756 _ => {}
757 }
758 }
759 selection.head = pos;
760 if !event.modifiers.shift {
761 selection.anchor = pos;
762 }
763 true
764 }
765 UiEventKind::LongPress => {
766 let (Some((px, _py)), Some(target)) = (event.pointer, event.target.as_ref()) else {
767 return false;
768 };
769 let viewport_w = (target.rect.w - 2.0 * tokens::SPACE_3).max(0.0);
770 let x_offset = current_x_offset(value, selection.head, viewport_w, opts);
771 let local_x = px - target.rect.x - tokens::SPACE_3 + x_offset;
772 let pos = caret_from_x(value, local_x, opts);
773 let (lo, hi) = crate::selection::word_range_at(value, pos);
774 selection.anchor = lo;
775 selection.head = hi;
776 true
777 }
778 UiEventKind::Drag => {
779 let (Some((px, _py)), Some(target)) = (event.pointer, event.target.as_ref()) else {
780 return false;
781 };
782 let viewport_w = (target.rect.w - 2.0 * tokens::SPACE_3).max(0.0);
787 let x_offset = current_x_offset(value, selection.head, viewport_w, opts);
788 let local_x = px - target.rect.x - tokens::SPACE_3 + x_offset;
789 let pos = caret_from_x(value, local_x, opts);
790 if !event.modifiers.shift {
791 match event.click_count {
792 2 => {
793 extend_word_selection(value, selection, pos);
794 return true;
795 }
796 n if n >= 3 => {
797 selection.anchor = 0;
798 selection.head = value.len();
799 return true;
800 }
801 _ => {}
802 }
803 }
804 selection.head = pos;
805 true
806 }
807 UiEventKind::Click => false,
808 _ => false,
809 }
810}
811
812fn extend_word_selection(value: &str, selection: &mut TextSelection, pos: usize) {
813 let (selected_lo, selected_hi) = selection.ordered();
814 let (word_lo, word_hi) = crate::selection::word_range_at(value, pos);
815 if pos < selected_lo {
816 selection.anchor = selected_hi;
817 selection.head = word_lo;
818 } else {
819 selection.anchor = selected_lo;
820 selection.head = word_hi;
821 }
822}
823
824pub fn selected_text(value: &str, selection: TextSelection) -> &str {
827 let head = clamp_to_char_boundary(value, selection.head.min(value.len()));
828 let anchor = clamp_to_char_boundary(value, selection.anchor.min(value.len()));
829 &value[anchor.min(head)..anchor.max(head)]
830}
831
832pub(crate) fn delete_word_backward(value: &mut String, selection: &mut TextSelection) -> bool {
837 if !selection.is_collapsed() {
838 replace_selection(value, selection, "");
839 return true;
840 }
841 if selection.head == 0 {
842 return false;
843 }
844 let target = crate::selection::prev_word_boundary(value, selection.head);
845 if target == selection.head {
846 return false;
847 }
848 value.replace_range(target..selection.head, "");
849 selection.head = target;
850 selection.anchor = target;
851 true
852}
853
854pub(crate) fn delete_word_forward(value: &mut String, selection: &mut TextSelection) -> bool {
859 if !selection.is_collapsed() {
860 replace_selection(value, selection, "");
861 return true;
862 }
863 if selection.head >= value.len() {
864 return false;
865 }
866 let target = crate::selection::next_word_boundary(value, selection.head);
867 if target == selection.head {
868 return false;
869 }
870 value.replace_range(selection.head..target, "");
871 true
872}
873
874pub fn replace_selection(value: &mut String, selection: &mut TextSelection, replacement: &str) {
878 selection.anchor = clamp_to_char_boundary(value, selection.anchor.min(value.len()));
879 selection.head = clamp_to_char_boundary(value, selection.head.min(value.len()));
880 let (lo, hi) = selection.ordered();
881 value.replace_range(lo..hi, replacement);
882 let new_caret = lo + replacement.len();
883 selection.anchor = new_caret;
884 selection.head = new_caret;
885}
886
887pub fn replace_selection_with(
894 value: &mut String,
895 selection: &mut TextSelection,
896 replacement: &str,
897 opts: &TextInputOpts<'_>,
898) -> usize {
899 let clipped = clip_to_max_length(value, *selection, replacement, opts.max_length);
900 let len = clipped.len();
901 replace_selection(value, selection, &clipped);
902 len
903}
904
905pub fn select_all(value: &str) -> TextSelection {
907 TextSelection {
908 anchor: 0,
909 head: value.len(),
910 }
911}
912
913#[derive(Clone, Copy, Debug, PartialEq, Eq)]
924pub enum ClipboardKind {
925 Copy,
927 Cut,
929 Paste,
931}
932
933pub fn clipboard_request(event: &UiEvent) -> Option<ClipboardKind> {
979 clipboard_request_for(event, &TextInputOpts::default())
980}
981
982pub fn clipboard_request_for(event: &UiEvent, opts: &TextInputOpts<'_>) -> Option<ClipboardKind> {
986 if event.kind != UiEventKind::KeyDown {
987 return None;
988 }
989 let kp = event.key_press.as_ref()?;
990 let mods = kp.modifiers;
991 if mods.alt || mods.shift {
994 return None;
995 }
996 let kind = match &kp.logical {
997 LogicalKey::Character(c) if mods.ctrl || mods.logo => {
998 match c.to_ascii_lowercase().as_str() {
999 "c" => ClipboardKind::Copy,
1000 "x" => ClipboardKind::Cut,
1001 "v" => ClipboardKind::Paste,
1002 _ => return None,
1003 }
1004 }
1005 LogicalKey::Named(named) if !mods.ctrl && !mods.logo => match named {
1008 NamedKey::Copy => ClipboardKind::Copy,
1009 NamedKey::Cut => ClipboardKind::Cut,
1010 NamedKey::Paste => ClipboardKind::Paste,
1011 _ => return None,
1012 },
1013 _ => return None,
1014 };
1015 if opts.is_masked() && matches!(kind, ClipboardKind::Copy | ClipboardKind::Cut) {
1016 return None;
1017 }
1018 Some(kind)
1019}
1020
1021#[track_caller]
1031pub fn caret_byte_at(value: &str, event: &UiEvent, opts: &TextInputOpts<'_>) -> Option<usize> {
1032 let (px, _py) = event.pointer?;
1033 let target = event.target.as_ref()?;
1034 let local_x = px - target.rect.x - tokens::SPACE_3;
1035 Some(caret_from_x(value, local_x, opts))
1036}
1037
1038fn current_x_offset(value: &str, head: usize, viewport_w: f32, opts: &TextInputOpts<'_>) -> f32 {
1050 if viewport_w <= 0.0 {
1051 return 0.0;
1052 }
1053 let head = clamp_to_char_boundary(value, head.min(value.len()));
1054 let display = display_str(value, opts.mask);
1055 let geometry = single_line_geometry(&display, opts.tabular_numerals);
1056 let head_display = original_to_display_byte(value, head, opts.mask);
1057 let head_px = geometry.prefix_width(head_display);
1058 (head_px - viewport_w).max(0.0)
1059}
1060
1061fn caret_from_x(value: &str, local_x: f32, opts: &TextInputOpts<'_>) -> usize {
1062 if value.is_empty() || local_x <= 0.0 {
1063 return 0;
1064 }
1065 let probe = display_str(value, opts.mask);
1066 let local_y = line_height_px() * 0.5;
1067 let geometry = single_line_geometry(&probe, opts.tabular_numerals);
1068 let display_byte = match geometry.hit_byte(local_x, local_y) {
1069 Some(byte) => byte.min(probe.len()),
1070 None => probe.len(),
1071 };
1072 display_to_original_byte(value, display_byte, opts.mask)
1073}
1074
1075fn display_str(value: &str, mask: MaskMode) -> Cow<'_, str> {
1080 match mask {
1081 MaskMode::None => Cow::Borrowed(value),
1082 MaskMode::Password => {
1083 let n = value.chars().count();
1084 let mut s = String::with_capacity(n * MASK_CHAR.len_utf8());
1085 for _ in 0..n {
1086 s.push(MASK_CHAR);
1087 }
1088 Cow::Owned(s)
1089 }
1090 }
1091}
1092
1093fn original_to_display_byte(value: &str, byte_index: usize, mask: MaskMode) -> usize {
1094 match mask {
1095 MaskMode::None => byte_index.min(value.len()),
1096 MaskMode::Password => {
1097 let clamped = clamp_to_char_boundary(value, byte_index.min(value.len()));
1098 value[..clamped].chars().count() * MASK_CHAR.len_utf8()
1099 }
1100 }
1101}
1102
1103fn display_to_original_byte(value: &str, display_byte: usize, mask: MaskMode) -> usize {
1105 match mask {
1106 MaskMode::None => clamp_to_char_boundary(value, display_byte.min(value.len())),
1107 MaskMode::Password => {
1108 let scalar_idx = display_byte / MASK_CHAR.len_utf8();
1109 value
1110 .char_indices()
1111 .nth(scalar_idx)
1112 .map(|(i, _)| i)
1113 .unwrap_or(value.len())
1114 }
1115 }
1116}
1117
1118fn clip_to_max_length<'a>(
1126 value: &str,
1127 selection: TextSelection,
1128 replacement: &'a str,
1129 max_length: Option<usize>,
1130) -> Cow<'a, str> {
1131 let Some(max) = max_length else {
1132 return Cow::Borrowed(replacement);
1133 };
1134 let lo = clamp_to_char_boundary(value, selection.anchor.min(selection.head).min(value.len()));
1135 let hi = clamp_to_char_boundary(value, selection.anchor.max(selection.head).min(value.len()));
1136 let post_other = value[..lo].chars().count() + value[hi..].chars().count();
1137 let allowed = max.saturating_sub(post_other);
1138 if replacement.chars().count() <= allowed {
1139 Cow::Borrowed(replacement)
1140 } else {
1141 Cow::Owned(replacement.chars().take(allowed).collect())
1142 }
1143}
1144
1145fn clamp_to_char_boundary(s: &str, idx: usize) -> usize {
1146 let mut idx = idx.min(s.len());
1147 while idx > 0 && !s.is_char_boundary(idx) {
1148 idx -= 1;
1149 }
1150 idx
1151}
1152
1153fn prev_char_boundary(s: &str, from: usize) -> usize {
1154 let mut i = from.saturating_sub(1);
1155 while i > 0 && !s.is_char_boundary(i) {
1156 i -= 1;
1157 }
1158 i
1159}
1160
1161fn next_char_boundary(s: &str, from: usize) -> usize {
1162 let mut i = (from + 1).min(s.len());
1163 while i < s.len() && !s.is_char_boundary(i) {
1164 i += 1;
1165 }
1166 i
1167}
1168
1169#[cfg(test)]
1170mod tests {
1171 use super::*;
1172 use crate::event::{
1173 KeyModifiers, KeyPress, PhysicalKey, Pointer, PointerButton, PointerKind, UiTarget,
1174 };
1175 use crate::layout::layout;
1176 use crate::palette::Palette;
1177 use crate::runtime::RunnerCore;
1178 use crate::state::UiState;
1179 use crate::text::metrics;
1180
1181 const TEST_KEY: &str = "ti";
1186
1187 #[track_caller]
1192 fn text_input(value: &str, sel: TextSelection) -> El {
1193 super::text_input(TEST_KEY, value, &as_selection(sel))
1194 }
1195
1196 #[track_caller]
1197 fn text_input_with(value: &str, sel: TextSelection, opts: TextInputOpts<'_>) -> El {
1198 super::text_input_with(TEST_KEY, value, &as_selection(sel), opts)
1199 }
1200
1201 fn apply_event(value: &mut String, sel: &mut TextSelection, event: &UiEvent) -> bool {
1202 let mut g = as_selection(*sel);
1203 let changed = super::apply_event(value, &mut g, event, TEST_KEY);
1204 sync_back(sel, &g);
1205 changed
1206 }
1207
1208 fn apply_event_with(
1209 value: &mut String,
1210 sel: &mut TextSelection,
1211 event: &UiEvent,
1212 opts: &TextInputOpts<'_>,
1213 ) -> bool {
1214 let mut g = as_selection(*sel);
1215 let changed = super::apply_event_with(value, &mut g, event, TEST_KEY, opts);
1216 sync_back(sel, &g);
1217 changed
1218 }
1219
1220 fn as_selection(sel: TextSelection) -> Selection {
1221 Selection {
1222 range: Some(SelectionRange {
1223 anchor: SelectionPoint::new(TEST_KEY, sel.anchor),
1224 head: SelectionPoint::new(TEST_KEY, sel.head),
1225 }),
1226 }
1227 }
1228
1229 fn sync_back(local: &mut TextSelection, global: &Selection) {
1230 match global.within(TEST_KEY) {
1231 Some(view) => *local = view,
1232 None => *local = TextSelection::default(),
1233 }
1234 }
1235
1236 fn ev_text(s: &str) -> UiEvent {
1237 ev_text_with_mods(s, KeyModifiers::default())
1238 }
1239
1240 fn ev_text_with_mods(s: &str, modifiers: KeyModifiers) -> UiEvent {
1241 UiEvent {
1242 path: None,
1243 key: None,
1244 target: None,
1245 pointer: None,
1246 key_press: None,
1247 text: Some(s.into()),
1248 selection: None,
1249 modifiers,
1250 click_count: 0,
1251 pointer_kind: None,
1252 wheel_delta: None,
1253 kind: UiEventKind::TextInput,
1254 }
1255 }
1256
1257 fn ev_key(key: LogicalKey) -> UiEvent {
1258 ev_key_with_mods(key, KeyModifiers::default())
1259 }
1260
1261 fn ev_key_with_mods(key: LogicalKey, modifiers: KeyModifiers) -> UiEvent {
1262 UiEvent {
1263 path: None,
1264 key: None,
1265 target: None,
1266 pointer: None,
1267 key_press: Some(KeyPress {
1268 logical: key,
1269 physical: PhysicalKey::Unidentified,
1270 modifiers,
1271 repeat: false,
1272 }),
1273 text: None,
1274 selection: None,
1275 modifiers,
1276 click_count: 0,
1277 pointer_kind: None,
1278 wheel_delta: None,
1279 kind: UiEventKind::KeyDown,
1280 }
1281 }
1282
1283 fn ev_pointer_down(target: UiTarget, pointer: (f32, f32), modifiers: KeyModifiers) -> UiEvent {
1284 ev_pointer_down_with_count(target, pointer, modifiers, 1)
1285 }
1286
1287 fn ev_pointer_down_with_count(
1288 target: UiTarget,
1289 pointer: (f32, f32),
1290 modifiers: KeyModifiers,
1291 click_count: u8,
1292 ) -> UiEvent {
1293 UiEvent {
1294 path: None,
1295 key: Some(target.key.clone()),
1296 target: Some(target),
1297 pointer: Some(pointer),
1298 key_press: None,
1299 text: None,
1300 selection: None,
1301 modifiers,
1302 click_count,
1303 pointer_kind: None,
1304 wheel_delta: None,
1305 kind: UiEventKind::PointerDown,
1306 }
1307 }
1308
1309 fn ev_long_press(target: UiTarget, pointer: (f32, f32)) -> UiEvent {
1310 UiEvent {
1311 path: None,
1312 key: Some(target.key.clone()),
1313 target: Some(target),
1314 pointer: Some(pointer),
1315 key_press: None,
1316 text: None,
1317 selection: None,
1318 modifiers: KeyModifiers::default(),
1319 click_count: 0,
1320 pointer_kind: Some(PointerKind::Touch),
1321 wheel_delta: None,
1322 kind: UiEventKind::LongPress,
1323 }
1324 }
1325
1326 fn ev_drag(target: UiTarget, pointer: (f32, f32)) -> UiEvent {
1327 ev_drag_with_count(target, pointer, 0)
1328 }
1329
1330 fn ev_drag_with_count(target: UiTarget, pointer: (f32, f32), click_count: u8) -> UiEvent {
1331 UiEvent {
1332 path: None,
1333 key: Some(target.key.clone()),
1334 target: Some(target),
1335 pointer: Some(pointer),
1336 key_press: None,
1337 text: None,
1338 selection: None,
1339 modifiers: KeyModifiers::default(),
1340 click_count,
1341 pointer_kind: None,
1342 wheel_delta: None,
1343 kind: UiEventKind::Drag,
1344 }
1345 }
1346
1347 fn ev_middle_click(target: UiTarget, pointer: (f32, f32), text: Option<&str>) -> UiEvent {
1348 UiEvent {
1349 path: None,
1350 key: Some(target.key.clone()),
1351 target: Some(target),
1352 pointer: Some(pointer),
1353 key_press: None,
1354 text: text.map(str::to_string),
1355 selection: None,
1356 modifiers: KeyModifiers::default(),
1357 click_count: 1,
1358 pointer_kind: None,
1359 wheel_delta: None,
1360 kind: UiEventKind::MiddleClick,
1361 }
1362 }
1363
1364 fn ti_target() -> UiTarget {
1365 UiTarget {
1366 key: "ti".into(),
1367 node_id: "root.text_input[ti]".into(),
1368 rect: Rect::new(20.0, 20.0, 400.0, 36.0),
1369 tooltip: None,
1370 scroll_offset_y: 0.0,
1371 }
1372 }
1373
1374 #[test]
1375 fn apply_event_ignores_pointer_routed_to_another_widget() {
1376 let foreign = || UiTarget {
1381 key: "other".into(),
1382 node_id: "root.slider[other]".into(),
1383 rect: Rect::new(0.0, 0.0, 200.0, 20.0),
1384 tooltip: None,
1385 scroll_offset_y: 0.0,
1386 };
1387 let mut value = String::from("hello");
1388 let mut sel = TextSelection::range(1, 3);
1389
1390 let drag = ev_drag(foreign(), (40.0, 10.0));
1391 assert!(!apply_event(&mut value, &mut sel, &drag));
1392 let down = ev_pointer_down(foreign(), (40.0, 10.0), KeyModifiers::default());
1393 assert!(!apply_event(&mut value, &mut sel, &down));
1394
1395 assert_eq!(value, "hello");
1397 assert_eq!(sel, TextSelection::range(1, 3));
1398 }
1399
1400 fn content_children(el: &El) -> &[El] {
1408 assert_eq!(
1409 el.children.len(),
1410 1,
1411 "text_input wraps its content in a single inner group"
1412 );
1413 &el.children[0].children
1414 }
1415
1416 #[test]
1417 fn text_input_collapsed_renders_value_as_single_text_leaf_plus_caret() {
1418 let el = text_input("hello", TextSelection::caret(2));
1419 assert!(matches!(el.kind, Kind::Custom("text_input")));
1420 assert!(el.focusable);
1421 assert!(el.capture_keys);
1422 let cs = content_children(&el);
1426 assert_eq!(cs.len(), 2);
1427 assert!(matches!(cs[0].kind, Kind::Text));
1428 assert_eq!(cs[0].text.as_deref(), Some("hello"));
1429 assert!(matches!(cs[1].kind, Kind::Custom("text_input_caret")));
1430 assert!(cs[1].alpha_follows_focused_ancestor);
1431 }
1432
1433 #[test]
1434 fn text_input_declares_text_cursor() {
1435 let el = text_input("hello", TextSelection::caret(0));
1436 assert_eq!(el.cursor, Some(Cursor::Text));
1437 }
1438
1439 #[test]
1440 fn text_input_with_selection_inserts_selection_band_first() {
1441 let el = text_input("hello", TextSelection::range(2, 4));
1443 let cs = content_children(&el);
1444 assert_eq!(cs.len(), 3);
1446 assert!(matches!(cs[0].kind, Kind::Custom("text_input_selection")));
1447 assert_eq!(cs[1].text.as_deref(), Some("hello"));
1448 assert!(matches!(cs[2].kind, Kind::Custom("text_input_caret")));
1449 }
1450
1451 #[test]
1452 fn text_input_caret_translate_advances_with_head() {
1453 use crate::text::metrics::line_width;
1457 let value = "hello";
1458 let head = 3;
1459 let el = text_input(value, TextSelection::caret(head));
1460 let caret = content_children(&el)
1461 .iter()
1462 .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
1463 .expect("caret child");
1464 let expected = line_width(
1465 &value[..head],
1466 tokens::TEXT_SM.size,
1467 FontWeight::Regular,
1468 false,
1469 );
1470 assert!(
1471 (caret.translate.0 - expected).abs() < 0.01,
1472 "caret translate.x = {}, expected {}",
1473 caret.translate.0,
1474 expected
1475 );
1476 }
1477
1478 #[test]
1479 fn text_input_clamps_off_utf8_boundary() {
1480 let el = text_input("é", TextSelection::caret(1));
1484 let cs = content_children(&el);
1485 assert_eq!(cs[0].text.as_deref(), Some("é"));
1486 let caret = cs
1487 .iter()
1488 .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
1489 .expect("caret child");
1490 assert!(caret.translate.0.abs() < 0.01);
1492 }
1493
1494 #[test]
1495 fn selection_band_fill_dims_when_input_unfocused() {
1496 use crate::draw_ops::draw_ops;
1500 use crate::ir::DrawOp;
1501 use crate::shader::UniformValue;
1502 use crate::state::AnimationMode;
1503 use web_time::Instant;
1504
1505 let mut tree = crate::column([text_input("hello", TextSelection::range(0, 5)).key("ti")])
1506 .padding(20.0);
1507 let mut state = UiState::new();
1508 state.set_animation_mode(AnimationMode::Settled);
1509 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1510 state.sync_focus_order(&tree);
1511
1512 state.apply_to_state();
1516 state.tick_visual_animations(&mut tree, Instant::now(), &Palette::default());
1517 let unfocused = band_fill(&tree, &state).expect("band quad emitted");
1518 let [ur, ug, ub, _] = unfocused.to_srgb_u8a();
1519 let [tr, tg, tb, _] = tokens::SELECTION_BG_UNFOCUSED.to_srgb_u8a();
1520 assert_eq!(
1521 (ur, ug, ub),
1522 (tr, tg, tb),
1523 "unfocused → band rgb is the muted token"
1524 );
1525
1526 let target = state
1529 .focus
1530 .order
1531 .iter()
1532 .find(|t| t.key == "ti")
1533 .expect("ti in focus order")
1534 .clone();
1535 state.set_focus(Some(target));
1536 state.apply_to_state();
1537 state.tick_visual_animations(&mut tree, Instant::now(), &Palette::default());
1538 let focused = band_fill(&tree, &state).expect("band quad emitted");
1539 let [fr, fg, fb, _] = focused.to_srgb_u8a();
1540 let [tr, tg, tb, _] = tokens::SELECTION_BG.to_srgb_u8a();
1541 assert_eq!(
1542 (fr, fg, fb),
1543 (tr, tg, tb),
1544 "focused → band rgb is the saturated token"
1545 );
1546
1547 fn band_fill(tree: &El, state: &UiState) -> Option<crate::tree::Color> {
1548 let ops = draw_ops(tree, state);
1549 for op in ops {
1550 if let DrawOp::Quad { id, uniforms, .. } = op
1551 && id.contains("text_input_selection")
1552 && let Some(UniformValue::Color(c)) = uniforms.get("fill")
1553 {
1554 return Some(*c);
1555 }
1556 }
1557 None
1558 }
1559 }
1560
1561 #[test]
1562 fn caret_alpha_follows_focus_envelope() {
1563 use crate::draw_ops::draw_ops;
1568 use crate::ir::DrawOp;
1569 use crate::shader::UniformValue;
1570 use crate::state::AnimationMode;
1571 use web_time::Instant;
1572
1573 let mut tree =
1574 crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
1575 let mut state = UiState::new();
1576 state.set_animation_mode(AnimationMode::Settled);
1577 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1578 state.sync_focus_order(&tree);
1579
1580 state.apply_to_state();
1582 state.tick_visual_animations(&mut tree, Instant::now(), &Palette::default());
1583 let caret_alpha = caret_fill_alpha(&tree, &state);
1584 assert_eq!(caret_alpha, Some(0), "unfocused → caret invisible");
1585
1586 let target = state
1588 .focus
1589 .order
1590 .iter()
1591 .find(|t| t.key == "ti")
1592 .expect("ti in focus order")
1593 .clone();
1594 state.set_focus(Some(target));
1595 state.apply_to_state();
1596 state.tick_visual_animations(&mut tree, Instant::now(), &Palette::default());
1597 let caret_alpha = caret_fill_alpha(&tree, &state);
1598 assert_eq!(
1599 caret_alpha,
1600 Some(255),
1601 "focused → caret fully visible (alpha=255)"
1602 );
1603
1604 fn caret_fill_alpha(tree: &El, state: &UiState) -> Option<u8> {
1605 let ops = draw_ops(tree, state);
1606 for op in ops {
1607 if let DrawOp::Quad { id, uniforms, .. } = op
1608 && id.contains("text_input_caret")
1609 && let Some(UniformValue::Color(c)) = uniforms.get("fill")
1610 {
1611 return Some(c.to_srgb_u8a()[3]);
1612 }
1613 }
1614 None
1615 }
1616 }
1617
1618 #[test]
1619 fn caret_blink_alpha_holds_solid_through_grace_then_cycles() {
1620 use crate::state::caret_blink_alpha_for;
1623 use std::time::Duration;
1624 assert_eq!(caret_blink_alpha_for(Duration::from_millis(0)), 1.0);
1626 assert_eq!(caret_blink_alpha_for(Duration::from_millis(499)), 1.0);
1627 assert_eq!(caret_blink_alpha_for(Duration::from_millis(500)), 1.0);
1629 assert_eq!(caret_blink_alpha_for(Duration::from_millis(1029)), 1.0);
1630 assert_eq!(caret_blink_alpha_for(Duration::from_millis(1030)), 0.0);
1632 assert_eq!(caret_blink_alpha_for(Duration::from_millis(1559)), 0.0);
1633 assert_eq!(caret_blink_alpha_for(Duration::from_millis(1560)), 1.0);
1635 }
1636
1637 #[test]
1638 fn caret_paint_alpha_blinks_after_focus_in_live_mode() {
1639 use crate::draw_ops::draw_ops;
1643 use crate::ir::DrawOp;
1644 use crate::shader::UniformValue;
1645 use crate::state::AnimationMode;
1646 use std::time::Duration;
1647
1648 let mut tree =
1649 crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
1650 let mut state = UiState::new();
1651 state.set_animation_mode(AnimationMode::Live);
1652 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1653 state.sync_focus_order(&tree);
1654
1655 let target = state
1657 .focus
1658 .order
1659 .iter()
1660 .find(|t| t.key == "ti")
1661 .unwrap()
1662 .clone();
1663 state.set_focus(Some(target));
1664 let activity_at = state.caret.activity_at.expect("set_focus bumps activity");
1665 let input_id = tree.children[0].computed_id.clone();
1666
1667 let pin_focus = |state: &mut UiState| {
1671 state.animation.envelopes.insert(
1672 (input_id.clone(), crate::state::EnvelopeKind::FocusRing),
1673 1.0,
1674 );
1675 };
1676
1677 state.tick_visual_animations(&mut tree, activity_at, &Palette::default());
1679 pin_focus(&mut state);
1680 assert_eq!(caret_alpha(&tree, &state), Some(255));
1681
1682 state.tick_visual_animations(
1684 &mut tree,
1685 activity_at + Duration::from_millis(1100),
1686 &Palette::default(),
1687 );
1688 pin_focus(&mut state);
1689 assert_eq!(caret_alpha(&tree, &state), Some(0));
1690
1691 state.tick_visual_animations(
1693 &mut tree,
1694 activity_at + Duration::from_millis(1600),
1695 &Palette::default(),
1696 );
1697 pin_focus(&mut state);
1698 assert_eq!(caret_alpha(&tree, &state), Some(255));
1699
1700 fn caret_alpha(tree: &El, state: &UiState) -> Option<u8> {
1701 for op in draw_ops(tree, state) {
1702 if let DrawOp::Quad { id, uniforms, .. } = op
1703 && id.contains("text_input_caret")
1704 && let Some(UniformValue::Color(c)) = uniforms.get("fill")
1705 {
1706 return Some(c.to_srgb_u8a()[3]);
1707 }
1708 }
1709 None
1710 }
1711 }
1712
1713 #[test]
1714 fn caret_blink_resumes_solid_after_selection_change() {
1715 use crate::state::AnimationMode;
1718 use std::time::Duration;
1719 use web_time::Instant;
1720
1721 let mut tree =
1722 crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
1723 let mut state = UiState::new();
1724 state.set_animation_mode(AnimationMode::Live);
1725 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1726 state.sync_focus_order(&tree);
1727
1728 let t0 = Instant::now();
1730 state.bump_caret_activity(t0);
1731 state.tick_visual_animations(
1732 &mut tree,
1733 t0 + Duration::from_millis(1100),
1734 &Palette::default(),
1735 );
1736 assert_eq!(state.caret.blink_alpha, 0.0, "deep in off phase");
1737
1738 state.bump_caret_activity(t0 + Duration::from_millis(1100));
1740 assert_eq!(state.caret.blink_alpha, 1.0, "fresh activity → solid");
1741 }
1742
1743 #[test]
1744 fn caret_tick_requests_redraw_while_capture_keys_node_focused() {
1745 use crate::state::AnimationMode;
1749 use web_time::Instant;
1750
1751 let mut tree =
1752 crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
1753 let mut state = UiState::new();
1754 state.set_animation_mode(AnimationMode::Live);
1755 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1756 state.sync_focus_order(&tree);
1757
1758 let no_focus = state.tick_visual_animations(&mut tree, Instant::now(), &Palette::default());
1760 assert!(!no_focus, "without focus, blink doesn't request redraws");
1761
1762 let target = state
1765 .focus
1766 .order
1767 .iter()
1768 .find(|t| t.key == "ti")
1769 .unwrap()
1770 .clone();
1771 state.set_focus(Some(target));
1772 let focused = state.tick_visual_animations(&mut tree, Instant::now(), &Palette::default());
1773 assert!(focused, "focused capture_keys node → tick demands redraws");
1774 }
1775
1776 #[test]
1777 fn apply_text_input_inserts_at_caret_when_collapsed() {
1778 let mut value = String::from("ho");
1779 let mut sel = TextSelection::caret(1);
1780 assert!(apply_event(&mut value, &mut sel, &ev_text("i, t")));
1781 assert_eq!(value, "hi, to");
1782 assert_eq!(sel, TextSelection::caret(5));
1783 }
1784
1785 #[test]
1786 fn apply_text_input_replaces_selection() {
1787 let mut value = String::from("hello world");
1788 let mut sel = TextSelection::range(6, 11); assert!(apply_event(&mut value, &mut sel, &ev_text("kit")));
1790 assert_eq!(value, "hello kit");
1791 assert_eq!(sel, TextSelection::caret(9));
1792 }
1793
1794 #[test]
1795 fn apply_backspace_removes_selection_when_non_empty() {
1796 let mut value = String::from("hello world");
1797 let mut sel = TextSelection::range(6, 11);
1798 assert!(apply_event(
1799 &mut value,
1800 &mut sel,
1801 &ev_key(LogicalKey::Named(NamedKey::Backspace))
1802 ));
1803 assert_eq!(value, "hello ");
1804 assert_eq!(sel, TextSelection::caret(6));
1805 }
1806
1807 #[test]
1808 fn apply_delete_removes_selection_when_non_empty() {
1809 let mut value = String::from("hello world");
1810 let mut sel = TextSelection::range(0, 6); assert!(apply_event(
1812 &mut value,
1813 &mut sel,
1814 &ev_key(LogicalKey::Named(NamedKey::Delete))
1815 ));
1816 assert_eq!(value, "world");
1817 assert_eq!(sel, TextSelection::caret(0));
1818 }
1819
1820 #[test]
1821 fn apply_escape_collapses_selection_without_editing() {
1822 let mut value = String::from("hello");
1823 let mut sel = TextSelection::range(1, 4);
1824 assert!(apply_event(
1825 &mut value,
1826 &mut sel,
1827 &ev_key(LogicalKey::Named(NamedKey::Escape))
1828 ));
1829 assert_eq!(value, "hello");
1830 assert_eq!(sel, TextSelection::caret(4));
1831 assert!(!apply_event(
1832 &mut value,
1833 &mut sel,
1834 &ev_key(LogicalKey::Named(NamedKey::Escape))
1835 ));
1836 }
1837
1838 #[test]
1839 fn apply_backspace_collapsed_at_start_is_noop() {
1840 let mut value = String::from("hi");
1841 let mut sel = TextSelection::caret(0);
1842 assert!(!apply_event(
1843 &mut value,
1844 &mut sel,
1845 &ev_key(LogicalKey::Named(NamedKey::Backspace))
1846 ));
1847 }
1848
1849 #[test]
1850 fn apply_arrow_walks_utf8_boundaries() {
1851 let mut value = String::from("aé");
1852 let mut sel = TextSelection::caret(0);
1853 apply_event(
1854 &mut value,
1855 &mut sel,
1856 &ev_key(LogicalKey::Named(NamedKey::ArrowRight)),
1857 );
1858 assert_eq!(sel.head, 1);
1859 apply_event(
1860 &mut value,
1861 &mut sel,
1862 &ev_key(LogicalKey::Named(NamedKey::ArrowRight)),
1863 );
1864 assert_eq!(sel.head, 3);
1865 assert!(!apply_event(
1866 &mut value,
1867 &mut sel,
1868 &ev_key(LogicalKey::Named(NamedKey::ArrowRight))
1869 ));
1870 apply_event(
1871 &mut value,
1872 &mut sel,
1873 &ev_key(LogicalKey::Named(NamedKey::ArrowLeft)),
1874 );
1875 assert_eq!(sel.head, 1);
1876 }
1877
1878 #[test]
1879 fn apply_arrow_collapses_selection_without_shift() {
1880 let mut value = String::from("hello");
1881 let mut sel = TextSelection::range(1, 4); assert!(apply_event(
1885 &mut value,
1886 &mut sel,
1887 &ev_key(LogicalKey::Named(NamedKey::ArrowLeft))
1888 ));
1889 assert_eq!(sel, TextSelection::caret(1));
1890
1891 let mut sel = TextSelection::range(1, 4);
1892 assert!(apply_event(
1894 &mut value,
1895 &mut sel,
1896 &ev_key(LogicalKey::Named(NamedKey::ArrowRight))
1897 ));
1898 assert_eq!(sel, TextSelection::caret(4));
1899 }
1900
1901 #[test]
1902 fn apply_shift_arrow_extends_selection() {
1903 let mut value = String::from("hello");
1904 let mut sel = TextSelection::caret(2);
1905 let shift = KeyModifiers {
1906 shift: true,
1907 ..Default::default()
1908 };
1909 assert!(apply_event(
1910 &mut value,
1911 &mut sel,
1912 &ev_key_with_mods(LogicalKey::Named(NamedKey::ArrowRight), shift)
1913 ));
1914 assert_eq!(sel, TextSelection::range(2, 3));
1915 assert!(apply_event(
1916 &mut value,
1917 &mut sel,
1918 &ev_key_with_mods(LogicalKey::Named(NamedKey::ArrowRight), shift)
1919 ));
1920 assert_eq!(sel, TextSelection::range(2, 4));
1921 assert!(apply_event(
1923 &mut value,
1924 &mut sel,
1925 &ev_key_with_mods(LogicalKey::Named(NamedKey::ArrowLeft), shift)
1926 ));
1927 assert_eq!(sel, TextSelection::range(2, 3));
1928 }
1929
1930 #[test]
1931 fn apply_home_end_collapse_or_extend() {
1932 let mut value = String::from("hello");
1933 let mut sel = TextSelection::caret(2);
1934 assert!(apply_event(
1935 &mut value,
1936 &mut sel,
1937 &ev_key(LogicalKey::Named(NamedKey::End))
1938 ));
1939 assert_eq!(sel, TextSelection::caret(5));
1940 assert!(apply_event(
1941 &mut value,
1942 &mut sel,
1943 &ev_key(LogicalKey::Named(NamedKey::Home))
1944 ));
1945 assert_eq!(sel, TextSelection::caret(0));
1946
1947 let shift = KeyModifiers {
1949 shift: true,
1950 ..Default::default()
1951 };
1952 let mut sel = TextSelection::caret(2);
1953 assert!(apply_event(
1954 &mut value,
1955 &mut sel,
1956 &ev_key_with_mods(LogicalKey::Named(NamedKey::End), shift)
1957 ));
1958 assert_eq!(sel, TextSelection::range(2, 5));
1959 }
1960
1961 #[test]
1962 fn apply_ctrl_a_selects_all() {
1963 let mut value = String::from("hello");
1964 let mut sel = TextSelection::caret(2);
1965 let ctrl = KeyModifiers {
1966 ctrl: true,
1967 ..Default::default()
1968 };
1969 assert!(apply_event(
1970 &mut value,
1971 &mut sel,
1972 &ev_key_with_mods(LogicalKey::Character("a".into()), ctrl)
1973 ));
1974 assert_eq!(sel, TextSelection::range(0, 5));
1975 assert!(!apply_event(
1977 &mut value,
1978 &mut sel,
1979 &ev_key_with_mods(LogicalKey::Character("a".into()), ctrl)
1980 ));
1981 }
1982
1983 #[test]
1984 fn apply_pointer_down_sets_anchor_and_head() {
1985 let mut value = String::from("hello");
1986 let mut sel = TextSelection::range(0, 5);
1987 let down = ev_pointer_down(
1989 ti_target(),
1990 (ti_target().rect.x + 1.0, ti_target().rect.y + 18.0),
1991 KeyModifiers::default(),
1992 );
1993 assert!(apply_event(&mut value, &mut sel, &down));
1994 assert_eq!(sel, TextSelection::caret(0));
1995 }
1996
1997 #[test]
1998 fn apply_double_click_selects_word_at_caret() {
1999 let mut value = String::from("hello world");
2000 let mut sel = TextSelection::caret(0);
2001 let target = ti_target();
2003 let click_x = target.rect.x
2004 + tokens::SPACE_3
2005 + crate::text::metrics::line_width(
2006 "hello w",
2007 tokens::TEXT_SM.size,
2008 FontWeight::Regular,
2009 false,
2010 );
2011 let down = ev_pointer_down_with_count(
2012 target.clone(),
2013 (click_x, target.rect.y + 18.0),
2014 KeyModifiers::default(),
2015 2,
2016 );
2017 assert!(apply_event(&mut value, &mut sel, &down));
2018 assert_eq!(sel.anchor, 6);
2020 assert_eq!(sel.head, 11);
2021 }
2022
2023 #[test]
2024 fn apply_long_press_selects_word_at_caret() {
2025 let mut value = String::from("hello world");
2026 let mut sel = TextSelection::caret(0);
2027 let target = ti_target();
2028 let event = ev_long_press(target.clone(), (target.rect.x + 4.0, target.rect.y + 18.0));
2029
2030 assert!(apply_event(&mut value, &mut sel, &event));
2031 assert_eq!(sel, TextSelection::range(0, 5));
2032 }
2033
2034 #[test]
2035 fn apply_triple_click_selects_all() {
2036 let mut value = String::from("hello world");
2037 let mut sel = TextSelection::caret(0);
2038 let target = ti_target();
2039 let down = ev_pointer_down_with_count(
2040 target.clone(),
2041 (target.rect.x + 1.0, target.rect.y + 18.0),
2042 KeyModifiers::default(),
2043 3,
2044 );
2045 assert!(apply_event(&mut value, &mut sel, &down));
2046 assert_eq!(sel.anchor, 0);
2047 assert_eq!(sel.head, value.len());
2048 }
2049
2050 #[test]
2051 fn apply_shift_double_click_falls_back_to_extend_not_word_select() {
2052 let mut value = String::from("hello world");
2055 let mut sel = TextSelection::caret(0);
2056 let target = ti_target();
2057 let click_x = target.rect.x
2058 + tokens::SPACE_3
2059 + crate::text::metrics::line_width(
2060 "hello w",
2061 tokens::TEXT_SM.size,
2062 FontWeight::Regular,
2063 false,
2064 );
2065 let shift = KeyModifiers {
2066 shift: true,
2067 ..Default::default()
2068 };
2069 let down =
2070 ev_pointer_down_with_count(target.clone(), (click_x, target.rect.y + 18.0), shift, 2);
2071 assert!(apply_event(&mut value, &mut sel, &down));
2072 assert_eq!(sel.anchor, 0);
2074 assert!(sel.head > 0 && sel.head < value.len());
2075 }
2076
2077 #[test]
2078 fn apply_shift_pointer_down_only_moves_head() {
2079 let mut value = String::from("hello");
2080 let mut sel = TextSelection::caret(2);
2081 let shift = KeyModifiers {
2082 shift: true,
2083 ..Default::default()
2084 };
2085 let down = ev_pointer_down(
2087 ti_target(),
2088 (
2089 ti_target().rect.x + ti_target().rect.w - 4.0,
2090 ti_target().rect.y + 18.0,
2091 ),
2092 shift,
2093 );
2094 assert!(apply_event(&mut value, &mut sel, &down));
2095 assert_eq!(sel.anchor, 2);
2096 assert_eq!(sel.head, value.len());
2097 }
2098
2099 #[test]
2100 fn apply_drag_extends_head_only() {
2101 let mut value = String::from("hello world");
2102 let mut sel = TextSelection::caret(0);
2103 let down = ev_pointer_down(
2105 ti_target(),
2106 (ti_target().rect.x + 1.0, ti_target().rect.y + 18.0),
2107 KeyModifiers::default(),
2108 );
2109 apply_event(&mut value, &mut sel, &down);
2110 assert_eq!(sel, TextSelection::caret(0));
2111 let drag = ev_drag(
2113 ti_target(),
2114 (
2115 ti_target().rect.x + ti_target().rect.w - 4.0,
2116 ti_target().rect.y + 18.0,
2117 ),
2118 );
2119 assert!(apply_event(&mut value, &mut sel, &drag));
2120 assert_eq!(sel.anchor, 0);
2121 assert_eq!(sel.head, value.len());
2122 }
2123
2124 #[test]
2125 fn double_click_hold_drag_inside_word_keeps_word_selected() {
2126 let mut value = String::from("hello world");
2127 let mut sel = TextSelection::caret(0);
2128 let target = ti_target();
2129 let click_x = target.rect.x
2130 + tokens::SPACE_3
2131 + crate::text::metrics::line_width(
2132 "hello w",
2133 tokens::TEXT_SM.size,
2134 FontWeight::Regular,
2135 false,
2136 );
2137 let down = ev_pointer_down_with_count(
2138 target.clone(),
2139 (click_x, target.rect.y + 18.0),
2140 KeyModifiers::default(),
2141 2,
2142 );
2143 assert!(apply_event(&mut value, &mut sel, &down));
2144 assert_eq!(sel, TextSelection::range(6, 11));
2145
2146 let drag = ev_drag_with_count(target.clone(), (click_x + 1.0, target.rect.y + 18.0), 2);
2147 assert!(apply_event(&mut value, &mut sel, &drag));
2148 assert_eq!(sel, TextSelection::range(6, 11));
2149 }
2150
2151 #[test]
2152 fn apply_click_is_noop_for_selection() {
2153 let mut value = String::from("hello");
2157 let mut sel = TextSelection::range(0, 5);
2158 let click = UiEvent {
2159 path: None,
2160 key: Some("ti".into()),
2161 target: Some(ti_target()),
2162 pointer: Some((ti_target().rect.x + 1.0, ti_target().rect.y + 18.0)),
2163 key_press: None,
2164 text: None,
2165 selection: None,
2166 modifiers: KeyModifiers::default(),
2167 click_count: 1,
2168 pointer_kind: None,
2169 wheel_delta: None,
2170 kind: UiEventKind::Click,
2171 };
2172 assert!(!apply_event(&mut value, &mut sel, &click));
2173 assert_eq!(sel, TextSelection::range(0, 5));
2174 }
2175
2176 #[test]
2177 fn apply_middle_click_inserts_event_text_at_pointer() {
2178 let mut value = String::from("world");
2179 let mut sel = TextSelection::caret(value.len());
2180 let target = ti_target();
2181 let pointer = (
2182 target.rect.x + tokens::SPACE_3,
2183 target.rect.y + target.rect.h * 0.5,
2184 );
2185 let event = ev_middle_click(target, pointer, Some("hello "));
2186 assert!(apply_event(&mut value, &mut sel, &event));
2187 assert_eq!(value, "hello world");
2188 assert_eq!(sel, TextSelection::caret("hello ".len()));
2189 }
2190
2191 #[test]
2192 fn helpers_selected_text_and_replace_selection() {
2193 let value = String::from("hello world");
2194 let sel = TextSelection::range(6, 11);
2195 assert_eq!(selected_text(&value, sel), "world");
2196
2197 let mut value = value;
2198 let mut sel = sel;
2199 replace_selection(&mut value, &mut sel, "kit");
2200 assert_eq!(value, "hello kit");
2201 assert_eq!(sel, TextSelection::caret(9));
2202
2203 assert_eq!(select_all(&value), TextSelection::range(0, value.len()));
2204 }
2205
2206 #[test]
2207 fn apply_text_input_filters_control_chars() {
2208 let mut value = String::from("hi");
2212 let mut sel = TextSelection::caret(2);
2213 for ctrl in ["\u{8}", "\u{7f}", "\r", "\n", "\u{1b}", "\t"] {
2214 assert!(
2215 !apply_event(&mut value, &mut sel, &ev_text(ctrl)),
2216 "expected {ctrl:?} to be filtered"
2217 );
2218 assert_eq!(value, "hi");
2219 assert_eq!(sel, TextSelection::caret(2));
2220 }
2221 assert!(apply_event(&mut value, &mut sel, &ev_text("a\u{8}b")));
2223 assert_eq!(value, "hiab");
2224 assert_eq!(sel, TextSelection::caret(4));
2225 }
2226
2227 #[test]
2228 fn apply_text_input_drops_when_ctrl_or_cmd_is_held() {
2229 let mut value = String::from("hello");
2234 let mut sel = TextSelection::range(0, 5);
2235 let ctrl = KeyModifiers {
2236 ctrl: true,
2237 ..Default::default()
2238 };
2239 let cmd = KeyModifiers {
2240 logo: true,
2241 ..Default::default()
2242 };
2243 assert!(!apply_event(
2244 &mut value,
2245 &mut sel,
2246 &ev_text_with_mods("c", ctrl)
2247 ));
2248 assert_eq!(value, "hello");
2249 assert!(!apply_event(
2250 &mut value,
2251 &mut sel,
2252 &ev_text_with_mods("v", cmd)
2253 ));
2254 assert_eq!(value, "hello");
2255 let altgr = KeyModifiers {
2257 ctrl: true,
2258 alt: true,
2259 ..Default::default()
2260 };
2261 let mut value = String::from("");
2262 let mut sel = TextSelection::caret(0);
2263 assert!(apply_event(
2264 &mut value,
2265 &mut sel,
2266 &ev_text_with_mods("é", altgr)
2267 ));
2268 assert_eq!(value, "é");
2269 }
2270
2271 #[test]
2272 fn text_input_value_emits_a_single_glyph_run() {
2273 use crate::draw_ops::draw_ops;
2279 use crate::ir::DrawOp;
2280 let mut tree =
2281 crate::column([text_input("Type", TextSelection::caret(1)).key("ti")]).padding(20.0);
2282 let mut state = UiState::new();
2283 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
2284
2285 let ops = draw_ops(&tree, &state);
2286 let glyph_runs = ops
2287 .iter()
2288 .filter(|op| matches!(op, DrawOp::GlyphRun { id, .. } if id.contains("text_input[ti]")))
2289 .count();
2290 assert_eq!(
2291 glyph_runs, 1,
2292 "value should shape as one run; got {glyph_runs}"
2293 );
2294 }
2295
2296 #[test]
2297 fn clipboard_request_detects_ctrl_c_x_v() {
2298 let ctrl = KeyModifiers {
2299 ctrl: true,
2300 ..Default::default()
2301 };
2302 let cases = [
2303 ("c", ClipboardKind::Copy),
2304 ("C", ClipboardKind::Copy),
2305 ("x", ClipboardKind::Cut),
2306 ("v", ClipboardKind::Paste),
2307 ];
2308 for (ch, expected) in cases {
2309 let e = ev_key_with_mods(LogicalKey::Character(ch.into()), ctrl);
2310 assert_eq!(clipboard_request(&e), Some(expected), "char {ch:?}");
2311 }
2312 }
2313
2314 #[test]
2315 fn clipboard_request_accepts_cmd_on_macos() {
2316 let logo = KeyModifiers {
2319 logo: true,
2320 ..Default::default()
2321 };
2322 let e = ev_key_with_mods(LogicalKey::Character("c".into()), logo);
2323 assert_eq!(clipboard_request(&e), Some(ClipboardKind::Copy));
2324 }
2325
2326 #[test]
2327 fn clipboard_request_detects_semantic_clipboard_keys() {
2328 let cases = [
2329 (NamedKey::Copy, ClipboardKind::Copy),
2330 (NamedKey::Cut, ClipboardKind::Cut),
2331 (NamedKey::Paste, ClipboardKind::Paste),
2332 ];
2333 for (named, expected) in cases {
2334 let e = ev_key(LogicalKey::Named(named));
2335 assert_eq!(
2336 clipboard_request(&e),
2337 Some(expected),
2338 "semantic key {named:?}"
2339 );
2340 }
2341 }
2342
2343 #[test]
2344 fn clipboard_request_rejects_with_shift_or_alt() {
2345 let e = ev_key_with_mods(
2347 LogicalKey::Character("c".into()),
2348 KeyModifiers {
2349 ctrl: true,
2350 shift: true,
2351 ..Default::default()
2352 },
2353 );
2354 assert_eq!(clipboard_request(&e), None);
2355
2356 let e = ev_key_with_mods(
2357 LogicalKey::Character("v".into()),
2358 KeyModifiers {
2359 ctrl: true,
2360 alt: true,
2361 ..Default::default()
2362 },
2363 );
2364 assert_eq!(clipboard_request(&e), None);
2365 }
2366
2367 #[test]
2368 fn clipboard_request_ignores_other_keys_and_event_kinds() {
2369 let e = ev_key(LogicalKey::Character("c".into()));
2371 assert_eq!(clipboard_request(&e), None);
2372 let e = ev_key_with_mods(
2374 LogicalKey::Character("a".into()),
2375 KeyModifiers {
2376 ctrl: true,
2377 ..Default::default()
2378 },
2379 );
2380 assert_eq!(clipboard_request(&e), None);
2381 assert_eq!(clipboard_request(&ev_text("c")), None);
2383 }
2384
2385 fn password_opts() -> TextInputOpts<'static> {
2386 TextInputOpts::default().password()
2387 }
2388
2389 #[test]
2390 fn password_input_renders_value_as_bullets_not_plaintext() {
2391 let el = text_input_with("hunter2", TextSelection::caret(0), password_opts());
2394 let leaf = content_children(&el)
2395 .iter()
2396 .find(|c| matches!(c.kind, Kind::Text))
2397 .expect("text leaf");
2398 assert_eq!(leaf.text.as_deref(), Some("•••••••"));
2399 }
2400
2401 #[test]
2402 fn password_input_caret_position_uses_masked_widths() {
2403 use crate::text::metrics::line_width;
2407 let value = "abc";
2408 let head = 2;
2409 let el = text_input_with(value, TextSelection::caret(head), password_opts());
2410 let caret = content_children(&el)
2411 .iter()
2412 .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
2413 .expect("caret child");
2414 let expected = line_width("••", tokens::TEXT_SM.size, FontWeight::Regular, false);
2416 assert!(
2417 (caret.translate.0 - expected).abs() < 0.01,
2418 "caret translate.x = {}, expected {}",
2419 caret.translate.0,
2420 expected
2421 );
2422 }
2423
2424 #[test]
2425 fn password_pointer_click_maps_back_to_original_byte() {
2426 let mut value = String::from("abcde");
2429 let mut sel = TextSelection::default();
2430 let target = ti_target();
2431 let down = ev_pointer_down(
2432 target.clone(),
2433 (target.rect.x + target.rect.w - 4.0, target.rect.y + 18.0),
2434 KeyModifiers::default(),
2435 );
2436 assert!(apply_event_with(
2437 &mut value,
2438 &mut sel,
2439 &down,
2440 &password_opts()
2441 ));
2442 assert_eq!(sel.head, value.len());
2443 }
2444
2445 #[test]
2446 fn password_pointer_click_with_multibyte_value() {
2447 let mut value = String::from("éé");
2451 let mut sel = TextSelection::default();
2452 let target = ti_target();
2453 let bullet_w = metrics::line_width("•", tokens::TEXT_SM.size, FontWeight::Regular, false);
2455 let click_x = target.rect.x + tokens::SPACE_3 + bullet_w * 1.4;
2456 let down = ev_pointer_down(
2457 target,
2458 (click_x, ti_target().rect.y + 18.0),
2459 KeyModifiers::default(),
2460 );
2461 assert!(apply_event_with(
2462 &mut value,
2463 &mut sel,
2464 &down,
2465 &password_opts()
2466 ));
2467 assert!(
2471 value.is_char_boundary(sel.head),
2472 "head={} not on a char boundary in {value:?}",
2473 sel.head
2474 );
2475 assert!(sel.head == 2 || sel.head == 4, "head={}", sel.head);
2476 }
2477
2478 #[test]
2479 fn password_clipboard_request_suppresses_copy_and_cut_only() {
2480 let ctrl = KeyModifiers {
2481 ctrl: true,
2482 ..Default::default()
2483 };
2484 let opts = password_opts();
2485 let copy = ev_key_with_mods(LogicalKey::Character("c".into()), ctrl);
2486 let cut = ev_key_with_mods(LogicalKey::Character("x".into()), ctrl);
2487 let paste = ev_key_with_mods(LogicalKey::Character("v".into()), ctrl);
2488 assert_eq!(clipboard_request_for(©, &opts), None);
2489 assert_eq!(clipboard_request_for(&cut, &opts), None);
2490 assert_eq!(
2491 clipboard_request_for(&paste, &opts),
2492 Some(ClipboardKind::Paste)
2493 );
2494 let plain = TextInputOpts::default();
2496 assert_eq!(
2497 clipboard_request_for(©, &plain),
2498 Some(ClipboardKind::Copy)
2499 );
2500 }
2501
2502 #[test]
2503 fn placeholder_renders_only_when_value_is_empty() {
2504 let opts = TextInputOpts::default().placeholder("Email");
2505 let empty = text_input_with("", TextSelection::default(), opts);
2506 let muted_leaf = content_children(&empty)
2507 .iter()
2508 .find(|c| matches!(c.kind, Kind::Text) && c.text.as_deref() == Some("Email"));
2509 assert!(muted_leaf.is_some(), "placeholder leaf should be present");
2510
2511 let nonempty = text_input_with("hi", TextSelection::caret(2), opts);
2512 let muted_leaf = content_children(&nonempty)
2513 .iter()
2514 .find(|c| matches!(c.kind, Kind::Text) && c.text.as_deref() == Some("Email"));
2515 assert!(
2516 muted_leaf.is_none(),
2517 "placeholder should not render once the field has a value"
2518 );
2519 }
2520
2521 #[test]
2522 fn long_value_with_caret_at_end_shifts_content_left_to_keep_caret_in_view() {
2523 use crate::tree::Size;
2532 let value = "abcdefghijklmnopqrstuvwxyz0123456789".repeat(2);
2533 let mut root = super::text_input(
2534 "ti",
2535 &value,
2536 &as_selection_in("ti", TextSelection::caret(value.len())),
2537 )
2538 .width(Size::Fixed(120.0));
2539 let mut ui_state = crate::state::UiState::new();
2540 crate::layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 120.0, 40.0));
2541
2542 let inner = &root.children[0];
2544 let text_leaf = inner
2545 .children
2546 .iter()
2547 .find(|c| matches!(c.kind, Kind::Text))
2548 .expect("text leaf");
2549 let leaf_rect = text_leaf.computed_rect;
2550
2551 let inner_rect = inner.computed_rect;
2555 assert!(
2556 leaf_rect.x < inner_rect.x,
2557 "text leaf rect.x={} should be left of inner rect.x={} after \
2558 horizontal caret-into-view; layout did not shift content",
2559 leaf_rect.x,
2560 inner_rect.x,
2561 );
2562 }
2563
2564 #[test]
2565 fn short_value_does_not_shift_content() {
2566 use crate::tree::Size;
2570 let mut root =
2571 super::text_input("ti", "hi", &as_selection_in("ti", TextSelection::caret(2)))
2572 .width(Size::Fixed(120.0));
2573 let mut ui_state = crate::state::UiState::new();
2574 crate::layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 120.0, 40.0));
2575
2576 let inner = &root.children[0];
2577 let text_leaf = inner
2578 .children
2579 .iter()
2580 .find(|c| matches!(c.kind, Kind::Text))
2581 .expect("text leaf");
2582 let leaf_rect = text_leaf.computed_rect;
2583 let inner_rect = inner.computed_rect;
2584 assert!(
2585 (leaf_rect.x - inner_rect.x).abs() < 0.5,
2586 "short value should not shift; got leaf.x={} inner.x={}",
2587 leaf_rect.x,
2588 inner_rect.x
2589 );
2590 }
2591
2592 fn as_selection_in(key: &str, sel: TextSelection) -> Selection {
2595 Selection {
2596 range: Some(SelectionRange {
2597 anchor: SelectionPoint::new(key, sel.anchor),
2598 head: SelectionPoint::new(key, sel.head),
2599 }),
2600 }
2601 }
2602
2603 #[test]
2604 fn max_length_truncates_text_input_inserts() {
2605 let mut value = String::from("ab");
2606 let mut sel = TextSelection::caret(2);
2607 let opts = TextInputOpts::default().max_length(4);
2608 assert!(apply_event_with(
2610 &mut value,
2611 &mut sel,
2612 &ev_text("cdef"),
2613 &opts
2614 ));
2615 assert_eq!(value, "abcd");
2616 assert_eq!(sel, TextSelection::caret(4));
2617 assert!(!apply_event_with(
2619 &mut value,
2620 &mut sel,
2621 &ev_text("z"),
2622 &opts
2623 ));
2624 assert_eq!(value, "abcd");
2625 }
2626
2627 #[test]
2628 fn max_length_replaces_selection_with_capacity_freed_by_removal() {
2629 let mut value = String::from("abc");
2632 let mut sel = TextSelection::range(0, 3); let opts = TextInputOpts::default().max_length(4);
2634 assert!(apply_event_with(
2635 &mut value,
2636 &mut sel,
2637 &ev_text("12345"),
2638 &opts
2639 ));
2640 assert_eq!(value, "1234");
2641 assert_eq!(sel, TextSelection::caret(4));
2642 }
2643
2644 #[test]
2645 fn replace_selection_with_max_length_clips_a_paste() {
2646 let mut value = String::from("ab");
2647 let mut sel = TextSelection::caret(2);
2648 let opts = TextInputOpts::default().max_length(5);
2649 let inserted = replace_selection_with(&mut value, &mut sel, "0123456789", &opts);
2651 assert_eq!(value, "ab012");
2652 assert_eq!(inserted, 3);
2653 assert_eq!(sel, TextSelection::caret(5));
2654 }
2655
2656 #[test]
2657 fn max_length_does_not_shrink_an_already_overlong_value() {
2658 let mut value = String::from("abcdef");
2661 let mut sel = TextSelection::caret(6);
2662 let opts = TextInputOpts::default().max_length(3);
2663 assert!(!apply_event_with(
2665 &mut value,
2666 &mut sel,
2667 &ev_text("z"),
2668 &opts
2669 ));
2670 assert_eq!(value, "abcdef");
2671 assert!(apply_event_with(
2674 &mut value,
2675 &mut sel,
2676 &ev_key(LogicalKey::Named(NamedKey::Backspace)),
2677 &opts
2678 ));
2679 assert_eq!(value, "abcde");
2680 }
2681
2682 #[test]
2683 fn end_to_end_drag_select_through_runner_core() {
2684 let mut value = String::from("hello world");
2688 let mut sel = TextSelection::default();
2689 let mut tree = crate::column([text_input(&value, sel).key("ti")]).padding(20.0);
2690 let mut core = RunnerCore::new();
2691 let mut state = UiState::new();
2692 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
2693 core.ui_state = state;
2694 core.snapshot(&tree, &mut Default::default());
2695
2696 let rect = core.rect_of_key("ti").expect("ti rect");
2697 let down_x = rect.x + 8.0;
2698 let drag_x = rect.x + 80.0;
2699 let cy = rect.y + rect.h * 0.5;
2700
2701 core.pointer_moved(Pointer::moving(down_x, cy));
2702 let down = core
2703 .pointer_down(Pointer::mouse(down_x, cy, PointerButton::Primary))
2704 .into_iter()
2705 .find(|e| e.kind == UiEventKind::PointerDown)
2706 .expect("pointer_down emits PointerDown");
2707 assert!(apply_event(&mut value, &mut sel, &down));
2708
2709 let drag = core
2710 .pointer_moved(Pointer::moving(drag_x, cy))
2711 .events
2712 .into_iter()
2713 .find(|e| e.kind == UiEventKind::Drag)
2714 .expect("Drag while pressed");
2715 assert!(apply_event(&mut value, &mut sel, &drag));
2716
2717 let events = core.pointer_up(Pointer::mouse(drag_x, cy, PointerButton::Primary));
2718 for e in &events {
2719 apply_event(&mut value, &mut sel, e);
2720 }
2721 assert!(
2722 !sel.is_collapsed(),
2723 "expected drag-select to leave a non-empty selection"
2724 );
2725 assert_eq!(
2726 sel.anchor, 0,
2727 "anchor should sit at the down position (caret 0)"
2728 );
2729 assert!(
2730 sel.head > 0 && sel.head <= value.len(),
2731 "head={} value.len={}",
2732 sel.head,
2733 value.len()
2734 );
2735 }
2736
2737 #[test]
2745 fn apply_event_writes_back_under_the_inputs_key() {
2746 let mut value = String::new();
2748 let mut sel = Selection::default();
2749 let event = ev_text("h");
2750 assert!(super::apply_event(&mut value, &mut sel, &event, "name"));
2751 assert_eq!(value, "h");
2752 let r = sel.range.as_ref().expect("selection set");
2753 assert_eq!(r.anchor.key, "name");
2754 assert_eq!(r.head.key, "name");
2755 assert_eq!(r.head.byte, 1);
2756 }
2757
2758 #[test]
2759 fn apply_event_claims_selection_when_event_routed_from_elsewhere() {
2760 let mut value = String::new();
2766 let mut sel = Selection {
2767 range: Some(SelectionRange {
2768 anchor: SelectionPoint::new("para-a", 0),
2769 head: SelectionPoint::new("para-a", 5),
2770 }),
2771 };
2772 let event = ev_text("x");
2773 assert!(super::apply_event(&mut value, &mut sel, &event, "email"));
2774 assert_eq!(value, "x");
2775 let r = sel.range.as_ref().unwrap();
2776 assert_eq!(r.anchor.key, "email", "selection ownership migrated");
2777 assert_eq!(r.head.byte, 1);
2778 }
2779
2780 #[test]
2781 fn apply_event_leaves_selection_alone_when_event_is_unhandled() {
2782 let mut value = String::from("hi");
2786 let mut sel = Selection {
2787 range: Some(SelectionRange {
2788 anchor: SelectionPoint::new("para-a", 0),
2789 head: SelectionPoint::new("para-a", 3),
2790 }),
2791 };
2792 let event = ev_key(LogicalKey::Named(NamedKey::F1));
2793 assert!(!super::apply_event(&mut value, &mut sel, &event, "name"));
2794 let r = sel.range.as_ref().unwrap();
2796 assert_eq!(r.anchor.key, "para-a");
2797 assert_eq!(r.head.byte, 3);
2798 }
2799
2800 #[test]
2801 fn text_input_renders_caret_at_local_byte_when_selection_is_within_key() {
2802 let sel = Selection::caret("name", 2);
2803 let el = super::text_input("name", "hello", &sel);
2804 assert_eq!(el.key.as_deref(), Some("name"));
2806 let caret = content_children(&el)
2808 .iter()
2809 .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
2810 .expect("caret child");
2811 let expected = metrics::line_width("he", tokens::TEXT_SM.size, FontWeight::Regular, false);
2812 assert!(
2813 (caret.translate.0 - expected).abs() < 0.01,
2814 "caret.x={} expected {}",
2815 caret.translate.0,
2816 expected
2817 );
2818 }
2819
2820 #[test]
2821 fn tabular_opts_move_caret_and_value_leaf_together() {
2822 let sel = Selection::caret("qty", 3);
2826 let value = "1111";
2827 let el = super::text_input_with(
2828 "qty",
2829 value,
2830 &sel,
2831 TextInputOpts::default().tabular_numerals(),
2832 );
2833
2834 let leaf = content_children(&el)
2835 .iter()
2836 .find(|c| matches!(c.kind, Kind::Text))
2837 .cloned()
2838 .expect("value leaf");
2839 assert!(
2840 leaf.text_tabular_numerals,
2841 "value leaf must render with tabular numerals"
2842 );
2843
2844 let caret = content_children(&el)
2845 .iter()
2846 .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
2847 .cloned()
2848 .expect("caret child");
2849 let tabular_prefix = crate::text::metrics::layout_text_with_family(
2850 "111",
2851 tokens::TEXT_SM.size,
2852 crate::tree::FontFamily::default(),
2853 FontWeight::Regular,
2854 false,
2855 true,
2856 TextWrap::NoWrap,
2857 None,
2858 )
2859 .width;
2860 assert!(
2861 (caret.translate.0 - tabular_prefix).abs() < 0.01,
2862 "caret.x={} expected tabular prefix width {}",
2863 caret.translate.0,
2864 tabular_prefix
2865 );
2866
2867 let proportional_prefix =
2870 metrics::line_width("111", tokens::TEXT_SM.size, FontWeight::Regular, false);
2871 assert!(
2872 (tabular_prefix - proportional_prefix).abs() > 0.5,
2873 "tabular ({tabular_prefix}) and proportional ({proportional_prefix}) \
2874 '111' widths must differ for this test to have teeth"
2875 );
2876 }
2877
2878 #[test]
2879 fn text_input_omits_caret_when_selection_lives_elsewhere() {
2880 let sel = Selection {
2887 range: Some(SelectionRange {
2888 anchor: SelectionPoint::new("other", 0),
2889 head: SelectionPoint::new("other", 5),
2890 }),
2891 };
2892 let el = super::text_input("name", "hello", &sel);
2893 let band = el
2894 .children
2895 .iter()
2896 .find(|c| matches!(c.kind, Kind::Custom("text_input_selection")));
2897 assert!(band.is_none(), "no band when selection lives elsewhere");
2898 let caret = el
2899 .children
2900 .iter()
2901 .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")));
2902 assert!(
2903 caret.is_none(),
2904 "no caret when selection lives elsewhere — focus-fade has nothing to bring back to byte 0"
2905 );
2906 }
2907
2908 fn ctrl_mods() -> KeyModifiers {
2909 KeyModifiers {
2910 ctrl: true,
2911 ..Default::default()
2912 }
2913 }
2914
2915 fn ctrl_shift_mods() -> KeyModifiers {
2916 KeyModifiers {
2917 ctrl: true,
2918 shift: true,
2919 ..Default::default()
2920 }
2921 }
2922
2923 #[test]
2924 fn ctrl_backspace_deletes_previous_word() {
2925 let mut value = String::from("hello world foo");
2926 let mut sel = TextSelection::caret(value.len());
2927 assert!(apply_event(
2928 &mut value,
2929 &mut sel,
2930 &ev_key_with_mods(LogicalKey::Named(NamedKey::Backspace), ctrl_mods())
2931 ));
2932 assert_eq!(value, "hello world ");
2933 assert_eq!(sel, TextSelection::caret(value.len()));
2934 }
2935
2936 #[test]
2937 fn ctrl_backspace_at_caret_zero_is_noop() {
2938 let mut value = String::from("hello");
2939 let mut sel = TextSelection::caret(0);
2940 assert!(!apply_event(
2941 &mut value,
2942 &mut sel,
2943 &ev_key_with_mods(LogicalKey::Named(NamedKey::Backspace), ctrl_mods())
2944 ));
2945 assert_eq!(value, "hello");
2946 }
2947
2948 #[test]
2949 fn ctrl_w_deletes_previous_word_like_terminal() {
2950 let mut value = String::from("alpha beta gamma");
2951 let mut sel = TextSelection::caret(value.len());
2952 assert!(apply_event(
2953 &mut value,
2954 &mut sel,
2955 &ev_key_with_mods(LogicalKey::Character("w".into()), ctrl_mods())
2956 ));
2957 assert_eq!(value, "alpha beta ");
2958 }
2959
2960 #[test]
2961 fn ctrl_delete_deletes_next_word() {
2962 let mut value = String::from("alpha beta gamma");
2963 let mut sel = TextSelection::caret(0);
2964 assert!(apply_event(
2965 &mut value,
2966 &mut sel,
2967 &ev_key_with_mods(LogicalKey::Named(NamedKey::Delete), ctrl_mods())
2968 ));
2969 assert_eq!(value, " beta gamma");
2970 assert_eq!(sel, TextSelection::caret(0));
2971 }
2972
2973 #[test]
2974 fn ctrl_arrow_left_jumps_word_backward() {
2975 let mut value = String::from("alpha beta gamma");
2976 let mut sel = TextSelection::caret(value.len());
2977 assert!(apply_event(
2978 &mut value,
2979 &mut sel,
2980 &ev_key_with_mods(LogicalKey::Named(NamedKey::ArrowLeft), ctrl_mods())
2981 ));
2982 assert_eq!(sel, TextSelection::caret(11));
2984 }
2985
2986 #[test]
2987 fn ctrl_arrow_right_jumps_word_forward() {
2988 let mut value = String::from("alpha beta gamma");
2989 let mut sel = TextSelection::caret(0);
2990 assert!(apply_event(
2991 &mut value,
2992 &mut sel,
2993 &ev_key_with_mods(LogicalKey::Named(NamedKey::ArrowRight), ctrl_mods())
2994 ));
2995 assert_eq!(sel, TextSelection::caret(5));
2997 }
2998
2999 #[test]
3000 fn ctrl_shift_arrow_extends_selection_by_word() {
3001 let mut value = String::from("alpha beta gamma");
3002 let mut sel = TextSelection::caret(0);
3003 assert!(apply_event(
3004 &mut value,
3005 &mut sel,
3006 &ev_key_with_mods(LogicalKey::Named(NamedKey::ArrowRight), ctrl_shift_mods())
3007 ));
3008 assert_eq!(sel, TextSelection::range(0, 5));
3009 assert!(apply_event(
3010 &mut value,
3011 &mut sel,
3012 &ev_key_with_mods(LogicalKey::Named(NamedKey::ArrowRight), ctrl_shift_mods())
3013 ));
3014 assert_eq!(sel, TextSelection::range(0, 10));
3015 }
3016}