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 Size::Aspect(r) => h * r,
335 };
336 let h = match c.height {
337 Size::Fixed(v) => v,
338 Size::Hug => h,
339 Size::Fill(_) => ctx.container.h,
340 Size::Aspect(r) => w * r,
341 };
342 let y = ctx.container.y + (ctx.container.h - h) * 0.5;
347 Rect::new(ctx.container.x - x_offset, y, w, h)
348 })
349 .collect()
350 })
351 .children(children);
352
353 El::new(Kind::Custom("text_input"))
354 .at_loc(Location::caller())
355 .style_profile(StyleProfile::Surface)
356 .metrics_role(MetricsRole::Input)
357 .surface_role(SurfaceRole::Input)
358 .focusable()
359 .always_show_focus_ring()
362 .capture_keys()
363 .paint_overflow(Sides::all(tokens::RING_WIDTH))
364 .hit_overflow(Sides::all(tokens::HIT_OVERFLOW))
365 .cursor(Cursor::Text)
366 .fill(tokens::MUTED)
367 .stroke(tokens::BORDER)
368 .default_radius(tokens::RADIUS_MD)
369 .axis(Axis::Overlay)
370 .align(Align::Start)
371 .justify(Justify::Center)
372 .default_width(Size::Fill(1.0))
373 .default_height(Size::Fixed(tokens::CONTROL_HEIGHT))
374 .default_padding(Sides::xy(tokens::SPACE_3, 0.0))
375 .child(inner)
376}
377
378fn caret_bar() -> El {
379 El::new(Kind::Custom("text_input_caret"))
380 .style_profile(StyleProfile::Solid)
381 .fill(tokens::FOREGROUND)
382 .width(Size::Fixed(2.0))
383 .height(Size::Fixed(line_height_px()))
384 .radius(1.0)
385}
386
387fn line_height_px() -> f32 {
388 tokens::TEXT_SM.line_height
389}
390
391fn single_line_geometry(value: &str) -> TextGeometry<'_> {
392 TextGeometry::new(
393 value,
394 tokens::TEXT_SM.size,
395 FontWeight::Regular,
396 false,
397 TextWrap::NoWrap,
398 None,
399 )
400}
401
402pub fn apply_event(
431 value: &mut String,
432 selection: &mut Selection,
433 key: &str,
434 event: &UiEvent,
435) -> bool {
436 apply_event_with(value, selection, key, event, &TextInputOpts::default())
437}
438
439pub fn apply_event_with(
443 value: &mut String,
444 selection: &mut Selection,
445 key: &str,
446 event: &UiEvent,
447 opts: &TextInputOpts<'_>,
448) -> bool {
449 let mut local = selection.within(key).unwrap_or_default();
450 let changed = fold_event_local(value, &mut local, event, opts);
451 if changed {
452 selection.range = Some(SelectionRange {
453 anchor: SelectionPoint::new(key, local.anchor),
454 head: SelectionPoint::new(key, local.head),
455 });
456 }
457 changed
458}
459
460fn fold_event_local(
464 value: &mut String,
465 selection: &mut TextSelection,
466 event: &UiEvent,
467 opts: &TextInputOpts<'_>,
468) -> bool {
469 selection.anchor = clamp_to_char_boundary(value, selection.anchor.min(value.len()));
470 selection.head = clamp_to_char_boundary(value, selection.head.min(value.len()));
471 match event.kind {
472 UiEventKind::TextInput => {
473 let Some(insert) = event.text.as_deref() else {
474 return false;
475 };
476 if (event.modifiers.ctrl && !event.modifiers.alt) || event.modifiers.logo {
492 return false;
493 }
494 let filtered: String = insert.chars().filter(|c| !c.is_control()).collect();
495 if filtered.is_empty() {
496 return false;
497 }
498 let to_insert = clip_to_max_length(value, *selection, &filtered, opts.max_length);
499 if to_insert.is_empty() {
500 return false;
501 }
502 replace_selection(value, selection, &to_insert);
503 true
504 }
505 UiEventKind::MiddleClick => {
506 let Some(byte) = caret_byte_at(value, event, opts) else {
507 return false;
508 };
509 *selection = TextSelection::caret(byte);
510 if let Some(insert) = event.text.as_deref() {
511 replace_selection_with(value, selection, insert, opts);
512 }
513 true
514 }
515 UiEventKind::KeyDown => {
516 let Some(kp) = event.key_press.as_ref() else {
517 return false;
518 };
519 let mods = kp.modifiers;
520 if mods.ctrl
524 && !mods.alt
525 && !mods.logo
526 && let UiKey::Character(c) = &kp.key
527 && c.eq_ignore_ascii_case("a")
528 {
529 let len = value.len();
530 if selection.anchor == 0 && selection.head == len {
531 return false;
532 }
533 *selection = TextSelection {
534 anchor: 0,
535 head: len,
536 };
537 return true;
538 }
539 if mods.ctrl
544 && !mods.alt
545 && !mods.logo
546 && !mods.shift
547 && let UiKey::Character(c) = &kp.key
548 && c.eq_ignore_ascii_case("w")
549 {
550 return delete_word_backward(value, selection);
551 }
552 match kp.key {
553 UiKey::Escape => {
554 if selection.is_collapsed() {
555 return false;
556 }
557 selection.anchor = selection.head;
558 true
559 }
560 UiKey::Backspace => {
561 if !selection.is_collapsed() {
562 replace_selection(value, selection, "");
563 return true;
564 }
565 if selection.head == 0 {
566 return false;
567 }
568 if mods.ctrl && !mods.alt && !mods.logo {
569 return delete_word_backward(value, selection);
570 }
571 let prev = prev_char_boundary(value, selection.head);
572 value.replace_range(prev..selection.head, "");
573 selection.head = prev;
574 selection.anchor = prev;
575 true
576 }
577 UiKey::Delete => {
578 if !selection.is_collapsed() {
579 replace_selection(value, selection, "");
580 return true;
581 }
582 if selection.head >= value.len() {
583 return false;
584 }
585 if mods.ctrl && !mods.alt && !mods.logo {
586 return delete_word_forward(value, selection);
587 }
588 let next = next_char_boundary(value, selection.head);
589 value.replace_range(selection.head..next, "");
590 true
591 }
592 UiKey::ArrowLeft => {
593 let target = if selection.is_collapsed() || mods.shift {
594 if selection.head == 0 {
595 return false;
596 }
597 if mods.ctrl && !mods.alt && !mods.logo {
598 crate::selection::prev_word_boundary(value, selection.head)
599 } else {
600 prev_char_boundary(value, selection.head)
601 }
602 } else if mods.ctrl && !mods.alt && !mods.logo {
603 crate::selection::prev_word_boundary(value, selection.head)
606 } else {
607 selection.ordered().0
609 };
610 selection.head = target;
611 if !mods.shift {
612 selection.anchor = target;
613 }
614 true
615 }
616 UiKey::ArrowRight => {
617 let target = if selection.is_collapsed() || mods.shift {
618 if selection.head >= value.len() {
619 return false;
620 }
621 if mods.ctrl && !mods.alt && !mods.logo {
622 crate::selection::next_word_boundary(value, selection.head)
623 } else {
624 next_char_boundary(value, selection.head)
625 }
626 } else if mods.ctrl && !mods.alt && !mods.logo {
627 crate::selection::next_word_boundary(value, selection.head)
628 } else {
629 selection.ordered().1
631 };
632 selection.head = target;
633 if !mods.shift {
634 selection.anchor = target;
635 }
636 true
637 }
638 UiKey::Home => {
639 if selection.head == 0 && (mods.shift || selection.anchor == 0) {
640 return false;
641 }
642 selection.head = 0;
643 if !mods.shift {
644 selection.anchor = 0;
645 }
646 true
647 }
648 UiKey::End => {
649 let end = value.len();
650 if selection.head == end && (mods.shift || selection.anchor == end) {
651 return false;
652 }
653 selection.head = end;
654 if !mods.shift {
655 selection.anchor = end;
656 }
657 true
658 }
659 _ => false,
660 }
661 }
662 UiEventKind::PointerDown => {
663 let (Some((px, _py)), Some(target)) = (event.pointer, event.target.as_ref()) else {
664 return false;
665 };
666 let viewport_w = (target.rect.w - 2.0 * tokens::SPACE_3).max(0.0);
672 let x_offset = current_x_offset(value, selection.head, viewport_w, opts.mask);
673 let local_x = px - target.rect.x - tokens::SPACE_3 + x_offset;
674 let pos = caret_from_x(value, local_x, opts.mask);
675 if !event.modifiers.shift {
682 match event.click_count {
683 2 => {
684 let (lo, hi) = crate::selection::word_range_at(value, pos);
685 selection.anchor = lo;
686 selection.head = hi;
687 return true;
688 }
689 n if n >= 3 => {
690 selection.anchor = 0;
691 selection.head = value.len();
692 return true;
693 }
694 _ => {}
695 }
696 }
697 selection.head = pos;
698 if !event.modifiers.shift {
699 selection.anchor = pos;
700 }
701 true
702 }
703 UiEventKind::LongPress => {
704 let (Some((px, _py)), Some(target)) = (event.pointer, event.target.as_ref()) else {
705 return false;
706 };
707 let viewport_w = (target.rect.w - 2.0 * tokens::SPACE_3).max(0.0);
708 let x_offset = current_x_offset(value, selection.head, viewport_w, opts.mask);
709 let local_x = px - target.rect.x - tokens::SPACE_3 + x_offset;
710 let pos = caret_from_x(value, local_x, opts.mask);
711 let (lo, hi) = crate::selection::word_range_at(value, pos);
712 selection.anchor = lo;
713 selection.head = hi;
714 true
715 }
716 UiEventKind::Drag => {
717 let (Some((px, _py)), Some(target)) = (event.pointer, event.target.as_ref()) else {
718 return false;
719 };
720 let viewport_w = (target.rect.w - 2.0 * tokens::SPACE_3).max(0.0);
725 let x_offset = current_x_offset(value, selection.head, viewport_w, opts.mask);
726 let local_x = px - target.rect.x - tokens::SPACE_3 + x_offset;
727 let pos = caret_from_x(value, local_x, opts.mask);
728 if !event.modifiers.shift {
729 match event.click_count {
730 2 => {
731 extend_word_selection(value, selection, pos);
732 return true;
733 }
734 n if n >= 3 => {
735 selection.anchor = 0;
736 selection.head = value.len();
737 return true;
738 }
739 _ => {}
740 }
741 }
742 selection.head = pos;
743 true
744 }
745 UiEventKind::Click => false,
746 _ => false,
747 }
748}
749
750fn extend_word_selection(value: &str, selection: &mut TextSelection, pos: usize) {
751 let (selected_lo, selected_hi) = selection.ordered();
752 let (word_lo, word_hi) = crate::selection::word_range_at(value, pos);
753 if pos < selected_lo {
754 selection.anchor = selected_hi;
755 selection.head = word_lo;
756 } else {
757 selection.anchor = selected_lo;
758 selection.head = word_hi;
759 }
760}
761
762pub fn selected_text(value: &str, selection: TextSelection) -> &str {
765 let head = clamp_to_char_boundary(value, selection.head.min(value.len()));
766 let anchor = clamp_to_char_boundary(value, selection.anchor.min(value.len()));
767 &value[anchor.min(head)..anchor.max(head)]
768}
769
770pub(crate) fn delete_word_backward(value: &mut String, selection: &mut TextSelection) -> bool {
775 if !selection.is_collapsed() {
776 replace_selection(value, selection, "");
777 return true;
778 }
779 if selection.head == 0 {
780 return false;
781 }
782 let target = crate::selection::prev_word_boundary(value, selection.head);
783 if target == selection.head {
784 return false;
785 }
786 value.replace_range(target..selection.head, "");
787 selection.head = target;
788 selection.anchor = target;
789 true
790}
791
792pub(crate) fn delete_word_forward(value: &mut String, selection: &mut TextSelection) -> bool {
797 if !selection.is_collapsed() {
798 replace_selection(value, selection, "");
799 return true;
800 }
801 if selection.head >= value.len() {
802 return false;
803 }
804 let target = crate::selection::next_word_boundary(value, selection.head);
805 if target == selection.head {
806 return false;
807 }
808 value.replace_range(selection.head..target, "");
809 true
810}
811
812pub fn replace_selection(value: &mut String, selection: &mut TextSelection, replacement: &str) {
816 selection.anchor = clamp_to_char_boundary(value, selection.anchor.min(value.len()));
817 selection.head = clamp_to_char_boundary(value, selection.head.min(value.len()));
818 let (lo, hi) = selection.ordered();
819 value.replace_range(lo..hi, replacement);
820 let new_caret = lo + replacement.len();
821 selection.anchor = new_caret;
822 selection.head = new_caret;
823}
824
825pub fn replace_selection_with(
832 value: &mut String,
833 selection: &mut TextSelection,
834 replacement: &str,
835 opts: &TextInputOpts<'_>,
836) -> usize {
837 let clipped = clip_to_max_length(value, *selection, replacement, opts.max_length);
838 let len = clipped.len();
839 replace_selection(value, selection, &clipped);
840 len
841}
842
843pub fn select_all(value: &str) -> TextSelection {
845 TextSelection {
846 anchor: 0,
847 head: value.len(),
848 }
849}
850
851#[derive(Clone, Copy, Debug, PartialEq, Eq)]
862pub enum ClipboardKind {
863 Copy,
865 Cut,
867 Paste,
869}
870
871pub fn clipboard_request(event: &UiEvent) -> Option<ClipboardKind> {
917 clipboard_request_for(event, &TextInputOpts::default())
918}
919
920pub fn clipboard_request_for(event: &UiEvent, opts: &TextInputOpts<'_>) -> Option<ClipboardKind> {
924 if event.kind != UiEventKind::KeyDown {
925 return None;
926 }
927 let kp = event.key_press.as_ref()?;
928 let mods = kp.modifiers;
929 if mods.alt || mods.shift {
932 return None;
933 }
934 let kind = match &kp.key {
935 UiKey::Character(c) if mods.ctrl || mods.logo => match c.to_ascii_lowercase().as_str() {
936 "c" => ClipboardKind::Copy,
937 "x" => ClipboardKind::Cut,
938 "v" => ClipboardKind::Paste,
939 _ => return None,
940 },
941 UiKey::Other(action) if !mods.ctrl && !mods.logo => match action.as_str() {
944 "Copy" => ClipboardKind::Copy,
945 "Cut" => ClipboardKind::Cut,
946 "Paste" => ClipboardKind::Paste,
947 _ => return None,
948 },
949 _ => return None,
950 };
951 if opts.is_masked() && matches!(kind, ClipboardKind::Copy | ClipboardKind::Cut) {
952 return None;
953 }
954 Some(kind)
955}
956
957#[track_caller]
967pub fn caret_byte_at(value: &str, event: &UiEvent, opts: &TextInputOpts<'_>) -> Option<usize> {
968 let (px, _py) = event.pointer?;
969 let target = event.target.as_ref()?;
970 let local_x = px - target.rect.x - tokens::SPACE_3;
971 Some(caret_from_x(value, local_x, opts.mask))
972}
973
974fn current_x_offset(value: &str, head: usize, viewport_w: f32, mask: MaskMode) -> f32 {
986 if viewport_w <= 0.0 {
987 return 0.0;
988 }
989 let head = clamp_to_char_boundary(value, head.min(value.len()));
990 let display = display_str(value, mask);
991 let geometry = single_line_geometry(&display);
992 let head_display = original_to_display_byte(value, head, mask);
993 let head_px = geometry.prefix_width(head_display);
994 (head_px - viewport_w).max(0.0)
995}
996
997fn caret_from_x(value: &str, local_x: f32, mask: MaskMode) -> usize {
998 if value.is_empty() || local_x <= 0.0 {
999 return 0;
1000 }
1001 let probe = display_str(value, mask);
1002 let local_y = line_height_px() * 0.5;
1003 let geometry = single_line_geometry(&probe);
1004 let display_byte = match geometry.hit_byte(local_x, local_y) {
1005 Some(byte) => byte.min(probe.len()),
1006 None => probe.len(),
1007 };
1008 display_to_original_byte(value, display_byte, mask)
1009}
1010
1011fn display_str(value: &str, mask: MaskMode) -> Cow<'_, str> {
1016 match mask {
1017 MaskMode::None => Cow::Borrowed(value),
1018 MaskMode::Password => {
1019 let n = value.chars().count();
1020 let mut s = String::with_capacity(n * MASK_CHAR.len_utf8());
1021 for _ in 0..n {
1022 s.push(MASK_CHAR);
1023 }
1024 Cow::Owned(s)
1025 }
1026 }
1027}
1028
1029fn original_to_display_byte(value: &str, byte_index: usize, mask: MaskMode) -> usize {
1030 match mask {
1031 MaskMode::None => byte_index.min(value.len()),
1032 MaskMode::Password => {
1033 let clamped = clamp_to_char_boundary(value, byte_index.min(value.len()));
1034 value[..clamped].chars().count() * MASK_CHAR.len_utf8()
1035 }
1036 }
1037}
1038
1039fn display_to_original_byte(value: &str, display_byte: usize, mask: MaskMode) -> usize {
1041 match mask {
1042 MaskMode::None => clamp_to_char_boundary(value, display_byte.min(value.len())),
1043 MaskMode::Password => {
1044 let scalar_idx = display_byte / MASK_CHAR.len_utf8();
1045 value
1046 .char_indices()
1047 .nth(scalar_idx)
1048 .map(|(i, _)| i)
1049 .unwrap_or(value.len())
1050 }
1051 }
1052}
1053
1054fn clip_to_max_length<'a>(
1062 value: &str,
1063 selection: TextSelection,
1064 replacement: &'a str,
1065 max_length: Option<usize>,
1066) -> Cow<'a, str> {
1067 let Some(max) = max_length else {
1068 return Cow::Borrowed(replacement);
1069 };
1070 let lo = clamp_to_char_boundary(value, selection.anchor.min(selection.head).min(value.len()));
1071 let hi = clamp_to_char_boundary(value, selection.anchor.max(selection.head).min(value.len()));
1072 let post_other = value[..lo].chars().count() + value[hi..].chars().count();
1073 let allowed = max.saturating_sub(post_other);
1074 if replacement.chars().count() <= allowed {
1075 Cow::Borrowed(replacement)
1076 } else {
1077 Cow::Owned(replacement.chars().take(allowed).collect())
1078 }
1079}
1080
1081fn clamp_to_char_boundary(s: &str, idx: usize) -> usize {
1082 let mut idx = idx.min(s.len());
1083 while idx > 0 && !s.is_char_boundary(idx) {
1084 idx -= 1;
1085 }
1086 idx
1087}
1088
1089fn prev_char_boundary(s: &str, from: usize) -> usize {
1090 let mut i = from.saturating_sub(1);
1091 while i > 0 && !s.is_char_boundary(i) {
1092 i -= 1;
1093 }
1094 i
1095}
1096
1097fn next_char_boundary(s: &str, from: usize) -> usize {
1098 let mut i = (from + 1).min(s.len());
1099 while i < s.len() && !s.is_char_boundary(i) {
1100 i += 1;
1101 }
1102 i
1103}
1104
1105#[cfg(test)]
1106mod tests {
1107 use super::*;
1108 use crate::event::{KeyModifiers, KeyPress, Pointer, PointerButton, PointerKind, UiTarget};
1109 use crate::layout::layout;
1110 use crate::palette::Palette;
1111 use crate::runtime::RunnerCore;
1112 use crate::state::UiState;
1113 use crate::text::metrics;
1114
1115 const TEST_KEY: &str = "ti";
1120
1121 #[track_caller]
1126 fn text_input(value: &str, sel: TextSelection) -> El {
1127 super::text_input(value, &as_selection(sel), TEST_KEY)
1128 }
1129
1130 #[track_caller]
1131 fn text_input_with(value: &str, sel: TextSelection, opts: TextInputOpts<'_>) -> El {
1132 super::text_input_with(value, &as_selection(sel), TEST_KEY, opts)
1133 }
1134
1135 fn apply_event(value: &mut String, sel: &mut TextSelection, event: &UiEvent) -> bool {
1136 let mut g = as_selection(*sel);
1137 let changed = super::apply_event(value, &mut g, TEST_KEY, event);
1138 sync_back(sel, &g);
1139 changed
1140 }
1141
1142 fn apply_event_with(
1143 value: &mut String,
1144 sel: &mut TextSelection,
1145 event: &UiEvent,
1146 opts: &TextInputOpts<'_>,
1147 ) -> bool {
1148 let mut g = as_selection(*sel);
1149 let changed = super::apply_event_with(value, &mut g, TEST_KEY, event, opts);
1150 sync_back(sel, &g);
1151 changed
1152 }
1153
1154 fn as_selection(sel: TextSelection) -> Selection {
1155 Selection {
1156 range: Some(SelectionRange {
1157 anchor: SelectionPoint::new(TEST_KEY, sel.anchor),
1158 head: SelectionPoint::new(TEST_KEY, sel.head),
1159 }),
1160 }
1161 }
1162
1163 fn sync_back(local: &mut TextSelection, global: &Selection) {
1164 match global.within(TEST_KEY) {
1165 Some(view) => *local = view,
1166 None => *local = TextSelection::default(),
1167 }
1168 }
1169
1170 fn ev_text(s: &str) -> UiEvent {
1171 ev_text_with_mods(s, KeyModifiers::default())
1172 }
1173
1174 fn ev_text_with_mods(s: &str, modifiers: KeyModifiers) -> UiEvent {
1175 UiEvent {
1176 path: None,
1177 key: None,
1178 target: None,
1179 pointer: None,
1180 key_press: None,
1181 text: Some(s.into()),
1182 selection: None,
1183 modifiers,
1184 click_count: 0,
1185 pointer_kind: None,
1186 wheel_delta: None,
1187 kind: UiEventKind::TextInput,
1188 }
1189 }
1190
1191 fn ev_key(key: UiKey) -> UiEvent {
1192 ev_key_with_mods(key, KeyModifiers::default())
1193 }
1194
1195 fn ev_key_with_mods(key: UiKey, modifiers: KeyModifiers) -> UiEvent {
1196 UiEvent {
1197 path: None,
1198 key: None,
1199 target: None,
1200 pointer: None,
1201 key_press: Some(KeyPress {
1202 key,
1203 modifiers,
1204 repeat: false,
1205 }),
1206 text: None,
1207 selection: None,
1208 modifiers,
1209 click_count: 0,
1210 pointer_kind: None,
1211 wheel_delta: None,
1212 kind: UiEventKind::KeyDown,
1213 }
1214 }
1215
1216 fn ev_pointer_down(target: UiTarget, pointer: (f32, f32), modifiers: KeyModifiers) -> UiEvent {
1217 ev_pointer_down_with_count(target, pointer, modifiers, 1)
1218 }
1219
1220 fn ev_pointer_down_with_count(
1221 target: UiTarget,
1222 pointer: (f32, f32),
1223 modifiers: KeyModifiers,
1224 click_count: u8,
1225 ) -> UiEvent {
1226 UiEvent {
1227 path: None,
1228 key: Some(target.key.clone()),
1229 target: Some(target),
1230 pointer: Some(pointer),
1231 key_press: None,
1232 text: None,
1233 selection: None,
1234 modifiers,
1235 click_count,
1236 pointer_kind: None,
1237 wheel_delta: None,
1238 kind: UiEventKind::PointerDown,
1239 }
1240 }
1241
1242 fn ev_long_press(target: UiTarget, pointer: (f32, f32)) -> UiEvent {
1243 UiEvent {
1244 path: None,
1245 key: Some(target.key.clone()),
1246 target: Some(target),
1247 pointer: Some(pointer),
1248 key_press: None,
1249 text: None,
1250 selection: None,
1251 modifiers: KeyModifiers::default(),
1252 click_count: 0,
1253 pointer_kind: Some(PointerKind::Touch),
1254 wheel_delta: None,
1255 kind: UiEventKind::LongPress,
1256 }
1257 }
1258
1259 fn ev_drag(target: UiTarget, pointer: (f32, f32)) -> UiEvent {
1260 ev_drag_with_count(target, pointer, 0)
1261 }
1262
1263 fn ev_drag_with_count(target: UiTarget, pointer: (f32, f32), click_count: u8) -> UiEvent {
1264 UiEvent {
1265 path: None,
1266 key: Some(target.key.clone()),
1267 target: Some(target),
1268 pointer: Some(pointer),
1269 key_press: None,
1270 text: None,
1271 selection: None,
1272 modifiers: KeyModifiers::default(),
1273 click_count,
1274 pointer_kind: None,
1275 wheel_delta: None,
1276 kind: UiEventKind::Drag,
1277 }
1278 }
1279
1280 fn ev_middle_click(target: UiTarget, pointer: (f32, f32), text: Option<&str>) -> UiEvent {
1281 UiEvent {
1282 path: None,
1283 key: Some(target.key.clone()),
1284 target: Some(target),
1285 pointer: Some(pointer),
1286 key_press: None,
1287 text: text.map(str::to_string),
1288 selection: None,
1289 modifiers: KeyModifiers::default(),
1290 click_count: 1,
1291 pointer_kind: None,
1292 wheel_delta: None,
1293 kind: UiEventKind::MiddleClick,
1294 }
1295 }
1296
1297 fn ti_target() -> UiTarget {
1298 UiTarget {
1299 key: "ti".into(),
1300 node_id: "root.text_input[ti]".into(),
1301 rect: Rect::new(20.0, 20.0, 400.0, 36.0),
1302 tooltip: None,
1303 scroll_offset_y: 0.0,
1304 }
1305 }
1306
1307 fn content_children(el: &El) -> &[El] {
1315 assert_eq!(
1316 el.children.len(),
1317 1,
1318 "text_input wraps its content in a single inner group"
1319 );
1320 &el.children[0].children
1321 }
1322
1323 #[test]
1324 fn text_input_collapsed_renders_value_as_single_text_leaf_plus_caret() {
1325 let el = text_input("hello", TextSelection::caret(2));
1326 assert!(matches!(el.kind, Kind::Custom("text_input")));
1327 assert!(el.focusable);
1328 assert!(el.capture_keys);
1329 let cs = content_children(&el);
1333 assert_eq!(cs.len(), 2);
1334 assert!(matches!(cs[0].kind, Kind::Text));
1335 assert_eq!(cs[0].text.as_deref(), Some("hello"));
1336 assert!(matches!(cs[1].kind, Kind::Custom("text_input_caret")));
1337 assert!(cs[1].alpha_follows_focused_ancestor);
1338 }
1339
1340 #[test]
1341 fn text_input_declares_text_cursor() {
1342 let el = text_input("hello", TextSelection::caret(0));
1343 assert_eq!(el.cursor, Some(Cursor::Text));
1344 }
1345
1346 #[test]
1347 fn text_input_with_selection_inserts_selection_band_first() {
1348 let el = text_input("hello", TextSelection::range(2, 4));
1350 let cs = content_children(&el);
1351 assert_eq!(cs.len(), 3);
1353 assert!(matches!(cs[0].kind, Kind::Custom("text_input_selection")));
1354 assert_eq!(cs[1].text.as_deref(), Some("hello"));
1355 assert!(matches!(cs[2].kind, Kind::Custom("text_input_caret")));
1356 }
1357
1358 #[test]
1359 fn text_input_caret_translate_advances_with_head() {
1360 use crate::text::metrics::line_width;
1364 let value = "hello";
1365 let head = 3;
1366 let el = text_input(value, TextSelection::caret(head));
1367 let caret = content_children(&el)
1368 .iter()
1369 .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
1370 .expect("caret child");
1371 let expected = line_width(
1372 &value[..head],
1373 tokens::TEXT_SM.size,
1374 FontWeight::Regular,
1375 false,
1376 );
1377 assert!(
1378 (caret.translate.0 - expected).abs() < 0.01,
1379 "caret translate.x = {}, expected {}",
1380 caret.translate.0,
1381 expected
1382 );
1383 }
1384
1385 #[test]
1386 fn text_input_clamps_off_utf8_boundary() {
1387 let el = text_input("é", TextSelection::caret(1));
1391 let cs = content_children(&el);
1392 assert_eq!(cs[0].text.as_deref(), Some("é"));
1393 let caret = cs
1394 .iter()
1395 .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
1396 .expect("caret child");
1397 assert!(caret.translate.0.abs() < 0.01);
1399 }
1400
1401 #[test]
1402 fn selection_band_fill_dims_when_input_unfocused() {
1403 use crate::draw_ops::draw_ops;
1407 use crate::ir::DrawOp;
1408 use crate::shader::UniformValue;
1409 use crate::state::AnimationMode;
1410 use web_time::Instant;
1411
1412 let mut tree = crate::column([text_input("hello", TextSelection::range(0, 5)).key("ti")])
1413 .padding(20.0);
1414 let mut state = UiState::new();
1415 state.set_animation_mode(AnimationMode::Settled);
1416 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1417 state.sync_focus_order(&tree);
1418
1419 state.apply_to_state();
1423 state.tick_visual_animations(&mut tree, Instant::now(), &Palette::default());
1424 let unfocused = band_fill(&tree, &state).expect("band quad emitted");
1425 let [ur, ug, ub, _] = unfocused.to_srgb_u8a();
1426 let [tr, tg, tb, _] = tokens::SELECTION_BG_UNFOCUSED.to_srgb_u8a();
1427 assert_eq!(
1428 (ur, ug, ub),
1429 (tr, tg, tb),
1430 "unfocused → band rgb is the muted token"
1431 );
1432
1433 let target = state
1436 .focus
1437 .order
1438 .iter()
1439 .find(|t| t.key == "ti")
1440 .expect("ti in focus order")
1441 .clone();
1442 state.set_focus(Some(target));
1443 state.apply_to_state();
1444 state.tick_visual_animations(&mut tree, Instant::now(), &Palette::default());
1445 let focused = band_fill(&tree, &state).expect("band quad emitted");
1446 let [fr, fg, fb, _] = focused.to_srgb_u8a();
1447 let [tr, tg, tb, _] = tokens::SELECTION_BG.to_srgb_u8a();
1448 assert_eq!(
1449 (fr, fg, fb),
1450 (tr, tg, tb),
1451 "focused → band rgb is the saturated token"
1452 );
1453
1454 fn band_fill(tree: &El, state: &UiState) -> Option<crate::tree::Color> {
1455 let ops = draw_ops(tree, state);
1456 for op in ops {
1457 if let DrawOp::Quad { id, uniforms, .. } = op
1458 && id.contains("text_input_selection")
1459 && let Some(UniformValue::Color(c)) = uniforms.get("fill")
1460 {
1461 return Some(*c);
1462 }
1463 }
1464 None
1465 }
1466 }
1467
1468 #[test]
1469 fn caret_alpha_follows_focus_envelope() {
1470 use crate::draw_ops::draw_ops;
1475 use crate::ir::DrawOp;
1476 use crate::shader::UniformValue;
1477 use crate::state::AnimationMode;
1478 use web_time::Instant;
1479
1480 let mut tree =
1481 crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
1482 let mut state = UiState::new();
1483 state.set_animation_mode(AnimationMode::Settled);
1484 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1485 state.sync_focus_order(&tree);
1486
1487 state.apply_to_state();
1489 state.tick_visual_animations(&mut tree, Instant::now(), &Palette::default());
1490 let caret_alpha = caret_fill_alpha(&tree, &state);
1491 assert_eq!(caret_alpha, Some(0), "unfocused → caret invisible");
1492
1493 let target = state
1495 .focus
1496 .order
1497 .iter()
1498 .find(|t| t.key == "ti")
1499 .expect("ti in focus order")
1500 .clone();
1501 state.set_focus(Some(target));
1502 state.apply_to_state();
1503 state.tick_visual_animations(&mut tree, Instant::now(), &Palette::default());
1504 let caret_alpha = caret_fill_alpha(&tree, &state);
1505 assert_eq!(
1506 caret_alpha,
1507 Some(255),
1508 "focused → caret fully visible (alpha=255)"
1509 );
1510
1511 fn caret_fill_alpha(tree: &El, state: &UiState) -> Option<u8> {
1512 let ops = draw_ops(tree, state);
1513 for op in ops {
1514 if let DrawOp::Quad { id, uniforms, .. } = op
1515 && id.contains("text_input_caret")
1516 && let Some(UniformValue::Color(c)) = uniforms.get("fill")
1517 {
1518 return Some(c.to_srgb_u8a()[3]);
1519 }
1520 }
1521 None
1522 }
1523 }
1524
1525 #[test]
1526 fn caret_blink_alpha_holds_solid_through_grace_then_cycles() {
1527 use crate::state::caret_blink_alpha_for;
1530 use std::time::Duration;
1531 assert_eq!(caret_blink_alpha_for(Duration::from_millis(0)), 1.0);
1533 assert_eq!(caret_blink_alpha_for(Duration::from_millis(499)), 1.0);
1534 assert_eq!(caret_blink_alpha_for(Duration::from_millis(500)), 1.0);
1536 assert_eq!(caret_blink_alpha_for(Duration::from_millis(1029)), 1.0);
1537 assert_eq!(caret_blink_alpha_for(Duration::from_millis(1030)), 0.0);
1539 assert_eq!(caret_blink_alpha_for(Duration::from_millis(1559)), 0.0);
1540 assert_eq!(caret_blink_alpha_for(Duration::from_millis(1560)), 1.0);
1542 }
1543
1544 #[test]
1545 fn caret_paint_alpha_blinks_after_focus_in_live_mode() {
1546 use crate::draw_ops::draw_ops;
1550 use crate::ir::DrawOp;
1551 use crate::shader::UniformValue;
1552 use crate::state::AnimationMode;
1553 use std::time::Duration;
1554
1555 let mut tree =
1556 crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
1557 let mut state = UiState::new();
1558 state.set_animation_mode(AnimationMode::Live);
1559 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1560 state.sync_focus_order(&tree);
1561
1562 let target = state
1564 .focus
1565 .order
1566 .iter()
1567 .find(|t| t.key == "ti")
1568 .unwrap()
1569 .clone();
1570 state.set_focus(Some(target));
1571 let activity_at = state.caret.activity_at.expect("set_focus bumps activity");
1572 let input_id = tree.children[0].computed_id.clone();
1573
1574 let pin_focus = |state: &mut UiState| {
1578 state.animation.envelopes.insert(
1579 (input_id.clone(), crate::state::EnvelopeKind::FocusRing),
1580 1.0,
1581 );
1582 };
1583
1584 state.tick_visual_animations(&mut tree, activity_at, &Palette::default());
1586 pin_focus(&mut state);
1587 assert_eq!(caret_alpha(&tree, &state), Some(255));
1588
1589 state.tick_visual_animations(
1591 &mut tree,
1592 activity_at + Duration::from_millis(1100),
1593 &Palette::default(),
1594 );
1595 pin_focus(&mut state);
1596 assert_eq!(caret_alpha(&tree, &state), Some(0));
1597
1598 state.tick_visual_animations(
1600 &mut tree,
1601 activity_at + Duration::from_millis(1600),
1602 &Palette::default(),
1603 );
1604 pin_focus(&mut state);
1605 assert_eq!(caret_alpha(&tree, &state), Some(255));
1606
1607 fn caret_alpha(tree: &El, state: &UiState) -> Option<u8> {
1608 for op in draw_ops(tree, state) {
1609 if let DrawOp::Quad { id, uniforms, .. } = op
1610 && id.contains("text_input_caret")
1611 && let Some(UniformValue::Color(c)) = uniforms.get("fill")
1612 {
1613 return Some(c.to_srgb_u8a()[3]);
1614 }
1615 }
1616 None
1617 }
1618 }
1619
1620 #[test]
1621 fn caret_blink_resumes_solid_after_selection_change() {
1622 use crate::state::AnimationMode;
1625 use std::time::Duration;
1626 use web_time::Instant;
1627
1628 let mut tree =
1629 crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
1630 let mut state = UiState::new();
1631 state.set_animation_mode(AnimationMode::Live);
1632 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1633 state.sync_focus_order(&tree);
1634
1635 let t0 = Instant::now();
1637 state.bump_caret_activity(t0);
1638 state.tick_visual_animations(
1639 &mut tree,
1640 t0 + Duration::from_millis(1100),
1641 &Palette::default(),
1642 );
1643 assert_eq!(state.caret.blink_alpha, 0.0, "deep in off phase");
1644
1645 state.bump_caret_activity(t0 + Duration::from_millis(1100));
1647 assert_eq!(state.caret.blink_alpha, 1.0, "fresh activity → solid");
1648 }
1649
1650 #[test]
1651 fn caret_tick_requests_redraw_while_capture_keys_node_focused() {
1652 use crate::state::AnimationMode;
1656 use web_time::Instant;
1657
1658 let mut tree =
1659 crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
1660 let mut state = UiState::new();
1661 state.set_animation_mode(AnimationMode::Live);
1662 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1663 state.sync_focus_order(&tree);
1664
1665 let no_focus = state.tick_visual_animations(&mut tree, Instant::now(), &Palette::default());
1667 assert!(!no_focus, "without focus, blink doesn't request redraws");
1668
1669 let target = state
1672 .focus
1673 .order
1674 .iter()
1675 .find(|t| t.key == "ti")
1676 .unwrap()
1677 .clone();
1678 state.set_focus(Some(target));
1679 let focused = state.tick_visual_animations(&mut tree, Instant::now(), &Palette::default());
1680 assert!(focused, "focused capture_keys node → tick demands redraws");
1681 }
1682
1683 #[test]
1684 fn apply_text_input_inserts_at_caret_when_collapsed() {
1685 let mut value = String::from("ho");
1686 let mut sel = TextSelection::caret(1);
1687 assert!(apply_event(&mut value, &mut sel, &ev_text("i, t")));
1688 assert_eq!(value, "hi, to");
1689 assert_eq!(sel, TextSelection::caret(5));
1690 }
1691
1692 #[test]
1693 fn apply_text_input_replaces_selection() {
1694 let mut value = String::from("hello world");
1695 let mut sel = TextSelection::range(6, 11); assert!(apply_event(&mut value, &mut sel, &ev_text("kit")));
1697 assert_eq!(value, "hello kit");
1698 assert_eq!(sel, TextSelection::caret(9));
1699 }
1700
1701 #[test]
1702 fn apply_backspace_removes_selection_when_non_empty() {
1703 let mut value = String::from("hello world");
1704 let mut sel = TextSelection::range(6, 11);
1705 assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::Backspace)));
1706 assert_eq!(value, "hello ");
1707 assert_eq!(sel, TextSelection::caret(6));
1708 }
1709
1710 #[test]
1711 fn apply_delete_removes_selection_when_non_empty() {
1712 let mut value = String::from("hello world");
1713 let mut sel = TextSelection::range(0, 6); assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::Delete)));
1715 assert_eq!(value, "world");
1716 assert_eq!(sel, TextSelection::caret(0));
1717 }
1718
1719 #[test]
1720 fn apply_escape_collapses_selection_without_editing() {
1721 let mut value = String::from("hello");
1722 let mut sel = TextSelection::range(1, 4);
1723 assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::Escape)));
1724 assert_eq!(value, "hello");
1725 assert_eq!(sel, TextSelection::caret(4));
1726 assert!(!apply_event(&mut value, &mut sel, &ev_key(UiKey::Escape)));
1727 }
1728
1729 #[test]
1730 fn apply_backspace_collapsed_at_start_is_noop() {
1731 let mut value = String::from("hi");
1732 let mut sel = TextSelection::caret(0);
1733 assert!(!apply_event(
1734 &mut value,
1735 &mut sel,
1736 &ev_key(UiKey::Backspace)
1737 ));
1738 }
1739
1740 #[test]
1741 fn apply_arrow_walks_utf8_boundaries() {
1742 let mut value = String::from("aé");
1743 let mut sel = TextSelection::caret(0);
1744 apply_event(&mut value, &mut sel, &ev_key(UiKey::ArrowRight));
1745 assert_eq!(sel.head, 1);
1746 apply_event(&mut value, &mut sel, &ev_key(UiKey::ArrowRight));
1747 assert_eq!(sel.head, 3);
1748 assert!(!apply_event(
1749 &mut value,
1750 &mut sel,
1751 &ev_key(UiKey::ArrowRight)
1752 ));
1753 apply_event(&mut value, &mut sel, &ev_key(UiKey::ArrowLeft));
1754 assert_eq!(sel.head, 1);
1755 }
1756
1757 #[test]
1758 fn apply_arrow_collapses_selection_without_shift() {
1759 let mut value = String::from("hello");
1760 let mut sel = TextSelection::range(1, 4); assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::ArrowLeft)));
1764 assert_eq!(sel, TextSelection::caret(1));
1765
1766 let mut sel = TextSelection::range(1, 4);
1767 assert!(apply_event(
1769 &mut value,
1770 &mut sel,
1771 &ev_key(UiKey::ArrowRight)
1772 ));
1773 assert_eq!(sel, TextSelection::caret(4));
1774 }
1775
1776 #[test]
1777 fn apply_shift_arrow_extends_selection() {
1778 let mut value = String::from("hello");
1779 let mut sel = TextSelection::caret(2);
1780 let shift = KeyModifiers {
1781 shift: true,
1782 ..Default::default()
1783 };
1784 assert!(apply_event(
1785 &mut value,
1786 &mut sel,
1787 &ev_key_with_mods(UiKey::ArrowRight, shift)
1788 ));
1789 assert_eq!(sel, TextSelection::range(2, 3));
1790 assert!(apply_event(
1791 &mut value,
1792 &mut sel,
1793 &ev_key_with_mods(UiKey::ArrowRight, shift)
1794 ));
1795 assert_eq!(sel, TextSelection::range(2, 4));
1796 assert!(apply_event(
1798 &mut value,
1799 &mut sel,
1800 &ev_key_with_mods(UiKey::ArrowLeft, shift)
1801 ));
1802 assert_eq!(sel, TextSelection::range(2, 3));
1803 }
1804
1805 #[test]
1806 fn apply_home_end_collapse_or_extend() {
1807 let mut value = String::from("hello");
1808 let mut sel = TextSelection::caret(2);
1809 assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::End)));
1810 assert_eq!(sel, TextSelection::caret(5));
1811 assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::Home)));
1812 assert_eq!(sel, TextSelection::caret(0));
1813
1814 let shift = KeyModifiers {
1816 shift: true,
1817 ..Default::default()
1818 };
1819 let mut sel = TextSelection::caret(2);
1820 assert!(apply_event(
1821 &mut value,
1822 &mut sel,
1823 &ev_key_with_mods(UiKey::End, shift)
1824 ));
1825 assert_eq!(sel, TextSelection::range(2, 5));
1826 }
1827
1828 #[test]
1829 fn apply_ctrl_a_selects_all() {
1830 let mut value = String::from("hello");
1831 let mut sel = TextSelection::caret(2);
1832 let ctrl = KeyModifiers {
1833 ctrl: true,
1834 ..Default::default()
1835 };
1836 assert!(apply_event(
1837 &mut value,
1838 &mut sel,
1839 &ev_key_with_mods(UiKey::Character("a".into()), ctrl)
1840 ));
1841 assert_eq!(sel, TextSelection::range(0, 5));
1842 assert!(!apply_event(
1844 &mut value,
1845 &mut sel,
1846 &ev_key_with_mods(UiKey::Character("a".into()), ctrl)
1847 ));
1848 }
1849
1850 #[test]
1851 fn apply_pointer_down_sets_anchor_and_head() {
1852 let mut value = String::from("hello");
1853 let mut sel = TextSelection::range(0, 5);
1854 let down = ev_pointer_down(
1856 ti_target(),
1857 (ti_target().rect.x + 1.0, ti_target().rect.y + 18.0),
1858 KeyModifiers::default(),
1859 );
1860 assert!(apply_event(&mut value, &mut sel, &down));
1861 assert_eq!(sel, TextSelection::caret(0));
1862 }
1863
1864 #[test]
1865 fn apply_double_click_selects_word_at_caret() {
1866 let mut value = String::from("hello world");
1867 let mut sel = TextSelection::caret(0);
1868 let target = ti_target();
1870 let click_x = target.rect.x
1871 + tokens::SPACE_3
1872 + crate::text::metrics::line_width(
1873 "hello w",
1874 tokens::TEXT_SM.size,
1875 FontWeight::Regular,
1876 false,
1877 );
1878 let down = ev_pointer_down_with_count(
1879 target.clone(),
1880 (click_x, target.rect.y + 18.0),
1881 KeyModifiers::default(),
1882 2,
1883 );
1884 assert!(apply_event(&mut value, &mut sel, &down));
1885 assert_eq!(sel.anchor, 6);
1887 assert_eq!(sel.head, 11);
1888 }
1889
1890 #[test]
1891 fn apply_long_press_selects_word_at_caret() {
1892 let mut value = String::from("hello world");
1893 let mut sel = TextSelection::caret(0);
1894 let target = ti_target();
1895 let event = ev_long_press(target.clone(), (target.rect.x + 4.0, target.rect.y + 18.0));
1896
1897 assert!(apply_event(&mut value, &mut sel, &event));
1898 assert_eq!(sel, TextSelection::range(0, 5));
1899 }
1900
1901 #[test]
1902 fn apply_triple_click_selects_all() {
1903 let mut value = String::from("hello world");
1904 let mut sel = TextSelection::caret(0);
1905 let target = ti_target();
1906 let down = ev_pointer_down_with_count(
1907 target.clone(),
1908 (target.rect.x + 1.0, target.rect.y + 18.0),
1909 KeyModifiers::default(),
1910 3,
1911 );
1912 assert!(apply_event(&mut value, &mut sel, &down));
1913 assert_eq!(sel.anchor, 0);
1914 assert_eq!(sel.head, value.len());
1915 }
1916
1917 #[test]
1918 fn apply_shift_double_click_falls_back_to_extend_not_word_select() {
1919 let mut value = String::from("hello world");
1922 let mut sel = TextSelection::caret(0);
1923 let target = ti_target();
1924 let click_x = target.rect.x
1925 + tokens::SPACE_3
1926 + crate::text::metrics::line_width(
1927 "hello w",
1928 tokens::TEXT_SM.size,
1929 FontWeight::Regular,
1930 false,
1931 );
1932 let shift = KeyModifiers {
1933 shift: true,
1934 ..Default::default()
1935 };
1936 let down =
1937 ev_pointer_down_with_count(target.clone(), (click_x, target.rect.y + 18.0), shift, 2);
1938 assert!(apply_event(&mut value, &mut sel, &down));
1939 assert_eq!(sel.anchor, 0);
1941 assert!(sel.head > 0 && sel.head < value.len());
1942 }
1943
1944 #[test]
1945 fn apply_shift_pointer_down_only_moves_head() {
1946 let mut value = String::from("hello");
1947 let mut sel = TextSelection::caret(2);
1948 let shift = KeyModifiers {
1949 shift: true,
1950 ..Default::default()
1951 };
1952 let down = ev_pointer_down(
1954 ti_target(),
1955 (
1956 ti_target().rect.x + ti_target().rect.w - 4.0,
1957 ti_target().rect.y + 18.0,
1958 ),
1959 shift,
1960 );
1961 assert!(apply_event(&mut value, &mut sel, &down));
1962 assert_eq!(sel.anchor, 2);
1963 assert_eq!(sel.head, value.len());
1964 }
1965
1966 #[test]
1967 fn apply_drag_extends_head_only() {
1968 let mut value = String::from("hello world");
1969 let mut sel = TextSelection::caret(0);
1970 let down = ev_pointer_down(
1972 ti_target(),
1973 (ti_target().rect.x + 1.0, ti_target().rect.y + 18.0),
1974 KeyModifiers::default(),
1975 );
1976 apply_event(&mut value, &mut sel, &down);
1977 assert_eq!(sel, TextSelection::caret(0));
1978 let drag = ev_drag(
1980 ti_target(),
1981 (
1982 ti_target().rect.x + ti_target().rect.w - 4.0,
1983 ti_target().rect.y + 18.0,
1984 ),
1985 );
1986 assert!(apply_event(&mut value, &mut sel, &drag));
1987 assert_eq!(sel.anchor, 0);
1988 assert_eq!(sel.head, value.len());
1989 }
1990
1991 #[test]
1992 fn double_click_hold_drag_inside_word_keeps_word_selected() {
1993 let mut value = String::from("hello world");
1994 let mut sel = TextSelection::caret(0);
1995 let target = ti_target();
1996 let click_x = target.rect.x
1997 + tokens::SPACE_3
1998 + crate::text::metrics::line_width(
1999 "hello w",
2000 tokens::TEXT_SM.size,
2001 FontWeight::Regular,
2002 false,
2003 );
2004 let down = ev_pointer_down_with_count(
2005 target.clone(),
2006 (click_x, target.rect.y + 18.0),
2007 KeyModifiers::default(),
2008 2,
2009 );
2010 assert!(apply_event(&mut value, &mut sel, &down));
2011 assert_eq!(sel, TextSelection::range(6, 11));
2012
2013 let drag = ev_drag_with_count(target.clone(), (click_x + 1.0, target.rect.y + 18.0), 2);
2014 assert!(apply_event(&mut value, &mut sel, &drag));
2015 assert_eq!(sel, TextSelection::range(6, 11));
2016 }
2017
2018 #[test]
2019 fn apply_click_is_noop_for_selection() {
2020 let mut value = String::from("hello");
2024 let mut sel = TextSelection::range(0, 5);
2025 let click = UiEvent {
2026 path: None,
2027 key: Some("ti".into()),
2028 target: Some(ti_target()),
2029 pointer: Some((ti_target().rect.x + 1.0, ti_target().rect.y + 18.0)),
2030 key_press: None,
2031 text: None,
2032 selection: None,
2033 modifiers: KeyModifiers::default(),
2034 click_count: 1,
2035 pointer_kind: None,
2036 wheel_delta: None,
2037 kind: UiEventKind::Click,
2038 };
2039 assert!(!apply_event(&mut value, &mut sel, &click));
2040 assert_eq!(sel, TextSelection::range(0, 5));
2041 }
2042
2043 #[test]
2044 fn apply_middle_click_inserts_event_text_at_pointer() {
2045 let mut value = String::from("world");
2046 let mut sel = TextSelection::caret(value.len());
2047 let target = ti_target();
2048 let pointer = (
2049 target.rect.x + tokens::SPACE_3,
2050 target.rect.y + target.rect.h * 0.5,
2051 );
2052 let event = ev_middle_click(target, pointer, Some("hello "));
2053 assert!(apply_event(&mut value, &mut sel, &event));
2054 assert_eq!(value, "hello world");
2055 assert_eq!(sel, TextSelection::caret("hello ".len()));
2056 }
2057
2058 #[test]
2059 fn helpers_selected_text_and_replace_selection() {
2060 let value = String::from("hello world");
2061 let sel = TextSelection::range(6, 11);
2062 assert_eq!(selected_text(&value, sel), "world");
2063
2064 let mut value = value;
2065 let mut sel = sel;
2066 replace_selection(&mut value, &mut sel, "kit");
2067 assert_eq!(value, "hello kit");
2068 assert_eq!(sel, TextSelection::caret(9));
2069
2070 assert_eq!(select_all(&value), TextSelection::range(0, value.len()));
2071 }
2072
2073 #[test]
2074 fn apply_text_input_filters_control_chars() {
2075 let mut value = String::from("hi");
2079 let mut sel = TextSelection::caret(2);
2080 for ctrl in ["\u{8}", "\u{7f}", "\r", "\n", "\u{1b}", "\t"] {
2081 assert!(
2082 !apply_event(&mut value, &mut sel, &ev_text(ctrl)),
2083 "expected {ctrl:?} to be filtered"
2084 );
2085 assert_eq!(value, "hi");
2086 assert_eq!(sel, TextSelection::caret(2));
2087 }
2088 assert!(apply_event(&mut value, &mut sel, &ev_text("a\u{8}b")));
2090 assert_eq!(value, "hiab");
2091 assert_eq!(sel, TextSelection::caret(4));
2092 }
2093
2094 #[test]
2095 fn apply_text_input_drops_when_ctrl_or_cmd_is_held() {
2096 let mut value = String::from("hello");
2101 let mut sel = TextSelection::range(0, 5);
2102 let ctrl = KeyModifiers {
2103 ctrl: true,
2104 ..Default::default()
2105 };
2106 let cmd = KeyModifiers {
2107 logo: true,
2108 ..Default::default()
2109 };
2110 assert!(!apply_event(
2111 &mut value,
2112 &mut sel,
2113 &ev_text_with_mods("c", ctrl)
2114 ));
2115 assert_eq!(value, "hello");
2116 assert!(!apply_event(
2117 &mut value,
2118 &mut sel,
2119 &ev_text_with_mods("v", cmd)
2120 ));
2121 assert_eq!(value, "hello");
2122 let altgr = KeyModifiers {
2124 ctrl: true,
2125 alt: true,
2126 ..Default::default()
2127 };
2128 let mut value = String::from("");
2129 let mut sel = TextSelection::caret(0);
2130 assert!(apply_event(
2131 &mut value,
2132 &mut sel,
2133 &ev_text_with_mods("é", altgr)
2134 ));
2135 assert_eq!(value, "é");
2136 }
2137
2138 #[test]
2139 fn text_input_value_emits_a_single_glyph_run() {
2140 use crate::draw_ops::draw_ops;
2146 use crate::ir::DrawOp;
2147 let mut tree =
2148 crate::column([text_input("Type", TextSelection::caret(1)).key("ti")]).padding(20.0);
2149 let mut state = UiState::new();
2150 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
2151
2152 let ops = draw_ops(&tree, &state);
2153 let glyph_runs = ops
2154 .iter()
2155 .filter(|op| matches!(op, DrawOp::GlyphRun { id, .. } if id.contains("text_input[ti]")))
2156 .count();
2157 assert_eq!(
2158 glyph_runs, 1,
2159 "value should shape as one run; got {glyph_runs}"
2160 );
2161 }
2162
2163 #[test]
2164 fn clipboard_request_detects_ctrl_c_x_v() {
2165 let ctrl = KeyModifiers {
2166 ctrl: true,
2167 ..Default::default()
2168 };
2169 let cases = [
2170 ("c", ClipboardKind::Copy),
2171 ("C", ClipboardKind::Copy),
2172 ("x", ClipboardKind::Cut),
2173 ("v", ClipboardKind::Paste),
2174 ];
2175 for (ch, expected) in cases {
2176 let e = ev_key_with_mods(UiKey::Character(ch.into()), ctrl);
2177 assert_eq!(clipboard_request(&e), Some(expected), "char {ch:?}");
2178 }
2179 }
2180
2181 #[test]
2182 fn clipboard_request_accepts_cmd_on_macos() {
2183 let logo = KeyModifiers {
2186 logo: true,
2187 ..Default::default()
2188 };
2189 let e = ev_key_with_mods(UiKey::Character("c".into()), logo);
2190 assert_eq!(clipboard_request(&e), Some(ClipboardKind::Copy));
2191 }
2192
2193 #[test]
2194 fn clipboard_request_detects_semantic_clipboard_keys() {
2195 let cases = [
2196 ("Copy", ClipboardKind::Copy),
2197 ("Cut", ClipboardKind::Cut),
2198 ("Paste", ClipboardKind::Paste),
2199 ];
2200 for (action, expected) in cases {
2201 let e = ev_key(UiKey::Other(action.into()));
2202 assert_eq!(
2203 clipboard_request(&e),
2204 Some(expected),
2205 "semantic key {action:?}"
2206 );
2207 }
2208 }
2209
2210 #[test]
2211 fn clipboard_request_rejects_with_shift_or_alt() {
2212 let e = ev_key_with_mods(
2214 UiKey::Character("c".into()),
2215 KeyModifiers {
2216 ctrl: true,
2217 shift: true,
2218 ..Default::default()
2219 },
2220 );
2221 assert_eq!(clipboard_request(&e), None);
2222
2223 let e = ev_key_with_mods(
2224 UiKey::Character("v".into()),
2225 KeyModifiers {
2226 ctrl: true,
2227 alt: true,
2228 ..Default::default()
2229 },
2230 );
2231 assert_eq!(clipboard_request(&e), None);
2232 }
2233
2234 #[test]
2235 fn clipboard_request_ignores_other_keys_and_event_kinds() {
2236 let e = ev_key(UiKey::Character("c".into()));
2238 assert_eq!(clipboard_request(&e), None);
2239 let e = ev_key_with_mods(
2241 UiKey::Character("a".into()),
2242 KeyModifiers {
2243 ctrl: true,
2244 ..Default::default()
2245 },
2246 );
2247 assert_eq!(clipboard_request(&e), None);
2248 assert_eq!(clipboard_request(&ev_text("c")), None);
2250 }
2251
2252 fn password_opts() -> TextInputOpts<'static> {
2253 TextInputOpts::default().password()
2254 }
2255
2256 #[test]
2257 fn password_input_renders_value_as_bullets_not_plaintext() {
2258 let el = text_input_with("hunter2", TextSelection::caret(0), password_opts());
2261 let leaf = content_children(&el)
2262 .iter()
2263 .find(|c| matches!(c.kind, Kind::Text))
2264 .expect("text leaf");
2265 assert_eq!(leaf.text.as_deref(), Some("•••••••"));
2266 }
2267
2268 #[test]
2269 fn password_input_caret_position_uses_masked_widths() {
2270 use crate::text::metrics::line_width;
2274 let value = "abc";
2275 let head = 2;
2276 let el = text_input_with(value, TextSelection::caret(head), password_opts());
2277 let caret = content_children(&el)
2278 .iter()
2279 .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
2280 .expect("caret child");
2281 let expected = line_width("••", tokens::TEXT_SM.size, FontWeight::Regular, false);
2283 assert!(
2284 (caret.translate.0 - expected).abs() < 0.01,
2285 "caret translate.x = {}, expected {}",
2286 caret.translate.0,
2287 expected
2288 );
2289 }
2290
2291 #[test]
2292 fn password_pointer_click_maps_back_to_original_byte() {
2293 let mut value = String::from("abcde");
2296 let mut sel = TextSelection::default();
2297 let target = ti_target();
2298 let down = ev_pointer_down(
2299 target.clone(),
2300 (target.rect.x + target.rect.w - 4.0, target.rect.y + 18.0),
2301 KeyModifiers::default(),
2302 );
2303 assert!(apply_event_with(
2304 &mut value,
2305 &mut sel,
2306 &down,
2307 &password_opts()
2308 ));
2309 assert_eq!(sel.head, value.len());
2310 }
2311
2312 #[test]
2313 fn password_pointer_click_with_multibyte_value() {
2314 let mut value = String::from("éé");
2318 let mut sel = TextSelection::default();
2319 let target = ti_target();
2320 let bullet_w = metrics::line_width("•", tokens::TEXT_SM.size, FontWeight::Regular, false);
2322 let click_x = target.rect.x + tokens::SPACE_3 + bullet_w * 1.4;
2323 let down = ev_pointer_down(
2324 target,
2325 (click_x, ti_target().rect.y + 18.0),
2326 KeyModifiers::default(),
2327 );
2328 assert!(apply_event_with(
2329 &mut value,
2330 &mut sel,
2331 &down,
2332 &password_opts()
2333 ));
2334 assert!(
2338 value.is_char_boundary(sel.head),
2339 "head={} not on a char boundary in {value:?}",
2340 sel.head
2341 );
2342 assert!(sel.head == 2 || sel.head == 4, "head={}", sel.head);
2343 }
2344
2345 #[test]
2346 fn password_clipboard_request_suppresses_copy_and_cut_only() {
2347 let ctrl = KeyModifiers {
2348 ctrl: true,
2349 ..Default::default()
2350 };
2351 let opts = password_opts();
2352 let copy = ev_key_with_mods(UiKey::Character("c".into()), ctrl);
2353 let cut = ev_key_with_mods(UiKey::Character("x".into()), ctrl);
2354 let paste = ev_key_with_mods(UiKey::Character("v".into()), ctrl);
2355 assert_eq!(clipboard_request_for(©, &opts), None);
2356 assert_eq!(clipboard_request_for(&cut, &opts), None);
2357 assert_eq!(
2358 clipboard_request_for(&paste, &opts),
2359 Some(ClipboardKind::Paste)
2360 );
2361 let plain = TextInputOpts::default();
2363 assert_eq!(
2364 clipboard_request_for(©, &plain),
2365 Some(ClipboardKind::Copy)
2366 );
2367 }
2368
2369 #[test]
2370 fn placeholder_renders_only_when_value_is_empty() {
2371 let opts = TextInputOpts::default().placeholder("Email");
2372 let empty = text_input_with("", TextSelection::default(), opts);
2373 let muted_leaf = content_children(&empty)
2374 .iter()
2375 .find(|c| matches!(c.kind, Kind::Text) && c.text.as_deref() == Some("Email"));
2376 assert!(muted_leaf.is_some(), "placeholder leaf should be present");
2377
2378 let nonempty = text_input_with("hi", TextSelection::caret(2), opts);
2379 let muted_leaf = content_children(&nonempty)
2380 .iter()
2381 .find(|c| matches!(c.kind, Kind::Text) && c.text.as_deref() == Some("Email"));
2382 assert!(
2383 muted_leaf.is_none(),
2384 "placeholder should not render once the field has a value"
2385 );
2386 }
2387
2388 #[test]
2389 fn long_value_with_caret_at_end_shifts_content_left_to_keep_caret_in_view() {
2390 use crate::tree::Size;
2399 let value = "abcdefghijklmnopqrstuvwxyz0123456789".repeat(2);
2400 let mut root = super::text_input(
2401 &value,
2402 &as_selection_in("ti", TextSelection::caret(value.len())),
2403 "ti",
2404 )
2405 .width(Size::Fixed(120.0));
2406 let mut ui_state = crate::state::UiState::new();
2407 crate::layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 120.0, 40.0));
2408
2409 let inner = &root.children[0];
2411 let text_leaf = inner
2412 .children
2413 .iter()
2414 .find(|c| matches!(c.kind, Kind::Text))
2415 .expect("text leaf");
2416 let leaf_rect = ui_state.rect(&text_leaf.computed_id);
2417
2418 let inner_rect = ui_state.rect(&inner.computed_id);
2422 assert!(
2423 leaf_rect.x < inner_rect.x,
2424 "text leaf rect.x={} should be left of inner rect.x={} after \
2425 horizontal caret-into-view; layout did not shift content",
2426 leaf_rect.x,
2427 inner_rect.x,
2428 );
2429 }
2430
2431 #[test]
2432 fn short_value_does_not_shift_content() {
2433 use crate::tree::Size;
2437 let mut root =
2438 super::text_input("hi", &as_selection_in("ti", TextSelection::caret(2)), "ti")
2439 .width(Size::Fixed(120.0));
2440 let mut ui_state = crate::state::UiState::new();
2441 crate::layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 120.0, 40.0));
2442
2443 let inner = &root.children[0];
2444 let text_leaf = inner
2445 .children
2446 .iter()
2447 .find(|c| matches!(c.kind, Kind::Text))
2448 .expect("text leaf");
2449 let leaf_rect = ui_state.rect(&text_leaf.computed_id);
2450 let inner_rect = ui_state.rect(&inner.computed_id);
2451 assert!(
2452 (leaf_rect.x - inner_rect.x).abs() < 0.5,
2453 "short value should not shift; got leaf.x={} inner.x={}",
2454 leaf_rect.x,
2455 inner_rect.x
2456 );
2457 }
2458
2459 fn as_selection_in(key: &str, sel: TextSelection) -> Selection {
2462 Selection {
2463 range: Some(SelectionRange {
2464 anchor: SelectionPoint::new(key, sel.anchor),
2465 head: SelectionPoint::new(key, sel.head),
2466 }),
2467 }
2468 }
2469
2470 #[test]
2471 fn max_length_truncates_text_input_inserts() {
2472 let mut value = String::from("ab");
2473 let mut sel = TextSelection::caret(2);
2474 let opts = TextInputOpts::default().max_length(4);
2475 assert!(apply_event_with(
2477 &mut value,
2478 &mut sel,
2479 &ev_text("cdef"),
2480 &opts
2481 ));
2482 assert_eq!(value, "abcd");
2483 assert_eq!(sel, TextSelection::caret(4));
2484 assert!(!apply_event_with(
2486 &mut value,
2487 &mut sel,
2488 &ev_text("z"),
2489 &opts
2490 ));
2491 assert_eq!(value, "abcd");
2492 }
2493
2494 #[test]
2495 fn max_length_replaces_selection_with_capacity_freed_by_removal() {
2496 let mut value = String::from("abc");
2499 let mut sel = TextSelection::range(0, 3); let opts = TextInputOpts::default().max_length(4);
2501 assert!(apply_event_with(
2502 &mut value,
2503 &mut sel,
2504 &ev_text("12345"),
2505 &opts
2506 ));
2507 assert_eq!(value, "1234");
2508 assert_eq!(sel, TextSelection::caret(4));
2509 }
2510
2511 #[test]
2512 fn replace_selection_with_max_length_clips_a_paste() {
2513 let mut value = String::from("ab");
2514 let mut sel = TextSelection::caret(2);
2515 let opts = TextInputOpts::default().max_length(5);
2516 let inserted = replace_selection_with(&mut value, &mut sel, "0123456789", &opts);
2518 assert_eq!(value, "ab012");
2519 assert_eq!(inserted, 3);
2520 assert_eq!(sel, TextSelection::caret(5));
2521 }
2522
2523 #[test]
2524 fn max_length_does_not_shrink_an_already_overlong_value() {
2525 let mut value = String::from("abcdef");
2528 let mut sel = TextSelection::caret(6);
2529 let opts = TextInputOpts::default().max_length(3);
2530 assert!(!apply_event_with(
2532 &mut value,
2533 &mut sel,
2534 &ev_text("z"),
2535 &opts
2536 ));
2537 assert_eq!(value, "abcdef");
2538 assert!(apply_event_with(
2541 &mut value,
2542 &mut sel,
2543 &ev_key(UiKey::Backspace),
2544 &opts
2545 ));
2546 assert_eq!(value, "abcde");
2547 }
2548
2549 #[test]
2550 fn end_to_end_drag_select_through_runner_core() {
2551 let mut value = String::from("hello world");
2555 let mut sel = TextSelection::default();
2556 let mut tree = crate::column([text_input(&value, sel).key("ti")]).padding(20.0);
2557 let mut core = RunnerCore::new();
2558 let mut state = UiState::new();
2559 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
2560 core.ui_state = state;
2561 core.snapshot(&tree, &mut Default::default());
2562
2563 let rect = core.rect_of_key("ti").expect("ti rect");
2564 let down_x = rect.x + 8.0;
2565 let drag_x = rect.x + 80.0;
2566 let cy = rect.y + rect.h * 0.5;
2567
2568 core.pointer_moved(Pointer::moving(down_x, cy));
2569 let down = core
2570 .pointer_down(Pointer::mouse(down_x, cy, PointerButton::Primary))
2571 .into_iter()
2572 .find(|e| e.kind == UiEventKind::PointerDown)
2573 .expect("pointer_down emits PointerDown");
2574 assert!(apply_event(&mut value, &mut sel, &down));
2575
2576 let drag = core
2577 .pointer_moved(Pointer::moving(drag_x, cy))
2578 .events
2579 .into_iter()
2580 .find(|e| e.kind == UiEventKind::Drag)
2581 .expect("Drag while pressed");
2582 assert!(apply_event(&mut value, &mut sel, &drag));
2583
2584 let events = core.pointer_up(Pointer::mouse(drag_x, cy, PointerButton::Primary));
2585 for e in &events {
2586 apply_event(&mut value, &mut sel, e);
2587 }
2588 assert!(
2589 !sel.is_collapsed(),
2590 "expected drag-select to leave a non-empty selection"
2591 );
2592 assert_eq!(
2593 sel.anchor, 0,
2594 "anchor should sit at the down position (caret 0)"
2595 );
2596 assert!(
2597 sel.head > 0 && sel.head <= value.len(),
2598 "head={} value.len={}",
2599 sel.head,
2600 value.len()
2601 );
2602 }
2603
2604 #[test]
2612 fn apply_event_writes_back_under_the_inputs_key() {
2613 let mut value = String::new();
2615 let mut sel = Selection::default();
2616 let event = ev_text("h");
2617 assert!(super::apply_event(&mut value, &mut sel, "name", &event));
2618 assert_eq!(value, "h");
2619 let r = sel.range.as_ref().expect("selection set");
2620 assert_eq!(r.anchor.key, "name");
2621 assert_eq!(r.head.key, "name");
2622 assert_eq!(r.head.byte, 1);
2623 }
2624
2625 #[test]
2626 fn apply_event_claims_selection_when_event_routed_from_elsewhere() {
2627 let mut value = String::new();
2633 let mut sel = Selection {
2634 range: Some(SelectionRange {
2635 anchor: SelectionPoint::new("para-a", 0),
2636 head: SelectionPoint::new("para-a", 5),
2637 }),
2638 };
2639 let event = ev_text("x");
2640 assert!(super::apply_event(&mut value, &mut sel, "email", &event));
2641 assert_eq!(value, "x");
2642 let r = sel.range.as_ref().unwrap();
2643 assert_eq!(r.anchor.key, "email", "selection ownership migrated");
2644 assert_eq!(r.head.byte, 1);
2645 }
2646
2647 #[test]
2648 fn apply_event_leaves_selection_alone_when_event_is_unhandled() {
2649 let mut value = String::from("hi");
2653 let mut sel = Selection {
2654 range: Some(SelectionRange {
2655 anchor: SelectionPoint::new("para-a", 0),
2656 head: SelectionPoint::new("para-a", 3),
2657 }),
2658 };
2659 let event = ev_key(UiKey::Other("F1".into()));
2660 assert!(!super::apply_event(&mut value, &mut sel, "name", &event));
2661 let r = sel.range.as_ref().unwrap();
2663 assert_eq!(r.anchor.key, "para-a");
2664 assert_eq!(r.head.byte, 3);
2665 }
2666
2667 #[test]
2668 fn text_input_renders_caret_at_local_byte_when_selection_is_within_key() {
2669 let sel = Selection::caret("name", 2);
2670 let el = super::text_input("hello", &sel, "name");
2671 assert_eq!(el.key.as_deref(), Some("name"));
2673 let caret = content_children(&el)
2675 .iter()
2676 .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
2677 .expect("caret child");
2678 let expected = metrics::line_width("he", tokens::TEXT_SM.size, FontWeight::Regular, false);
2679 assert!(
2680 (caret.translate.0 - expected).abs() < 0.01,
2681 "caret.x={} expected {}",
2682 caret.translate.0,
2683 expected
2684 );
2685 }
2686
2687 #[test]
2688 fn text_input_omits_caret_when_selection_lives_elsewhere() {
2689 let sel = Selection {
2696 range: Some(SelectionRange {
2697 anchor: SelectionPoint::new("other", 0),
2698 head: SelectionPoint::new("other", 5),
2699 }),
2700 };
2701 let el = super::text_input("hello", &sel, "name");
2702 let band = el
2703 .children
2704 .iter()
2705 .find(|c| matches!(c.kind, Kind::Custom("text_input_selection")));
2706 assert!(band.is_none(), "no band when selection lives elsewhere");
2707 let caret = el
2708 .children
2709 .iter()
2710 .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")));
2711 assert!(
2712 caret.is_none(),
2713 "no caret when selection lives elsewhere — focus-fade has nothing to bring back to byte 0"
2714 );
2715 }
2716
2717 fn ctrl_mods() -> KeyModifiers {
2718 KeyModifiers {
2719 ctrl: true,
2720 ..Default::default()
2721 }
2722 }
2723
2724 fn ctrl_shift_mods() -> KeyModifiers {
2725 KeyModifiers {
2726 ctrl: true,
2727 shift: true,
2728 ..Default::default()
2729 }
2730 }
2731
2732 #[test]
2733 fn ctrl_backspace_deletes_previous_word() {
2734 let mut value = String::from("hello world foo");
2735 let mut sel = TextSelection::caret(value.len());
2736 assert!(apply_event(
2737 &mut value,
2738 &mut sel,
2739 &ev_key_with_mods(UiKey::Backspace, ctrl_mods())
2740 ));
2741 assert_eq!(value, "hello world ");
2742 assert_eq!(sel, TextSelection::caret(value.len()));
2743 }
2744
2745 #[test]
2746 fn ctrl_backspace_at_caret_zero_is_noop() {
2747 let mut value = String::from("hello");
2748 let mut sel = TextSelection::caret(0);
2749 assert!(!apply_event(
2750 &mut value,
2751 &mut sel,
2752 &ev_key_with_mods(UiKey::Backspace, ctrl_mods())
2753 ));
2754 assert_eq!(value, "hello");
2755 }
2756
2757 #[test]
2758 fn ctrl_w_deletes_previous_word_like_terminal() {
2759 let mut value = String::from("alpha beta gamma");
2760 let mut sel = TextSelection::caret(value.len());
2761 assert!(apply_event(
2762 &mut value,
2763 &mut sel,
2764 &ev_key_with_mods(UiKey::Character("w".into()), ctrl_mods())
2765 ));
2766 assert_eq!(value, "alpha beta ");
2767 }
2768
2769 #[test]
2770 fn ctrl_delete_deletes_next_word() {
2771 let mut value = String::from("alpha beta gamma");
2772 let mut sel = TextSelection::caret(0);
2773 assert!(apply_event(
2774 &mut value,
2775 &mut sel,
2776 &ev_key_with_mods(UiKey::Delete, ctrl_mods())
2777 ));
2778 assert_eq!(value, " beta gamma");
2779 assert_eq!(sel, TextSelection::caret(0));
2780 }
2781
2782 #[test]
2783 fn ctrl_arrow_left_jumps_word_backward() {
2784 let mut value = String::from("alpha beta gamma");
2785 let mut sel = TextSelection::caret(value.len());
2786 assert!(apply_event(
2787 &mut value,
2788 &mut sel,
2789 &ev_key_with_mods(UiKey::ArrowLeft, ctrl_mods())
2790 ));
2791 assert_eq!(sel, TextSelection::caret(11));
2793 }
2794
2795 #[test]
2796 fn ctrl_arrow_right_jumps_word_forward() {
2797 let mut value = String::from("alpha beta gamma");
2798 let mut sel = TextSelection::caret(0);
2799 assert!(apply_event(
2800 &mut value,
2801 &mut sel,
2802 &ev_key_with_mods(UiKey::ArrowRight, ctrl_mods())
2803 ));
2804 assert_eq!(sel, TextSelection::caret(5));
2806 }
2807
2808 #[test]
2809 fn ctrl_shift_arrow_extends_selection_by_word() {
2810 let mut value = String::from("alpha beta gamma");
2811 let mut sel = TextSelection::caret(0);
2812 assert!(apply_event(
2813 &mut value,
2814 &mut sel,
2815 &ev_key_with_mods(UiKey::ArrowRight, ctrl_shift_mods())
2816 ));
2817 assert_eq!(sel, TextSelection::range(0, 5));
2818 assert!(apply_event(
2819 &mut value,
2820 &mut sel,
2821 &ev_key_with_mods(UiKey::ArrowRight, ctrl_shift_mods())
2822 ));
2823 assert_eq!(sel, TextSelection::range(0, 10));
2824 }
2825}