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 let pos = caret_from_x(value, local_x, opts.mask);
678 if !event.modifiers.shift {
679 match event.click_count {
680 2 => {
681 extend_word_selection(value, selection, pos);
682 return true;
683 }
684 n if n >= 3 => {
685 selection.anchor = 0;
686 selection.head = value.len();
687 return true;
688 }
689 _ => {}
690 }
691 }
692 selection.head = pos;
693 true
694 }
695 UiEventKind::Click => false,
696 _ => false,
697 }
698}
699
700fn extend_word_selection(value: &str, selection: &mut TextSelection, pos: usize) {
701 let (selected_lo, selected_hi) = selection.ordered();
702 let (word_lo, word_hi) = crate::selection::word_range_at(value, pos);
703 if pos < selected_lo {
704 selection.anchor = selected_hi;
705 selection.head = word_lo;
706 } else {
707 selection.anchor = selected_lo;
708 selection.head = word_hi;
709 }
710}
711
712pub fn selected_text(value: &str, selection: TextSelection) -> &str {
715 let head = clamp_to_char_boundary(value, selection.head.min(value.len()));
716 let anchor = clamp_to_char_boundary(value, selection.anchor.min(value.len()));
717 &value[anchor.min(head)..anchor.max(head)]
718}
719
720pub fn replace_selection(value: &mut String, selection: &mut TextSelection, replacement: &str) {
724 selection.anchor = clamp_to_char_boundary(value, selection.anchor.min(value.len()));
725 selection.head = clamp_to_char_boundary(value, selection.head.min(value.len()));
726 let (lo, hi) = selection.ordered();
727 value.replace_range(lo..hi, replacement);
728 let new_caret = lo + replacement.len();
729 selection.anchor = new_caret;
730 selection.head = new_caret;
731}
732
733pub fn replace_selection_with(
740 value: &mut String,
741 selection: &mut TextSelection,
742 replacement: &str,
743 opts: &TextInputOpts<'_>,
744) -> usize {
745 let clipped = clip_to_max_length(value, *selection, replacement, opts.max_length);
746 let len = clipped.len();
747 replace_selection(value, selection, &clipped);
748 len
749}
750
751pub fn select_all(value: &str) -> TextSelection {
753 TextSelection {
754 anchor: 0,
755 head: value.len(),
756 }
757}
758
759#[derive(Clone, Copy, Debug, PartialEq, Eq)]
770pub enum ClipboardKind {
771 Copy,
773 Cut,
775 Paste,
777}
778
779pub fn clipboard_request(event: &UiEvent) -> Option<ClipboardKind> {
825 clipboard_request_for(event, &TextInputOpts::default())
826}
827
828pub fn clipboard_request_for(event: &UiEvent, opts: &TextInputOpts<'_>) -> Option<ClipboardKind> {
832 if event.kind != UiEventKind::KeyDown {
833 return None;
834 }
835 let kp = event.key_press.as_ref()?;
836 let mods = kp.modifiers;
837 if mods.alt || mods.shift {
840 return None;
841 }
842 if !(mods.ctrl || mods.logo) {
844 return None;
845 }
846 let UiKey::Character(c) = &kp.key else {
847 return None;
848 };
849 let kind = match c.to_ascii_lowercase().as_str() {
850 "c" => ClipboardKind::Copy,
851 "x" => ClipboardKind::Cut,
852 "v" => ClipboardKind::Paste,
853 _ => return None,
854 };
855 if opts.is_masked() && matches!(kind, ClipboardKind::Copy | ClipboardKind::Cut) {
856 return None;
857 }
858 Some(kind)
859}
860
861#[track_caller]
871pub fn caret_byte_at(value: &str, event: &UiEvent, opts: &TextInputOpts<'_>) -> Option<usize> {
872 let (px, _py) = event.pointer?;
873 let target = event.target.as_ref()?;
874 let local_x = px - target.rect.x - tokens::SPACE_3;
875 Some(caret_from_x(value, local_x, opts.mask))
876}
877
878fn current_x_offset(value: &str, head: usize, viewport_w: f32, mask: MaskMode) -> f32 {
890 if viewport_w <= 0.0 {
891 return 0.0;
892 }
893 let head = clamp_to_char_boundary(value, head.min(value.len()));
894 let display = display_str(value, mask);
895 let geometry = single_line_geometry(&display);
896 let head_display = original_to_display_byte(value, head, mask);
897 let head_px = geometry.prefix_width(head_display);
898 (head_px - viewport_w).max(0.0)
899}
900
901fn caret_from_x(value: &str, local_x: f32, mask: MaskMode) -> usize {
902 if value.is_empty() || local_x <= 0.0 {
903 return 0;
904 }
905 let probe = display_str(value, mask);
906 let local_y = line_height_px() * 0.5;
907 let geometry = single_line_geometry(&probe);
908 let display_byte = match geometry.hit_byte(local_x, local_y) {
909 Some(byte) => byte.min(probe.len()),
910 None => probe.len(),
911 };
912 display_to_original_byte(value, display_byte, mask)
913}
914
915fn display_str(value: &str, mask: MaskMode) -> Cow<'_, str> {
920 match mask {
921 MaskMode::None => Cow::Borrowed(value),
922 MaskMode::Password => {
923 let n = value.chars().count();
924 let mut s = String::with_capacity(n * MASK_CHAR.len_utf8());
925 for _ in 0..n {
926 s.push(MASK_CHAR);
927 }
928 Cow::Owned(s)
929 }
930 }
931}
932
933fn original_to_display_byte(value: &str, byte_index: usize, mask: MaskMode) -> usize {
934 match mask {
935 MaskMode::None => byte_index.min(value.len()),
936 MaskMode::Password => {
937 let clamped = clamp_to_char_boundary(value, byte_index.min(value.len()));
938 value[..clamped].chars().count() * MASK_CHAR.len_utf8()
939 }
940 }
941}
942
943fn display_to_original_byte(value: &str, display_byte: usize, mask: MaskMode) -> usize {
945 match mask {
946 MaskMode::None => clamp_to_char_boundary(value, display_byte.min(value.len())),
947 MaskMode::Password => {
948 let scalar_idx = display_byte / MASK_CHAR.len_utf8();
949 value
950 .char_indices()
951 .nth(scalar_idx)
952 .map(|(i, _)| i)
953 .unwrap_or(value.len())
954 }
955 }
956}
957
958fn clip_to_max_length<'a>(
966 value: &str,
967 selection: TextSelection,
968 replacement: &'a str,
969 max_length: Option<usize>,
970) -> Cow<'a, str> {
971 let Some(max) = max_length else {
972 return Cow::Borrowed(replacement);
973 };
974 let lo = clamp_to_char_boundary(value, selection.anchor.min(selection.head).min(value.len()));
975 let hi = clamp_to_char_boundary(value, selection.anchor.max(selection.head).min(value.len()));
976 let post_other = value[..lo].chars().count() + value[hi..].chars().count();
977 let allowed = max.saturating_sub(post_other);
978 if replacement.chars().count() <= allowed {
979 Cow::Borrowed(replacement)
980 } else {
981 Cow::Owned(replacement.chars().take(allowed).collect())
982 }
983}
984
985fn clamp_to_char_boundary(s: &str, idx: usize) -> usize {
986 let mut idx = idx.min(s.len());
987 while idx > 0 && !s.is_char_boundary(idx) {
988 idx -= 1;
989 }
990 idx
991}
992
993fn prev_char_boundary(s: &str, from: usize) -> usize {
994 let mut i = from.saturating_sub(1);
995 while i > 0 && !s.is_char_boundary(i) {
996 i -= 1;
997 }
998 i
999}
1000
1001fn next_char_boundary(s: &str, from: usize) -> usize {
1002 let mut i = (from + 1).min(s.len());
1003 while i < s.len() && !s.is_char_boundary(i) {
1004 i += 1;
1005 }
1006 i
1007}
1008
1009#[cfg(test)]
1010mod tests {
1011 use super::*;
1012 use crate::event::{KeyModifiers, KeyPress, Pointer, PointerButton, UiTarget};
1013 use crate::layout::layout;
1014 use crate::runtime::RunnerCore;
1015 use crate::state::UiState;
1016 use crate::text::metrics;
1017
1018 const TEST_KEY: &str = "ti";
1023
1024 #[track_caller]
1029 fn text_input(value: &str, sel: TextSelection) -> El {
1030 super::text_input(value, &as_selection(sel), TEST_KEY)
1031 }
1032
1033 #[track_caller]
1034 fn text_input_with(value: &str, sel: TextSelection, opts: TextInputOpts<'_>) -> El {
1035 super::text_input_with(value, &as_selection(sel), TEST_KEY, opts)
1036 }
1037
1038 fn apply_event(value: &mut String, sel: &mut TextSelection, event: &UiEvent) -> bool {
1039 let mut g = as_selection(*sel);
1040 let changed = super::apply_event(value, &mut g, TEST_KEY, event);
1041 sync_back(sel, &g);
1042 changed
1043 }
1044
1045 fn apply_event_with(
1046 value: &mut String,
1047 sel: &mut TextSelection,
1048 event: &UiEvent,
1049 opts: &TextInputOpts<'_>,
1050 ) -> bool {
1051 let mut g = as_selection(*sel);
1052 let changed = super::apply_event_with(value, &mut g, TEST_KEY, event, opts);
1053 sync_back(sel, &g);
1054 changed
1055 }
1056
1057 fn as_selection(sel: TextSelection) -> Selection {
1058 Selection {
1059 range: Some(SelectionRange {
1060 anchor: SelectionPoint::new(TEST_KEY, sel.anchor),
1061 head: SelectionPoint::new(TEST_KEY, sel.head),
1062 }),
1063 }
1064 }
1065
1066 fn sync_back(local: &mut TextSelection, global: &Selection) {
1067 match global.within(TEST_KEY) {
1068 Some(view) => *local = view,
1069 None => *local = TextSelection::default(),
1070 }
1071 }
1072
1073 fn ev_text(s: &str) -> UiEvent {
1074 ev_text_with_mods(s, KeyModifiers::default())
1075 }
1076
1077 fn ev_text_with_mods(s: &str, modifiers: KeyModifiers) -> UiEvent {
1078 UiEvent {
1079 path: None,
1080 key: None,
1081 target: None,
1082 pointer: None,
1083 key_press: None,
1084 text: Some(s.into()),
1085 selection: None,
1086 modifiers,
1087 click_count: 0,
1088 pointer_kind: None,
1089 kind: UiEventKind::TextInput,
1090 }
1091 }
1092
1093 fn ev_key(key: UiKey) -> UiEvent {
1094 ev_key_with_mods(key, KeyModifiers::default())
1095 }
1096
1097 fn ev_key_with_mods(key: UiKey, modifiers: KeyModifiers) -> UiEvent {
1098 UiEvent {
1099 path: None,
1100 key: None,
1101 target: None,
1102 pointer: None,
1103 key_press: Some(KeyPress {
1104 key,
1105 modifiers,
1106 repeat: false,
1107 }),
1108 text: None,
1109 selection: None,
1110 modifiers,
1111 click_count: 0,
1112 pointer_kind: None,
1113 kind: UiEventKind::KeyDown,
1114 }
1115 }
1116
1117 fn ev_pointer_down(target: UiTarget, pointer: (f32, f32), modifiers: KeyModifiers) -> UiEvent {
1118 ev_pointer_down_with_count(target, pointer, modifiers, 1)
1119 }
1120
1121 fn ev_pointer_down_with_count(
1122 target: UiTarget,
1123 pointer: (f32, f32),
1124 modifiers: KeyModifiers,
1125 click_count: u8,
1126 ) -> UiEvent {
1127 UiEvent {
1128 path: None,
1129 key: Some(target.key.clone()),
1130 target: Some(target),
1131 pointer: Some(pointer),
1132 key_press: None,
1133 text: None,
1134 selection: None,
1135 modifiers,
1136 click_count,
1137 pointer_kind: None,
1138 kind: UiEventKind::PointerDown,
1139 }
1140 }
1141
1142 fn ev_drag(target: UiTarget, pointer: (f32, f32)) -> UiEvent {
1143 ev_drag_with_count(target, pointer, 0)
1144 }
1145
1146 fn ev_drag_with_count(target: UiTarget, pointer: (f32, f32), click_count: u8) -> UiEvent {
1147 UiEvent {
1148 path: None,
1149 key: Some(target.key.clone()),
1150 target: Some(target),
1151 pointer: Some(pointer),
1152 key_press: None,
1153 text: None,
1154 selection: None,
1155 modifiers: KeyModifiers::default(),
1156 click_count,
1157 pointer_kind: None,
1158 kind: UiEventKind::Drag,
1159 }
1160 }
1161
1162 fn ev_middle_click(target: UiTarget, pointer: (f32, f32), text: Option<&str>) -> UiEvent {
1163 UiEvent {
1164 path: None,
1165 key: Some(target.key.clone()),
1166 target: Some(target),
1167 pointer: Some(pointer),
1168 key_press: None,
1169 text: text.map(str::to_string),
1170 selection: None,
1171 modifiers: KeyModifiers::default(),
1172 click_count: 1,
1173 pointer_kind: None,
1174 kind: UiEventKind::MiddleClick,
1175 }
1176 }
1177
1178 fn ti_target() -> UiTarget {
1179 UiTarget {
1180 key: "ti".into(),
1181 node_id: "root.text_input[ti]".into(),
1182 rect: Rect::new(20.0, 20.0, 400.0, 36.0),
1183 tooltip: None,
1184 scroll_offset_y: 0.0,
1185 }
1186 }
1187
1188 fn content_children(el: &El) -> &[El] {
1196 assert_eq!(
1197 el.children.len(),
1198 1,
1199 "text_input wraps its content in a single inner group"
1200 );
1201 &el.children[0].children
1202 }
1203
1204 #[test]
1205 fn text_input_collapsed_renders_value_as_single_text_leaf_plus_caret() {
1206 let el = text_input("hello", TextSelection::caret(2));
1207 assert!(matches!(el.kind, Kind::Custom("text_input")));
1208 assert!(el.focusable);
1209 assert!(el.capture_keys);
1210 let cs = content_children(&el);
1214 assert_eq!(cs.len(), 2);
1215 assert!(matches!(cs[0].kind, Kind::Text));
1216 assert_eq!(cs[0].text.as_deref(), Some("hello"));
1217 assert!(matches!(cs[1].kind, Kind::Custom("text_input_caret")));
1218 assert!(cs[1].alpha_follows_focused_ancestor);
1219 }
1220
1221 #[test]
1222 fn text_input_declares_text_cursor() {
1223 let el = text_input("hello", TextSelection::caret(0));
1224 assert_eq!(el.cursor, Some(Cursor::Text));
1225 }
1226
1227 #[test]
1228 fn text_input_with_selection_inserts_selection_band_first() {
1229 let el = text_input("hello", TextSelection::range(2, 4));
1231 let cs = content_children(&el);
1232 assert_eq!(cs.len(), 3);
1234 assert!(matches!(cs[0].kind, Kind::Custom("text_input_selection")));
1235 assert_eq!(cs[1].text.as_deref(), Some("hello"));
1236 assert!(matches!(cs[2].kind, Kind::Custom("text_input_caret")));
1237 }
1238
1239 #[test]
1240 fn text_input_caret_translate_advances_with_head() {
1241 use crate::text::metrics::line_width;
1245 let value = "hello";
1246 let head = 3;
1247 let el = text_input(value, TextSelection::caret(head));
1248 let caret = content_children(&el)
1249 .iter()
1250 .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
1251 .expect("caret child");
1252 let expected = line_width(
1253 &value[..head],
1254 tokens::TEXT_SM.size,
1255 FontWeight::Regular,
1256 false,
1257 );
1258 assert!(
1259 (caret.translate.0 - expected).abs() < 0.01,
1260 "caret translate.x = {}, expected {}",
1261 caret.translate.0,
1262 expected
1263 );
1264 }
1265
1266 #[test]
1267 fn text_input_clamps_off_utf8_boundary() {
1268 let el = text_input("é", TextSelection::caret(1));
1272 let cs = content_children(&el);
1273 assert_eq!(cs[0].text.as_deref(), Some("é"));
1274 let caret = cs
1275 .iter()
1276 .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
1277 .expect("caret child");
1278 assert!(caret.translate.0.abs() < 0.01);
1280 }
1281
1282 #[test]
1283 fn selection_band_fill_dims_when_input_unfocused() {
1284 use crate::draw_ops::draw_ops;
1288 use crate::ir::DrawOp;
1289 use crate::shader::UniformValue;
1290 use crate::state::AnimationMode;
1291 use web_time::Instant;
1292
1293 let mut tree = crate::column([text_input("hello", TextSelection::range(0, 5)).key("ti")])
1294 .padding(20.0);
1295 let mut state = UiState::new();
1296 state.set_animation_mode(AnimationMode::Settled);
1297 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1298 state.sync_focus_order(&tree);
1299
1300 state.apply_to_state();
1304 state.tick_visual_animations(&mut tree, Instant::now());
1305 let unfocused = band_fill(&tree, &state).expect("band quad emitted");
1306 assert_eq!(
1307 (unfocused.r, unfocused.g, unfocused.b),
1308 (
1309 tokens::SELECTION_BG_UNFOCUSED.r,
1310 tokens::SELECTION_BG_UNFOCUSED.g,
1311 tokens::SELECTION_BG_UNFOCUSED.b
1312 ),
1313 "unfocused → band rgb is the muted token"
1314 );
1315
1316 let target = state
1319 .focus
1320 .order
1321 .iter()
1322 .find(|t| t.key == "ti")
1323 .expect("ti in focus order")
1324 .clone();
1325 state.set_focus(Some(target));
1326 state.apply_to_state();
1327 state.tick_visual_animations(&mut tree, Instant::now());
1328 let focused = band_fill(&tree, &state).expect("band quad emitted");
1329 assert_eq!(
1330 (focused.r, focused.g, focused.b),
1331 (
1332 tokens::SELECTION_BG.r,
1333 tokens::SELECTION_BG.g,
1334 tokens::SELECTION_BG.b
1335 ),
1336 "focused → band rgb is the saturated token"
1337 );
1338
1339 fn band_fill(tree: &El, state: &UiState) -> Option<crate::tree::Color> {
1340 let ops = draw_ops(tree, state);
1341 for op in ops {
1342 if let DrawOp::Quad { id, uniforms, .. } = op
1343 && id.contains("text_input_selection")
1344 && let Some(UniformValue::Color(c)) = uniforms.get("fill")
1345 {
1346 return Some(*c);
1347 }
1348 }
1349 None
1350 }
1351 }
1352
1353 #[test]
1354 fn caret_alpha_follows_focus_envelope() {
1355 use crate::draw_ops::draw_ops;
1360 use crate::ir::DrawOp;
1361 use crate::shader::UniformValue;
1362 use crate::state::AnimationMode;
1363 use web_time::Instant;
1364
1365 let mut tree =
1366 crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
1367 let mut state = UiState::new();
1368 state.set_animation_mode(AnimationMode::Settled);
1369 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1370 state.sync_focus_order(&tree);
1371
1372 state.apply_to_state();
1374 state.tick_visual_animations(&mut tree, Instant::now());
1375 let caret_alpha = caret_fill_alpha(&tree, &state);
1376 assert_eq!(caret_alpha, Some(0), "unfocused → caret invisible");
1377
1378 let target = state
1380 .focus
1381 .order
1382 .iter()
1383 .find(|t| t.key == "ti")
1384 .expect("ti in focus order")
1385 .clone();
1386 state.set_focus(Some(target));
1387 state.apply_to_state();
1388 state.tick_visual_animations(&mut tree, Instant::now());
1389 let caret_alpha = caret_fill_alpha(&tree, &state);
1390 assert_eq!(
1391 caret_alpha,
1392 Some(255),
1393 "focused → caret fully visible (alpha=255)"
1394 );
1395
1396 fn caret_fill_alpha(tree: &El, state: &UiState) -> Option<u8> {
1397 let ops = draw_ops(tree, state);
1398 for op in ops {
1399 if let DrawOp::Quad { id, uniforms, .. } = op
1400 && id.contains("text_input_caret")
1401 && let Some(UniformValue::Color(c)) = uniforms.get("fill")
1402 {
1403 return Some(c.a);
1404 }
1405 }
1406 None
1407 }
1408 }
1409
1410 #[test]
1411 fn caret_blink_alpha_holds_solid_through_grace_then_cycles() {
1412 use crate::state::caret_blink_alpha_for;
1415 use std::time::Duration;
1416 assert_eq!(caret_blink_alpha_for(Duration::from_millis(0)), 1.0);
1418 assert_eq!(caret_blink_alpha_for(Duration::from_millis(499)), 1.0);
1419 assert_eq!(caret_blink_alpha_for(Duration::from_millis(500)), 1.0);
1421 assert_eq!(caret_blink_alpha_for(Duration::from_millis(1029)), 1.0);
1422 assert_eq!(caret_blink_alpha_for(Duration::from_millis(1030)), 0.0);
1424 assert_eq!(caret_blink_alpha_for(Duration::from_millis(1559)), 0.0);
1425 assert_eq!(caret_blink_alpha_for(Duration::from_millis(1560)), 1.0);
1427 }
1428
1429 #[test]
1430 fn caret_paint_alpha_blinks_after_focus_in_live_mode() {
1431 use crate::draw_ops::draw_ops;
1435 use crate::ir::DrawOp;
1436 use crate::shader::UniformValue;
1437 use crate::state::AnimationMode;
1438 use std::time::Duration;
1439
1440 let mut tree =
1441 crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
1442 let mut state = UiState::new();
1443 state.set_animation_mode(AnimationMode::Live);
1444 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1445 state.sync_focus_order(&tree);
1446
1447 let target = state
1449 .focus
1450 .order
1451 .iter()
1452 .find(|t| t.key == "ti")
1453 .unwrap()
1454 .clone();
1455 state.set_focus(Some(target));
1456 let activity_at = state.caret.activity_at.expect("set_focus bumps activity");
1457 let input_id = tree.children[0].computed_id.clone();
1458
1459 let pin_focus = |state: &mut UiState| {
1463 state.animation.envelopes.insert(
1464 (input_id.clone(), crate::state::EnvelopeKind::FocusRing),
1465 1.0,
1466 );
1467 };
1468
1469 state.tick_visual_animations(&mut tree, activity_at);
1471 pin_focus(&mut state);
1472 assert_eq!(caret_alpha(&tree, &state), Some(255));
1473
1474 state.tick_visual_animations(&mut tree, activity_at + Duration::from_millis(1100));
1476 pin_focus(&mut state);
1477 assert_eq!(caret_alpha(&tree, &state), Some(0));
1478
1479 state.tick_visual_animations(&mut tree, activity_at + Duration::from_millis(1600));
1481 pin_focus(&mut state);
1482 assert_eq!(caret_alpha(&tree, &state), Some(255));
1483
1484 fn caret_alpha(tree: &El, state: &UiState) -> Option<u8> {
1485 for op in draw_ops(tree, state) {
1486 if let DrawOp::Quad { id, uniforms, .. } = op
1487 && id.contains("text_input_caret")
1488 && let Some(UniformValue::Color(c)) = uniforms.get("fill")
1489 {
1490 return Some(c.a);
1491 }
1492 }
1493 None
1494 }
1495 }
1496
1497 #[test]
1498 fn caret_blink_resumes_solid_after_selection_change() {
1499 use crate::state::AnimationMode;
1502 use std::time::Duration;
1503 use web_time::Instant;
1504
1505 let mut tree =
1506 crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
1507 let mut state = UiState::new();
1508 state.set_animation_mode(AnimationMode::Live);
1509 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1510 state.sync_focus_order(&tree);
1511
1512 let t0 = Instant::now();
1514 state.bump_caret_activity(t0);
1515 state.tick_visual_animations(&mut tree, t0 + Duration::from_millis(1100));
1516 assert_eq!(state.caret.blink_alpha, 0.0, "deep in off phase");
1517
1518 state.bump_caret_activity(t0 + Duration::from_millis(1100));
1520 assert_eq!(state.caret.blink_alpha, 1.0, "fresh activity → solid");
1521 }
1522
1523 #[test]
1524 fn caret_tick_requests_redraw_while_capture_keys_node_focused() {
1525 use crate::state::AnimationMode;
1529 use web_time::Instant;
1530
1531 let mut tree =
1532 crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
1533 let mut state = UiState::new();
1534 state.set_animation_mode(AnimationMode::Live);
1535 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1536 state.sync_focus_order(&tree);
1537
1538 let no_focus = state.tick_visual_animations(&mut tree, Instant::now());
1540 assert!(!no_focus, "without focus, blink doesn't request redraws");
1541
1542 let target = state
1545 .focus
1546 .order
1547 .iter()
1548 .find(|t| t.key == "ti")
1549 .unwrap()
1550 .clone();
1551 state.set_focus(Some(target));
1552 let focused = state.tick_visual_animations(&mut tree, Instant::now());
1553 assert!(focused, "focused capture_keys node → tick demands redraws");
1554 }
1555
1556 #[test]
1557 fn apply_text_input_inserts_at_caret_when_collapsed() {
1558 let mut value = String::from("ho");
1559 let mut sel = TextSelection::caret(1);
1560 assert!(apply_event(&mut value, &mut sel, &ev_text("i, t")));
1561 assert_eq!(value, "hi, to");
1562 assert_eq!(sel, TextSelection::caret(5));
1563 }
1564
1565 #[test]
1566 fn apply_text_input_replaces_selection() {
1567 let mut value = String::from("hello world");
1568 let mut sel = TextSelection::range(6, 11); assert!(apply_event(&mut value, &mut sel, &ev_text("kit")));
1570 assert_eq!(value, "hello kit");
1571 assert_eq!(sel, TextSelection::caret(9));
1572 }
1573
1574 #[test]
1575 fn apply_backspace_removes_selection_when_non_empty() {
1576 let mut value = String::from("hello world");
1577 let mut sel = TextSelection::range(6, 11);
1578 assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::Backspace)));
1579 assert_eq!(value, "hello ");
1580 assert_eq!(sel, TextSelection::caret(6));
1581 }
1582
1583 #[test]
1584 fn apply_delete_removes_selection_when_non_empty() {
1585 let mut value = String::from("hello world");
1586 let mut sel = TextSelection::range(0, 6); assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::Delete)));
1588 assert_eq!(value, "world");
1589 assert_eq!(sel, TextSelection::caret(0));
1590 }
1591
1592 #[test]
1593 fn apply_escape_collapses_selection_without_editing() {
1594 let mut value = String::from("hello");
1595 let mut sel = TextSelection::range(1, 4);
1596 assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::Escape)));
1597 assert_eq!(value, "hello");
1598 assert_eq!(sel, TextSelection::caret(4));
1599 assert!(!apply_event(&mut value, &mut sel, &ev_key(UiKey::Escape)));
1600 }
1601
1602 #[test]
1603 fn apply_backspace_collapsed_at_start_is_noop() {
1604 let mut value = String::from("hi");
1605 let mut sel = TextSelection::caret(0);
1606 assert!(!apply_event(
1607 &mut value,
1608 &mut sel,
1609 &ev_key(UiKey::Backspace)
1610 ));
1611 }
1612
1613 #[test]
1614 fn apply_arrow_walks_utf8_boundaries() {
1615 let mut value = String::from("aé");
1616 let mut sel = TextSelection::caret(0);
1617 apply_event(&mut value, &mut sel, &ev_key(UiKey::ArrowRight));
1618 assert_eq!(sel.head, 1);
1619 apply_event(&mut value, &mut sel, &ev_key(UiKey::ArrowRight));
1620 assert_eq!(sel.head, 3);
1621 assert!(!apply_event(
1622 &mut value,
1623 &mut sel,
1624 &ev_key(UiKey::ArrowRight)
1625 ));
1626 apply_event(&mut value, &mut sel, &ev_key(UiKey::ArrowLeft));
1627 assert_eq!(sel.head, 1);
1628 }
1629
1630 #[test]
1631 fn apply_arrow_collapses_selection_without_shift() {
1632 let mut value = String::from("hello");
1633 let mut sel = TextSelection::range(1, 4); assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::ArrowLeft)));
1637 assert_eq!(sel, TextSelection::caret(1));
1638
1639 let mut sel = TextSelection::range(1, 4);
1640 assert!(apply_event(
1642 &mut value,
1643 &mut sel,
1644 &ev_key(UiKey::ArrowRight)
1645 ));
1646 assert_eq!(sel, TextSelection::caret(4));
1647 }
1648
1649 #[test]
1650 fn apply_shift_arrow_extends_selection() {
1651 let mut value = String::from("hello");
1652 let mut sel = TextSelection::caret(2);
1653 let shift = KeyModifiers {
1654 shift: true,
1655 ..Default::default()
1656 };
1657 assert!(apply_event(
1658 &mut value,
1659 &mut sel,
1660 &ev_key_with_mods(UiKey::ArrowRight, shift)
1661 ));
1662 assert_eq!(sel, TextSelection::range(2, 3));
1663 assert!(apply_event(
1664 &mut value,
1665 &mut sel,
1666 &ev_key_with_mods(UiKey::ArrowRight, shift)
1667 ));
1668 assert_eq!(sel, TextSelection::range(2, 4));
1669 assert!(apply_event(
1671 &mut value,
1672 &mut sel,
1673 &ev_key_with_mods(UiKey::ArrowLeft, shift)
1674 ));
1675 assert_eq!(sel, TextSelection::range(2, 3));
1676 }
1677
1678 #[test]
1679 fn apply_home_end_collapse_or_extend() {
1680 let mut value = String::from("hello");
1681 let mut sel = TextSelection::caret(2);
1682 assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::End)));
1683 assert_eq!(sel, TextSelection::caret(5));
1684 assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::Home)));
1685 assert_eq!(sel, TextSelection::caret(0));
1686
1687 let shift = KeyModifiers {
1689 shift: true,
1690 ..Default::default()
1691 };
1692 let mut sel = TextSelection::caret(2);
1693 assert!(apply_event(
1694 &mut value,
1695 &mut sel,
1696 &ev_key_with_mods(UiKey::End, shift)
1697 ));
1698 assert_eq!(sel, TextSelection::range(2, 5));
1699 }
1700
1701 #[test]
1702 fn apply_ctrl_a_selects_all() {
1703 let mut value = String::from("hello");
1704 let mut sel = TextSelection::caret(2);
1705 let ctrl = KeyModifiers {
1706 ctrl: true,
1707 ..Default::default()
1708 };
1709 assert!(apply_event(
1710 &mut value,
1711 &mut sel,
1712 &ev_key_with_mods(UiKey::Character("a".into()), ctrl)
1713 ));
1714 assert_eq!(sel, TextSelection::range(0, 5));
1715 assert!(!apply_event(
1717 &mut value,
1718 &mut sel,
1719 &ev_key_with_mods(UiKey::Character("a".into()), ctrl)
1720 ));
1721 }
1722
1723 #[test]
1724 fn apply_pointer_down_sets_anchor_and_head() {
1725 let mut value = String::from("hello");
1726 let mut sel = TextSelection::range(0, 5);
1727 let down = ev_pointer_down(
1729 ti_target(),
1730 (ti_target().rect.x + 1.0, ti_target().rect.y + 18.0),
1731 KeyModifiers::default(),
1732 );
1733 assert!(apply_event(&mut value, &mut sel, &down));
1734 assert_eq!(sel, TextSelection::caret(0));
1735 }
1736
1737 #[test]
1738 fn apply_double_click_selects_word_at_caret() {
1739 let mut value = String::from("hello world");
1740 let mut sel = TextSelection::caret(0);
1741 let target = ti_target();
1743 let click_x = target.rect.x
1744 + tokens::SPACE_3
1745 + crate::text::metrics::line_width(
1746 "hello w",
1747 tokens::TEXT_SM.size,
1748 FontWeight::Regular,
1749 false,
1750 );
1751 let down = ev_pointer_down_with_count(
1752 target.clone(),
1753 (click_x, target.rect.y + 18.0),
1754 KeyModifiers::default(),
1755 2,
1756 );
1757 assert!(apply_event(&mut value, &mut sel, &down));
1758 assert_eq!(sel.anchor, 6);
1760 assert_eq!(sel.head, 11);
1761 }
1762
1763 #[test]
1764 fn apply_triple_click_selects_all() {
1765 let mut value = String::from("hello world");
1766 let mut sel = TextSelection::caret(0);
1767 let target = ti_target();
1768 let down = ev_pointer_down_with_count(
1769 target.clone(),
1770 (target.rect.x + 1.0, target.rect.y + 18.0),
1771 KeyModifiers::default(),
1772 3,
1773 );
1774 assert!(apply_event(&mut value, &mut sel, &down));
1775 assert_eq!(sel.anchor, 0);
1776 assert_eq!(sel.head, value.len());
1777 }
1778
1779 #[test]
1780 fn apply_shift_double_click_falls_back_to_extend_not_word_select() {
1781 let mut value = String::from("hello world");
1784 let mut sel = TextSelection::caret(0);
1785 let target = ti_target();
1786 let click_x = target.rect.x
1787 + tokens::SPACE_3
1788 + crate::text::metrics::line_width(
1789 "hello w",
1790 tokens::TEXT_SM.size,
1791 FontWeight::Regular,
1792 false,
1793 );
1794 let shift = KeyModifiers {
1795 shift: true,
1796 ..Default::default()
1797 };
1798 let down =
1799 ev_pointer_down_with_count(target.clone(), (click_x, target.rect.y + 18.0), shift, 2);
1800 assert!(apply_event(&mut value, &mut sel, &down));
1801 assert_eq!(sel.anchor, 0);
1803 assert!(sel.head > 0 && sel.head < value.len());
1804 }
1805
1806 #[test]
1807 fn apply_shift_pointer_down_only_moves_head() {
1808 let mut value = String::from("hello");
1809 let mut sel = TextSelection::caret(2);
1810 let shift = KeyModifiers {
1811 shift: true,
1812 ..Default::default()
1813 };
1814 let down = ev_pointer_down(
1816 ti_target(),
1817 (
1818 ti_target().rect.x + ti_target().rect.w - 4.0,
1819 ti_target().rect.y + 18.0,
1820 ),
1821 shift,
1822 );
1823 assert!(apply_event(&mut value, &mut sel, &down));
1824 assert_eq!(sel.anchor, 2);
1825 assert_eq!(sel.head, value.len());
1826 }
1827
1828 #[test]
1829 fn apply_drag_extends_head_only() {
1830 let mut value = String::from("hello world");
1831 let mut sel = TextSelection::caret(0);
1832 let down = ev_pointer_down(
1834 ti_target(),
1835 (ti_target().rect.x + 1.0, ti_target().rect.y + 18.0),
1836 KeyModifiers::default(),
1837 );
1838 apply_event(&mut value, &mut sel, &down);
1839 assert_eq!(sel, TextSelection::caret(0));
1840 let drag = ev_drag(
1842 ti_target(),
1843 (
1844 ti_target().rect.x + ti_target().rect.w - 4.0,
1845 ti_target().rect.y + 18.0,
1846 ),
1847 );
1848 assert!(apply_event(&mut value, &mut sel, &drag));
1849 assert_eq!(sel.anchor, 0);
1850 assert_eq!(sel.head, value.len());
1851 }
1852
1853 #[test]
1854 fn double_click_hold_drag_inside_word_keeps_word_selected() {
1855 let mut value = String::from("hello world");
1856 let mut sel = TextSelection::caret(0);
1857 let target = ti_target();
1858 let click_x = target.rect.x
1859 + tokens::SPACE_3
1860 + crate::text::metrics::line_width(
1861 "hello w",
1862 tokens::TEXT_SM.size,
1863 FontWeight::Regular,
1864 false,
1865 );
1866 let down = ev_pointer_down_with_count(
1867 target.clone(),
1868 (click_x, target.rect.y + 18.0),
1869 KeyModifiers::default(),
1870 2,
1871 );
1872 assert!(apply_event(&mut value, &mut sel, &down));
1873 assert_eq!(sel, TextSelection::range(6, 11));
1874
1875 let drag = ev_drag_with_count(target.clone(), (click_x + 1.0, target.rect.y + 18.0), 2);
1876 assert!(apply_event(&mut value, &mut sel, &drag));
1877 assert_eq!(sel, TextSelection::range(6, 11));
1878 }
1879
1880 #[test]
1881 fn apply_click_is_noop_for_selection() {
1882 let mut value = String::from("hello");
1886 let mut sel = TextSelection::range(0, 5);
1887 let click = UiEvent {
1888 path: None,
1889 key: Some("ti".into()),
1890 target: Some(ti_target()),
1891 pointer: Some((ti_target().rect.x + 1.0, ti_target().rect.y + 18.0)),
1892 key_press: None,
1893 text: None,
1894 selection: None,
1895 modifiers: KeyModifiers::default(),
1896 click_count: 1,
1897 pointer_kind: None,
1898 kind: UiEventKind::Click,
1899 };
1900 assert!(!apply_event(&mut value, &mut sel, &click));
1901 assert_eq!(sel, TextSelection::range(0, 5));
1902 }
1903
1904 #[test]
1905 fn apply_middle_click_inserts_event_text_at_pointer() {
1906 let mut value = String::from("world");
1907 let mut sel = TextSelection::caret(value.len());
1908 let target = ti_target();
1909 let pointer = (
1910 target.rect.x + tokens::SPACE_3,
1911 target.rect.y + target.rect.h * 0.5,
1912 );
1913 let event = ev_middle_click(target, pointer, Some("hello "));
1914 assert!(apply_event(&mut value, &mut sel, &event));
1915 assert_eq!(value, "hello world");
1916 assert_eq!(sel, TextSelection::caret("hello ".len()));
1917 }
1918
1919 #[test]
1920 fn helpers_selected_text_and_replace_selection() {
1921 let value = String::from("hello world");
1922 let sel = TextSelection::range(6, 11);
1923 assert_eq!(selected_text(&value, sel), "world");
1924
1925 let mut value = value;
1926 let mut sel = sel;
1927 replace_selection(&mut value, &mut sel, "kit");
1928 assert_eq!(value, "hello kit");
1929 assert_eq!(sel, TextSelection::caret(9));
1930
1931 assert_eq!(select_all(&value), TextSelection::range(0, value.len()));
1932 }
1933
1934 #[test]
1935 fn apply_text_input_filters_control_chars() {
1936 let mut value = String::from("hi");
1940 let mut sel = TextSelection::caret(2);
1941 for ctrl in ["\u{8}", "\u{7f}", "\r", "\n", "\u{1b}", "\t"] {
1942 assert!(
1943 !apply_event(&mut value, &mut sel, &ev_text(ctrl)),
1944 "expected {ctrl:?} to be filtered"
1945 );
1946 assert_eq!(value, "hi");
1947 assert_eq!(sel, TextSelection::caret(2));
1948 }
1949 assert!(apply_event(&mut value, &mut sel, &ev_text("a\u{8}b")));
1951 assert_eq!(value, "hiab");
1952 assert_eq!(sel, TextSelection::caret(4));
1953 }
1954
1955 #[test]
1956 fn apply_text_input_drops_when_ctrl_or_cmd_is_held() {
1957 let mut value = String::from("hello");
1962 let mut sel = TextSelection::range(0, 5);
1963 let ctrl = KeyModifiers {
1964 ctrl: true,
1965 ..Default::default()
1966 };
1967 let cmd = KeyModifiers {
1968 logo: true,
1969 ..Default::default()
1970 };
1971 assert!(!apply_event(
1972 &mut value,
1973 &mut sel,
1974 &ev_text_with_mods("c", ctrl)
1975 ));
1976 assert_eq!(value, "hello");
1977 assert!(!apply_event(
1978 &mut value,
1979 &mut sel,
1980 &ev_text_with_mods("v", cmd)
1981 ));
1982 assert_eq!(value, "hello");
1983 let altgr = KeyModifiers {
1985 ctrl: true,
1986 alt: true,
1987 ..Default::default()
1988 };
1989 let mut value = String::from("");
1990 let mut sel = TextSelection::caret(0);
1991 assert!(apply_event(
1992 &mut value,
1993 &mut sel,
1994 &ev_text_with_mods("é", altgr)
1995 ));
1996 assert_eq!(value, "é");
1997 }
1998
1999 #[test]
2000 fn text_input_value_emits_a_single_glyph_run() {
2001 use crate::draw_ops::draw_ops;
2007 use crate::ir::DrawOp;
2008 let mut tree =
2009 crate::column([text_input("Type", TextSelection::caret(1)).key("ti")]).padding(20.0);
2010 let mut state = UiState::new();
2011 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
2012
2013 let ops = draw_ops(&tree, &state);
2014 let glyph_runs = ops
2015 .iter()
2016 .filter(|op| matches!(op, DrawOp::GlyphRun { id, .. } if id.contains("text_input[ti]")))
2017 .count();
2018 assert_eq!(
2019 glyph_runs, 1,
2020 "value should shape as one run; got {glyph_runs}"
2021 );
2022 }
2023
2024 #[test]
2025 fn clipboard_request_detects_ctrl_c_x_v() {
2026 let ctrl = KeyModifiers {
2027 ctrl: true,
2028 ..Default::default()
2029 };
2030 let cases = [
2031 ("c", ClipboardKind::Copy),
2032 ("C", ClipboardKind::Copy),
2033 ("x", ClipboardKind::Cut),
2034 ("v", ClipboardKind::Paste),
2035 ];
2036 for (ch, expected) in cases {
2037 let e = ev_key_with_mods(UiKey::Character(ch.into()), ctrl);
2038 assert_eq!(clipboard_request(&e), Some(expected), "char {ch:?}");
2039 }
2040 }
2041
2042 #[test]
2043 fn clipboard_request_accepts_cmd_on_macos() {
2044 let logo = KeyModifiers {
2047 logo: true,
2048 ..Default::default()
2049 };
2050 let e = ev_key_with_mods(UiKey::Character("c".into()), logo);
2051 assert_eq!(clipboard_request(&e), Some(ClipboardKind::Copy));
2052 }
2053
2054 #[test]
2055 fn clipboard_request_rejects_with_shift_or_alt() {
2056 let e = ev_key_with_mods(
2058 UiKey::Character("c".into()),
2059 KeyModifiers {
2060 ctrl: true,
2061 shift: true,
2062 ..Default::default()
2063 },
2064 );
2065 assert_eq!(clipboard_request(&e), None);
2066
2067 let e = ev_key_with_mods(
2068 UiKey::Character("v".into()),
2069 KeyModifiers {
2070 ctrl: true,
2071 alt: true,
2072 ..Default::default()
2073 },
2074 );
2075 assert_eq!(clipboard_request(&e), None);
2076 }
2077
2078 #[test]
2079 fn clipboard_request_ignores_other_keys_and_event_kinds() {
2080 let e = ev_key(UiKey::Character("c".into()));
2082 assert_eq!(clipboard_request(&e), None);
2083 let e = ev_key_with_mods(
2085 UiKey::Character("a".into()),
2086 KeyModifiers {
2087 ctrl: true,
2088 ..Default::default()
2089 },
2090 );
2091 assert_eq!(clipboard_request(&e), None);
2092 assert_eq!(clipboard_request(&ev_text("c")), None);
2094 }
2095
2096 fn password_opts() -> TextInputOpts<'static> {
2097 TextInputOpts::default().password()
2098 }
2099
2100 #[test]
2101 fn password_input_renders_value_as_bullets_not_plaintext() {
2102 let el = text_input_with("hunter2", TextSelection::caret(0), password_opts());
2105 let leaf = content_children(&el)
2106 .iter()
2107 .find(|c| matches!(c.kind, Kind::Text))
2108 .expect("text leaf");
2109 assert_eq!(leaf.text.as_deref(), Some("•••••••"));
2110 }
2111
2112 #[test]
2113 fn password_input_caret_position_uses_masked_widths() {
2114 use crate::text::metrics::line_width;
2118 let value = "abc";
2119 let head = 2;
2120 let el = text_input_with(value, TextSelection::caret(head), password_opts());
2121 let caret = content_children(&el)
2122 .iter()
2123 .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
2124 .expect("caret child");
2125 let expected = line_width("••", tokens::TEXT_SM.size, FontWeight::Regular, false);
2127 assert!(
2128 (caret.translate.0 - expected).abs() < 0.01,
2129 "caret translate.x = {}, expected {}",
2130 caret.translate.0,
2131 expected
2132 );
2133 }
2134
2135 #[test]
2136 fn password_pointer_click_maps_back_to_original_byte() {
2137 let mut value = String::from("abcde");
2140 let mut sel = TextSelection::default();
2141 let target = ti_target();
2142 let down = ev_pointer_down(
2143 target.clone(),
2144 (target.rect.x + target.rect.w - 4.0, target.rect.y + 18.0),
2145 KeyModifiers::default(),
2146 );
2147 assert!(apply_event_with(
2148 &mut value,
2149 &mut sel,
2150 &down,
2151 &password_opts()
2152 ));
2153 assert_eq!(sel.head, value.len());
2154 }
2155
2156 #[test]
2157 fn password_pointer_click_with_multibyte_value() {
2158 let mut value = String::from("éé");
2162 let mut sel = TextSelection::default();
2163 let target = ti_target();
2164 let bullet_w = metrics::line_width("•", tokens::TEXT_SM.size, FontWeight::Regular, false);
2166 let click_x = target.rect.x + tokens::SPACE_3 + bullet_w * 1.4;
2167 let down = ev_pointer_down(
2168 target,
2169 (click_x, ti_target().rect.y + 18.0),
2170 KeyModifiers::default(),
2171 );
2172 assert!(apply_event_with(
2173 &mut value,
2174 &mut sel,
2175 &down,
2176 &password_opts()
2177 ));
2178 assert!(
2182 value.is_char_boundary(sel.head),
2183 "head={} not on a char boundary in {value:?}",
2184 sel.head
2185 );
2186 assert!(sel.head == 2 || sel.head == 4, "head={}", sel.head);
2187 }
2188
2189 #[test]
2190 fn password_clipboard_request_suppresses_copy_and_cut_only() {
2191 let ctrl = KeyModifiers {
2192 ctrl: true,
2193 ..Default::default()
2194 };
2195 let opts = password_opts();
2196 let copy = ev_key_with_mods(UiKey::Character("c".into()), ctrl);
2197 let cut = ev_key_with_mods(UiKey::Character("x".into()), ctrl);
2198 let paste = ev_key_with_mods(UiKey::Character("v".into()), ctrl);
2199 assert_eq!(clipboard_request_for(©, &opts), None);
2200 assert_eq!(clipboard_request_for(&cut, &opts), None);
2201 assert_eq!(
2202 clipboard_request_for(&paste, &opts),
2203 Some(ClipboardKind::Paste)
2204 );
2205 let plain = TextInputOpts::default();
2207 assert_eq!(
2208 clipboard_request_for(©, &plain),
2209 Some(ClipboardKind::Copy)
2210 );
2211 }
2212
2213 #[test]
2214 fn placeholder_renders_only_when_value_is_empty() {
2215 let opts = TextInputOpts::default().placeholder("Email");
2216 let empty = text_input_with("", TextSelection::default(), opts);
2217 let muted_leaf = content_children(&empty)
2218 .iter()
2219 .find(|c| matches!(c.kind, Kind::Text) && c.text.as_deref() == Some("Email"));
2220 assert!(muted_leaf.is_some(), "placeholder leaf should be present");
2221
2222 let nonempty = text_input_with("hi", TextSelection::caret(2), opts);
2223 let muted_leaf = content_children(&nonempty)
2224 .iter()
2225 .find(|c| matches!(c.kind, Kind::Text) && c.text.as_deref() == Some("Email"));
2226 assert!(
2227 muted_leaf.is_none(),
2228 "placeholder should not render once the field has a value"
2229 );
2230 }
2231
2232 #[test]
2233 fn long_value_with_caret_at_end_shifts_content_left_to_keep_caret_in_view() {
2234 use crate::tree::Size;
2243 let value = "abcdefghijklmnopqrstuvwxyz0123456789".repeat(2);
2244 let mut root = super::text_input(
2245 &value,
2246 &as_selection_in("ti", TextSelection::caret(value.len())),
2247 "ti",
2248 )
2249 .width(Size::Fixed(120.0));
2250 let mut ui_state = crate::state::UiState::new();
2251 crate::layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 120.0, 40.0));
2252
2253 let inner = &root.children[0];
2255 let text_leaf = inner
2256 .children
2257 .iter()
2258 .find(|c| matches!(c.kind, Kind::Text))
2259 .expect("text leaf");
2260 let leaf_rect = ui_state.rect(&text_leaf.computed_id);
2261
2262 let inner_rect = ui_state.rect(&inner.computed_id);
2266 assert!(
2267 leaf_rect.x < inner_rect.x,
2268 "text leaf rect.x={} should be left of inner rect.x={} after \
2269 horizontal caret-into-view; layout did not shift content",
2270 leaf_rect.x,
2271 inner_rect.x,
2272 );
2273 }
2274
2275 #[test]
2276 fn short_value_does_not_shift_content() {
2277 use crate::tree::Size;
2281 let mut root =
2282 super::text_input("hi", &as_selection_in("ti", TextSelection::caret(2)), "ti")
2283 .width(Size::Fixed(120.0));
2284 let mut ui_state = crate::state::UiState::new();
2285 crate::layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 120.0, 40.0));
2286
2287 let inner = &root.children[0];
2288 let text_leaf = inner
2289 .children
2290 .iter()
2291 .find(|c| matches!(c.kind, Kind::Text))
2292 .expect("text leaf");
2293 let leaf_rect = ui_state.rect(&text_leaf.computed_id);
2294 let inner_rect = ui_state.rect(&inner.computed_id);
2295 assert!(
2296 (leaf_rect.x - inner_rect.x).abs() < 0.5,
2297 "short value should not shift; got leaf.x={} inner.x={}",
2298 leaf_rect.x,
2299 inner_rect.x
2300 );
2301 }
2302
2303 fn as_selection_in(key: &str, sel: TextSelection) -> Selection {
2306 Selection {
2307 range: Some(SelectionRange {
2308 anchor: SelectionPoint::new(key, sel.anchor),
2309 head: SelectionPoint::new(key, sel.head),
2310 }),
2311 }
2312 }
2313
2314 #[test]
2315 fn max_length_truncates_text_input_inserts() {
2316 let mut value = String::from("ab");
2317 let mut sel = TextSelection::caret(2);
2318 let opts = TextInputOpts::default().max_length(4);
2319 assert!(apply_event_with(
2321 &mut value,
2322 &mut sel,
2323 &ev_text("cdef"),
2324 &opts
2325 ));
2326 assert_eq!(value, "abcd");
2327 assert_eq!(sel, TextSelection::caret(4));
2328 assert!(!apply_event_with(
2330 &mut value,
2331 &mut sel,
2332 &ev_text("z"),
2333 &opts
2334 ));
2335 assert_eq!(value, "abcd");
2336 }
2337
2338 #[test]
2339 fn max_length_replaces_selection_with_capacity_freed_by_removal() {
2340 let mut value = String::from("abc");
2343 let mut sel = TextSelection::range(0, 3); let opts = TextInputOpts::default().max_length(4);
2345 assert!(apply_event_with(
2346 &mut value,
2347 &mut sel,
2348 &ev_text("12345"),
2349 &opts
2350 ));
2351 assert_eq!(value, "1234");
2352 assert_eq!(sel, TextSelection::caret(4));
2353 }
2354
2355 #[test]
2356 fn replace_selection_with_max_length_clips_a_paste() {
2357 let mut value = String::from("ab");
2358 let mut sel = TextSelection::caret(2);
2359 let opts = TextInputOpts::default().max_length(5);
2360 let inserted = replace_selection_with(&mut value, &mut sel, "0123456789", &opts);
2362 assert_eq!(value, "ab012");
2363 assert_eq!(inserted, 3);
2364 assert_eq!(sel, TextSelection::caret(5));
2365 }
2366
2367 #[test]
2368 fn max_length_does_not_shrink_an_already_overlong_value() {
2369 let mut value = String::from("abcdef");
2372 let mut sel = TextSelection::caret(6);
2373 let opts = TextInputOpts::default().max_length(3);
2374 assert!(!apply_event_with(
2376 &mut value,
2377 &mut sel,
2378 &ev_text("z"),
2379 &opts
2380 ));
2381 assert_eq!(value, "abcdef");
2382 assert!(apply_event_with(
2385 &mut value,
2386 &mut sel,
2387 &ev_key(UiKey::Backspace),
2388 &opts
2389 ));
2390 assert_eq!(value, "abcde");
2391 }
2392
2393 #[test]
2394 fn end_to_end_drag_select_through_runner_core() {
2395 let mut value = String::from("hello world");
2399 let mut sel = TextSelection::default();
2400 let mut tree = crate::column([text_input(&value, sel).key("ti")]).padding(20.0);
2401 let mut core = RunnerCore::new();
2402 let mut state = UiState::new();
2403 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
2404 core.ui_state = state;
2405 core.snapshot(&tree, &mut Default::default());
2406
2407 let rect = core.rect_of_key("ti").expect("ti rect");
2408 let down_x = rect.x + 8.0;
2409 let drag_x = rect.x + 80.0;
2410 let cy = rect.y + rect.h * 0.5;
2411
2412 core.pointer_moved(Pointer::moving(down_x, cy));
2413 let down = core
2414 .pointer_down(Pointer::mouse(down_x, cy, PointerButton::Primary))
2415 .into_iter()
2416 .find(|e| e.kind == UiEventKind::PointerDown)
2417 .expect("pointer_down emits PointerDown");
2418 assert!(apply_event(&mut value, &mut sel, &down));
2419
2420 let drag = core
2421 .pointer_moved(Pointer::moving(drag_x, cy))
2422 .events
2423 .into_iter()
2424 .find(|e| e.kind == UiEventKind::Drag)
2425 .expect("Drag while pressed");
2426 assert!(apply_event(&mut value, &mut sel, &drag));
2427
2428 let events = core.pointer_up(Pointer::mouse(drag_x, cy, PointerButton::Primary));
2429 for e in &events {
2430 apply_event(&mut value, &mut sel, e);
2431 }
2432 assert!(
2433 !sel.is_collapsed(),
2434 "expected drag-select to leave a non-empty selection"
2435 );
2436 assert_eq!(
2437 sel.anchor, 0,
2438 "anchor should sit at the down position (caret 0)"
2439 );
2440 assert!(
2441 sel.head > 0 && sel.head <= value.len(),
2442 "head={} value.len={}",
2443 sel.head,
2444 value.len()
2445 );
2446 }
2447
2448 #[test]
2456 fn apply_event_writes_back_under_the_inputs_key() {
2457 let mut value = String::new();
2459 let mut sel = Selection::default();
2460 let event = ev_text("h");
2461 assert!(super::apply_event(&mut value, &mut sel, "name", &event));
2462 assert_eq!(value, "h");
2463 let r = sel.range.as_ref().expect("selection set");
2464 assert_eq!(r.anchor.key, "name");
2465 assert_eq!(r.head.key, "name");
2466 assert_eq!(r.head.byte, 1);
2467 }
2468
2469 #[test]
2470 fn apply_event_claims_selection_when_event_routed_from_elsewhere() {
2471 let mut value = String::new();
2477 let mut sel = Selection {
2478 range: Some(SelectionRange {
2479 anchor: SelectionPoint::new("para-a", 0),
2480 head: SelectionPoint::new("para-a", 5),
2481 }),
2482 };
2483 let event = ev_text("x");
2484 assert!(super::apply_event(&mut value, &mut sel, "email", &event));
2485 assert_eq!(value, "x");
2486 let r = sel.range.as_ref().unwrap();
2487 assert_eq!(r.anchor.key, "email", "selection ownership migrated");
2488 assert_eq!(r.head.byte, 1);
2489 }
2490
2491 #[test]
2492 fn apply_event_leaves_selection_alone_when_event_is_unhandled() {
2493 let mut value = String::from("hi");
2497 let mut sel = Selection {
2498 range: Some(SelectionRange {
2499 anchor: SelectionPoint::new("para-a", 0),
2500 head: SelectionPoint::new("para-a", 3),
2501 }),
2502 };
2503 let event = ev_key(UiKey::Other("F1".into()));
2504 assert!(!super::apply_event(&mut value, &mut sel, "name", &event));
2505 let r = sel.range.as_ref().unwrap();
2507 assert_eq!(r.anchor.key, "para-a");
2508 assert_eq!(r.head.byte, 3);
2509 }
2510
2511 #[test]
2512 fn text_input_renders_caret_at_local_byte_when_selection_is_within_key() {
2513 let sel = Selection::caret("name", 2);
2514 let el = super::text_input("hello", &sel, "name");
2515 assert_eq!(el.key.as_deref(), Some("name"));
2517 let caret = content_children(&el)
2519 .iter()
2520 .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
2521 .expect("caret child");
2522 let expected = metrics::line_width("he", tokens::TEXT_SM.size, FontWeight::Regular, false);
2523 assert!(
2524 (caret.translate.0 - expected).abs() < 0.01,
2525 "caret.x={} expected {}",
2526 caret.translate.0,
2527 expected
2528 );
2529 }
2530
2531 #[test]
2532 fn text_input_omits_caret_when_selection_lives_elsewhere() {
2533 let sel = Selection {
2540 range: Some(SelectionRange {
2541 anchor: SelectionPoint::new("other", 0),
2542 head: SelectionPoint::new("other", 5),
2543 }),
2544 };
2545 let el = super::text_input("hello", &sel, "name");
2546 let band = el
2547 .children
2548 .iter()
2549 .find(|c| matches!(c.kind, Kind::Custom("text_input_selection")));
2550 assert!(band.is_none(), "no band when selection lives elsewhere");
2551 let caret = el
2552 .children
2553 .iter()
2554 .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")));
2555 assert!(
2556 caret.is_none(),
2557 "no caret when selection lives elsewhere — focus-fade has nothing to bring back to byte 0"
2558 );
2559 }
2560}