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, 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 kind: UiEventKind::TextInput,
1089 }
1090 }
1091
1092 fn ev_key(key: UiKey) -> UiEvent {
1093 ev_key_with_mods(key, KeyModifiers::default())
1094 }
1095
1096 fn ev_key_with_mods(key: UiKey, modifiers: KeyModifiers) -> UiEvent {
1097 UiEvent {
1098 path: None,
1099 key: None,
1100 target: None,
1101 pointer: None,
1102 key_press: Some(KeyPress {
1103 key,
1104 modifiers,
1105 repeat: false,
1106 }),
1107 text: None,
1108 selection: None,
1109 modifiers,
1110 click_count: 0,
1111 kind: UiEventKind::KeyDown,
1112 }
1113 }
1114
1115 fn ev_pointer_down(target: UiTarget, pointer: (f32, f32), modifiers: KeyModifiers) -> UiEvent {
1116 ev_pointer_down_with_count(target, pointer, modifiers, 1)
1117 }
1118
1119 fn ev_pointer_down_with_count(
1120 target: UiTarget,
1121 pointer: (f32, f32),
1122 modifiers: KeyModifiers,
1123 click_count: u8,
1124 ) -> UiEvent {
1125 UiEvent {
1126 path: None,
1127 key: Some(target.key.clone()),
1128 target: Some(target),
1129 pointer: Some(pointer),
1130 key_press: None,
1131 text: None,
1132 selection: None,
1133 modifiers,
1134 click_count,
1135 kind: UiEventKind::PointerDown,
1136 }
1137 }
1138
1139 fn ev_drag(target: UiTarget, pointer: (f32, f32)) -> UiEvent {
1140 ev_drag_with_count(target, pointer, 0)
1141 }
1142
1143 fn ev_drag_with_count(target: UiTarget, pointer: (f32, f32), click_count: u8) -> UiEvent {
1144 UiEvent {
1145 path: None,
1146 key: Some(target.key.clone()),
1147 target: Some(target),
1148 pointer: Some(pointer),
1149 key_press: None,
1150 text: None,
1151 selection: None,
1152 modifiers: KeyModifiers::default(),
1153 click_count,
1154 kind: UiEventKind::Drag,
1155 }
1156 }
1157
1158 fn ev_middle_click(target: UiTarget, pointer: (f32, f32), text: Option<&str>) -> UiEvent {
1159 UiEvent {
1160 path: None,
1161 key: Some(target.key.clone()),
1162 target: Some(target),
1163 pointer: Some(pointer),
1164 key_press: None,
1165 text: text.map(str::to_string),
1166 selection: None,
1167 modifiers: KeyModifiers::default(),
1168 click_count: 1,
1169 kind: UiEventKind::MiddleClick,
1170 }
1171 }
1172
1173 fn ti_target() -> UiTarget {
1174 UiTarget {
1175 key: "ti".into(),
1176 node_id: "root.text_input[ti]".into(),
1177 rect: Rect::new(20.0, 20.0, 400.0, 36.0),
1178 tooltip: None,
1179 scroll_offset_y: 0.0,
1180 }
1181 }
1182
1183 fn content_children(el: &El) -> &[El] {
1191 assert_eq!(
1192 el.children.len(),
1193 1,
1194 "text_input wraps its content in a single inner group"
1195 );
1196 &el.children[0].children
1197 }
1198
1199 #[test]
1200 fn text_input_collapsed_renders_value_as_single_text_leaf_plus_caret() {
1201 let el = text_input("hello", TextSelection::caret(2));
1202 assert!(matches!(el.kind, Kind::Custom("text_input")));
1203 assert!(el.focusable);
1204 assert!(el.capture_keys);
1205 let cs = content_children(&el);
1209 assert_eq!(cs.len(), 2);
1210 assert!(matches!(cs[0].kind, Kind::Text));
1211 assert_eq!(cs[0].text.as_deref(), Some("hello"));
1212 assert!(matches!(cs[1].kind, Kind::Custom("text_input_caret")));
1213 assert!(cs[1].alpha_follows_focused_ancestor);
1214 }
1215
1216 #[test]
1217 fn text_input_declares_text_cursor() {
1218 let el = text_input("hello", TextSelection::caret(0));
1219 assert_eq!(el.cursor, Some(Cursor::Text));
1220 }
1221
1222 #[test]
1223 fn text_input_with_selection_inserts_selection_band_first() {
1224 let el = text_input("hello", TextSelection::range(2, 4));
1226 let cs = content_children(&el);
1227 assert_eq!(cs.len(), 3);
1229 assert!(matches!(cs[0].kind, Kind::Custom("text_input_selection")));
1230 assert_eq!(cs[1].text.as_deref(), Some("hello"));
1231 assert!(matches!(cs[2].kind, Kind::Custom("text_input_caret")));
1232 }
1233
1234 #[test]
1235 fn text_input_caret_translate_advances_with_head() {
1236 use crate::text::metrics::line_width;
1240 let value = "hello";
1241 let head = 3;
1242 let el = text_input(value, TextSelection::caret(head));
1243 let caret = content_children(&el)
1244 .iter()
1245 .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
1246 .expect("caret child");
1247 let expected = line_width(
1248 &value[..head],
1249 tokens::TEXT_SM.size,
1250 FontWeight::Regular,
1251 false,
1252 );
1253 assert!(
1254 (caret.translate.0 - expected).abs() < 0.01,
1255 "caret translate.x = {}, expected {}",
1256 caret.translate.0,
1257 expected
1258 );
1259 }
1260
1261 #[test]
1262 fn text_input_clamps_off_utf8_boundary() {
1263 let el = text_input("é", TextSelection::caret(1));
1267 let cs = content_children(&el);
1268 assert_eq!(cs[0].text.as_deref(), Some("é"));
1269 let caret = cs
1270 .iter()
1271 .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
1272 .expect("caret child");
1273 assert!(caret.translate.0.abs() < 0.01);
1275 }
1276
1277 #[test]
1278 fn selection_band_fill_dims_when_input_unfocused() {
1279 use crate::draw_ops::draw_ops;
1283 use crate::ir::DrawOp;
1284 use crate::shader::UniformValue;
1285 use crate::state::AnimationMode;
1286 use web_time::Instant;
1287
1288 let mut tree = crate::column([text_input("hello", TextSelection::range(0, 5)).key("ti")])
1289 .padding(20.0);
1290 let mut state = UiState::new();
1291 state.set_animation_mode(AnimationMode::Settled);
1292 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1293 state.sync_focus_order(&tree);
1294
1295 state.apply_to_state();
1299 state.tick_visual_animations(&mut tree, Instant::now());
1300 let unfocused = band_fill(&tree, &state).expect("band quad emitted");
1301 assert_eq!(
1302 (unfocused.r, unfocused.g, unfocused.b),
1303 (
1304 tokens::SELECTION_BG_UNFOCUSED.r,
1305 tokens::SELECTION_BG_UNFOCUSED.g,
1306 tokens::SELECTION_BG_UNFOCUSED.b
1307 ),
1308 "unfocused → band rgb is the muted token"
1309 );
1310
1311 let target = state
1314 .focus
1315 .order
1316 .iter()
1317 .find(|t| t.key == "ti")
1318 .expect("ti in focus order")
1319 .clone();
1320 state.set_focus(Some(target));
1321 state.apply_to_state();
1322 state.tick_visual_animations(&mut tree, Instant::now());
1323 let focused = band_fill(&tree, &state).expect("band quad emitted");
1324 assert_eq!(
1325 (focused.r, focused.g, focused.b),
1326 (
1327 tokens::SELECTION_BG.r,
1328 tokens::SELECTION_BG.g,
1329 tokens::SELECTION_BG.b
1330 ),
1331 "focused → band rgb is the saturated token"
1332 );
1333
1334 fn band_fill(tree: &El, state: &UiState) -> Option<crate::tree::Color> {
1335 let ops = draw_ops(tree, state);
1336 for op in ops {
1337 if let DrawOp::Quad { id, uniforms, .. } = op
1338 && id.contains("text_input_selection")
1339 && let Some(UniformValue::Color(c)) = uniforms.get("fill")
1340 {
1341 return Some(*c);
1342 }
1343 }
1344 None
1345 }
1346 }
1347
1348 #[test]
1349 fn caret_alpha_follows_focus_envelope() {
1350 use crate::draw_ops::draw_ops;
1355 use crate::ir::DrawOp;
1356 use crate::shader::UniformValue;
1357 use crate::state::AnimationMode;
1358 use web_time::Instant;
1359
1360 let mut tree =
1361 crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
1362 let mut state = UiState::new();
1363 state.set_animation_mode(AnimationMode::Settled);
1364 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1365 state.sync_focus_order(&tree);
1366
1367 state.apply_to_state();
1369 state.tick_visual_animations(&mut tree, Instant::now());
1370 let caret_alpha = caret_fill_alpha(&tree, &state);
1371 assert_eq!(caret_alpha, Some(0), "unfocused → caret invisible");
1372
1373 let target = state
1375 .focus
1376 .order
1377 .iter()
1378 .find(|t| t.key == "ti")
1379 .expect("ti in focus order")
1380 .clone();
1381 state.set_focus(Some(target));
1382 state.apply_to_state();
1383 state.tick_visual_animations(&mut tree, Instant::now());
1384 let caret_alpha = caret_fill_alpha(&tree, &state);
1385 assert_eq!(
1386 caret_alpha,
1387 Some(255),
1388 "focused → caret fully visible (alpha=255)"
1389 );
1390
1391 fn caret_fill_alpha(tree: &El, state: &UiState) -> Option<u8> {
1392 let ops = draw_ops(tree, state);
1393 for op in ops {
1394 if let DrawOp::Quad { id, uniforms, .. } = op
1395 && id.contains("text_input_caret")
1396 && let Some(UniformValue::Color(c)) = uniforms.get("fill")
1397 {
1398 return Some(c.a);
1399 }
1400 }
1401 None
1402 }
1403 }
1404
1405 #[test]
1406 fn caret_blink_alpha_holds_solid_through_grace_then_cycles() {
1407 use crate::state::caret_blink_alpha_for;
1410 use std::time::Duration;
1411 assert_eq!(caret_blink_alpha_for(Duration::from_millis(0)), 1.0);
1413 assert_eq!(caret_blink_alpha_for(Duration::from_millis(499)), 1.0);
1414 assert_eq!(caret_blink_alpha_for(Duration::from_millis(500)), 1.0);
1416 assert_eq!(caret_blink_alpha_for(Duration::from_millis(1029)), 1.0);
1417 assert_eq!(caret_blink_alpha_for(Duration::from_millis(1030)), 0.0);
1419 assert_eq!(caret_blink_alpha_for(Duration::from_millis(1559)), 0.0);
1420 assert_eq!(caret_blink_alpha_for(Duration::from_millis(1560)), 1.0);
1422 }
1423
1424 #[test]
1425 fn caret_paint_alpha_blinks_after_focus_in_live_mode() {
1426 use crate::draw_ops::draw_ops;
1430 use crate::ir::DrawOp;
1431 use crate::shader::UniformValue;
1432 use crate::state::AnimationMode;
1433 use std::time::Duration;
1434
1435 let mut tree =
1436 crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
1437 let mut state = UiState::new();
1438 state.set_animation_mode(AnimationMode::Live);
1439 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1440 state.sync_focus_order(&tree);
1441
1442 let target = state
1444 .focus
1445 .order
1446 .iter()
1447 .find(|t| t.key == "ti")
1448 .unwrap()
1449 .clone();
1450 state.set_focus(Some(target));
1451 let activity_at = state.caret.activity_at.expect("set_focus bumps activity");
1452 let input_id = tree.children[0].computed_id.clone();
1453
1454 let pin_focus = |state: &mut UiState| {
1458 state.animation.envelopes.insert(
1459 (input_id.clone(), crate::state::EnvelopeKind::FocusRing),
1460 1.0,
1461 );
1462 };
1463
1464 state.tick_visual_animations(&mut tree, activity_at);
1466 pin_focus(&mut state);
1467 assert_eq!(caret_alpha(&tree, &state), Some(255));
1468
1469 state.tick_visual_animations(&mut tree, activity_at + Duration::from_millis(1100));
1471 pin_focus(&mut state);
1472 assert_eq!(caret_alpha(&tree, &state), Some(0));
1473
1474 state.tick_visual_animations(&mut tree, activity_at + Duration::from_millis(1600));
1476 pin_focus(&mut state);
1477 assert_eq!(caret_alpha(&tree, &state), Some(255));
1478
1479 fn caret_alpha(tree: &El, state: &UiState) -> Option<u8> {
1480 for op in draw_ops(tree, state) {
1481 if let DrawOp::Quad { id, uniforms, .. } = op
1482 && id.contains("text_input_caret")
1483 && let Some(UniformValue::Color(c)) = uniforms.get("fill")
1484 {
1485 return Some(c.a);
1486 }
1487 }
1488 None
1489 }
1490 }
1491
1492 #[test]
1493 fn caret_blink_resumes_solid_after_selection_change() {
1494 use crate::state::AnimationMode;
1497 use std::time::Duration;
1498 use web_time::Instant;
1499
1500 let mut tree =
1501 crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
1502 let mut state = UiState::new();
1503 state.set_animation_mode(AnimationMode::Live);
1504 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1505 state.sync_focus_order(&tree);
1506
1507 let t0 = Instant::now();
1509 state.bump_caret_activity(t0);
1510 state.tick_visual_animations(&mut tree, t0 + Duration::from_millis(1100));
1511 assert_eq!(state.caret.blink_alpha, 0.0, "deep in off phase");
1512
1513 state.bump_caret_activity(t0 + Duration::from_millis(1100));
1515 assert_eq!(state.caret.blink_alpha, 1.0, "fresh activity → solid");
1516 }
1517
1518 #[test]
1519 fn caret_tick_requests_redraw_while_capture_keys_node_focused() {
1520 use crate::state::AnimationMode;
1524 use web_time::Instant;
1525
1526 let mut tree =
1527 crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
1528 let mut state = UiState::new();
1529 state.set_animation_mode(AnimationMode::Live);
1530 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1531 state.sync_focus_order(&tree);
1532
1533 let no_focus = state.tick_visual_animations(&mut tree, Instant::now());
1535 assert!(!no_focus, "without focus, blink doesn't request redraws");
1536
1537 let target = state
1540 .focus
1541 .order
1542 .iter()
1543 .find(|t| t.key == "ti")
1544 .unwrap()
1545 .clone();
1546 state.set_focus(Some(target));
1547 let focused = state.tick_visual_animations(&mut tree, Instant::now());
1548 assert!(focused, "focused capture_keys node → tick demands redraws");
1549 }
1550
1551 #[test]
1552 fn apply_text_input_inserts_at_caret_when_collapsed() {
1553 let mut value = String::from("ho");
1554 let mut sel = TextSelection::caret(1);
1555 assert!(apply_event(&mut value, &mut sel, &ev_text("i, t")));
1556 assert_eq!(value, "hi, to");
1557 assert_eq!(sel, TextSelection::caret(5));
1558 }
1559
1560 #[test]
1561 fn apply_text_input_replaces_selection() {
1562 let mut value = String::from("hello world");
1563 let mut sel = TextSelection::range(6, 11); assert!(apply_event(&mut value, &mut sel, &ev_text("kit")));
1565 assert_eq!(value, "hello kit");
1566 assert_eq!(sel, TextSelection::caret(9));
1567 }
1568
1569 #[test]
1570 fn apply_backspace_removes_selection_when_non_empty() {
1571 let mut value = String::from("hello world");
1572 let mut sel = TextSelection::range(6, 11);
1573 assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::Backspace)));
1574 assert_eq!(value, "hello ");
1575 assert_eq!(sel, TextSelection::caret(6));
1576 }
1577
1578 #[test]
1579 fn apply_delete_removes_selection_when_non_empty() {
1580 let mut value = String::from("hello world");
1581 let mut sel = TextSelection::range(0, 6); assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::Delete)));
1583 assert_eq!(value, "world");
1584 assert_eq!(sel, TextSelection::caret(0));
1585 }
1586
1587 #[test]
1588 fn apply_escape_collapses_selection_without_editing() {
1589 let mut value = String::from("hello");
1590 let mut sel = TextSelection::range(1, 4);
1591 assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::Escape)));
1592 assert_eq!(value, "hello");
1593 assert_eq!(sel, TextSelection::caret(4));
1594 assert!(!apply_event(&mut value, &mut sel, &ev_key(UiKey::Escape)));
1595 }
1596
1597 #[test]
1598 fn apply_backspace_collapsed_at_start_is_noop() {
1599 let mut value = String::from("hi");
1600 let mut sel = TextSelection::caret(0);
1601 assert!(!apply_event(
1602 &mut value,
1603 &mut sel,
1604 &ev_key(UiKey::Backspace)
1605 ));
1606 }
1607
1608 #[test]
1609 fn apply_arrow_walks_utf8_boundaries() {
1610 let mut value = String::from("aé");
1611 let mut sel = TextSelection::caret(0);
1612 apply_event(&mut value, &mut sel, &ev_key(UiKey::ArrowRight));
1613 assert_eq!(sel.head, 1);
1614 apply_event(&mut value, &mut sel, &ev_key(UiKey::ArrowRight));
1615 assert_eq!(sel.head, 3);
1616 assert!(!apply_event(
1617 &mut value,
1618 &mut sel,
1619 &ev_key(UiKey::ArrowRight)
1620 ));
1621 apply_event(&mut value, &mut sel, &ev_key(UiKey::ArrowLeft));
1622 assert_eq!(sel.head, 1);
1623 }
1624
1625 #[test]
1626 fn apply_arrow_collapses_selection_without_shift() {
1627 let mut value = String::from("hello");
1628 let mut sel = TextSelection::range(1, 4); assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::ArrowLeft)));
1632 assert_eq!(sel, TextSelection::caret(1));
1633
1634 let mut sel = TextSelection::range(1, 4);
1635 assert!(apply_event(
1637 &mut value,
1638 &mut sel,
1639 &ev_key(UiKey::ArrowRight)
1640 ));
1641 assert_eq!(sel, TextSelection::caret(4));
1642 }
1643
1644 #[test]
1645 fn apply_shift_arrow_extends_selection() {
1646 let mut value = String::from("hello");
1647 let mut sel = TextSelection::caret(2);
1648 let shift = KeyModifiers {
1649 shift: true,
1650 ..Default::default()
1651 };
1652 assert!(apply_event(
1653 &mut value,
1654 &mut sel,
1655 &ev_key_with_mods(UiKey::ArrowRight, shift)
1656 ));
1657 assert_eq!(sel, TextSelection::range(2, 3));
1658 assert!(apply_event(
1659 &mut value,
1660 &mut sel,
1661 &ev_key_with_mods(UiKey::ArrowRight, shift)
1662 ));
1663 assert_eq!(sel, TextSelection::range(2, 4));
1664 assert!(apply_event(
1666 &mut value,
1667 &mut sel,
1668 &ev_key_with_mods(UiKey::ArrowLeft, shift)
1669 ));
1670 assert_eq!(sel, TextSelection::range(2, 3));
1671 }
1672
1673 #[test]
1674 fn apply_home_end_collapse_or_extend() {
1675 let mut value = String::from("hello");
1676 let mut sel = TextSelection::caret(2);
1677 assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::End)));
1678 assert_eq!(sel, TextSelection::caret(5));
1679 assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::Home)));
1680 assert_eq!(sel, TextSelection::caret(0));
1681
1682 let shift = KeyModifiers {
1684 shift: true,
1685 ..Default::default()
1686 };
1687 let mut sel = TextSelection::caret(2);
1688 assert!(apply_event(
1689 &mut value,
1690 &mut sel,
1691 &ev_key_with_mods(UiKey::End, shift)
1692 ));
1693 assert_eq!(sel, TextSelection::range(2, 5));
1694 }
1695
1696 #[test]
1697 fn apply_ctrl_a_selects_all() {
1698 let mut value = String::from("hello");
1699 let mut sel = TextSelection::caret(2);
1700 let ctrl = KeyModifiers {
1701 ctrl: true,
1702 ..Default::default()
1703 };
1704 assert!(apply_event(
1705 &mut value,
1706 &mut sel,
1707 &ev_key_with_mods(UiKey::Character("a".into()), ctrl)
1708 ));
1709 assert_eq!(sel, TextSelection::range(0, 5));
1710 assert!(!apply_event(
1712 &mut value,
1713 &mut sel,
1714 &ev_key_with_mods(UiKey::Character("a".into()), ctrl)
1715 ));
1716 }
1717
1718 #[test]
1719 fn apply_pointer_down_sets_anchor_and_head() {
1720 let mut value = String::from("hello");
1721 let mut sel = TextSelection::range(0, 5);
1722 let down = ev_pointer_down(
1724 ti_target(),
1725 (ti_target().rect.x + 1.0, ti_target().rect.y + 18.0),
1726 KeyModifiers::default(),
1727 );
1728 assert!(apply_event(&mut value, &mut sel, &down));
1729 assert_eq!(sel, TextSelection::caret(0));
1730 }
1731
1732 #[test]
1733 fn apply_double_click_selects_word_at_caret() {
1734 let mut value = String::from("hello world");
1735 let mut sel = TextSelection::caret(0);
1736 let target = ti_target();
1738 let click_x = target.rect.x
1739 + tokens::SPACE_3
1740 + crate::text::metrics::line_width(
1741 "hello w",
1742 tokens::TEXT_SM.size,
1743 FontWeight::Regular,
1744 false,
1745 );
1746 let down = ev_pointer_down_with_count(
1747 target.clone(),
1748 (click_x, target.rect.y + 18.0),
1749 KeyModifiers::default(),
1750 2,
1751 );
1752 assert!(apply_event(&mut value, &mut sel, &down));
1753 assert_eq!(sel.anchor, 6);
1755 assert_eq!(sel.head, 11);
1756 }
1757
1758 #[test]
1759 fn apply_triple_click_selects_all() {
1760 let mut value = String::from("hello world");
1761 let mut sel = TextSelection::caret(0);
1762 let target = ti_target();
1763 let down = ev_pointer_down_with_count(
1764 target.clone(),
1765 (target.rect.x + 1.0, target.rect.y + 18.0),
1766 KeyModifiers::default(),
1767 3,
1768 );
1769 assert!(apply_event(&mut value, &mut sel, &down));
1770 assert_eq!(sel.anchor, 0);
1771 assert_eq!(sel.head, value.len());
1772 }
1773
1774 #[test]
1775 fn apply_shift_double_click_falls_back_to_extend_not_word_select() {
1776 let mut value = String::from("hello world");
1779 let mut sel = TextSelection::caret(0);
1780 let target = ti_target();
1781 let click_x = target.rect.x
1782 + tokens::SPACE_3
1783 + crate::text::metrics::line_width(
1784 "hello w",
1785 tokens::TEXT_SM.size,
1786 FontWeight::Regular,
1787 false,
1788 );
1789 let shift = KeyModifiers {
1790 shift: true,
1791 ..Default::default()
1792 };
1793 let down =
1794 ev_pointer_down_with_count(target.clone(), (click_x, target.rect.y + 18.0), shift, 2);
1795 assert!(apply_event(&mut value, &mut sel, &down));
1796 assert_eq!(sel.anchor, 0);
1798 assert!(sel.head > 0 && sel.head < value.len());
1799 }
1800
1801 #[test]
1802 fn apply_shift_pointer_down_only_moves_head() {
1803 let mut value = String::from("hello");
1804 let mut sel = TextSelection::caret(2);
1805 let shift = KeyModifiers {
1806 shift: true,
1807 ..Default::default()
1808 };
1809 let down = ev_pointer_down(
1811 ti_target(),
1812 (
1813 ti_target().rect.x + ti_target().rect.w - 4.0,
1814 ti_target().rect.y + 18.0,
1815 ),
1816 shift,
1817 );
1818 assert!(apply_event(&mut value, &mut sel, &down));
1819 assert_eq!(sel.anchor, 2);
1820 assert_eq!(sel.head, value.len());
1821 }
1822
1823 #[test]
1824 fn apply_drag_extends_head_only() {
1825 let mut value = String::from("hello world");
1826 let mut sel = TextSelection::caret(0);
1827 let down = ev_pointer_down(
1829 ti_target(),
1830 (ti_target().rect.x + 1.0, ti_target().rect.y + 18.0),
1831 KeyModifiers::default(),
1832 );
1833 apply_event(&mut value, &mut sel, &down);
1834 assert_eq!(sel, TextSelection::caret(0));
1835 let drag = ev_drag(
1837 ti_target(),
1838 (
1839 ti_target().rect.x + ti_target().rect.w - 4.0,
1840 ti_target().rect.y + 18.0,
1841 ),
1842 );
1843 assert!(apply_event(&mut value, &mut sel, &drag));
1844 assert_eq!(sel.anchor, 0);
1845 assert_eq!(sel.head, value.len());
1846 }
1847
1848 #[test]
1849 fn double_click_hold_drag_inside_word_keeps_word_selected() {
1850 let mut value = String::from("hello world");
1851 let mut sel = TextSelection::caret(0);
1852 let target = ti_target();
1853 let click_x = target.rect.x
1854 + tokens::SPACE_3
1855 + crate::text::metrics::line_width(
1856 "hello w",
1857 tokens::TEXT_SM.size,
1858 FontWeight::Regular,
1859 false,
1860 );
1861 let down = ev_pointer_down_with_count(
1862 target.clone(),
1863 (click_x, target.rect.y + 18.0),
1864 KeyModifiers::default(),
1865 2,
1866 );
1867 assert!(apply_event(&mut value, &mut sel, &down));
1868 assert_eq!(sel, TextSelection::range(6, 11));
1869
1870 let drag = ev_drag_with_count(target.clone(), (click_x + 1.0, target.rect.y + 18.0), 2);
1871 assert!(apply_event(&mut value, &mut sel, &drag));
1872 assert_eq!(sel, TextSelection::range(6, 11));
1873 }
1874
1875 #[test]
1876 fn apply_click_is_noop_for_selection() {
1877 let mut value = String::from("hello");
1881 let mut sel = TextSelection::range(0, 5);
1882 let click = UiEvent {
1883 path: None,
1884 key: Some("ti".into()),
1885 target: Some(ti_target()),
1886 pointer: Some((ti_target().rect.x + 1.0, ti_target().rect.y + 18.0)),
1887 key_press: None,
1888 text: None,
1889 selection: None,
1890 modifiers: KeyModifiers::default(),
1891 click_count: 1,
1892 kind: UiEventKind::Click,
1893 };
1894 assert!(!apply_event(&mut value, &mut sel, &click));
1895 assert_eq!(sel, TextSelection::range(0, 5));
1896 }
1897
1898 #[test]
1899 fn apply_middle_click_inserts_event_text_at_pointer() {
1900 let mut value = String::from("world");
1901 let mut sel = TextSelection::caret(value.len());
1902 let target = ti_target();
1903 let pointer = (
1904 target.rect.x + tokens::SPACE_3,
1905 target.rect.y + target.rect.h * 0.5,
1906 );
1907 let event = ev_middle_click(target, pointer, Some("hello "));
1908 assert!(apply_event(&mut value, &mut sel, &event));
1909 assert_eq!(value, "hello world");
1910 assert_eq!(sel, TextSelection::caret("hello ".len()));
1911 }
1912
1913 #[test]
1914 fn helpers_selected_text_and_replace_selection() {
1915 let value = String::from("hello world");
1916 let sel = TextSelection::range(6, 11);
1917 assert_eq!(selected_text(&value, sel), "world");
1918
1919 let mut value = value;
1920 let mut sel = sel;
1921 replace_selection(&mut value, &mut sel, "kit");
1922 assert_eq!(value, "hello kit");
1923 assert_eq!(sel, TextSelection::caret(9));
1924
1925 assert_eq!(select_all(&value), TextSelection::range(0, value.len()));
1926 }
1927
1928 #[test]
1929 fn apply_text_input_filters_control_chars() {
1930 let mut value = String::from("hi");
1934 let mut sel = TextSelection::caret(2);
1935 for ctrl in ["\u{8}", "\u{7f}", "\r", "\n", "\u{1b}", "\t"] {
1936 assert!(
1937 !apply_event(&mut value, &mut sel, &ev_text(ctrl)),
1938 "expected {ctrl:?} to be filtered"
1939 );
1940 assert_eq!(value, "hi");
1941 assert_eq!(sel, TextSelection::caret(2));
1942 }
1943 assert!(apply_event(&mut value, &mut sel, &ev_text("a\u{8}b")));
1945 assert_eq!(value, "hiab");
1946 assert_eq!(sel, TextSelection::caret(4));
1947 }
1948
1949 #[test]
1950 fn apply_text_input_drops_when_ctrl_or_cmd_is_held() {
1951 let mut value = String::from("hello");
1956 let mut sel = TextSelection::range(0, 5);
1957 let ctrl = KeyModifiers {
1958 ctrl: true,
1959 ..Default::default()
1960 };
1961 let cmd = KeyModifiers {
1962 logo: true,
1963 ..Default::default()
1964 };
1965 assert!(!apply_event(
1966 &mut value,
1967 &mut sel,
1968 &ev_text_with_mods("c", ctrl)
1969 ));
1970 assert_eq!(value, "hello");
1971 assert!(!apply_event(
1972 &mut value,
1973 &mut sel,
1974 &ev_text_with_mods("v", cmd)
1975 ));
1976 assert_eq!(value, "hello");
1977 let altgr = KeyModifiers {
1979 ctrl: true,
1980 alt: true,
1981 ..Default::default()
1982 };
1983 let mut value = String::from("");
1984 let mut sel = TextSelection::caret(0);
1985 assert!(apply_event(
1986 &mut value,
1987 &mut sel,
1988 &ev_text_with_mods("é", altgr)
1989 ));
1990 assert_eq!(value, "é");
1991 }
1992
1993 #[test]
1994 fn text_input_value_emits_a_single_glyph_run() {
1995 use crate::draw_ops::draw_ops;
2001 use crate::ir::DrawOp;
2002 let mut tree =
2003 crate::column([text_input("Type", TextSelection::caret(1)).key("ti")]).padding(20.0);
2004 let mut state = UiState::new();
2005 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
2006
2007 let ops = draw_ops(&tree, &state);
2008 let glyph_runs = ops
2009 .iter()
2010 .filter(|op| matches!(op, DrawOp::GlyphRun { id, .. } if id.contains("text_input[ti]")))
2011 .count();
2012 assert_eq!(
2013 glyph_runs, 1,
2014 "value should shape as one run; got {glyph_runs}"
2015 );
2016 }
2017
2018 #[test]
2019 fn clipboard_request_detects_ctrl_c_x_v() {
2020 let ctrl = KeyModifiers {
2021 ctrl: true,
2022 ..Default::default()
2023 };
2024 let cases = [
2025 ("c", ClipboardKind::Copy),
2026 ("C", ClipboardKind::Copy),
2027 ("x", ClipboardKind::Cut),
2028 ("v", ClipboardKind::Paste),
2029 ];
2030 for (ch, expected) in cases {
2031 let e = ev_key_with_mods(UiKey::Character(ch.into()), ctrl);
2032 assert_eq!(clipboard_request(&e), Some(expected), "char {ch:?}");
2033 }
2034 }
2035
2036 #[test]
2037 fn clipboard_request_accepts_cmd_on_macos() {
2038 let logo = KeyModifiers {
2041 logo: true,
2042 ..Default::default()
2043 };
2044 let e = ev_key_with_mods(UiKey::Character("c".into()), logo);
2045 assert_eq!(clipboard_request(&e), Some(ClipboardKind::Copy));
2046 }
2047
2048 #[test]
2049 fn clipboard_request_rejects_with_shift_or_alt() {
2050 let e = ev_key_with_mods(
2052 UiKey::Character("c".into()),
2053 KeyModifiers {
2054 ctrl: true,
2055 shift: true,
2056 ..Default::default()
2057 },
2058 );
2059 assert_eq!(clipboard_request(&e), None);
2060
2061 let e = ev_key_with_mods(
2062 UiKey::Character("v".into()),
2063 KeyModifiers {
2064 ctrl: true,
2065 alt: true,
2066 ..Default::default()
2067 },
2068 );
2069 assert_eq!(clipboard_request(&e), None);
2070 }
2071
2072 #[test]
2073 fn clipboard_request_ignores_other_keys_and_event_kinds() {
2074 let e = ev_key(UiKey::Character("c".into()));
2076 assert_eq!(clipboard_request(&e), None);
2077 let e = ev_key_with_mods(
2079 UiKey::Character("a".into()),
2080 KeyModifiers {
2081 ctrl: true,
2082 ..Default::default()
2083 },
2084 );
2085 assert_eq!(clipboard_request(&e), None);
2086 assert_eq!(clipboard_request(&ev_text("c")), None);
2088 }
2089
2090 fn password_opts() -> TextInputOpts<'static> {
2091 TextInputOpts::default().password()
2092 }
2093
2094 #[test]
2095 fn password_input_renders_value_as_bullets_not_plaintext() {
2096 let el = text_input_with("hunter2", TextSelection::caret(0), password_opts());
2099 let leaf = content_children(&el)
2100 .iter()
2101 .find(|c| matches!(c.kind, Kind::Text))
2102 .expect("text leaf");
2103 assert_eq!(leaf.text.as_deref(), Some("•••••••"));
2104 }
2105
2106 #[test]
2107 fn password_input_caret_position_uses_masked_widths() {
2108 use crate::text::metrics::line_width;
2112 let value = "abc";
2113 let head = 2;
2114 let el = text_input_with(value, TextSelection::caret(head), password_opts());
2115 let caret = content_children(&el)
2116 .iter()
2117 .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
2118 .expect("caret child");
2119 let expected = line_width("••", tokens::TEXT_SM.size, FontWeight::Regular, false);
2121 assert!(
2122 (caret.translate.0 - expected).abs() < 0.01,
2123 "caret translate.x = {}, expected {}",
2124 caret.translate.0,
2125 expected
2126 );
2127 }
2128
2129 #[test]
2130 fn password_pointer_click_maps_back_to_original_byte() {
2131 let mut value = String::from("abcde");
2134 let mut sel = TextSelection::default();
2135 let target = ti_target();
2136 let down = ev_pointer_down(
2137 target.clone(),
2138 (target.rect.x + target.rect.w - 4.0, target.rect.y + 18.0),
2139 KeyModifiers::default(),
2140 );
2141 assert!(apply_event_with(
2142 &mut value,
2143 &mut sel,
2144 &down,
2145 &password_opts()
2146 ));
2147 assert_eq!(sel.head, value.len());
2148 }
2149
2150 #[test]
2151 fn password_pointer_click_with_multibyte_value() {
2152 let mut value = String::from("éé");
2156 let mut sel = TextSelection::default();
2157 let target = ti_target();
2158 let bullet_w = metrics::line_width("•", tokens::TEXT_SM.size, FontWeight::Regular, false);
2160 let click_x = target.rect.x + tokens::SPACE_3 + bullet_w * 1.4;
2161 let down = ev_pointer_down(
2162 target,
2163 (click_x, ti_target().rect.y + 18.0),
2164 KeyModifiers::default(),
2165 );
2166 assert!(apply_event_with(
2167 &mut value,
2168 &mut sel,
2169 &down,
2170 &password_opts()
2171 ));
2172 assert!(
2176 value.is_char_boundary(sel.head),
2177 "head={} not on a char boundary in {value:?}",
2178 sel.head
2179 );
2180 assert!(sel.head == 2 || sel.head == 4, "head={}", sel.head);
2181 }
2182
2183 #[test]
2184 fn password_clipboard_request_suppresses_copy_and_cut_only() {
2185 let ctrl = KeyModifiers {
2186 ctrl: true,
2187 ..Default::default()
2188 };
2189 let opts = password_opts();
2190 let copy = ev_key_with_mods(UiKey::Character("c".into()), ctrl);
2191 let cut = ev_key_with_mods(UiKey::Character("x".into()), ctrl);
2192 let paste = ev_key_with_mods(UiKey::Character("v".into()), ctrl);
2193 assert_eq!(clipboard_request_for(©, &opts), None);
2194 assert_eq!(clipboard_request_for(&cut, &opts), None);
2195 assert_eq!(
2196 clipboard_request_for(&paste, &opts),
2197 Some(ClipboardKind::Paste)
2198 );
2199 let plain = TextInputOpts::default();
2201 assert_eq!(
2202 clipboard_request_for(©, &plain),
2203 Some(ClipboardKind::Copy)
2204 );
2205 }
2206
2207 #[test]
2208 fn placeholder_renders_only_when_value_is_empty() {
2209 let opts = TextInputOpts::default().placeholder("Email");
2210 let empty = text_input_with("", TextSelection::default(), opts);
2211 let muted_leaf = content_children(&empty)
2212 .iter()
2213 .find(|c| matches!(c.kind, Kind::Text) && c.text.as_deref() == Some("Email"));
2214 assert!(muted_leaf.is_some(), "placeholder leaf should be present");
2215
2216 let nonempty = text_input_with("hi", TextSelection::caret(2), opts);
2217 let muted_leaf = content_children(&nonempty)
2218 .iter()
2219 .find(|c| matches!(c.kind, Kind::Text) && c.text.as_deref() == Some("Email"));
2220 assert!(
2221 muted_leaf.is_none(),
2222 "placeholder should not render once the field has a value"
2223 );
2224 }
2225
2226 #[test]
2227 fn long_value_with_caret_at_end_shifts_content_left_to_keep_caret_in_view() {
2228 use crate::tree::Size;
2237 let value = "abcdefghijklmnopqrstuvwxyz0123456789".repeat(2);
2238 let mut root = super::text_input(
2239 &value,
2240 &as_selection_in("ti", TextSelection::caret(value.len())),
2241 "ti",
2242 )
2243 .width(Size::Fixed(120.0));
2244 let mut ui_state = crate::state::UiState::new();
2245 crate::layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 120.0, 40.0));
2246
2247 let inner = &root.children[0];
2249 let text_leaf = inner
2250 .children
2251 .iter()
2252 .find(|c| matches!(c.kind, Kind::Text))
2253 .expect("text leaf");
2254 let leaf_rect = ui_state.rect(&text_leaf.computed_id);
2255
2256 let inner_rect = ui_state.rect(&inner.computed_id);
2260 assert!(
2261 leaf_rect.x < inner_rect.x,
2262 "text leaf rect.x={} should be left of inner rect.x={} after \
2263 horizontal caret-into-view; layout did not shift content",
2264 leaf_rect.x,
2265 inner_rect.x,
2266 );
2267 }
2268
2269 #[test]
2270 fn short_value_does_not_shift_content() {
2271 use crate::tree::Size;
2275 let mut root =
2276 super::text_input("hi", &as_selection_in("ti", TextSelection::caret(2)), "ti")
2277 .width(Size::Fixed(120.0));
2278 let mut ui_state = crate::state::UiState::new();
2279 crate::layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 120.0, 40.0));
2280
2281 let inner = &root.children[0];
2282 let text_leaf = inner
2283 .children
2284 .iter()
2285 .find(|c| matches!(c.kind, Kind::Text))
2286 .expect("text leaf");
2287 let leaf_rect = ui_state.rect(&text_leaf.computed_id);
2288 let inner_rect = ui_state.rect(&inner.computed_id);
2289 assert!(
2290 (leaf_rect.x - inner_rect.x).abs() < 0.5,
2291 "short value should not shift; got leaf.x={} inner.x={}",
2292 leaf_rect.x,
2293 inner_rect.x
2294 );
2295 }
2296
2297 fn as_selection_in(key: &str, sel: TextSelection) -> Selection {
2300 Selection {
2301 range: Some(SelectionRange {
2302 anchor: SelectionPoint::new(key, sel.anchor),
2303 head: SelectionPoint::new(key, sel.head),
2304 }),
2305 }
2306 }
2307
2308 #[test]
2309 fn max_length_truncates_text_input_inserts() {
2310 let mut value = String::from("ab");
2311 let mut sel = TextSelection::caret(2);
2312 let opts = TextInputOpts::default().max_length(4);
2313 assert!(apply_event_with(
2315 &mut value,
2316 &mut sel,
2317 &ev_text("cdef"),
2318 &opts
2319 ));
2320 assert_eq!(value, "abcd");
2321 assert_eq!(sel, TextSelection::caret(4));
2322 assert!(!apply_event_with(
2324 &mut value,
2325 &mut sel,
2326 &ev_text("z"),
2327 &opts
2328 ));
2329 assert_eq!(value, "abcd");
2330 }
2331
2332 #[test]
2333 fn max_length_replaces_selection_with_capacity_freed_by_removal() {
2334 let mut value = String::from("abc");
2337 let mut sel = TextSelection::range(0, 3); let opts = TextInputOpts::default().max_length(4);
2339 assert!(apply_event_with(
2340 &mut value,
2341 &mut sel,
2342 &ev_text("12345"),
2343 &opts
2344 ));
2345 assert_eq!(value, "1234");
2346 assert_eq!(sel, TextSelection::caret(4));
2347 }
2348
2349 #[test]
2350 fn replace_selection_with_max_length_clips_a_paste() {
2351 let mut value = String::from("ab");
2352 let mut sel = TextSelection::caret(2);
2353 let opts = TextInputOpts::default().max_length(5);
2354 let inserted = replace_selection_with(&mut value, &mut sel, "0123456789", &opts);
2356 assert_eq!(value, "ab012");
2357 assert_eq!(inserted, 3);
2358 assert_eq!(sel, TextSelection::caret(5));
2359 }
2360
2361 #[test]
2362 fn max_length_does_not_shrink_an_already_overlong_value() {
2363 let mut value = String::from("abcdef");
2366 let mut sel = TextSelection::caret(6);
2367 let opts = TextInputOpts::default().max_length(3);
2368 assert!(!apply_event_with(
2370 &mut value,
2371 &mut sel,
2372 &ev_text("z"),
2373 &opts
2374 ));
2375 assert_eq!(value, "abcdef");
2376 assert!(apply_event_with(
2379 &mut value,
2380 &mut sel,
2381 &ev_key(UiKey::Backspace),
2382 &opts
2383 ));
2384 assert_eq!(value, "abcde");
2385 }
2386
2387 #[test]
2388 fn end_to_end_drag_select_through_runner_core() {
2389 let mut value = String::from("hello world");
2393 let mut sel = TextSelection::default();
2394 let mut tree = crate::column([text_input(&value, sel).key("ti")]).padding(20.0);
2395 let mut core = RunnerCore::new();
2396 let mut state = UiState::new();
2397 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
2398 core.ui_state = state;
2399 core.snapshot(&tree, &mut Default::default());
2400
2401 let rect = core.rect_of_key("ti").expect("ti rect");
2402 let down_x = rect.x + 8.0;
2403 let drag_x = rect.x + 80.0;
2404 let cy = rect.y + rect.h * 0.5;
2405
2406 core.pointer_moved(down_x, cy);
2407 let down = core
2408 .pointer_down(down_x, cy, PointerButton::Primary)
2409 .into_iter()
2410 .find(|e| e.kind == UiEventKind::PointerDown)
2411 .expect("pointer_down emits PointerDown");
2412 assert!(apply_event(&mut value, &mut sel, &down));
2413
2414 let drag = core
2415 .pointer_moved(drag_x, cy)
2416 .events
2417 .into_iter()
2418 .find(|e| e.kind == UiEventKind::Drag)
2419 .expect("Drag while pressed");
2420 assert!(apply_event(&mut value, &mut sel, &drag));
2421
2422 let events = core.pointer_up(drag_x, cy, PointerButton::Primary);
2423 for e in &events {
2424 apply_event(&mut value, &mut sel, e);
2425 }
2426 assert!(
2427 !sel.is_collapsed(),
2428 "expected drag-select to leave a non-empty selection"
2429 );
2430 assert_eq!(
2431 sel.anchor, 0,
2432 "anchor should sit at the down position (caret 0)"
2433 );
2434 assert!(
2435 sel.head > 0 && sel.head <= value.len(),
2436 "head={} value.len={}",
2437 sel.head,
2438 value.len()
2439 );
2440 }
2441
2442 #[test]
2450 fn apply_event_writes_back_under_the_inputs_key() {
2451 let mut value = String::new();
2453 let mut sel = Selection::default();
2454 let event = ev_text("h");
2455 assert!(super::apply_event(&mut value, &mut sel, "name", &event));
2456 assert_eq!(value, "h");
2457 let r = sel.range.as_ref().expect("selection set");
2458 assert_eq!(r.anchor.key, "name");
2459 assert_eq!(r.head.key, "name");
2460 assert_eq!(r.head.byte, 1);
2461 }
2462
2463 #[test]
2464 fn apply_event_claims_selection_when_event_routed_from_elsewhere() {
2465 let mut value = String::new();
2471 let mut sel = Selection {
2472 range: Some(SelectionRange {
2473 anchor: SelectionPoint::new("para-a", 0),
2474 head: SelectionPoint::new("para-a", 5),
2475 }),
2476 };
2477 let event = ev_text("x");
2478 assert!(super::apply_event(&mut value, &mut sel, "email", &event));
2479 assert_eq!(value, "x");
2480 let r = sel.range.as_ref().unwrap();
2481 assert_eq!(r.anchor.key, "email", "selection ownership migrated");
2482 assert_eq!(r.head.byte, 1);
2483 }
2484
2485 #[test]
2486 fn apply_event_leaves_selection_alone_when_event_is_unhandled() {
2487 let mut value = String::from("hi");
2491 let mut sel = Selection {
2492 range: Some(SelectionRange {
2493 anchor: SelectionPoint::new("para-a", 0),
2494 head: SelectionPoint::new("para-a", 3),
2495 }),
2496 };
2497 let event = ev_key(UiKey::Other("F1".into()));
2498 assert!(!super::apply_event(&mut value, &mut sel, "name", &event));
2499 let r = sel.range.as_ref().unwrap();
2501 assert_eq!(r.anchor.key, "para-a");
2502 assert_eq!(r.head.byte, 3);
2503 }
2504
2505 #[test]
2506 fn text_input_renders_caret_at_local_byte_when_selection_is_within_key() {
2507 let sel = Selection::caret("name", 2);
2508 let el = super::text_input("hello", &sel, "name");
2509 assert_eq!(el.key.as_deref(), Some("name"));
2511 let caret = content_children(&el)
2513 .iter()
2514 .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
2515 .expect("caret child");
2516 let expected = metrics::line_width("he", tokens::TEXT_SM.size, FontWeight::Regular, false);
2517 assert!(
2518 (caret.translate.0 - expected).abs() < 0.01,
2519 "caret.x={} expected {}",
2520 caret.translate.0,
2521 expected
2522 );
2523 }
2524
2525 #[test]
2526 fn text_input_omits_caret_when_selection_lives_elsewhere() {
2527 let sel = Selection {
2534 range: Some(SelectionRange {
2535 anchor: SelectionPoint::new("other", 0),
2536 head: SelectionPoint::new("other", 5),
2537 }),
2538 };
2539 let el = super::text_input("hello", &sel, "name");
2540 let band = el
2541 .children
2542 .iter()
2543 .find(|c| matches!(c.kind, Kind::Custom("text_input_selection")));
2544 assert!(band.is_none(), "no band when selection lives elsewhere");
2545 let caret = el
2546 .children
2547 .iter()
2548 .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")));
2549 assert!(
2550 caret.is_none(),
2551 "no caret when selection lives elsewhere — focus-fade has nothing to bring back to byte 0"
2552 );
2553 }
2554}