1use std::borrow::Cow;
45use std::panic::Location;
46
47use crate::cursor::Cursor;
48use crate::event::{UiEvent, UiEventKind, UiKey};
49use crate::metrics::MetricsRole;
50use crate::selection::{Selection, SelectionPoint, SelectionRange};
51use crate::style::StyleProfile;
52use crate::text::metrics::TextGeometry;
53use crate::tokens;
54use crate::tree::*;
55use crate::widgets::text::text;
56
57#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
66pub struct TextSelection {
67 pub anchor: usize,
68 pub head: usize,
69}
70
71#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
82pub enum MaskMode {
83 #[default]
84 None,
85 Password,
86}
87
88const MASK_CHAR: char = '•';
89
90#[derive(Clone, Copy, Debug, Default)]
102pub struct TextInputOpts<'a> {
103 pub placeholder: Option<&'a str>,
106 pub max_length: Option<usize>,
111 pub mask: MaskMode,
113}
114
115impl<'a> TextInputOpts<'a> {
116 pub fn placeholder(mut self, p: &'a str) -> Self {
117 self.placeholder = Some(p);
118 self
119 }
120
121 pub fn max_length(mut self, n: usize) -> Self {
122 self.max_length = Some(n);
123 self
124 }
125
126 pub fn password(mut self) -> Self {
127 self.mask = MaskMode::Password;
128 self
129 }
130
131 fn is_masked(&self) -> bool {
132 !matches!(self.mask, MaskMode::None)
133 }
134}
135
136impl TextSelection {
137 pub const fn caret(head: usize) -> Self {
139 Self { anchor: head, head }
140 }
141
142 pub const fn range(anchor: usize, head: usize) -> Self {
145 Self { anchor, head }
146 }
147
148 pub fn ordered(self) -> (usize, usize) {
150 (self.anchor.min(self.head), self.anchor.max(self.head))
151 }
152
153 pub fn is_collapsed(self) -> bool {
155 self.anchor == self.head
156 }
157}
158
159#[track_caller]
191pub fn text_input(value: &str, selection: &Selection, key: &str) -> El {
192 text_input_with(value, selection, key, TextInputOpts::default())
193}
194
195#[track_caller]
200pub fn text_input_with(
201 value: &str,
202 selection: &Selection,
203 key: &str,
204 opts: TextInputOpts<'_>,
205) -> El {
206 build_text_input(value, selection.within(key), opts).key(key)
207}
208
209#[track_caller]
219fn build_text_input(value: &str, view: Option<TextSelection>, opts: TextInputOpts<'_>) -> El {
220 let selection = view.unwrap_or_default();
221 let head = clamp_to_char_boundary(value, selection.head.min(value.len()));
222 let anchor = clamp_to_char_boundary(value, selection.anchor.min(value.len()));
223 let lo = anchor.min(head);
224 let hi = anchor.max(head);
225 let line_h = line_height_px();
226
227 let display = display_str(value, opts.mask);
232
233 let geometry = single_line_geometry(&display);
237 let to_display = |b: usize| original_to_display_byte(value, b, opts.mask);
238 let head_px = geometry.prefix_width(to_display(head));
239 let lo_px = geometry.prefix_width(to_display(lo));
240 let hi_px = geometry.prefix_width(to_display(hi));
241
242 let mut children: Vec<El> = Vec::with_capacity(4);
243
244 if lo < hi {
249 children.push(
250 El::new(Kind::Custom("text_input_selection"))
251 .style_profile(StyleProfile::Solid)
252 .fill(tokens::SELECTION_BG)
253 .dim_fill(tokens::SELECTION_BG_UNFOCUSED)
254 .radius(2.0)
255 .width(Size::Fixed(hi_px - lo_px))
256 .height(Size::Fixed(line_h))
257 .translate(lo_px, 0.0),
258 );
259 }
260
261 if value.is_empty()
265 && let Some(ph) = opts.placeholder
266 {
267 children.push(
268 text(ph)
269 .muted()
270 .width(Size::Hug)
271 .height(Size::Fixed(line_h)),
272 );
273 }
274
275 children.push(
278 text(display.into_owned())
279 .width(Size::Hug)
280 .height(Size::Fixed(line_h)),
281 );
282
283 if view.is_some() {
293 children.push(
294 caret_bar()
295 .translate(head_px, 0.0)
296 .alpha_follows_focused_ancestor()
297 .blink_when_focused(),
298 );
299 }
300
301 let inner = El::new(Kind::Group)
311 .clip()
312 .width(Size::Fill(1.0))
313 .height(Size::Fill(1.0))
314 .layout(move |ctx| {
315 let x_offset = (head_px - ctx.container.w).max(0.0);
323 ctx.children
324 .iter()
325 .map(|c| {
326 let (w, h) = (ctx.measure)(c);
327 let w = match c.width {
331 Size::Fixed(v) => v,
332 Size::Hug => w,
333 Size::Fill(_) => ctx.container.w,
334 };
335 let h = match c.height {
336 Size::Fixed(v) => v,
337 Size::Hug => h,
338 Size::Fill(_) => ctx.container.h,
339 };
340 let y = ctx.container.y + (ctx.container.h - h) * 0.5;
345 Rect::new(ctx.container.x - x_offset, y, w, h)
346 })
347 .collect()
348 })
349 .children(children);
350
351 El::new(Kind::Custom("text_input"))
352 .at_loc(Location::caller())
353 .style_profile(StyleProfile::Surface)
354 .metrics_role(MetricsRole::Input)
355 .surface_role(SurfaceRole::Input)
356 .focusable()
357 .always_show_focus_ring()
360 .capture_keys()
361 .paint_overflow(Sides::all(tokens::RING_WIDTH))
362 .cursor(Cursor::Text)
363 .fill(tokens::MUTED)
364 .stroke(tokens::BORDER)
365 .default_radius(tokens::RADIUS_MD)
366 .axis(Axis::Overlay)
367 .align(Align::Start)
368 .justify(Justify::Center)
369 .default_width(Size::Fill(1.0))
370 .default_height(Size::Fixed(tokens::CONTROL_HEIGHT))
371 .default_padding(Sides::xy(tokens::SPACE_3, 0.0))
372 .child(inner)
373}
374
375fn caret_bar() -> El {
376 El::new(Kind::Custom("text_input_caret"))
377 .style_profile(StyleProfile::Solid)
378 .fill(tokens::FOREGROUND)
379 .width(Size::Fixed(2.0))
380 .height(Size::Fixed(line_height_px()))
381 .radius(1.0)
382}
383
384fn line_height_px() -> f32 {
385 tokens::TEXT_SM.line_height
386}
387
388fn single_line_geometry(value: &str) -> TextGeometry<'_> {
389 TextGeometry::new(
390 value,
391 tokens::TEXT_SM.size,
392 FontWeight::Regular,
393 false,
394 TextWrap::NoWrap,
395 None,
396 )
397}
398
399pub fn apply_event(
426 value: &mut String,
427 selection: &mut Selection,
428 key: &str,
429 event: &UiEvent,
430) -> bool {
431 apply_event_with(value, selection, key, event, &TextInputOpts::default())
432}
433
434pub fn apply_event_with(
438 value: &mut String,
439 selection: &mut Selection,
440 key: &str,
441 event: &UiEvent,
442 opts: &TextInputOpts<'_>,
443) -> bool {
444 let mut local = selection.within(key).unwrap_or_default();
445 let changed = fold_event_local(value, &mut local, event, opts);
446 if changed {
447 selection.range = Some(SelectionRange {
448 anchor: SelectionPoint::new(key, local.anchor),
449 head: SelectionPoint::new(key, local.head),
450 });
451 }
452 changed
453}
454
455fn fold_event_local(
459 value: &mut String,
460 selection: &mut TextSelection,
461 event: &UiEvent,
462 opts: &TextInputOpts<'_>,
463) -> bool {
464 selection.anchor = clamp_to_char_boundary(value, selection.anchor.min(value.len()));
465 selection.head = clamp_to_char_boundary(value, selection.head.min(value.len()));
466 match event.kind {
467 UiEventKind::TextInput => {
468 let Some(insert) = event.text.as_deref() else {
469 return false;
470 };
471 if (event.modifiers.ctrl && !event.modifiers.alt) || event.modifiers.logo {
487 return false;
488 }
489 let filtered: String = insert.chars().filter(|c| !c.is_control()).collect();
490 if filtered.is_empty() {
491 return false;
492 }
493 let to_insert = clip_to_max_length(value, *selection, &filtered, opts.max_length);
494 if to_insert.is_empty() {
495 return false;
496 }
497 replace_selection(value, selection, &to_insert);
498 true
499 }
500 UiEventKind::KeyDown => {
501 let Some(kp) = event.key_press.as_ref() else {
502 return false;
503 };
504 let mods = kp.modifiers;
505 if mods.ctrl
509 && !mods.alt
510 && !mods.logo
511 && let UiKey::Character(c) = &kp.key
512 && c.eq_ignore_ascii_case("a")
513 {
514 let len = value.len();
515 if selection.anchor == 0 && selection.head == len {
516 return false;
517 }
518 *selection = TextSelection {
519 anchor: 0,
520 head: len,
521 };
522 return true;
523 }
524 match kp.key {
525 UiKey::Backspace => {
526 if !selection.is_collapsed() {
527 replace_selection(value, selection, "");
528 return true;
529 }
530 if selection.head == 0 {
531 return false;
532 }
533 let prev = prev_char_boundary(value, selection.head);
534 value.replace_range(prev..selection.head, "");
535 selection.head = prev;
536 selection.anchor = prev;
537 true
538 }
539 UiKey::Delete => {
540 if !selection.is_collapsed() {
541 replace_selection(value, selection, "");
542 return true;
543 }
544 if selection.head >= value.len() {
545 return false;
546 }
547 let next = next_char_boundary(value, selection.head);
548 value.replace_range(selection.head..next, "");
549 true
550 }
551 UiKey::ArrowLeft => {
552 let target = if selection.is_collapsed() || mods.shift {
553 if selection.head == 0 {
554 return false;
555 }
556 prev_char_boundary(value, selection.head)
557 } else {
558 selection.ordered().0
560 };
561 selection.head = target;
562 if !mods.shift {
563 selection.anchor = target;
564 }
565 true
566 }
567 UiKey::ArrowRight => {
568 let target = if selection.is_collapsed() || mods.shift {
569 if selection.head >= value.len() {
570 return false;
571 }
572 next_char_boundary(value, selection.head)
573 } else {
574 selection.ordered().1
576 };
577 selection.head = target;
578 if !mods.shift {
579 selection.anchor = target;
580 }
581 true
582 }
583 UiKey::Home => {
584 if selection.head == 0 && (mods.shift || selection.anchor == 0) {
585 return false;
586 }
587 selection.head = 0;
588 if !mods.shift {
589 selection.anchor = 0;
590 }
591 true
592 }
593 UiKey::End => {
594 let end = value.len();
595 if selection.head == end && (mods.shift || selection.anchor == end) {
596 return false;
597 }
598 selection.head = end;
599 if !mods.shift {
600 selection.anchor = end;
601 }
602 true
603 }
604 _ => false,
605 }
606 }
607 UiEventKind::PointerDown => {
608 let (Some((px, _py)), Some(target)) = (event.pointer, event.target.as_ref()) else {
609 return false;
610 };
611 let viewport_w = (target.rect.w - 2.0 * tokens::SPACE_3).max(0.0);
617 let x_offset = current_x_offset(value, selection.head, viewport_w, opts.mask);
618 let local_x = px - target.rect.x - tokens::SPACE_3 + x_offset;
619 let pos = caret_from_x(value, local_x, opts.mask);
620 if !event.modifiers.shift {
627 match event.click_count {
628 2 => {
629 let (lo, hi) = crate::selection::word_range_at(value, pos);
630 selection.anchor = lo;
631 selection.head = hi;
632 return true;
633 }
634 n if n >= 3 => {
635 selection.anchor = 0;
636 selection.head = value.len();
637 return true;
638 }
639 _ => {}
640 }
641 }
642 selection.head = pos;
643 if !event.modifiers.shift {
644 selection.anchor = pos;
645 }
646 true
647 }
648 UiEventKind::Drag => {
649 let (Some((px, _py)), Some(target)) = (event.pointer, event.target.as_ref()) else {
650 return false;
651 };
652 let viewport_w = (target.rect.w - 2.0 * tokens::SPACE_3).max(0.0);
657 let x_offset = current_x_offset(value, selection.head, viewport_w, opts.mask);
658 let local_x = px - target.rect.x - tokens::SPACE_3 + x_offset;
659 selection.head = caret_from_x(value, local_x, opts.mask);
660 true
661 }
662 UiEventKind::Click => false,
663 _ => false,
664 }
665}
666
667pub fn selected_text(value: &str, selection: TextSelection) -> &str {
670 let head = clamp_to_char_boundary(value, selection.head.min(value.len()));
671 let anchor = clamp_to_char_boundary(value, selection.anchor.min(value.len()));
672 &value[anchor.min(head)..anchor.max(head)]
673}
674
675pub fn replace_selection(value: &mut String, selection: &mut TextSelection, replacement: &str) {
679 selection.anchor = clamp_to_char_boundary(value, selection.anchor.min(value.len()));
680 selection.head = clamp_to_char_boundary(value, selection.head.min(value.len()));
681 let (lo, hi) = selection.ordered();
682 value.replace_range(lo..hi, replacement);
683 let new_caret = lo + replacement.len();
684 selection.anchor = new_caret;
685 selection.head = new_caret;
686}
687
688pub fn replace_selection_with(
695 value: &mut String,
696 selection: &mut TextSelection,
697 replacement: &str,
698 opts: &TextInputOpts<'_>,
699) -> usize {
700 let clipped = clip_to_max_length(value, *selection, replacement, opts.max_length);
701 let len = clipped.len();
702 replace_selection(value, selection, &clipped);
703 len
704}
705
706pub fn select_all(value: &str) -> TextSelection {
708 TextSelection {
709 anchor: 0,
710 head: value.len(),
711 }
712}
713
714#[derive(Clone, Copy, Debug, PartialEq, Eq)]
720pub enum ClipboardKind {
721 Copy,
723 Cut,
725 Paste,
727}
728
729pub fn clipboard_request(event: &UiEvent) -> Option<ClipboardKind> {
775 clipboard_request_for(event, &TextInputOpts::default())
776}
777
778pub fn clipboard_request_for(event: &UiEvent, opts: &TextInputOpts<'_>) -> Option<ClipboardKind> {
782 if event.kind != UiEventKind::KeyDown {
783 return None;
784 }
785 let kp = event.key_press.as_ref()?;
786 let mods = kp.modifiers;
787 if mods.alt || mods.shift {
790 return None;
791 }
792 if !(mods.ctrl || mods.logo) {
794 return None;
795 }
796 let UiKey::Character(c) = &kp.key else {
797 return None;
798 };
799 let kind = match c.to_ascii_lowercase().as_str() {
800 "c" => ClipboardKind::Copy,
801 "x" => ClipboardKind::Cut,
802 "v" => ClipboardKind::Paste,
803 _ => return None,
804 };
805 if opts.is_masked() && matches!(kind, ClipboardKind::Copy | ClipboardKind::Cut) {
806 return None;
807 }
808 Some(kind)
809}
810
811#[track_caller]
821pub fn caret_byte_at(value: &str, event: &UiEvent, opts: &TextInputOpts<'_>) -> Option<usize> {
822 let (px, _py) = event.pointer?;
823 let target = event.target.as_ref()?;
824 let local_x = px - target.rect.x - tokens::SPACE_3;
825 Some(caret_from_x(value, local_x, opts.mask))
826}
827
828fn current_x_offset(value: &str, head: usize, viewport_w: f32, mask: MaskMode) -> f32 {
840 if viewport_w <= 0.0 {
841 return 0.0;
842 }
843 let head = clamp_to_char_boundary(value, head.min(value.len()));
844 let display = display_str(value, mask);
845 let geometry = single_line_geometry(&display);
846 let head_display = original_to_display_byte(value, head, mask);
847 let head_px = geometry.prefix_width(head_display);
848 (head_px - viewport_w).max(0.0)
849}
850
851fn caret_from_x(value: &str, local_x: f32, mask: MaskMode) -> usize {
852 if value.is_empty() || local_x <= 0.0 {
853 return 0;
854 }
855 let probe = display_str(value, mask);
856 let local_y = line_height_px() * 0.5;
857 let geometry = single_line_geometry(&probe);
858 let display_byte = match geometry.hit_byte(local_x, local_y) {
859 Some(byte) => byte.min(probe.len()),
860 None => probe.len(),
861 };
862 display_to_original_byte(value, display_byte, mask)
863}
864
865fn display_str(value: &str, mask: MaskMode) -> Cow<'_, str> {
870 match mask {
871 MaskMode::None => Cow::Borrowed(value),
872 MaskMode::Password => {
873 let n = value.chars().count();
874 let mut s = String::with_capacity(n * MASK_CHAR.len_utf8());
875 for _ in 0..n {
876 s.push(MASK_CHAR);
877 }
878 Cow::Owned(s)
879 }
880 }
881}
882
883fn original_to_display_byte(value: &str, byte_index: usize, mask: MaskMode) -> usize {
884 match mask {
885 MaskMode::None => byte_index.min(value.len()),
886 MaskMode::Password => {
887 let clamped = clamp_to_char_boundary(value, byte_index.min(value.len()));
888 value[..clamped].chars().count() * MASK_CHAR.len_utf8()
889 }
890 }
891}
892
893fn display_to_original_byte(value: &str, display_byte: usize, mask: MaskMode) -> usize {
895 match mask {
896 MaskMode::None => clamp_to_char_boundary(value, display_byte.min(value.len())),
897 MaskMode::Password => {
898 let scalar_idx = display_byte / MASK_CHAR.len_utf8();
899 value
900 .char_indices()
901 .nth(scalar_idx)
902 .map(|(i, _)| i)
903 .unwrap_or(value.len())
904 }
905 }
906}
907
908fn clip_to_max_length<'a>(
916 value: &str,
917 selection: TextSelection,
918 replacement: &'a str,
919 max_length: Option<usize>,
920) -> Cow<'a, str> {
921 let Some(max) = max_length else {
922 return Cow::Borrowed(replacement);
923 };
924 let lo = clamp_to_char_boundary(value, selection.anchor.min(selection.head).min(value.len()));
925 let hi = clamp_to_char_boundary(value, selection.anchor.max(selection.head).min(value.len()));
926 let post_other = value[..lo].chars().count() + value[hi..].chars().count();
927 let allowed = max.saturating_sub(post_other);
928 if replacement.chars().count() <= allowed {
929 Cow::Borrowed(replacement)
930 } else {
931 Cow::Owned(replacement.chars().take(allowed).collect())
932 }
933}
934
935fn clamp_to_char_boundary(s: &str, idx: usize) -> usize {
936 let mut idx = idx.min(s.len());
937 while idx > 0 && !s.is_char_boundary(idx) {
938 idx -= 1;
939 }
940 idx
941}
942
943fn prev_char_boundary(s: &str, from: usize) -> usize {
944 let mut i = from.saturating_sub(1);
945 while i > 0 && !s.is_char_boundary(i) {
946 i -= 1;
947 }
948 i
949}
950
951fn next_char_boundary(s: &str, from: usize) -> usize {
952 let mut i = (from + 1).min(s.len());
953 while i < s.len() && !s.is_char_boundary(i) {
954 i += 1;
955 }
956 i
957}
958
959#[cfg(test)]
960mod tests {
961 use super::*;
962 use crate::event::{KeyModifiers, KeyPress, PointerButton, UiTarget};
963 use crate::layout::layout;
964 use crate::runtime::RunnerCore;
965 use crate::state::UiState;
966 use crate::text::metrics;
967
968 const TEST_KEY: &str = "ti";
973
974 #[track_caller]
979 fn text_input(value: &str, sel: TextSelection) -> El {
980 super::text_input(value, &as_selection(sel), TEST_KEY)
981 }
982
983 #[track_caller]
984 fn text_input_with(value: &str, sel: TextSelection, opts: TextInputOpts<'_>) -> El {
985 super::text_input_with(value, &as_selection(sel), TEST_KEY, opts)
986 }
987
988 fn apply_event(value: &mut String, sel: &mut TextSelection, event: &UiEvent) -> bool {
989 let mut g = as_selection(*sel);
990 let changed = super::apply_event(value, &mut g, TEST_KEY, event);
991 sync_back(sel, &g);
992 changed
993 }
994
995 fn apply_event_with(
996 value: &mut String,
997 sel: &mut TextSelection,
998 event: &UiEvent,
999 opts: &TextInputOpts<'_>,
1000 ) -> bool {
1001 let mut g = as_selection(*sel);
1002 let changed = super::apply_event_with(value, &mut g, TEST_KEY, event, opts);
1003 sync_back(sel, &g);
1004 changed
1005 }
1006
1007 fn as_selection(sel: TextSelection) -> Selection {
1008 Selection {
1009 range: Some(SelectionRange {
1010 anchor: SelectionPoint::new(TEST_KEY, sel.anchor),
1011 head: SelectionPoint::new(TEST_KEY, sel.head),
1012 }),
1013 }
1014 }
1015
1016 fn sync_back(local: &mut TextSelection, global: &Selection) {
1017 match global.within(TEST_KEY) {
1018 Some(view) => *local = view,
1019 None => *local = TextSelection::default(),
1020 }
1021 }
1022
1023 fn ev_text(s: &str) -> UiEvent {
1024 ev_text_with_mods(s, KeyModifiers::default())
1025 }
1026
1027 fn ev_text_with_mods(s: &str, modifiers: KeyModifiers) -> UiEvent {
1028 UiEvent {
1029 path: None,
1030 key: None,
1031 target: None,
1032 pointer: None,
1033 key_press: None,
1034 text: Some(s.into()),
1035 selection: None,
1036 modifiers,
1037 click_count: 0,
1038 kind: UiEventKind::TextInput,
1039 }
1040 }
1041
1042 fn ev_key(key: UiKey) -> UiEvent {
1043 ev_key_with_mods(key, KeyModifiers::default())
1044 }
1045
1046 fn ev_key_with_mods(key: UiKey, modifiers: KeyModifiers) -> UiEvent {
1047 UiEvent {
1048 path: None,
1049 key: None,
1050 target: None,
1051 pointer: None,
1052 key_press: Some(KeyPress {
1053 key,
1054 modifiers,
1055 repeat: false,
1056 }),
1057 text: None,
1058 selection: None,
1059 modifiers,
1060 click_count: 0,
1061 kind: UiEventKind::KeyDown,
1062 }
1063 }
1064
1065 fn ev_pointer_down(target: UiTarget, pointer: (f32, f32), modifiers: KeyModifiers) -> UiEvent {
1066 ev_pointer_down_with_count(target, pointer, modifiers, 1)
1067 }
1068
1069 fn ev_pointer_down_with_count(
1070 target: UiTarget,
1071 pointer: (f32, f32),
1072 modifiers: KeyModifiers,
1073 click_count: u8,
1074 ) -> UiEvent {
1075 UiEvent {
1076 path: None,
1077 key: Some(target.key.clone()),
1078 target: Some(target),
1079 pointer: Some(pointer),
1080 key_press: None,
1081 text: None,
1082 selection: None,
1083 modifiers,
1084 click_count,
1085 kind: UiEventKind::PointerDown,
1086 }
1087 }
1088
1089 fn ev_drag(target: UiTarget, pointer: (f32, f32)) -> UiEvent {
1090 UiEvent {
1091 path: None,
1092 key: Some(target.key.clone()),
1093 target: Some(target),
1094 pointer: Some(pointer),
1095 key_press: None,
1096 text: None,
1097 selection: None,
1098 modifiers: KeyModifiers::default(),
1099 click_count: 0,
1100 kind: UiEventKind::Drag,
1101 }
1102 }
1103
1104 fn ti_target() -> UiTarget {
1105 UiTarget {
1106 key: "ti".into(),
1107 node_id: "root.text_input[ti]".into(),
1108 rect: Rect::new(20.0, 20.0, 400.0, 36.0),
1109 tooltip: None,
1110 scroll_offset_y: 0.0,
1111 }
1112 }
1113
1114 fn content_children(el: &El) -> &[El] {
1122 assert_eq!(
1123 el.children.len(),
1124 1,
1125 "text_input wraps its content in a single inner group"
1126 );
1127 &el.children[0].children
1128 }
1129
1130 #[test]
1131 fn text_input_collapsed_renders_value_as_single_text_leaf_plus_caret() {
1132 let el = text_input("hello", TextSelection::caret(2));
1133 assert!(matches!(el.kind, Kind::Custom("text_input")));
1134 assert!(el.focusable);
1135 assert!(el.capture_keys);
1136 let cs = content_children(&el);
1140 assert_eq!(cs.len(), 2);
1141 assert!(matches!(cs[0].kind, Kind::Text));
1142 assert_eq!(cs[0].text.as_deref(), Some("hello"));
1143 assert!(matches!(cs[1].kind, Kind::Custom("text_input_caret")));
1144 assert!(cs[1].alpha_follows_focused_ancestor);
1145 }
1146
1147 #[test]
1148 fn text_input_declares_text_cursor() {
1149 let el = text_input("hello", TextSelection::caret(0));
1150 assert_eq!(el.cursor, Some(Cursor::Text));
1151 }
1152
1153 #[test]
1154 fn text_input_with_selection_inserts_selection_band_first() {
1155 let el = text_input("hello", TextSelection::range(2, 4));
1157 let cs = content_children(&el);
1158 assert_eq!(cs.len(), 3);
1160 assert!(matches!(cs[0].kind, Kind::Custom("text_input_selection")));
1161 assert_eq!(cs[1].text.as_deref(), Some("hello"));
1162 assert!(matches!(cs[2].kind, Kind::Custom("text_input_caret")));
1163 }
1164
1165 #[test]
1166 fn text_input_caret_translate_advances_with_head() {
1167 use crate::text::metrics::line_width;
1171 let value = "hello";
1172 let head = 3;
1173 let el = text_input(value, TextSelection::caret(head));
1174 let caret = content_children(&el)
1175 .iter()
1176 .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
1177 .expect("caret child");
1178 let expected = line_width(
1179 &value[..head],
1180 tokens::TEXT_SM.size,
1181 FontWeight::Regular,
1182 false,
1183 );
1184 assert!(
1185 (caret.translate.0 - expected).abs() < 0.01,
1186 "caret translate.x = {}, expected {}",
1187 caret.translate.0,
1188 expected
1189 );
1190 }
1191
1192 #[test]
1193 fn text_input_clamps_off_utf8_boundary() {
1194 let el = text_input("é", TextSelection::caret(1));
1198 let cs = content_children(&el);
1199 assert_eq!(cs[0].text.as_deref(), Some("é"));
1200 let caret = cs
1201 .iter()
1202 .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
1203 .expect("caret child");
1204 assert!(caret.translate.0.abs() < 0.01);
1206 }
1207
1208 #[test]
1209 fn selection_band_fill_dims_when_input_unfocused() {
1210 use crate::draw_ops::draw_ops;
1214 use crate::ir::DrawOp;
1215 use crate::shader::UniformValue;
1216 use crate::state::AnimationMode;
1217 use web_time::Instant;
1218
1219 let mut tree = crate::column([text_input("hello", TextSelection::range(0, 5)).key("ti")])
1220 .padding(20.0);
1221 let mut state = UiState::new();
1222 state.set_animation_mode(AnimationMode::Settled);
1223 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1224 state.sync_focus_order(&tree);
1225
1226 state.apply_to_state();
1230 state.tick_visual_animations(&mut tree, Instant::now());
1231 let unfocused = band_fill(&tree, &state).expect("band quad emitted");
1232 assert_eq!(
1233 (unfocused.r, unfocused.g, unfocused.b),
1234 (
1235 tokens::SELECTION_BG_UNFOCUSED.r,
1236 tokens::SELECTION_BG_UNFOCUSED.g,
1237 tokens::SELECTION_BG_UNFOCUSED.b
1238 ),
1239 "unfocused → band rgb is the muted token"
1240 );
1241
1242 let target = state
1245 .focus
1246 .order
1247 .iter()
1248 .find(|t| t.key == "ti")
1249 .expect("ti in focus order")
1250 .clone();
1251 state.set_focus(Some(target));
1252 state.apply_to_state();
1253 state.tick_visual_animations(&mut tree, Instant::now());
1254 let focused = band_fill(&tree, &state).expect("band quad emitted");
1255 assert_eq!(
1256 (focused.r, focused.g, focused.b),
1257 (
1258 tokens::SELECTION_BG.r,
1259 tokens::SELECTION_BG.g,
1260 tokens::SELECTION_BG.b
1261 ),
1262 "focused → band rgb is the saturated token"
1263 );
1264
1265 fn band_fill(tree: &El, state: &UiState) -> Option<crate::tree::Color> {
1266 let ops = draw_ops(tree, state);
1267 for op in ops {
1268 if let DrawOp::Quad { id, uniforms, .. } = op
1269 && id.contains("text_input_selection")
1270 && let Some(UniformValue::Color(c)) = uniforms.get("fill")
1271 {
1272 return Some(*c);
1273 }
1274 }
1275 None
1276 }
1277 }
1278
1279 #[test]
1280 fn caret_alpha_follows_focus_envelope() {
1281 use crate::draw_ops::draw_ops;
1286 use crate::ir::DrawOp;
1287 use crate::shader::UniformValue;
1288 use crate::state::AnimationMode;
1289 use web_time::Instant;
1290
1291 let mut tree =
1292 crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
1293 let mut state = UiState::new();
1294 state.set_animation_mode(AnimationMode::Settled);
1295 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1296 state.sync_focus_order(&tree);
1297
1298 state.apply_to_state();
1300 state.tick_visual_animations(&mut tree, Instant::now());
1301 let caret_alpha = caret_fill_alpha(&tree, &state);
1302 assert_eq!(caret_alpha, Some(0), "unfocused → caret invisible");
1303
1304 let target = state
1306 .focus
1307 .order
1308 .iter()
1309 .find(|t| t.key == "ti")
1310 .expect("ti in focus order")
1311 .clone();
1312 state.set_focus(Some(target));
1313 state.apply_to_state();
1314 state.tick_visual_animations(&mut tree, Instant::now());
1315 let caret_alpha = caret_fill_alpha(&tree, &state);
1316 assert_eq!(
1317 caret_alpha,
1318 Some(255),
1319 "focused → caret fully visible (alpha=255)"
1320 );
1321
1322 fn caret_fill_alpha(tree: &El, state: &UiState) -> Option<u8> {
1323 let ops = draw_ops(tree, state);
1324 for op in ops {
1325 if let DrawOp::Quad { id, uniforms, .. } = op
1326 && id.contains("text_input_caret")
1327 && let Some(UniformValue::Color(c)) = uniforms.get("fill")
1328 {
1329 return Some(c.a);
1330 }
1331 }
1332 None
1333 }
1334 }
1335
1336 #[test]
1337 fn caret_blink_alpha_holds_solid_through_grace_then_cycles() {
1338 use crate::state::caret_blink_alpha_for;
1341 use std::time::Duration;
1342 assert_eq!(caret_blink_alpha_for(Duration::from_millis(0)), 1.0);
1344 assert_eq!(caret_blink_alpha_for(Duration::from_millis(499)), 1.0);
1345 assert_eq!(caret_blink_alpha_for(Duration::from_millis(500)), 1.0);
1347 assert_eq!(caret_blink_alpha_for(Duration::from_millis(1029)), 1.0);
1348 assert_eq!(caret_blink_alpha_for(Duration::from_millis(1030)), 0.0);
1350 assert_eq!(caret_blink_alpha_for(Duration::from_millis(1559)), 0.0);
1351 assert_eq!(caret_blink_alpha_for(Duration::from_millis(1560)), 1.0);
1353 }
1354
1355 #[test]
1356 fn caret_paint_alpha_blinks_after_focus_in_live_mode() {
1357 use crate::draw_ops::draw_ops;
1361 use crate::ir::DrawOp;
1362 use crate::shader::UniformValue;
1363 use crate::state::AnimationMode;
1364 use std::time::Duration;
1365
1366 let mut tree =
1367 crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
1368 let mut state = UiState::new();
1369 state.set_animation_mode(AnimationMode::Live);
1370 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1371 state.sync_focus_order(&tree);
1372
1373 let target = state
1375 .focus
1376 .order
1377 .iter()
1378 .find(|t| t.key == "ti")
1379 .unwrap()
1380 .clone();
1381 state.set_focus(Some(target));
1382 let activity_at = state.caret.activity_at.expect("set_focus bumps activity");
1383 let input_id = tree.children[0].computed_id.clone();
1384
1385 let pin_focus = |state: &mut UiState| {
1389 state.animation.envelopes.insert(
1390 (input_id.clone(), crate::state::EnvelopeKind::FocusRing),
1391 1.0,
1392 );
1393 };
1394
1395 state.tick_visual_animations(&mut tree, activity_at);
1397 pin_focus(&mut state);
1398 assert_eq!(caret_alpha(&tree, &state), Some(255));
1399
1400 state.tick_visual_animations(&mut tree, activity_at + Duration::from_millis(1100));
1402 pin_focus(&mut state);
1403 assert_eq!(caret_alpha(&tree, &state), Some(0));
1404
1405 state.tick_visual_animations(&mut tree, activity_at + Duration::from_millis(1600));
1407 pin_focus(&mut state);
1408 assert_eq!(caret_alpha(&tree, &state), Some(255));
1409
1410 fn caret_alpha(tree: &El, state: &UiState) -> Option<u8> {
1411 for op in draw_ops(tree, state) {
1412 if let DrawOp::Quad { id, uniforms, .. } = op
1413 && id.contains("text_input_caret")
1414 && let Some(UniformValue::Color(c)) = uniforms.get("fill")
1415 {
1416 return Some(c.a);
1417 }
1418 }
1419 None
1420 }
1421 }
1422
1423 #[test]
1424 fn caret_blink_resumes_solid_after_selection_change() {
1425 use crate::state::AnimationMode;
1428 use std::time::Duration;
1429 use web_time::Instant;
1430
1431 let mut tree =
1432 crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
1433 let mut state = UiState::new();
1434 state.set_animation_mode(AnimationMode::Live);
1435 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1436 state.sync_focus_order(&tree);
1437
1438 let t0 = Instant::now();
1440 state.bump_caret_activity(t0);
1441 state.tick_visual_animations(&mut tree, t0 + Duration::from_millis(1100));
1442 assert_eq!(state.caret.blink_alpha, 0.0, "deep in off phase");
1443
1444 state.bump_caret_activity(t0 + Duration::from_millis(1100));
1446 assert_eq!(state.caret.blink_alpha, 1.0, "fresh activity → solid");
1447 }
1448
1449 #[test]
1450 fn caret_tick_requests_redraw_while_capture_keys_node_focused() {
1451 use crate::state::AnimationMode;
1455 use web_time::Instant;
1456
1457 let mut tree =
1458 crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
1459 let mut state = UiState::new();
1460 state.set_animation_mode(AnimationMode::Live);
1461 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1462 state.sync_focus_order(&tree);
1463
1464 let no_focus = state.tick_visual_animations(&mut tree, Instant::now());
1466 assert!(!no_focus, "without focus, blink doesn't request redraws");
1467
1468 let target = state
1471 .focus
1472 .order
1473 .iter()
1474 .find(|t| t.key == "ti")
1475 .unwrap()
1476 .clone();
1477 state.set_focus(Some(target));
1478 let focused = state.tick_visual_animations(&mut tree, Instant::now());
1479 assert!(focused, "focused capture_keys node → tick demands redraws");
1480 }
1481
1482 #[test]
1483 fn apply_text_input_inserts_at_caret_when_collapsed() {
1484 let mut value = String::from("ho");
1485 let mut sel = TextSelection::caret(1);
1486 assert!(apply_event(&mut value, &mut sel, &ev_text("i, t")));
1487 assert_eq!(value, "hi, to");
1488 assert_eq!(sel, TextSelection::caret(5));
1489 }
1490
1491 #[test]
1492 fn apply_text_input_replaces_selection() {
1493 let mut value = String::from("hello world");
1494 let mut sel = TextSelection::range(6, 11); assert!(apply_event(&mut value, &mut sel, &ev_text("kit")));
1496 assert_eq!(value, "hello kit");
1497 assert_eq!(sel, TextSelection::caret(9));
1498 }
1499
1500 #[test]
1501 fn apply_backspace_removes_selection_when_non_empty() {
1502 let mut value = String::from("hello world");
1503 let mut sel = TextSelection::range(6, 11);
1504 assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::Backspace)));
1505 assert_eq!(value, "hello ");
1506 assert_eq!(sel, TextSelection::caret(6));
1507 }
1508
1509 #[test]
1510 fn apply_delete_removes_selection_when_non_empty() {
1511 let mut value = String::from("hello world");
1512 let mut sel = TextSelection::range(0, 6); assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::Delete)));
1514 assert_eq!(value, "world");
1515 assert_eq!(sel, TextSelection::caret(0));
1516 }
1517
1518 #[test]
1519 fn apply_backspace_collapsed_at_start_is_noop() {
1520 let mut value = String::from("hi");
1521 let mut sel = TextSelection::caret(0);
1522 assert!(!apply_event(
1523 &mut value,
1524 &mut sel,
1525 &ev_key(UiKey::Backspace)
1526 ));
1527 }
1528
1529 #[test]
1530 fn apply_arrow_walks_utf8_boundaries() {
1531 let mut value = String::from("aé");
1532 let mut sel = TextSelection::caret(0);
1533 apply_event(&mut value, &mut sel, &ev_key(UiKey::ArrowRight));
1534 assert_eq!(sel.head, 1);
1535 apply_event(&mut value, &mut sel, &ev_key(UiKey::ArrowRight));
1536 assert_eq!(sel.head, 3);
1537 assert!(!apply_event(
1538 &mut value,
1539 &mut sel,
1540 &ev_key(UiKey::ArrowRight)
1541 ));
1542 apply_event(&mut value, &mut sel, &ev_key(UiKey::ArrowLeft));
1543 assert_eq!(sel.head, 1);
1544 }
1545
1546 #[test]
1547 fn apply_arrow_collapses_selection_without_shift() {
1548 let mut value = String::from("hello");
1549 let mut sel = TextSelection::range(1, 4); assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::ArrowLeft)));
1553 assert_eq!(sel, TextSelection::caret(1));
1554
1555 let mut sel = TextSelection::range(1, 4);
1556 assert!(apply_event(
1558 &mut value,
1559 &mut sel,
1560 &ev_key(UiKey::ArrowRight)
1561 ));
1562 assert_eq!(sel, TextSelection::caret(4));
1563 }
1564
1565 #[test]
1566 fn apply_shift_arrow_extends_selection() {
1567 let mut value = String::from("hello");
1568 let mut sel = TextSelection::caret(2);
1569 let shift = KeyModifiers {
1570 shift: true,
1571 ..Default::default()
1572 };
1573 assert!(apply_event(
1574 &mut value,
1575 &mut sel,
1576 &ev_key_with_mods(UiKey::ArrowRight, shift)
1577 ));
1578 assert_eq!(sel, TextSelection::range(2, 3));
1579 assert!(apply_event(
1580 &mut value,
1581 &mut sel,
1582 &ev_key_with_mods(UiKey::ArrowRight, shift)
1583 ));
1584 assert_eq!(sel, TextSelection::range(2, 4));
1585 assert!(apply_event(
1587 &mut value,
1588 &mut sel,
1589 &ev_key_with_mods(UiKey::ArrowLeft, shift)
1590 ));
1591 assert_eq!(sel, TextSelection::range(2, 3));
1592 }
1593
1594 #[test]
1595 fn apply_home_end_collapse_or_extend() {
1596 let mut value = String::from("hello");
1597 let mut sel = TextSelection::caret(2);
1598 assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::End)));
1599 assert_eq!(sel, TextSelection::caret(5));
1600 assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::Home)));
1601 assert_eq!(sel, TextSelection::caret(0));
1602
1603 let shift = KeyModifiers {
1605 shift: true,
1606 ..Default::default()
1607 };
1608 let mut sel = TextSelection::caret(2);
1609 assert!(apply_event(
1610 &mut value,
1611 &mut sel,
1612 &ev_key_with_mods(UiKey::End, shift)
1613 ));
1614 assert_eq!(sel, TextSelection::range(2, 5));
1615 }
1616
1617 #[test]
1618 fn apply_ctrl_a_selects_all() {
1619 let mut value = String::from("hello");
1620 let mut sel = TextSelection::caret(2);
1621 let ctrl = KeyModifiers {
1622 ctrl: true,
1623 ..Default::default()
1624 };
1625 assert!(apply_event(
1626 &mut value,
1627 &mut sel,
1628 &ev_key_with_mods(UiKey::Character("a".into()), ctrl)
1629 ));
1630 assert_eq!(sel, TextSelection::range(0, 5));
1631 assert!(!apply_event(
1633 &mut value,
1634 &mut sel,
1635 &ev_key_with_mods(UiKey::Character("a".into()), ctrl)
1636 ));
1637 }
1638
1639 #[test]
1640 fn apply_pointer_down_sets_anchor_and_head() {
1641 let mut value = String::from("hello");
1642 let mut sel = TextSelection::range(0, 5);
1643 let down = ev_pointer_down(
1645 ti_target(),
1646 (ti_target().rect.x + 1.0, ti_target().rect.y + 18.0),
1647 KeyModifiers::default(),
1648 );
1649 assert!(apply_event(&mut value, &mut sel, &down));
1650 assert_eq!(sel, TextSelection::caret(0));
1651 }
1652
1653 #[test]
1654 fn apply_double_click_selects_word_at_caret() {
1655 let mut value = String::from("hello world");
1656 let mut sel = TextSelection::caret(0);
1657 let target = ti_target();
1659 let click_x = target.rect.x
1660 + tokens::SPACE_3
1661 + crate::text::metrics::line_width(
1662 "hello w",
1663 tokens::TEXT_SM.size,
1664 FontWeight::Regular,
1665 false,
1666 );
1667 let down = ev_pointer_down_with_count(
1668 target.clone(),
1669 (click_x, target.rect.y + 18.0),
1670 KeyModifiers::default(),
1671 2,
1672 );
1673 assert!(apply_event(&mut value, &mut sel, &down));
1674 assert_eq!(sel.anchor, 6);
1676 assert_eq!(sel.head, 11);
1677 }
1678
1679 #[test]
1680 fn apply_triple_click_selects_all() {
1681 let mut value = String::from("hello world");
1682 let mut sel = TextSelection::caret(0);
1683 let target = ti_target();
1684 let down = ev_pointer_down_with_count(
1685 target.clone(),
1686 (target.rect.x + 1.0, target.rect.y + 18.0),
1687 KeyModifiers::default(),
1688 3,
1689 );
1690 assert!(apply_event(&mut value, &mut sel, &down));
1691 assert_eq!(sel.anchor, 0);
1692 assert_eq!(sel.head, value.len());
1693 }
1694
1695 #[test]
1696 fn apply_shift_double_click_falls_back_to_extend_not_word_select() {
1697 let mut value = String::from("hello world");
1700 let mut sel = TextSelection::caret(0);
1701 let target = ti_target();
1702 let click_x = target.rect.x
1703 + tokens::SPACE_3
1704 + crate::text::metrics::line_width(
1705 "hello w",
1706 tokens::TEXT_SM.size,
1707 FontWeight::Regular,
1708 false,
1709 );
1710 let shift = KeyModifiers {
1711 shift: true,
1712 ..Default::default()
1713 };
1714 let down =
1715 ev_pointer_down_with_count(target.clone(), (click_x, target.rect.y + 18.0), shift, 2);
1716 assert!(apply_event(&mut value, &mut sel, &down));
1717 assert_eq!(sel.anchor, 0);
1719 assert!(sel.head > 0 && sel.head < value.len());
1720 }
1721
1722 #[test]
1723 fn apply_shift_pointer_down_only_moves_head() {
1724 let mut value = String::from("hello");
1725 let mut sel = TextSelection::caret(2);
1726 let shift = KeyModifiers {
1727 shift: true,
1728 ..Default::default()
1729 };
1730 let down = ev_pointer_down(
1732 ti_target(),
1733 (
1734 ti_target().rect.x + ti_target().rect.w - 4.0,
1735 ti_target().rect.y + 18.0,
1736 ),
1737 shift,
1738 );
1739 assert!(apply_event(&mut value, &mut sel, &down));
1740 assert_eq!(sel.anchor, 2);
1741 assert_eq!(sel.head, value.len());
1742 }
1743
1744 #[test]
1745 fn apply_drag_extends_head_only() {
1746 let mut value = String::from("hello world");
1747 let mut sel = TextSelection::caret(0);
1748 let down = ev_pointer_down(
1750 ti_target(),
1751 (ti_target().rect.x + 1.0, ti_target().rect.y + 18.0),
1752 KeyModifiers::default(),
1753 );
1754 apply_event(&mut value, &mut sel, &down);
1755 assert_eq!(sel, TextSelection::caret(0));
1756 let drag = ev_drag(
1758 ti_target(),
1759 (
1760 ti_target().rect.x + ti_target().rect.w - 4.0,
1761 ti_target().rect.y + 18.0,
1762 ),
1763 );
1764 assert!(apply_event(&mut value, &mut sel, &drag));
1765 assert_eq!(sel.anchor, 0);
1766 assert_eq!(sel.head, value.len());
1767 }
1768
1769 #[test]
1770 fn apply_click_is_noop_for_selection() {
1771 let mut value = String::from("hello");
1775 let mut sel = TextSelection::range(0, 5);
1776 let click = UiEvent {
1777 path: None,
1778 key: Some("ti".into()),
1779 target: Some(ti_target()),
1780 pointer: Some((ti_target().rect.x + 1.0, ti_target().rect.y + 18.0)),
1781 key_press: None,
1782 text: None,
1783 selection: None,
1784 modifiers: KeyModifiers::default(),
1785 click_count: 1,
1786 kind: UiEventKind::Click,
1787 };
1788 assert!(!apply_event(&mut value, &mut sel, &click));
1789 assert_eq!(sel, TextSelection::range(0, 5));
1790 }
1791
1792 #[test]
1793 fn helpers_selected_text_and_replace_selection() {
1794 let value = String::from("hello world");
1795 let sel = TextSelection::range(6, 11);
1796 assert_eq!(selected_text(&value, sel), "world");
1797
1798 let mut value = value;
1799 let mut sel = sel;
1800 replace_selection(&mut value, &mut sel, "kit");
1801 assert_eq!(value, "hello kit");
1802 assert_eq!(sel, TextSelection::caret(9));
1803
1804 assert_eq!(select_all(&value), TextSelection::range(0, value.len()));
1805 }
1806
1807 #[test]
1808 fn apply_text_input_filters_control_chars() {
1809 let mut value = String::from("hi");
1813 let mut sel = TextSelection::caret(2);
1814 for ctrl in ["\u{8}", "\u{7f}", "\r", "\n", "\u{1b}", "\t"] {
1815 assert!(
1816 !apply_event(&mut value, &mut sel, &ev_text(ctrl)),
1817 "expected {ctrl:?} to be filtered"
1818 );
1819 assert_eq!(value, "hi");
1820 assert_eq!(sel, TextSelection::caret(2));
1821 }
1822 assert!(apply_event(&mut value, &mut sel, &ev_text("a\u{8}b")));
1824 assert_eq!(value, "hiab");
1825 assert_eq!(sel, TextSelection::caret(4));
1826 }
1827
1828 #[test]
1829 fn apply_text_input_drops_when_ctrl_or_cmd_is_held() {
1830 let mut value = String::from("hello");
1835 let mut sel = TextSelection::range(0, 5);
1836 let ctrl = KeyModifiers {
1837 ctrl: true,
1838 ..Default::default()
1839 };
1840 let cmd = KeyModifiers {
1841 logo: true,
1842 ..Default::default()
1843 };
1844 assert!(!apply_event(
1845 &mut value,
1846 &mut sel,
1847 &ev_text_with_mods("c", ctrl)
1848 ));
1849 assert_eq!(value, "hello");
1850 assert!(!apply_event(
1851 &mut value,
1852 &mut sel,
1853 &ev_text_with_mods("v", cmd)
1854 ));
1855 assert_eq!(value, "hello");
1856 let altgr = KeyModifiers {
1858 ctrl: true,
1859 alt: true,
1860 ..Default::default()
1861 };
1862 let mut value = String::from("");
1863 let mut sel = TextSelection::caret(0);
1864 assert!(apply_event(
1865 &mut value,
1866 &mut sel,
1867 &ev_text_with_mods("é", altgr)
1868 ));
1869 assert_eq!(value, "é");
1870 }
1871
1872 #[test]
1873 fn text_input_value_emits_a_single_glyph_run() {
1874 use crate::draw_ops::draw_ops;
1880 use crate::ir::DrawOp;
1881 let mut tree =
1882 crate::column([text_input("Type", TextSelection::caret(1)).key("ti")]).padding(20.0);
1883 let mut state = UiState::new();
1884 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1885
1886 let ops = draw_ops(&tree, &state);
1887 let glyph_runs = ops
1888 .iter()
1889 .filter(|op| matches!(op, DrawOp::GlyphRun { id, .. } if id.contains("text_input[ti]")))
1890 .count();
1891 assert_eq!(
1892 glyph_runs, 1,
1893 "value should shape as one run; got {glyph_runs}"
1894 );
1895 }
1896
1897 #[test]
1898 fn clipboard_request_detects_ctrl_c_x_v() {
1899 let ctrl = KeyModifiers {
1900 ctrl: true,
1901 ..Default::default()
1902 };
1903 let cases = [
1904 ("c", ClipboardKind::Copy),
1905 ("C", ClipboardKind::Copy),
1906 ("x", ClipboardKind::Cut),
1907 ("v", ClipboardKind::Paste),
1908 ];
1909 for (ch, expected) in cases {
1910 let e = ev_key_with_mods(UiKey::Character(ch.into()), ctrl);
1911 assert_eq!(clipboard_request(&e), Some(expected), "char {ch:?}");
1912 }
1913 }
1914
1915 #[test]
1916 fn clipboard_request_accepts_cmd_on_macos() {
1917 let logo = KeyModifiers {
1920 logo: true,
1921 ..Default::default()
1922 };
1923 let e = ev_key_with_mods(UiKey::Character("c".into()), logo);
1924 assert_eq!(clipboard_request(&e), Some(ClipboardKind::Copy));
1925 }
1926
1927 #[test]
1928 fn clipboard_request_rejects_with_shift_or_alt() {
1929 let e = ev_key_with_mods(
1931 UiKey::Character("c".into()),
1932 KeyModifiers {
1933 ctrl: true,
1934 shift: true,
1935 ..Default::default()
1936 },
1937 );
1938 assert_eq!(clipboard_request(&e), None);
1939
1940 let e = ev_key_with_mods(
1941 UiKey::Character("v".into()),
1942 KeyModifiers {
1943 ctrl: true,
1944 alt: true,
1945 ..Default::default()
1946 },
1947 );
1948 assert_eq!(clipboard_request(&e), None);
1949 }
1950
1951 #[test]
1952 fn clipboard_request_ignores_other_keys_and_event_kinds() {
1953 let e = ev_key(UiKey::Character("c".into()));
1955 assert_eq!(clipboard_request(&e), None);
1956 let e = ev_key_with_mods(
1958 UiKey::Character("a".into()),
1959 KeyModifiers {
1960 ctrl: true,
1961 ..Default::default()
1962 },
1963 );
1964 assert_eq!(clipboard_request(&e), None);
1965 assert_eq!(clipboard_request(&ev_text("c")), None);
1967 }
1968
1969 fn password_opts() -> TextInputOpts<'static> {
1970 TextInputOpts::default().password()
1971 }
1972
1973 #[test]
1974 fn password_input_renders_value_as_bullets_not_plaintext() {
1975 let el = text_input_with("hunter2", TextSelection::caret(0), password_opts());
1978 let leaf = content_children(&el)
1979 .iter()
1980 .find(|c| matches!(c.kind, Kind::Text))
1981 .expect("text leaf");
1982 assert_eq!(leaf.text.as_deref(), Some("•••••••"));
1983 }
1984
1985 #[test]
1986 fn password_input_caret_position_uses_masked_widths() {
1987 use crate::text::metrics::line_width;
1991 let value = "abc";
1992 let head = 2;
1993 let el = text_input_with(value, TextSelection::caret(head), password_opts());
1994 let caret = content_children(&el)
1995 .iter()
1996 .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
1997 .expect("caret child");
1998 let expected = line_width("••", tokens::TEXT_SM.size, FontWeight::Regular, false);
2000 assert!(
2001 (caret.translate.0 - expected).abs() < 0.01,
2002 "caret translate.x = {}, expected {}",
2003 caret.translate.0,
2004 expected
2005 );
2006 }
2007
2008 #[test]
2009 fn password_pointer_click_maps_back_to_original_byte() {
2010 let mut value = String::from("abcde");
2013 let mut sel = TextSelection::default();
2014 let target = ti_target();
2015 let down = ev_pointer_down(
2016 target.clone(),
2017 (target.rect.x + target.rect.w - 4.0, target.rect.y + 18.0),
2018 KeyModifiers::default(),
2019 );
2020 assert!(apply_event_with(
2021 &mut value,
2022 &mut sel,
2023 &down,
2024 &password_opts()
2025 ));
2026 assert_eq!(sel.head, value.len());
2027 }
2028
2029 #[test]
2030 fn password_pointer_click_with_multibyte_value() {
2031 let mut value = String::from("éé");
2035 let mut sel = TextSelection::default();
2036 let target = ti_target();
2037 let bullet_w = metrics::line_width("•", tokens::TEXT_SM.size, FontWeight::Regular, false);
2039 let click_x = target.rect.x + tokens::SPACE_3 + bullet_w * 1.4;
2040 let down = ev_pointer_down(
2041 target,
2042 (click_x, ti_target().rect.y + 18.0),
2043 KeyModifiers::default(),
2044 );
2045 assert!(apply_event_with(
2046 &mut value,
2047 &mut sel,
2048 &down,
2049 &password_opts()
2050 ));
2051 assert!(
2055 value.is_char_boundary(sel.head),
2056 "head={} not on a char boundary in {value:?}",
2057 sel.head
2058 );
2059 assert!(sel.head == 2 || sel.head == 4, "head={}", sel.head);
2060 }
2061
2062 #[test]
2063 fn password_clipboard_request_suppresses_copy_and_cut_only() {
2064 let ctrl = KeyModifiers {
2065 ctrl: true,
2066 ..Default::default()
2067 };
2068 let opts = password_opts();
2069 let copy = ev_key_with_mods(UiKey::Character("c".into()), ctrl);
2070 let cut = ev_key_with_mods(UiKey::Character("x".into()), ctrl);
2071 let paste = ev_key_with_mods(UiKey::Character("v".into()), ctrl);
2072 assert_eq!(clipboard_request_for(©, &opts), None);
2073 assert_eq!(clipboard_request_for(&cut, &opts), None);
2074 assert_eq!(
2075 clipboard_request_for(&paste, &opts),
2076 Some(ClipboardKind::Paste)
2077 );
2078 let plain = TextInputOpts::default();
2080 assert_eq!(
2081 clipboard_request_for(©, &plain),
2082 Some(ClipboardKind::Copy)
2083 );
2084 }
2085
2086 #[test]
2087 fn placeholder_renders_only_when_value_is_empty() {
2088 let opts = TextInputOpts::default().placeholder("Email");
2089 let empty = text_input_with("", TextSelection::default(), opts);
2090 let muted_leaf = content_children(&empty)
2091 .iter()
2092 .find(|c| matches!(c.kind, Kind::Text) && c.text.as_deref() == Some("Email"));
2093 assert!(muted_leaf.is_some(), "placeholder leaf should be present");
2094
2095 let nonempty = text_input_with("hi", TextSelection::caret(2), opts);
2096 let muted_leaf = content_children(&nonempty)
2097 .iter()
2098 .find(|c| matches!(c.kind, Kind::Text) && c.text.as_deref() == Some("Email"));
2099 assert!(
2100 muted_leaf.is_none(),
2101 "placeholder should not render once the field has a value"
2102 );
2103 }
2104
2105 #[test]
2106 fn long_value_with_caret_at_end_shifts_content_left_to_keep_caret_in_view() {
2107 use crate::tree::Size;
2116 let value = "abcdefghijklmnopqrstuvwxyz0123456789".repeat(2);
2117 let mut root = super::text_input(
2118 &value,
2119 &as_selection_in("ti", TextSelection::caret(value.len())),
2120 "ti",
2121 )
2122 .width(Size::Fixed(120.0));
2123 let mut ui_state = crate::state::UiState::new();
2124 crate::layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 120.0, 40.0));
2125
2126 let inner = &root.children[0];
2128 let text_leaf = inner
2129 .children
2130 .iter()
2131 .find(|c| matches!(c.kind, Kind::Text))
2132 .expect("text leaf");
2133 let leaf_rect = ui_state.rect(&text_leaf.computed_id);
2134
2135 let inner_rect = ui_state.rect(&inner.computed_id);
2139 assert!(
2140 leaf_rect.x < inner_rect.x,
2141 "text leaf rect.x={} should be left of inner rect.x={} after \
2142 horizontal caret-into-view; layout did not shift content",
2143 leaf_rect.x,
2144 inner_rect.x,
2145 );
2146 }
2147
2148 #[test]
2149 fn short_value_does_not_shift_content() {
2150 use crate::tree::Size;
2154 let mut root =
2155 super::text_input("hi", &as_selection_in("ti", TextSelection::caret(2)), "ti")
2156 .width(Size::Fixed(120.0));
2157 let mut ui_state = crate::state::UiState::new();
2158 crate::layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 120.0, 40.0));
2159
2160 let inner = &root.children[0];
2161 let text_leaf = inner
2162 .children
2163 .iter()
2164 .find(|c| matches!(c.kind, Kind::Text))
2165 .expect("text leaf");
2166 let leaf_rect = ui_state.rect(&text_leaf.computed_id);
2167 let inner_rect = ui_state.rect(&inner.computed_id);
2168 assert!(
2169 (leaf_rect.x - inner_rect.x).abs() < 0.5,
2170 "short value should not shift; got leaf.x={} inner.x={}",
2171 leaf_rect.x,
2172 inner_rect.x
2173 );
2174 }
2175
2176 fn as_selection_in(key: &str, sel: TextSelection) -> Selection {
2179 Selection {
2180 range: Some(SelectionRange {
2181 anchor: SelectionPoint::new(key, sel.anchor),
2182 head: SelectionPoint::new(key, sel.head),
2183 }),
2184 }
2185 }
2186
2187 #[test]
2188 fn max_length_truncates_text_input_inserts() {
2189 let mut value = String::from("ab");
2190 let mut sel = TextSelection::caret(2);
2191 let opts = TextInputOpts::default().max_length(4);
2192 assert!(apply_event_with(
2194 &mut value,
2195 &mut sel,
2196 &ev_text("cdef"),
2197 &opts
2198 ));
2199 assert_eq!(value, "abcd");
2200 assert_eq!(sel, TextSelection::caret(4));
2201 assert!(!apply_event_with(
2203 &mut value,
2204 &mut sel,
2205 &ev_text("z"),
2206 &opts
2207 ));
2208 assert_eq!(value, "abcd");
2209 }
2210
2211 #[test]
2212 fn max_length_replaces_selection_with_capacity_freed_by_removal() {
2213 let mut value = String::from("abc");
2216 let mut sel = TextSelection::range(0, 3); let opts = TextInputOpts::default().max_length(4);
2218 assert!(apply_event_with(
2219 &mut value,
2220 &mut sel,
2221 &ev_text("12345"),
2222 &opts
2223 ));
2224 assert_eq!(value, "1234");
2225 assert_eq!(sel, TextSelection::caret(4));
2226 }
2227
2228 #[test]
2229 fn replace_selection_with_max_length_clips_a_paste() {
2230 let mut value = String::from("ab");
2231 let mut sel = TextSelection::caret(2);
2232 let opts = TextInputOpts::default().max_length(5);
2233 let inserted = replace_selection_with(&mut value, &mut sel, "0123456789", &opts);
2235 assert_eq!(value, "ab012");
2236 assert_eq!(inserted, 3);
2237 assert_eq!(sel, TextSelection::caret(5));
2238 }
2239
2240 #[test]
2241 fn max_length_does_not_shrink_an_already_overlong_value() {
2242 let mut value = String::from("abcdef");
2245 let mut sel = TextSelection::caret(6);
2246 let opts = TextInputOpts::default().max_length(3);
2247 assert!(!apply_event_with(
2249 &mut value,
2250 &mut sel,
2251 &ev_text("z"),
2252 &opts
2253 ));
2254 assert_eq!(value, "abcdef");
2255 assert!(apply_event_with(
2258 &mut value,
2259 &mut sel,
2260 &ev_key(UiKey::Backspace),
2261 &opts
2262 ));
2263 assert_eq!(value, "abcde");
2264 }
2265
2266 #[test]
2267 fn end_to_end_drag_select_through_runner_core() {
2268 let mut value = String::from("hello world");
2272 let mut sel = TextSelection::default();
2273 let mut tree = crate::column([text_input(&value, sel).key("ti")]).padding(20.0);
2274 let mut core = RunnerCore::new();
2275 let mut state = UiState::new();
2276 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
2277 core.ui_state = state;
2278 core.snapshot(&tree, &mut Default::default());
2279
2280 let rect = core.rect_of_key("ti").expect("ti rect");
2281 let down_x = rect.x + 8.0;
2282 let drag_x = rect.x + 80.0;
2283 let cy = rect.y + rect.h * 0.5;
2284
2285 core.pointer_moved(down_x, cy);
2286 let down = core
2287 .pointer_down(down_x, cy, PointerButton::Primary)
2288 .into_iter()
2289 .find(|e| e.kind == UiEventKind::PointerDown)
2290 .expect("pointer_down emits PointerDown");
2291 assert!(apply_event(&mut value, &mut sel, &down));
2292
2293 let drag = core
2294 .pointer_moved(drag_x, cy)
2295 .events
2296 .into_iter()
2297 .find(|e| e.kind == UiEventKind::Drag)
2298 .expect("Drag while pressed");
2299 assert!(apply_event(&mut value, &mut sel, &drag));
2300
2301 let events = core.pointer_up(drag_x, cy, PointerButton::Primary);
2302 for e in &events {
2303 apply_event(&mut value, &mut sel, e);
2304 }
2305 assert!(
2306 !sel.is_collapsed(),
2307 "expected drag-select to leave a non-empty selection"
2308 );
2309 assert_eq!(
2310 sel.anchor, 0,
2311 "anchor should sit at the down position (caret 0)"
2312 );
2313 assert!(
2314 sel.head > 0 && sel.head <= value.len(),
2315 "head={} value.len={}",
2316 sel.head,
2317 value.len()
2318 );
2319 }
2320
2321 #[test]
2329 fn apply_event_writes_back_under_the_inputs_key() {
2330 let mut value = String::new();
2332 let mut sel = Selection::default();
2333 let event = ev_text("h");
2334 assert!(super::apply_event(&mut value, &mut sel, "name", &event));
2335 assert_eq!(value, "h");
2336 let r = sel.range.as_ref().expect("selection set");
2337 assert_eq!(r.anchor.key, "name");
2338 assert_eq!(r.head.key, "name");
2339 assert_eq!(r.head.byte, 1);
2340 }
2341
2342 #[test]
2343 fn apply_event_claims_selection_when_event_routed_from_elsewhere() {
2344 let mut value = String::new();
2350 let mut sel = Selection {
2351 range: Some(SelectionRange {
2352 anchor: SelectionPoint::new("para-a", 0),
2353 head: SelectionPoint::new("para-a", 5),
2354 }),
2355 };
2356 let event = ev_text("x");
2357 assert!(super::apply_event(&mut value, &mut sel, "email", &event));
2358 assert_eq!(value, "x");
2359 let r = sel.range.as_ref().unwrap();
2360 assert_eq!(r.anchor.key, "email", "selection ownership migrated");
2361 assert_eq!(r.head.byte, 1);
2362 }
2363
2364 #[test]
2365 fn apply_event_leaves_selection_alone_when_event_is_unhandled() {
2366 let mut value = String::from("hi");
2370 let mut sel = Selection {
2371 range: Some(SelectionRange {
2372 anchor: SelectionPoint::new("para-a", 0),
2373 head: SelectionPoint::new("para-a", 3),
2374 }),
2375 };
2376 let event = ev_key(UiKey::Other("F1".into()));
2377 assert!(!super::apply_event(&mut value, &mut sel, "name", &event));
2378 let r = sel.range.as_ref().unwrap();
2380 assert_eq!(r.anchor.key, "para-a");
2381 assert_eq!(r.head.byte, 3);
2382 }
2383
2384 #[test]
2385 fn text_input_renders_caret_at_local_byte_when_selection_is_within_key() {
2386 let sel = Selection::caret("name", 2);
2387 let el = super::text_input("hello", &sel, "name");
2388 assert_eq!(el.key.as_deref(), Some("name"));
2390 let caret = content_children(&el)
2392 .iter()
2393 .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
2394 .expect("caret child");
2395 let expected = metrics::line_width("he", tokens::TEXT_SM.size, FontWeight::Regular, false);
2396 assert!(
2397 (caret.translate.0 - expected).abs() < 0.01,
2398 "caret.x={} expected {}",
2399 caret.translate.0,
2400 expected
2401 );
2402 }
2403
2404 #[test]
2405 fn text_input_omits_caret_when_selection_lives_elsewhere() {
2406 let sel = Selection {
2413 range: Some(SelectionRange {
2414 anchor: SelectionPoint::new("other", 0),
2415 head: SelectionPoint::new("other", 5),
2416 }),
2417 };
2418 let el = super::text_input("hello", &sel, "name");
2419 let band = el
2420 .children
2421 .iter()
2422 .find(|c| matches!(c.kind, Kind::Custom("text_input_selection")));
2423 assert!(band.is_none(), "no band when selection lives elsewhere");
2424 let caret = el
2425 .children
2426 .iter()
2427 .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")));
2428 assert!(
2429 caret.is_none(),
2430 "no caret when selection lives elsewhere — focus-fade has nothing to bring back to byte 0"
2431 );
2432 }
2433}