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 .hit_overflow(Sides::all(tokens::HIT_OVERFLOW))
363 .cursor(Cursor::Text)
364 .fill(tokens::MUTED)
365 .stroke(tokens::BORDER)
366 .default_radius(tokens::RADIUS_MD)
367 .axis(Axis::Overlay)
368 .align(Align::Start)
369 .justify(Justify::Center)
370 .default_width(Size::Fill(1.0))
371 .default_height(Size::Fixed(tokens::CONTROL_HEIGHT))
372 .default_padding(Sides::xy(tokens::SPACE_3, 0.0))
373 .child(inner)
374}
375
376fn caret_bar() -> El {
377 El::new(Kind::Custom("text_input_caret"))
378 .style_profile(StyleProfile::Solid)
379 .fill(tokens::FOREGROUND)
380 .width(Size::Fixed(2.0))
381 .height(Size::Fixed(line_height_px()))
382 .radius(1.0)
383}
384
385fn line_height_px() -> f32 {
386 tokens::TEXT_SM.line_height
387}
388
389fn single_line_geometry(value: &str) -> TextGeometry<'_> {
390 TextGeometry::new(
391 value,
392 tokens::TEXT_SM.size,
393 FontWeight::Regular,
394 false,
395 TextWrap::NoWrap,
396 None,
397 )
398}
399
400pub fn apply_event(
427 value: &mut String,
428 selection: &mut Selection,
429 key: &str,
430 event: &UiEvent,
431) -> bool {
432 apply_event_with(value, selection, key, event, &TextInputOpts::default())
433}
434
435pub fn apply_event_with(
439 value: &mut String,
440 selection: &mut Selection,
441 key: &str,
442 event: &UiEvent,
443 opts: &TextInputOpts<'_>,
444) -> bool {
445 let mut local = selection.within(key).unwrap_or_default();
446 let changed = fold_event_local(value, &mut local, event, opts);
447 if changed {
448 selection.range = Some(SelectionRange {
449 anchor: SelectionPoint::new(key, local.anchor),
450 head: SelectionPoint::new(key, local.head),
451 });
452 }
453 changed
454}
455
456fn fold_event_local(
460 value: &mut String,
461 selection: &mut TextSelection,
462 event: &UiEvent,
463 opts: &TextInputOpts<'_>,
464) -> bool {
465 selection.anchor = clamp_to_char_boundary(value, selection.anchor.min(value.len()));
466 selection.head = clamp_to_char_boundary(value, selection.head.min(value.len()));
467 match event.kind {
468 UiEventKind::TextInput => {
469 let Some(insert) = event.text.as_deref() else {
470 return false;
471 };
472 if (event.modifiers.ctrl && !event.modifiers.alt) || event.modifiers.logo {
488 return false;
489 }
490 let filtered: String = insert.chars().filter(|c| !c.is_control()).collect();
491 if filtered.is_empty() {
492 return false;
493 }
494 let to_insert = clip_to_max_length(value, *selection, &filtered, opts.max_length);
495 if to_insert.is_empty() {
496 return false;
497 }
498 replace_selection(value, selection, &to_insert);
499 true
500 }
501 UiEventKind::MiddleClick => {
502 let Some(byte) = caret_byte_at(value, event, opts) else {
503 return false;
504 };
505 *selection = TextSelection::caret(byte);
506 if let Some(insert) = event.text.as_deref() {
507 replace_selection_with(value, selection, insert, opts);
508 }
509 true
510 }
511 UiEventKind::KeyDown => {
512 let Some(kp) = event.key_press.as_ref() else {
513 return false;
514 };
515 let mods = kp.modifiers;
516 if mods.ctrl
520 && !mods.alt
521 && !mods.logo
522 && let UiKey::Character(c) = &kp.key
523 && c.eq_ignore_ascii_case("a")
524 {
525 let len = value.len();
526 if selection.anchor == 0 && selection.head == len {
527 return false;
528 }
529 *selection = TextSelection {
530 anchor: 0,
531 head: len,
532 };
533 return true;
534 }
535 match kp.key {
536 UiKey::Escape => {
537 if selection.is_collapsed() {
538 return false;
539 }
540 selection.anchor = selection.head;
541 true
542 }
543 UiKey::Backspace => {
544 if !selection.is_collapsed() {
545 replace_selection(value, selection, "");
546 return true;
547 }
548 if selection.head == 0 {
549 return false;
550 }
551 let prev = prev_char_boundary(value, selection.head);
552 value.replace_range(prev..selection.head, "");
553 selection.head = prev;
554 selection.anchor = prev;
555 true
556 }
557 UiKey::Delete => {
558 if !selection.is_collapsed() {
559 replace_selection(value, selection, "");
560 return true;
561 }
562 if selection.head >= value.len() {
563 return false;
564 }
565 let next = next_char_boundary(value, selection.head);
566 value.replace_range(selection.head..next, "");
567 true
568 }
569 UiKey::ArrowLeft => {
570 let target = if selection.is_collapsed() || mods.shift {
571 if selection.head == 0 {
572 return false;
573 }
574 prev_char_boundary(value, selection.head)
575 } else {
576 selection.ordered().0
578 };
579 selection.head = target;
580 if !mods.shift {
581 selection.anchor = target;
582 }
583 true
584 }
585 UiKey::ArrowRight => {
586 let target = if selection.is_collapsed() || mods.shift {
587 if selection.head >= value.len() {
588 return false;
589 }
590 next_char_boundary(value, selection.head)
591 } else {
592 selection.ordered().1
594 };
595 selection.head = target;
596 if !mods.shift {
597 selection.anchor = target;
598 }
599 true
600 }
601 UiKey::Home => {
602 if selection.head == 0 && (mods.shift || selection.anchor == 0) {
603 return false;
604 }
605 selection.head = 0;
606 if !mods.shift {
607 selection.anchor = 0;
608 }
609 true
610 }
611 UiKey::End => {
612 let end = value.len();
613 if selection.head == end && (mods.shift || selection.anchor == end) {
614 return false;
615 }
616 selection.head = end;
617 if !mods.shift {
618 selection.anchor = end;
619 }
620 true
621 }
622 _ => false,
623 }
624 }
625 UiEventKind::PointerDown => {
626 let (Some((px, _py)), Some(target)) = (event.pointer, event.target.as_ref()) else {
627 return false;
628 };
629 let viewport_w = (target.rect.w - 2.0 * tokens::SPACE_3).max(0.0);
635 let x_offset = current_x_offset(value, selection.head, viewport_w, opts.mask);
636 let local_x = px - target.rect.x - tokens::SPACE_3 + x_offset;
637 let pos = caret_from_x(value, local_x, opts.mask);
638 if !event.modifiers.shift {
645 match event.click_count {
646 2 => {
647 let (lo, hi) = crate::selection::word_range_at(value, pos);
648 selection.anchor = lo;
649 selection.head = hi;
650 return true;
651 }
652 n if n >= 3 => {
653 selection.anchor = 0;
654 selection.head = value.len();
655 return true;
656 }
657 _ => {}
658 }
659 }
660 selection.head = pos;
661 if !event.modifiers.shift {
662 selection.anchor = pos;
663 }
664 true
665 }
666 UiEventKind::Drag => {
667 let (Some((px, _py)), Some(target)) = (event.pointer, event.target.as_ref()) else {
668 return false;
669 };
670 let viewport_w = (target.rect.w - 2.0 * tokens::SPACE_3).max(0.0);
675 let x_offset = current_x_offset(value, selection.head, viewport_w, opts.mask);
676 let local_x = px - target.rect.x - tokens::SPACE_3 + x_offset;
677 selection.head = caret_from_x(value, local_x, opts.mask);
678 true
679 }
680 UiEventKind::Click => false,
681 _ => false,
682 }
683}
684
685pub fn selected_text(value: &str, selection: TextSelection) -> &str {
688 let head = clamp_to_char_boundary(value, selection.head.min(value.len()));
689 let anchor = clamp_to_char_boundary(value, selection.anchor.min(value.len()));
690 &value[anchor.min(head)..anchor.max(head)]
691}
692
693pub fn replace_selection(value: &mut String, selection: &mut TextSelection, replacement: &str) {
697 selection.anchor = clamp_to_char_boundary(value, selection.anchor.min(value.len()));
698 selection.head = clamp_to_char_boundary(value, selection.head.min(value.len()));
699 let (lo, hi) = selection.ordered();
700 value.replace_range(lo..hi, replacement);
701 let new_caret = lo + replacement.len();
702 selection.anchor = new_caret;
703 selection.head = new_caret;
704}
705
706pub fn replace_selection_with(
713 value: &mut String,
714 selection: &mut TextSelection,
715 replacement: &str,
716 opts: &TextInputOpts<'_>,
717) -> usize {
718 let clipped = clip_to_max_length(value, *selection, replacement, opts.max_length);
719 let len = clipped.len();
720 replace_selection(value, selection, &clipped);
721 len
722}
723
724pub fn select_all(value: &str) -> TextSelection {
726 TextSelection {
727 anchor: 0,
728 head: value.len(),
729 }
730}
731
732#[derive(Clone, Copy, Debug, PartialEq, Eq)]
743pub enum ClipboardKind {
744 Copy,
746 Cut,
748 Paste,
750}
751
752pub fn clipboard_request(event: &UiEvent) -> Option<ClipboardKind> {
798 clipboard_request_for(event, &TextInputOpts::default())
799}
800
801pub fn clipboard_request_for(event: &UiEvent, opts: &TextInputOpts<'_>) -> Option<ClipboardKind> {
805 if event.kind != UiEventKind::KeyDown {
806 return None;
807 }
808 let kp = event.key_press.as_ref()?;
809 let mods = kp.modifiers;
810 if mods.alt || mods.shift {
813 return None;
814 }
815 if !(mods.ctrl || mods.logo) {
817 return None;
818 }
819 let UiKey::Character(c) = &kp.key else {
820 return None;
821 };
822 let kind = match c.to_ascii_lowercase().as_str() {
823 "c" => ClipboardKind::Copy,
824 "x" => ClipboardKind::Cut,
825 "v" => ClipboardKind::Paste,
826 _ => return None,
827 };
828 if opts.is_masked() && matches!(kind, ClipboardKind::Copy | ClipboardKind::Cut) {
829 return None;
830 }
831 Some(kind)
832}
833
834#[track_caller]
844pub fn caret_byte_at(value: &str, event: &UiEvent, opts: &TextInputOpts<'_>) -> Option<usize> {
845 let (px, _py) = event.pointer?;
846 let target = event.target.as_ref()?;
847 let local_x = px - target.rect.x - tokens::SPACE_3;
848 Some(caret_from_x(value, local_x, opts.mask))
849}
850
851fn current_x_offset(value: &str, head: usize, viewport_w: f32, mask: MaskMode) -> f32 {
863 if viewport_w <= 0.0 {
864 return 0.0;
865 }
866 let head = clamp_to_char_boundary(value, head.min(value.len()));
867 let display = display_str(value, mask);
868 let geometry = single_line_geometry(&display);
869 let head_display = original_to_display_byte(value, head, mask);
870 let head_px = geometry.prefix_width(head_display);
871 (head_px - viewport_w).max(0.0)
872}
873
874fn caret_from_x(value: &str, local_x: f32, mask: MaskMode) -> usize {
875 if value.is_empty() || local_x <= 0.0 {
876 return 0;
877 }
878 let probe = display_str(value, mask);
879 let local_y = line_height_px() * 0.5;
880 let geometry = single_line_geometry(&probe);
881 let display_byte = match geometry.hit_byte(local_x, local_y) {
882 Some(byte) => byte.min(probe.len()),
883 None => probe.len(),
884 };
885 display_to_original_byte(value, display_byte, mask)
886}
887
888fn display_str(value: &str, mask: MaskMode) -> Cow<'_, str> {
893 match mask {
894 MaskMode::None => Cow::Borrowed(value),
895 MaskMode::Password => {
896 let n = value.chars().count();
897 let mut s = String::with_capacity(n * MASK_CHAR.len_utf8());
898 for _ in 0..n {
899 s.push(MASK_CHAR);
900 }
901 Cow::Owned(s)
902 }
903 }
904}
905
906fn original_to_display_byte(value: &str, byte_index: usize, mask: MaskMode) -> usize {
907 match mask {
908 MaskMode::None => byte_index.min(value.len()),
909 MaskMode::Password => {
910 let clamped = clamp_to_char_boundary(value, byte_index.min(value.len()));
911 value[..clamped].chars().count() * MASK_CHAR.len_utf8()
912 }
913 }
914}
915
916fn display_to_original_byte(value: &str, display_byte: usize, mask: MaskMode) -> usize {
918 match mask {
919 MaskMode::None => clamp_to_char_boundary(value, display_byte.min(value.len())),
920 MaskMode::Password => {
921 let scalar_idx = display_byte / MASK_CHAR.len_utf8();
922 value
923 .char_indices()
924 .nth(scalar_idx)
925 .map(|(i, _)| i)
926 .unwrap_or(value.len())
927 }
928 }
929}
930
931fn clip_to_max_length<'a>(
939 value: &str,
940 selection: TextSelection,
941 replacement: &'a str,
942 max_length: Option<usize>,
943) -> Cow<'a, str> {
944 let Some(max) = max_length else {
945 return Cow::Borrowed(replacement);
946 };
947 let lo = clamp_to_char_boundary(value, selection.anchor.min(selection.head).min(value.len()));
948 let hi = clamp_to_char_boundary(value, selection.anchor.max(selection.head).min(value.len()));
949 let post_other = value[..lo].chars().count() + value[hi..].chars().count();
950 let allowed = max.saturating_sub(post_other);
951 if replacement.chars().count() <= allowed {
952 Cow::Borrowed(replacement)
953 } else {
954 Cow::Owned(replacement.chars().take(allowed).collect())
955 }
956}
957
958fn clamp_to_char_boundary(s: &str, idx: usize) -> usize {
959 let mut idx = idx.min(s.len());
960 while idx > 0 && !s.is_char_boundary(idx) {
961 idx -= 1;
962 }
963 idx
964}
965
966fn prev_char_boundary(s: &str, from: usize) -> usize {
967 let mut i = from.saturating_sub(1);
968 while i > 0 && !s.is_char_boundary(i) {
969 i -= 1;
970 }
971 i
972}
973
974fn next_char_boundary(s: &str, from: usize) -> usize {
975 let mut i = (from + 1).min(s.len());
976 while i < s.len() && !s.is_char_boundary(i) {
977 i += 1;
978 }
979 i
980}
981
982#[cfg(test)]
983mod tests {
984 use super::*;
985 use crate::event::{KeyModifiers, KeyPress, PointerButton, UiTarget};
986 use crate::layout::layout;
987 use crate::runtime::RunnerCore;
988 use crate::state::UiState;
989 use crate::text::metrics;
990
991 const TEST_KEY: &str = "ti";
996
997 #[track_caller]
1002 fn text_input(value: &str, sel: TextSelection) -> El {
1003 super::text_input(value, &as_selection(sel), TEST_KEY)
1004 }
1005
1006 #[track_caller]
1007 fn text_input_with(value: &str, sel: TextSelection, opts: TextInputOpts<'_>) -> El {
1008 super::text_input_with(value, &as_selection(sel), TEST_KEY, opts)
1009 }
1010
1011 fn apply_event(value: &mut String, sel: &mut TextSelection, event: &UiEvent) -> bool {
1012 let mut g = as_selection(*sel);
1013 let changed = super::apply_event(value, &mut g, TEST_KEY, event);
1014 sync_back(sel, &g);
1015 changed
1016 }
1017
1018 fn apply_event_with(
1019 value: &mut String,
1020 sel: &mut TextSelection,
1021 event: &UiEvent,
1022 opts: &TextInputOpts<'_>,
1023 ) -> bool {
1024 let mut g = as_selection(*sel);
1025 let changed = super::apply_event_with(value, &mut g, TEST_KEY, event, opts);
1026 sync_back(sel, &g);
1027 changed
1028 }
1029
1030 fn as_selection(sel: TextSelection) -> Selection {
1031 Selection {
1032 range: Some(SelectionRange {
1033 anchor: SelectionPoint::new(TEST_KEY, sel.anchor),
1034 head: SelectionPoint::new(TEST_KEY, sel.head),
1035 }),
1036 }
1037 }
1038
1039 fn sync_back(local: &mut TextSelection, global: &Selection) {
1040 match global.within(TEST_KEY) {
1041 Some(view) => *local = view,
1042 None => *local = TextSelection::default(),
1043 }
1044 }
1045
1046 fn ev_text(s: &str) -> UiEvent {
1047 ev_text_with_mods(s, KeyModifiers::default())
1048 }
1049
1050 fn ev_text_with_mods(s: &str, modifiers: KeyModifiers) -> UiEvent {
1051 UiEvent {
1052 path: None,
1053 key: None,
1054 target: None,
1055 pointer: None,
1056 key_press: None,
1057 text: Some(s.into()),
1058 selection: None,
1059 modifiers,
1060 click_count: 0,
1061 kind: UiEventKind::TextInput,
1062 }
1063 }
1064
1065 fn ev_key(key: UiKey) -> UiEvent {
1066 ev_key_with_mods(key, KeyModifiers::default())
1067 }
1068
1069 fn ev_key_with_mods(key: UiKey, modifiers: KeyModifiers) -> UiEvent {
1070 UiEvent {
1071 path: None,
1072 key: None,
1073 target: None,
1074 pointer: None,
1075 key_press: Some(KeyPress {
1076 key,
1077 modifiers,
1078 repeat: false,
1079 }),
1080 text: None,
1081 selection: None,
1082 modifiers,
1083 click_count: 0,
1084 kind: UiEventKind::KeyDown,
1085 }
1086 }
1087
1088 fn ev_pointer_down(target: UiTarget, pointer: (f32, f32), modifiers: KeyModifiers) -> UiEvent {
1089 ev_pointer_down_with_count(target, pointer, modifiers, 1)
1090 }
1091
1092 fn ev_pointer_down_with_count(
1093 target: UiTarget,
1094 pointer: (f32, f32),
1095 modifiers: KeyModifiers,
1096 click_count: u8,
1097 ) -> UiEvent {
1098 UiEvent {
1099 path: None,
1100 key: Some(target.key.clone()),
1101 target: Some(target),
1102 pointer: Some(pointer),
1103 key_press: None,
1104 text: None,
1105 selection: None,
1106 modifiers,
1107 click_count,
1108 kind: UiEventKind::PointerDown,
1109 }
1110 }
1111
1112 fn ev_drag(target: UiTarget, pointer: (f32, f32)) -> UiEvent {
1113 UiEvent {
1114 path: None,
1115 key: Some(target.key.clone()),
1116 target: Some(target),
1117 pointer: Some(pointer),
1118 key_press: None,
1119 text: None,
1120 selection: None,
1121 modifiers: KeyModifiers::default(),
1122 click_count: 0,
1123 kind: UiEventKind::Drag,
1124 }
1125 }
1126
1127 fn ev_middle_click(target: UiTarget, pointer: (f32, f32), text: Option<&str>) -> UiEvent {
1128 UiEvent {
1129 path: None,
1130 key: Some(target.key.clone()),
1131 target: Some(target),
1132 pointer: Some(pointer),
1133 key_press: None,
1134 text: text.map(str::to_string),
1135 selection: None,
1136 modifiers: KeyModifiers::default(),
1137 click_count: 1,
1138 kind: UiEventKind::MiddleClick,
1139 }
1140 }
1141
1142 fn ti_target() -> UiTarget {
1143 UiTarget {
1144 key: "ti".into(),
1145 node_id: "root.text_input[ti]".into(),
1146 rect: Rect::new(20.0, 20.0, 400.0, 36.0),
1147 tooltip: None,
1148 scroll_offset_y: 0.0,
1149 }
1150 }
1151
1152 fn content_children(el: &El) -> &[El] {
1160 assert_eq!(
1161 el.children.len(),
1162 1,
1163 "text_input wraps its content in a single inner group"
1164 );
1165 &el.children[0].children
1166 }
1167
1168 #[test]
1169 fn text_input_collapsed_renders_value_as_single_text_leaf_plus_caret() {
1170 let el = text_input("hello", TextSelection::caret(2));
1171 assert!(matches!(el.kind, Kind::Custom("text_input")));
1172 assert!(el.focusable);
1173 assert!(el.capture_keys);
1174 let cs = content_children(&el);
1178 assert_eq!(cs.len(), 2);
1179 assert!(matches!(cs[0].kind, Kind::Text));
1180 assert_eq!(cs[0].text.as_deref(), Some("hello"));
1181 assert!(matches!(cs[1].kind, Kind::Custom("text_input_caret")));
1182 assert!(cs[1].alpha_follows_focused_ancestor);
1183 }
1184
1185 #[test]
1186 fn text_input_declares_text_cursor() {
1187 let el = text_input("hello", TextSelection::caret(0));
1188 assert_eq!(el.cursor, Some(Cursor::Text));
1189 }
1190
1191 #[test]
1192 fn text_input_with_selection_inserts_selection_band_first() {
1193 let el = text_input("hello", TextSelection::range(2, 4));
1195 let cs = content_children(&el);
1196 assert_eq!(cs.len(), 3);
1198 assert!(matches!(cs[0].kind, Kind::Custom("text_input_selection")));
1199 assert_eq!(cs[1].text.as_deref(), Some("hello"));
1200 assert!(matches!(cs[2].kind, Kind::Custom("text_input_caret")));
1201 }
1202
1203 #[test]
1204 fn text_input_caret_translate_advances_with_head() {
1205 use crate::text::metrics::line_width;
1209 let value = "hello";
1210 let head = 3;
1211 let el = text_input(value, TextSelection::caret(head));
1212 let caret = content_children(&el)
1213 .iter()
1214 .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
1215 .expect("caret child");
1216 let expected = line_width(
1217 &value[..head],
1218 tokens::TEXT_SM.size,
1219 FontWeight::Regular,
1220 false,
1221 );
1222 assert!(
1223 (caret.translate.0 - expected).abs() < 0.01,
1224 "caret translate.x = {}, expected {}",
1225 caret.translate.0,
1226 expected
1227 );
1228 }
1229
1230 #[test]
1231 fn text_input_clamps_off_utf8_boundary() {
1232 let el = text_input("é", TextSelection::caret(1));
1236 let cs = content_children(&el);
1237 assert_eq!(cs[0].text.as_deref(), Some("é"));
1238 let caret = cs
1239 .iter()
1240 .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
1241 .expect("caret child");
1242 assert!(caret.translate.0.abs() < 0.01);
1244 }
1245
1246 #[test]
1247 fn selection_band_fill_dims_when_input_unfocused() {
1248 use crate::draw_ops::draw_ops;
1252 use crate::ir::DrawOp;
1253 use crate::shader::UniformValue;
1254 use crate::state::AnimationMode;
1255 use web_time::Instant;
1256
1257 let mut tree = crate::column([text_input("hello", TextSelection::range(0, 5)).key("ti")])
1258 .padding(20.0);
1259 let mut state = UiState::new();
1260 state.set_animation_mode(AnimationMode::Settled);
1261 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1262 state.sync_focus_order(&tree);
1263
1264 state.apply_to_state();
1268 state.tick_visual_animations(&mut tree, Instant::now());
1269 let unfocused = band_fill(&tree, &state).expect("band quad emitted");
1270 assert_eq!(
1271 (unfocused.r, unfocused.g, unfocused.b),
1272 (
1273 tokens::SELECTION_BG_UNFOCUSED.r,
1274 tokens::SELECTION_BG_UNFOCUSED.g,
1275 tokens::SELECTION_BG_UNFOCUSED.b
1276 ),
1277 "unfocused → band rgb is the muted token"
1278 );
1279
1280 let target = state
1283 .focus
1284 .order
1285 .iter()
1286 .find(|t| t.key == "ti")
1287 .expect("ti in focus order")
1288 .clone();
1289 state.set_focus(Some(target));
1290 state.apply_to_state();
1291 state.tick_visual_animations(&mut tree, Instant::now());
1292 let focused = band_fill(&tree, &state).expect("band quad emitted");
1293 assert_eq!(
1294 (focused.r, focused.g, focused.b),
1295 (
1296 tokens::SELECTION_BG.r,
1297 tokens::SELECTION_BG.g,
1298 tokens::SELECTION_BG.b
1299 ),
1300 "focused → band rgb is the saturated token"
1301 );
1302
1303 fn band_fill(tree: &El, state: &UiState) -> Option<crate::tree::Color> {
1304 let ops = draw_ops(tree, state);
1305 for op in ops {
1306 if let DrawOp::Quad { id, uniforms, .. } = op
1307 && id.contains("text_input_selection")
1308 && let Some(UniformValue::Color(c)) = uniforms.get("fill")
1309 {
1310 return Some(*c);
1311 }
1312 }
1313 None
1314 }
1315 }
1316
1317 #[test]
1318 fn caret_alpha_follows_focus_envelope() {
1319 use crate::draw_ops::draw_ops;
1324 use crate::ir::DrawOp;
1325 use crate::shader::UniformValue;
1326 use crate::state::AnimationMode;
1327 use web_time::Instant;
1328
1329 let mut tree =
1330 crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
1331 let mut state = UiState::new();
1332 state.set_animation_mode(AnimationMode::Settled);
1333 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1334 state.sync_focus_order(&tree);
1335
1336 state.apply_to_state();
1338 state.tick_visual_animations(&mut tree, Instant::now());
1339 let caret_alpha = caret_fill_alpha(&tree, &state);
1340 assert_eq!(caret_alpha, Some(0), "unfocused → caret invisible");
1341
1342 let target = state
1344 .focus
1345 .order
1346 .iter()
1347 .find(|t| t.key == "ti")
1348 .expect("ti in focus order")
1349 .clone();
1350 state.set_focus(Some(target));
1351 state.apply_to_state();
1352 state.tick_visual_animations(&mut tree, Instant::now());
1353 let caret_alpha = caret_fill_alpha(&tree, &state);
1354 assert_eq!(
1355 caret_alpha,
1356 Some(255),
1357 "focused → caret fully visible (alpha=255)"
1358 );
1359
1360 fn caret_fill_alpha(tree: &El, state: &UiState) -> Option<u8> {
1361 let ops = draw_ops(tree, state);
1362 for op in ops {
1363 if let DrawOp::Quad { id, uniforms, .. } = op
1364 && id.contains("text_input_caret")
1365 && let Some(UniformValue::Color(c)) = uniforms.get("fill")
1366 {
1367 return Some(c.a);
1368 }
1369 }
1370 None
1371 }
1372 }
1373
1374 #[test]
1375 fn caret_blink_alpha_holds_solid_through_grace_then_cycles() {
1376 use crate::state::caret_blink_alpha_for;
1379 use std::time::Duration;
1380 assert_eq!(caret_blink_alpha_for(Duration::from_millis(0)), 1.0);
1382 assert_eq!(caret_blink_alpha_for(Duration::from_millis(499)), 1.0);
1383 assert_eq!(caret_blink_alpha_for(Duration::from_millis(500)), 1.0);
1385 assert_eq!(caret_blink_alpha_for(Duration::from_millis(1029)), 1.0);
1386 assert_eq!(caret_blink_alpha_for(Duration::from_millis(1030)), 0.0);
1388 assert_eq!(caret_blink_alpha_for(Duration::from_millis(1559)), 0.0);
1389 assert_eq!(caret_blink_alpha_for(Duration::from_millis(1560)), 1.0);
1391 }
1392
1393 #[test]
1394 fn caret_paint_alpha_blinks_after_focus_in_live_mode() {
1395 use crate::draw_ops::draw_ops;
1399 use crate::ir::DrawOp;
1400 use crate::shader::UniformValue;
1401 use crate::state::AnimationMode;
1402 use std::time::Duration;
1403
1404 let mut tree =
1405 crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
1406 let mut state = UiState::new();
1407 state.set_animation_mode(AnimationMode::Live);
1408 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1409 state.sync_focus_order(&tree);
1410
1411 let target = state
1413 .focus
1414 .order
1415 .iter()
1416 .find(|t| t.key == "ti")
1417 .unwrap()
1418 .clone();
1419 state.set_focus(Some(target));
1420 let activity_at = state.caret.activity_at.expect("set_focus bumps activity");
1421 let input_id = tree.children[0].computed_id.clone();
1422
1423 let pin_focus = |state: &mut UiState| {
1427 state.animation.envelopes.insert(
1428 (input_id.clone(), crate::state::EnvelopeKind::FocusRing),
1429 1.0,
1430 );
1431 };
1432
1433 state.tick_visual_animations(&mut tree, activity_at);
1435 pin_focus(&mut state);
1436 assert_eq!(caret_alpha(&tree, &state), Some(255));
1437
1438 state.tick_visual_animations(&mut tree, activity_at + Duration::from_millis(1100));
1440 pin_focus(&mut state);
1441 assert_eq!(caret_alpha(&tree, &state), Some(0));
1442
1443 state.tick_visual_animations(&mut tree, activity_at + Duration::from_millis(1600));
1445 pin_focus(&mut state);
1446 assert_eq!(caret_alpha(&tree, &state), Some(255));
1447
1448 fn caret_alpha(tree: &El, state: &UiState) -> Option<u8> {
1449 for op in draw_ops(tree, state) {
1450 if let DrawOp::Quad { id, uniforms, .. } = op
1451 && id.contains("text_input_caret")
1452 && let Some(UniformValue::Color(c)) = uniforms.get("fill")
1453 {
1454 return Some(c.a);
1455 }
1456 }
1457 None
1458 }
1459 }
1460
1461 #[test]
1462 fn caret_blink_resumes_solid_after_selection_change() {
1463 use crate::state::AnimationMode;
1466 use std::time::Duration;
1467 use web_time::Instant;
1468
1469 let mut tree =
1470 crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
1471 let mut state = UiState::new();
1472 state.set_animation_mode(AnimationMode::Live);
1473 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1474 state.sync_focus_order(&tree);
1475
1476 let t0 = Instant::now();
1478 state.bump_caret_activity(t0);
1479 state.tick_visual_animations(&mut tree, t0 + Duration::from_millis(1100));
1480 assert_eq!(state.caret.blink_alpha, 0.0, "deep in off phase");
1481
1482 state.bump_caret_activity(t0 + Duration::from_millis(1100));
1484 assert_eq!(state.caret.blink_alpha, 1.0, "fresh activity → solid");
1485 }
1486
1487 #[test]
1488 fn caret_tick_requests_redraw_while_capture_keys_node_focused() {
1489 use crate::state::AnimationMode;
1493 use web_time::Instant;
1494
1495 let mut tree =
1496 crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
1497 let mut state = UiState::new();
1498 state.set_animation_mode(AnimationMode::Live);
1499 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1500 state.sync_focus_order(&tree);
1501
1502 let no_focus = state.tick_visual_animations(&mut tree, Instant::now());
1504 assert!(!no_focus, "without focus, blink doesn't request redraws");
1505
1506 let target = state
1509 .focus
1510 .order
1511 .iter()
1512 .find(|t| t.key == "ti")
1513 .unwrap()
1514 .clone();
1515 state.set_focus(Some(target));
1516 let focused = state.tick_visual_animations(&mut tree, Instant::now());
1517 assert!(focused, "focused capture_keys node → tick demands redraws");
1518 }
1519
1520 #[test]
1521 fn apply_text_input_inserts_at_caret_when_collapsed() {
1522 let mut value = String::from("ho");
1523 let mut sel = TextSelection::caret(1);
1524 assert!(apply_event(&mut value, &mut sel, &ev_text("i, t")));
1525 assert_eq!(value, "hi, to");
1526 assert_eq!(sel, TextSelection::caret(5));
1527 }
1528
1529 #[test]
1530 fn apply_text_input_replaces_selection() {
1531 let mut value = String::from("hello world");
1532 let mut sel = TextSelection::range(6, 11); assert!(apply_event(&mut value, &mut sel, &ev_text("kit")));
1534 assert_eq!(value, "hello kit");
1535 assert_eq!(sel, TextSelection::caret(9));
1536 }
1537
1538 #[test]
1539 fn apply_backspace_removes_selection_when_non_empty() {
1540 let mut value = String::from("hello world");
1541 let mut sel = TextSelection::range(6, 11);
1542 assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::Backspace)));
1543 assert_eq!(value, "hello ");
1544 assert_eq!(sel, TextSelection::caret(6));
1545 }
1546
1547 #[test]
1548 fn apply_delete_removes_selection_when_non_empty() {
1549 let mut value = String::from("hello world");
1550 let mut sel = TextSelection::range(0, 6); assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::Delete)));
1552 assert_eq!(value, "world");
1553 assert_eq!(sel, TextSelection::caret(0));
1554 }
1555
1556 #[test]
1557 fn apply_escape_collapses_selection_without_editing() {
1558 let mut value = String::from("hello");
1559 let mut sel = TextSelection::range(1, 4);
1560 assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::Escape)));
1561 assert_eq!(value, "hello");
1562 assert_eq!(sel, TextSelection::caret(4));
1563 assert!(!apply_event(&mut value, &mut sel, &ev_key(UiKey::Escape)));
1564 }
1565
1566 #[test]
1567 fn apply_backspace_collapsed_at_start_is_noop() {
1568 let mut value = String::from("hi");
1569 let mut sel = TextSelection::caret(0);
1570 assert!(!apply_event(
1571 &mut value,
1572 &mut sel,
1573 &ev_key(UiKey::Backspace)
1574 ));
1575 }
1576
1577 #[test]
1578 fn apply_arrow_walks_utf8_boundaries() {
1579 let mut value = String::from("aé");
1580 let mut sel = TextSelection::caret(0);
1581 apply_event(&mut value, &mut sel, &ev_key(UiKey::ArrowRight));
1582 assert_eq!(sel.head, 1);
1583 apply_event(&mut value, &mut sel, &ev_key(UiKey::ArrowRight));
1584 assert_eq!(sel.head, 3);
1585 assert!(!apply_event(
1586 &mut value,
1587 &mut sel,
1588 &ev_key(UiKey::ArrowRight)
1589 ));
1590 apply_event(&mut value, &mut sel, &ev_key(UiKey::ArrowLeft));
1591 assert_eq!(sel.head, 1);
1592 }
1593
1594 #[test]
1595 fn apply_arrow_collapses_selection_without_shift() {
1596 let mut value = String::from("hello");
1597 let mut sel = TextSelection::range(1, 4); assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::ArrowLeft)));
1601 assert_eq!(sel, TextSelection::caret(1));
1602
1603 let mut sel = TextSelection::range(1, 4);
1604 assert!(apply_event(
1606 &mut value,
1607 &mut sel,
1608 &ev_key(UiKey::ArrowRight)
1609 ));
1610 assert_eq!(sel, TextSelection::caret(4));
1611 }
1612
1613 #[test]
1614 fn apply_shift_arrow_extends_selection() {
1615 let mut value = String::from("hello");
1616 let mut sel = TextSelection::caret(2);
1617 let shift = KeyModifiers {
1618 shift: true,
1619 ..Default::default()
1620 };
1621 assert!(apply_event(
1622 &mut value,
1623 &mut sel,
1624 &ev_key_with_mods(UiKey::ArrowRight, shift)
1625 ));
1626 assert_eq!(sel, TextSelection::range(2, 3));
1627 assert!(apply_event(
1628 &mut value,
1629 &mut sel,
1630 &ev_key_with_mods(UiKey::ArrowRight, shift)
1631 ));
1632 assert_eq!(sel, TextSelection::range(2, 4));
1633 assert!(apply_event(
1635 &mut value,
1636 &mut sel,
1637 &ev_key_with_mods(UiKey::ArrowLeft, shift)
1638 ));
1639 assert_eq!(sel, TextSelection::range(2, 3));
1640 }
1641
1642 #[test]
1643 fn apply_home_end_collapse_or_extend() {
1644 let mut value = String::from("hello");
1645 let mut sel = TextSelection::caret(2);
1646 assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::End)));
1647 assert_eq!(sel, TextSelection::caret(5));
1648 assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::Home)));
1649 assert_eq!(sel, TextSelection::caret(0));
1650
1651 let shift = KeyModifiers {
1653 shift: true,
1654 ..Default::default()
1655 };
1656 let mut sel = TextSelection::caret(2);
1657 assert!(apply_event(
1658 &mut value,
1659 &mut sel,
1660 &ev_key_with_mods(UiKey::End, shift)
1661 ));
1662 assert_eq!(sel, TextSelection::range(2, 5));
1663 }
1664
1665 #[test]
1666 fn apply_ctrl_a_selects_all() {
1667 let mut value = String::from("hello");
1668 let mut sel = TextSelection::caret(2);
1669 let ctrl = KeyModifiers {
1670 ctrl: true,
1671 ..Default::default()
1672 };
1673 assert!(apply_event(
1674 &mut value,
1675 &mut sel,
1676 &ev_key_with_mods(UiKey::Character("a".into()), ctrl)
1677 ));
1678 assert_eq!(sel, TextSelection::range(0, 5));
1679 assert!(!apply_event(
1681 &mut value,
1682 &mut sel,
1683 &ev_key_with_mods(UiKey::Character("a".into()), ctrl)
1684 ));
1685 }
1686
1687 #[test]
1688 fn apply_pointer_down_sets_anchor_and_head() {
1689 let mut value = String::from("hello");
1690 let mut sel = TextSelection::range(0, 5);
1691 let down = ev_pointer_down(
1693 ti_target(),
1694 (ti_target().rect.x + 1.0, ti_target().rect.y + 18.0),
1695 KeyModifiers::default(),
1696 );
1697 assert!(apply_event(&mut value, &mut sel, &down));
1698 assert_eq!(sel, TextSelection::caret(0));
1699 }
1700
1701 #[test]
1702 fn apply_double_click_selects_word_at_caret() {
1703 let mut value = String::from("hello world");
1704 let mut sel = TextSelection::caret(0);
1705 let target = ti_target();
1707 let click_x = target.rect.x
1708 + tokens::SPACE_3
1709 + crate::text::metrics::line_width(
1710 "hello w",
1711 tokens::TEXT_SM.size,
1712 FontWeight::Regular,
1713 false,
1714 );
1715 let down = ev_pointer_down_with_count(
1716 target.clone(),
1717 (click_x, target.rect.y + 18.0),
1718 KeyModifiers::default(),
1719 2,
1720 );
1721 assert!(apply_event(&mut value, &mut sel, &down));
1722 assert_eq!(sel.anchor, 6);
1724 assert_eq!(sel.head, 11);
1725 }
1726
1727 #[test]
1728 fn apply_triple_click_selects_all() {
1729 let mut value = String::from("hello world");
1730 let mut sel = TextSelection::caret(0);
1731 let target = ti_target();
1732 let down = ev_pointer_down_with_count(
1733 target.clone(),
1734 (target.rect.x + 1.0, target.rect.y + 18.0),
1735 KeyModifiers::default(),
1736 3,
1737 );
1738 assert!(apply_event(&mut value, &mut sel, &down));
1739 assert_eq!(sel.anchor, 0);
1740 assert_eq!(sel.head, value.len());
1741 }
1742
1743 #[test]
1744 fn apply_shift_double_click_falls_back_to_extend_not_word_select() {
1745 let mut value = String::from("hello world");
1748 let mut sel = TextSelection::caret(0);
1749 let target = ti_target();
1750 let click_x = target.rect.x
1751 + tokens::SPACE_3
1752 + crate::text::metrics::line_width(
1753 "hello w",
1754 tokens::TEXT_SM.size,
1755 FontWeight::Regular,
1756 false,
1757 );
1758 let shift = KeyModifiers {
1759 shift: true,
1760 ..Default::default()
1761 };
1762 let down =
1763 ev_pointer_down_with_count(target.clone(), (click_x, target.rect.y + 18.0), shift, 2);
1764 assert!(apply_event(&mut value, &mut sel, &down));
1765 assert_eq!(sel.anchor, 0);
1767 assert!(sel.head > 0 && sel.head < value.len());
1768 }
1769
1770 #[test]
1771 fn apply_shift_pointer_down_only_moves_head() {
1772 let mut value = String::from("hello");
1773 let mut sel = TextSelection::caret(2);
1774 let shift = KeyModifiers {
1775 shift: true,
1776 ..Default::default()
1777 };
1778 let down = ev_pointer_down(
1780 ti_target(),
1781 (
1782 ti_target().rect.x + ti_target().rect.w - 4.0,
1783 ti_target().rect.y + 18.0,
1784 ),
1785 shift,
1786 );
1787 assert!(apply_event(&mut value, &mut sel, &down));
1788 assert_eq!(sel.anchor, 2);
1789 assert_eq!(sel.head, value.len());
1790 }
1791
1792 #[test]
1793 fn apply_drag_extends_head_only() {
1794 let mut value = String::from("hello world");
1795 let mut sel = TextSelection::caret(0);
1796 let down = ev_pointer_down(
1798 ti_target(),
1799 (ti_target().rect.x + 1.0, ti_target().rect.y + 18.0),
1800 KeyModifiers::default(),
1801 );
1802 apply_event(&mut value, &mut sel, &down);
1803 assert_eq!(sel, TextSelection::caret(0));
1804 let drag = ev_drag(
1806 ti_target(),
1807 (
1808 ti_target().rect.x + ti_target().rect.w - 4.0,
1809 ti_target().rect.y + 18.0,
1810 ),
1811 );
1812 assert!(apply_event(&mut value, &mut sel, &drag));
1813 assert_eq!(sel.anchor, 0);
1814 assert_eq!(sel.head, value.len());
1815 }
1816
1817 #[test]
1818 fn apply_click_is_noop_for_selection() {
1819 let mut value = String::from("hello");
1823 let mut sel = TextSelection::range(0, 5);
1824 let click = UiEvent {
1825 path: None,
1826 key: Some("ti".into()),
1827 target: Some(ti_target()),
1828 pointer: Some((ti_target().rect.x + 1.0, ti_target().rect.y + 18.0)),
1829 key_press: None,
1830 text: None,
1831 selection: None,
1832 modifiers: KeyModifiers::default(),
1833 click_count: 1,
1834 kind: UiEventKind::Click,
1835 };
1836 assert!(!apply_event(&mut value, &mut sel, &click));
1837 assert_eq!(sel, TextSelection::range(0, 5));
1838 }
1839
1840 #[test]
1841 fn apply_middle_click_inserts_event_text_at_pointer() {
1842 let mut value = String::from("world");
1843 let mut sel = TextSelection::caret(value.len());
1844 let target = ti_target();
1845 let pointer = (
1846 target.rect.x + tokens::SPACE_3,
1847 target.rect.y + target.rect.h * 0.5,
1848 );
1849 let event = ev_middle_click(target, pointer, Some("hello "));
1850 assert!(apply_event(&mut value, &mut sel, &event));
1851 assert_eq!(value, "hello world");
1852 assert_eq!(sel, TextSelection::caret("hello ".len()));
1853 }
1854
1855 #[test]
1856 fn helpers_selected_text_and_replace_selection() {
1857 let value = String::from("hello world");
1858 let sel = TextSelection::range(6, 11);
1859 assert_eq!(selected_text(&value, sel), "world");
1860
1861 let mut value = value;
1862 let mut sel = sel;
1863 replace_selection(&mut value, &mut sel, "kit");
1864 assert_eq!(value, "hello kit");
1865 assert_eq!(sel, TextSelection::caret(9));
1866
1867 assert_eq!(select_all(&value), TextSelection::range(0, value.len()));
1868 }
1869
1870 #[test]
1871 fn apply_text_input_filters_control_chars() {
1872 let mut value = String::from("hi");
1876 let mut sel = TextSelection::caret(2);
1877 for ctrl in ["\u{8}", "\u{7f}", "\r", "\n", "\u{1b}", "\t"] {
1878 assert!(
1879 !apply_event(&mut value, &mut sel, &ev_text(ctrl)),
1880 "expected {ctrl:?} to be filtered"
1881 );
1882 assert_eq!(value, "hi");
1883 assert_eq!(sel, TextSelection::caret(2));
1884 }
1885 assert!(apply_event(&mut value, &mut sel, &ev_text("a\u{8}b")));
1887 assert_eq!(value, "hiab");
1888 assert_eq!(sel, TextSelection::caret(4));
1889 }
1890
1891 #[test]
1892 fn apply_text_input_drops_when_ctrl_or_cmd_is_held() {
1893 let mut value = String::from("hello");
1898 let mut sel = TextSelection::range(0, 5);
1899 let ctrl = KeyModifiers {
1900 ctrl: true,
1901 ..Default::default()
1902 };
1903 let cmd = KeyModifiers {
1904 logo: true,
1905 ..Default::default()
1906 };
1907 assert!(!apply_event(
1908 &mut value,
1909 &mut sel,
1910 &ev_text_with_mods("c", ctrl)
1911 ));
1912 assert_eq!(value, "hello");
1913 assert!(!apply_event(
1914 &mut value,
1915 &mut sel,
1916 &ev_text_with_mods("v", cmd)
1917 ));
1918 assert_eq!(value, "hello");
1919 let altgr = KeyModifiers {
1921 ctrl: true,
1922 alt: true,
1923 ..Default::default()
1924 };
1925 let mut value = String::from("");
1926 let mut sel = TextSelection::caret(0);
1927 assert!(apply_event(
1928 &mut value,
1929 &mut sel,
1930 &ev_text_with_mods("é", altgr)
1931 ));
1932 assert_eq!(value, "é");
1933 }
1934
1935 #[test]
1936 fn text_input_value_emits_a_single_glyph_run() {
1937 use crate::draw_ops::draw_ops;
1943 use crate::ir::DrawOp;
1944 let mut tree =
1945 crate::column([text_input("Type", TextSelection::caret(1)).key("ti")]).padding(20.0);
1946 let mut state = UiState::new();
1947 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1948
1949 let ops = draw_ops(&tree, &state);
1950 let glyph_runs = ops
1951 .iter()
1952 .filter(|op| matches!(op, DrawOp::GlyphRun { id, .. } if id.contains("text_input[ti]")))
1953 .count();
1954 assert_eq!(
1955 glyph_runs, 1,
1956 "value should shape as one run; got {glyph_runs}"
1957 );
1958 }
1959
1960 #[test]
1961 fn clipboard_request_detects_ctrl_c_x_v() {
1962 let ctrl = KeyModifiers {
1963 ctrl: true,
1964 ..Default::default()
1965 };
1966 let cases = [
1967 ("c", ClipboardKind::Copy),
1968 ("C", ClipboardKind::Copy),
1969 ("x", ClipboardKind::Cut),
1970 ("v", ClipboardKind::Paste),
1971 ];
1972 for (ch, expected) in cases {
1973 let e = ev_key_with_mods(UiKey::Character(ch.into()), ctrl);
1974 assert_eq!(clipboard_request(&e), Some(expected), "char {ch:?}");
1975 }
1976 }
1977
1978 #[test]
1979 fn clipboard_request_accepts_cmd_on_macos() {
1980 let logo = KeyModifiers {
1983 logo: true,
1984 ..Default::default()
1985 };
1986 let e = ev_key_with_mods(UiKey::Character("c".into()), logo);
1987 assert_eq!(clipboard_request(&e), Some(ClipboardKind::Copy));
1988 }
1989
1990 #[test]
1991 fn clipboard_request_rejects_with_shift_or_alt() {
1992 let e = ev_key_with_mods(
1994 UiKey::Character("c".into()),
1995 KeyModifiers {
1996 ctrl: true,
1997 shift: true,
1998 ..Default::default()
1999 },
2000 );
2001 assert_eq!(clipboard_request(&e), None);
2002
2003 let e = ev_key_with_mods(
2004 UiKey::Character("v".into()),
2005 KeyModifiers {
2006 ctrl: true,
2007 alt: true,
2008 ..Default::default()
2009 },
2010 );
2011 assert_eq!(clipboard_request(&e), None);
2012 }
2013
2014 #[test]
2015 fn clipboard_request_ignores_other_keys_and_event_kinds() {
2016 let e = ev_key(UiKey::Character("c".into()));
2018 assert_eq!(clipboard_request(&e), None);
2019 let e = ev_key_with_mods(
2021 UiKey::Character("a".into()),
2022 KeyModifiers {
2023 ctrl: true,
2024 ..Default::default()
2025 },
2026 );
2027 assert_eq!(clipboard_request(&e), None);
2028 assert_eq!(clipboard_request(&ev_text("c")), None);
2030 }
2031
2032 fn password_opts() -> TextInputOpts<'static> {
2033 TextInputOpts::default().password()
2034 }
2035
2036 #[test]
2037 fn password_input_renders_value_as_bullets_not_plaintext() {
2038 let el = text_input_with("hunter2", TextSelection::caret(0), password_opts());
2041 let leaf = content_children(&el)
2042 .iter()
2043 .find(|c| matches!(c.kind, Kind::Text))
2044 .expect("text leaf");
2045 assert_eq!(leaf.text.as_deref(), Some("•••••••"));
2046 }
2047
2048 #[test]
2049 fn password_input_caret_position_uses_masked_widths() {
2050 use crate::text::metrics::line_width;
2054 let value = "abc";
2055 let head = 2;
2056 let el = text_input_with(value, TextSelection::caret(head), password_opts());
2057 let caret = content_children(&el)
2058 .iter()
2059 .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
2060 .expect("caret child");
2061 let expected = line_width("••", tokens::TEXT_SM.size, FontWeight::Regular, false);
2063 assert!(
2064 (caret.translate.0 - expected).abs() < 0.01,
2065 "caret translate.x = {}, expected {}",
2066 caret.translate.0,
2067 expected
2068 );
2069 }
2070
2071 #[test]
2072 fn password_pointer_click_maps_back_to_original_byte() {
2073 let mut value = String::from("abcde");
2076 let mut sel = TextSelection::default();
2077 let target = ti_target();
2078 let down = ev_pointer_down(
2079 target.clone(),
2080 (target.rect.x + target.rect.w - 4.0, target.rect.y + 18.0),
2081 KeyModifiers::default(),
2082 );
2083 assert!(apply_event_with(
2084 &mut value,
2085 &mut sel,
2086 &down,
2087 &password_opts()
2088 ));
2089 assert_eq!(sel.head, value.len());
2090 }
2091
2092 #[test]
2093 fn password_pointer_click_with_multibyte_value() {
2094 let mut value = String::from("éé");
2098 let mut sel = TextSelection::default();
2099 let target = ti_target();
2100 let bullet_w = metrics::line_width("•", tokens::TEXT_SM.size, FontWeight::Regular, false);
2102 let click_x = target.rect.x + tokens::SPACE_3 + bullet_w * 1.4;
2103 let down = ev_pointer_down(
2104 target,
2105 (click_x, ti_target().rect.y + 18.0),
2106 KeyModifiers::default(),
2107 );
2108 assert!(apply_event_with(
2109 &mut value,
2110 &mut sel,
2111 &down,
2112 &password_opts()
2113 ));
2114 assert!(
2118 value.is_char_boundary(sel.head),
2119 "head={} not on a char boundary in {value:?}",
2120 sel.head
2121 );
2122 assert!(sel.head == 2 || sel.head == 4, "head={}", sel.head);
2123 }
2124
2125 #[test]
2126 fn password_clipboard_request_suppresses_copy_and_cut_only() {
2127 let ctrl = KeyModifiers {
2128 ctrl: true,
2129 ..Default::default()
2130 };
2131 let opts = password_opts();
2132 let copy = ev_key_with_mods(UiKey::Character("c".into()), ctrl);
2133 let cut = ev_key_with_mods(UiKey::Character("x".into()), ctrl);
2134 let paste = ev_key_with_mods(UiKey::Character("v".into()), ctrl);
2135 assert_eq!(clipboard_request_for(©, &opts), None);
2136 assert_eq!(clipboard_request_for(&cut, &opts), None);
2137 assert_eq!(
2138 clipboard_request_for(&paste, &opts),
2139 Some(ClipboardKind::Paste)
2140 );
2141 let plain = TextInputOpts::default();
2143 assert_eq!(
2144 clipboard_request_for(©, &plain),
2145 Some(ClipboardKind::Copy)
2146 );
2147 }
2148
2149 #[test]
2150 fn placeholder_renders_only_when_value_is_empty() {
2151 let opts = TextInputOpts::default().placeholder("Email");
2152 let empty = text_input_with("", TextSelection::default(), opts);
2153 let muted_leaf = content_children(&empty)
2154 .iter()
2155 .find(|c| matches!(c.kind, Kind::Text) && c.text.as_deref() == Some("Email"));
2156 assert!(muted_leaf.is_some(), "placeholder leaf should be present");
2157
2158 let nonempty = text_input_with("hi", TextSelection::caret(2), opts);
2159 let muted_leaf = content_children(&nonempty)
2160 .iter()
2161 .find(|c| matches!(c.kind, Kind::Text) && c.text.as_deref() == Some("Email"));
2162 assert!(
2163 muted_leaf.is_none(),
2164 "placeholder should not render once the field has a value"
2165 );
2166 }
2167
2168 #[test]
2169 fn long_value_with_caret_at_end_shifts_content_left_to_keep_caret_in_view() {
2170 use crate::tree::Size;
2179 let value = "abcdefghijklmnopqrstuvwxyz0123456789".repeat(2);
2180 let mut root = super::text_input(
2181 &value,
2182 &as_selection_in("ti", TextSelection::caret(value.len())),
2183 "ti",
2184 )
2185 .width(Size::Fixed(120.0));
2186 let mut ui_state = crate::state::UiState::new();
2187 crate::layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 120.0, 40.0));
2188
2189 let inner = &root.children[0];
2191 let text_leaf = inner
2192 .children
2193 .iter()
2194 .find(|c| matches!(c.kind, Kind::Text))
2195 .expect("text leaf");
2196 let leaf_rect = ui_state.rect(&text_leaf.computed_id);
2197
2198 let inner_rect = ui_state.rect(&inner.computed_id);
2202 assert!(
2203 leaf_rect.x < inner_rect.x,
2204 "text leaf rect.x={} should be left of inner rect.x={} after \
2205 horizontal caret-into-view; layout did not shift content",
2206 leaf_rect.x,
2207 inner_rect.x,
2208 );
2209 }
2210
2211 #[test]
2212 fn short_value_does_not_shift_content() {
2213 use crate::tree::Size;
2217 let mut root =
2218 super::text_input("hi", &as_selection_in("ti", TextSelection::caret(2)), "ti")
2219 .width(Size::Fixed(120.0));
2220 let mut ui_state = crate::state::UiState::new();
2221 crate::layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 120.0, 40.0));
2222
2223 let inner = &root.children[0];
2224 let text_leaf = inner
2225 .children
2226 .iter()
2227 .find(|c| matches!(c.kind, Kind::Text))
2228 .expect("text leaf");
2229 let leaf_rect = ui_state.rect(&text_leaf.computed_id);
2230 let inner_rect = ui_state.rect(&inner.computed_id);
2231 assert!(
2232 (leaf_rect.x - inner_rect.x).abs() < 0.5,
2233 "short value should not shift; got leaf.x={} inner.x={}",
2234 leaf_rect.x,
2235 inner_rect.x
2236 );
2237 }
2238
2239 fn as_selection_in(key: &str, sel: TextSelection) -> Selection {
2242 Selection {
2243 range: Some(SelectionRange {
2244 anchor: SelectionPoint::new(key, sel.anchor),
2245 head: SelectionPoint::new(key, sel.head),
2246 }),
2247 }
2248 }
2249
2250 #[test]
2251 fn max_length_truncates_text_input_inserts() {
2252 let mut value = String::from("ab");
2253 let mut sel = TextSelection::caret(2);
2254 let opts = TextInputOpts::default().max_length(4);
2255 assert!(apply_event_with(
2257 &mut value,
2258 &mut sel,
2259 &ev_text("cdef"),
2260 &opts
2261 ));
2262 assert_eq!(value, "abcd");
2263 assert_eq!(sel, TextSelection::caret(4));
2264 assert!(!apply_event_with(
2266 &mut value,
2267 &mut sel,
2268 &ev_text("z"),
2269 &opts
2270 ));
2271 assert_eq!(value, "abcd");
2272 }
2273
2274 #[test]
2275 fn max_length_replaces_selection_with_capacity_freed_by_removal() {
2276 let mut value = String::from("abc");
2279 let mut sel = TextSelection::range(0, 3); let opts = TextInputOpts::default().max_length(4);
2281 assert!(apply_event_with(
2282 &mut value,
2283 &mut sel,
2284 &ev_text("12345"),
2285 &opts
2286 ));
2287 assert_eq!(value, "1234");
2288 assert_eq!(sel, TextSelection::caret(4));
2289 }
2290
2291 #[test]
2292 fn replace_selection_with_max_length_clips_a_paste() {
2293 let mut value = String::from("ab");
2294 let mut sel = TextSelection::caret(2);
2295 let opts = TextInputOpts::default().max_length(5);
2296 let inserted = replace_selection_with(&mut value, &mut sel, "0123456789", &opts);
2298 assert_eq!(value, "ab012");
2299 assert_eq!(inserted, 3);
2300 assert_eq!(sel, TextSelection::caret(5));
2301 }
2302
2303 #[test]
2304 fn max_length_does_not_shrink_an_already_overlong_value() {
2305 let mut value = String::from("abcdef");
2308 let mut sel = TextSelection::caret(6);
2309 let opts = TextInputOpts::default().max_length(3);
2310 assert!(!apply_event_with(
2312 &mut value,
2313 &mut sel,
2314 &ev_text("z"),
2315 &opts
2316 ));
2317 assert_eq!(value, "abcdef");
2318 assert!(apply_event_with(
2321 &mut value,
2322 &mut sel,
2323 &ev_key(UiKey::Backspace),
2324 &opts
2325 ));
2326 assert_eq!(value, "abcde");
2327 }
2328
2329 #[test]
2330 fn end_to_end_drag_select_through_runner_core() {
2331 let mut value = String::from("hello world");
2335 let mut sel = TextSelection::default();
2336 let mut tree = crate::column([text_input(&value, sel).key("ti")]).padding(20.0);
2337 let mut core = RunnerCore::new();
2338 let mut state = UiState::new();
2339 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
2340 core.ui_state = state;
2341 core.snapshot(&tree, &mut Default::default());
2342
2343 let rect = core.rect_of_key("ti").expect("ti rect");
2344 let down_x = rect.x + 8.0;
2345 let drag_x = rect.x + 80.0;
2346 let cy = rect.y + rect.h * 0.5;
2347
2348 core.pointer_moved(down_x, cy);
2349 let down = core
2350 .pointer_down(down_x, cy, PointerButton::Primary)
2351 .into_iter()
2352 .find(|e| e.kind == UiEventKind::PointerDown)
2353 .expect("pointer_down emits PointerDown");
2354 assert!(apply_event(&mut value, &mut sel, &down));
2355
2356 let drag = core
2357 .pointer_moved(drag_x, cy)
2358 .events
2359 .into_iter()
2360 .find(|e| e.kind == UiEventKind::Drag)
2361 .expect("Drag while pressed");
2362 assert!(apply_event(&mut value, &mut sel, &drag));
2363
2364 let events = core.pointer_up(drag_x, cy, PointerButton::Primary);
2365 for e in &events {
2366 apply_event(&mut value, &mut sel, e);
2367 }
2368 assert!(
2369 !sel.is_collapsed(),
2370 "expected drag-select to leave a non-empty selection"
2371 );
2372 assert_eq!(
2373 sel.anchor, 0,
2374 "anchor should sit at the down position (caret 0)"
2375 );
2376 assert!(
2377 sel.head > 0 && sel.head <= value.len(),
2378 "head={} value.len={}",
2379 sel.head,
2380 value.len()
2381 );
2382 }
2383
2384 #[test]
2392 fn apply_event_writes_back_under_the_inputs_key() {
2393 let mut value = String::new();
2395 let mut sel = Selection::default();
2396 let event = ev_text("h");
2397 assert!(super::apply_event(&mut value, &mut sel, "name", &event));
2398 assert_eq!(value, "h");
2399 let r = sel.range.as_ref().expect("selection set");
2400 assert_eq!(r.anchor.key, "name");
2401 assert_eq!(r.head.key, "name");
2402 assert_eq!(r.head.byte, 1);
2403 }
2404
2405 #[test]
2406 fn apply_event_claims_selection_when_event_routed_from_elsewhere() {
2407 let mut value = String::new();
2413 let mut sel = Selection {
2414 range: Some(SelectionRange {
2415 anchor: SelectionPoint::new("para-a", 0),
2416 head: SelectionPoint::new("para-a", 5),
2417 }),
2418 };
2419 let event = ev_text("x");
2420 assert!(super::apply_event(&mut value, &mut sel, "email", &event));
2421 assert_eq!(value, "x");
2422 let r = sel.range.as_ref().unwrap();
2423 assert_eq!(r.anchor.key, "email", "selection ownership migrated");
2424 assert_eq!(r.head.byte, 1);
2425 }
2426
2427 #[test]
2428 fn apply_event_leaves_selection_alone_when_event_is_unhandled() {
2429 let mut value = String::from("hi");
2433 let mut sel = Selection {
2434 range: Some(SelectionRange {
2435 anchor: SelectionPoint::new("para-a", 0),
2436 head: SelectionPoint::new("para-a", 3),
2437 }),
2438 };
2439 let event = ev_key(UiKey::Other("F1".into()));
2440 assert!(!super::apply_event(&mut value, &mut sel, "name", &event));
2441 let r = sel.range.as_ref().unwrap();
2443 assert_eq!(r.anchor.key, "para-a");
2444 assert_eq!(r.head.byte, 3);
2445 }
2446
2447 #[test]
2448 fn text_input_renders_caret_at_local_byte_when_selection_is_within_key() {
2449 let sel = Selection::caret("name", 2);
2450 let el = super::text_input("hello", &sel, "name");
2451 assert_eq!(el.key.as_deref(), Some("name"));
2453 let caret = content_children(&el)
2455 .iter()
2456 .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
2457 .expect("caret child");
2458 let expected = metrics::line_width("he", tokens::TEXT_SM.size, FontWeight::Regular, false);
2459 assert!(
2460 (caret.translate.0 - expected).abs() < 0.01,
2461 "caret.x={} expected {}",
2462 caret.translate.0,
2463 expected
2464 );
2465 }
2466
2467 #[test]
2468 fn text_input_omits_caret_when_selection_lives_elsewhere() {
2469 let sel = Selection {
2476 range: Some(SelectionRange {
2477 anchor: SelectionPoint::new("other", 0),
2478 head: SelectionPoint::new("other", 5),
2479 }),
2480 };
2481 let el = super::text_input("hello", &sel, "name");
2482 let band = el
2483 .children
2484 .iter()
2485 .find(|c| matches!(c.kind, Kind::Custom("text_input_selection")));
2486 assert!(band.is_none(), "no band when selection lives elsewhere");
2487 let caret = el
2488 .children
2489 .iter()
2490 .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")));
2491 assert!(
2492 caret.is_none(),
2493 "no caret when selection lives elsewhere — focus-fade has nothing to bring back to byte 0"
2494 );
2495 }
2496}