Skip to main content

mach/
text_input.rs

1//! A one-line editor. Every text field in mach is built from it —
2//! the title, the due date, category names, the command line, the search
3//! box, and each block of a task's description. Cursor and selection indices are
4//! Unicode grapheme clusters; display geometry is still measured in cells.
5//!
6//! Selection follows macOS: Shift extends, Option jumps by word, and
7//! Shift+Option+W selects the word under the cursor.
8
9use unicode_segmentation::UnicodeSegmentation;
10use unicode_width::UnicodeWidthStr;
11
12#[derive(Debug, Default, Clone, PartialEq, Eq)]
13pub struct TextInput {
14    /// Extended grapheme clusters. A visible user-perceived character is
15    /// never split by cursor movement, selection, or deletion.
16    chars: Vec<String>,
17    cursor: usize,
18    /// Other end of the selection; `None` means a caret only.
19    sel_anchor: Option<usize>,
20    scroll: usize,
21    max_len: usize,
22    max_bytes: usize,
23}
24
25/// What [`TextInput::visible`] hands the UI: text, caret column, and an
26/// optional selection span in display columns within that text.
27#[derive(Debug, Clone)]
28pub struct View {
29    pub text: String,
30    pub cursor_col: u16,
31    /// Inclusive start, exclusive end, in display columns of `text`.
32    pub sel_cols: Option<(u16, u16)>,
33}
34
35/// One visual row of a soft-wrapped field.
36#[derive(Debug, Clone)]
37pub struct WrappedLine {
38    pub text: String,
39    pub sel_cols: Option<(u16, u16)>,
40    /// Grapheme range into the full buffer covered by this row.
41    pub start: usize,
42    pub end: usize,
43}
44
45/// Soft-wrapped layout for multi-line description painting.
46#[derive(Debug, Clone)]
47pub struct WrappedView {
48    pub lines: Vec<WrappedLine>,
49    /// Cursor row within `lines`, and column within that row.
50    pub cursor_row: u16,
51    pub cursor_col: u16,
52}
53
54/// Soft-wrap graphemes into `(start, end)` ranges of at most `width`
55/// display columns. Prefers breaking after a space; otherwise hard-breaks.
56pub fn wrap_breaks(chars: &[String], width: usize) -> Vec<(usize, usize)> {
57    if chars.is_empty() {
58        return vec![(0, 0)];
59    }
60    let width = width.max(1);
61    let mut lines = Vec::new();
62    let mut start = 0usize;
63    let mut col = 0usize;
64    let mut last_ws: Option<usize> = None;
65    let mut i = 0usize;
66
67    while i < chars.len() {
68        let w = chars[i].width();
69        if w > width {
70            // Lone character wider than the field — own row.
71            if i > start {
72                lines.push((start, i));
73            }
74            lines.push((i, i + 1));
75            i += 1;
76            start = i;
77            col = 0;
78            last_ws = None;
79            continue;
80        }
81        if col + w > width && i > start {
82            // Prefer the last space so words stay intact when they fit.
83            let end = last_ws
84                .filter(|&ws| ws >= start)
85                .map(|ws| ws + 1)
86                .unwrap_or(i);
87            let end = end.max(start + 1);
88            lines.push((start, end));
89            start = end;
90            i = start;
91            col = 0;
92            last_ws = None;
93            continue;
94        }
95        if is_whitespace(&chars[i]) {
96            last_ws = Some(i);
97        }
98        col += w;
99        i += 1;
100    }
101    if start < chars.len() {
102        lines.push((start, chars.len()));
103    }
104    lines
105}
106
107fn is_whitespace(grapheme: &str) -> bool {
108    !grapheme.is_empty() && grapheme.chars().all(char::is_whitespace)
109}
110
111impl TextInput {
112    pub fn new(initial: &str, max_len: usize) -> Self {
113        let max_bytes = crate::model::text_byte_limit(max_len);
114        let chars = bounded_graphemes(initial, max_len, max_bytes);
115        let cursor = chars.len();
116        Self {
117            chars,
118            cursor,
119            sel_anchor: None,
120            scroll: 0,
121            max_len,
122            max_bytes,
123        }
124    }
125
126    pub fn value(&self) -> String {
127        self.chars.concat()
128    }
129
130    pub fn is_empty(&self) -> bool {
131        self.chars.is_empty()
132    }
133
134    pub fn cursor(&self) -> usize {
135        self.cursor
136    }
137
138    pub fn len(&self) -> usize {
139        self.chars.len()
140    }
141
142    pub fn slice(&self, start: usize, end: usize) -> String {
143        let start = start.min(self.chars.len());
144        let end = end.max(start).min(self.chars.len());
145        self.chars[start..end].concat()
146    }
147
148    pub fn clear_selection(&mut self) {
149        self.sel_anchor = None;
150    }
151
152    pub fn has_selection(&self) -> bool {
153        self.selection_range().is_some()
154    }
155
156    /// Inclusive start and exclusive end of the selection, if any.
157    pub fn selection_range(&self) -> Option<(usize, usize)> {
158        let a = self.sel_anchor?;
159        let (lo, hi) = if a <= self.cursor {
160            (a, self.cursor)
161        } else {
162            (self.cursor, a)
163        };
164        (lo < hi).then_some((lo, hi))
165    }
166
167    pub fn selected_text(&self) -> Option<String> {
168        let (lo, hi) = self.selection_range()?;
169        Some(self.chars[lo..hi].concat())
170    }
171
172    /// Removes the selected range. Returns true when something was deleted.
173    pub fn delete_selection(&mut self) -> bool {
174        let Some((lo, hi)) = self.selection_range() else {
175            return false;
176        };
177        self.replace_range(lo, hi, "");
178        true
179    }
180
181    /// Select the word under (or beside) the cursor — macOS Select Word.
182    pub fn select_word(&mut self) {
183        if self.chars.is_empty() {
184            self.sel_anchor = None;
185            return;
186        }
187        let n = self.chars.len();
188        let i = self.cursor.min(n);
189        // Prefer the word containing the caret; if on a boundary, the
190        // word to the left; if mid-whitespace, the next word to the right.
191        let (mut start, mut end) = if i < n && !is_whitespace(&self.chars[i]) {
192            (i, i + 1)
193        } else if i > 0 && !is_whitespace(&self.chars[i - 1]) {
194            (i - 1, i)
195        } else {
196            let mut next = i;
197            while next < n && is_whitespace(&self.chars[next]) {
198                next += 1;
199            }
200            if next < n {
201                (next, next + 1)
202            } else {
203                let mut previous = i;
204                while previous > 0 && is_whitespace(&self.chars[previous - 1]) {
205                    previous -= 1;
206                }
207                if previous == 0 {
208                    self.sel_anchor = None;
209                    return;
210                }
211                (previous - 1, previous)
212            }
213        };
214        while start > 0 && !is_whitespace(&self.chars[start - 1]) {
215            start -= 1;
216        }
217        while end < n && !is_whitespace(&self.chars[end]) {
218            end += 1;
219        }
220        self.sel_anchor = Some(start);
221        self.cursor = end;
222    }
223
224    pub fn set_cursor(&mut self, cursor: usize) {
225        self.clear_selection();
226        self.cursor = cursor.min(self.chars.len());
227    }
228
229    /// Places the cursor at a display column of the visible window, as
230    /// produced by [`TextInput::visible`]. Past the end of the text the
231    /// cursor lands at the end.
232    pub fn set_cursor_from_col(&mut self, col: usize) {
233        self.clear_selection();
234        let mut used = 0;
235        let mut cursor = self.scroll;
236        for c in &self.chars[self.scroll.min(self.chars.len())..] {
237            let w = c.width();
238            if used + w > col {
239                break;
240            }
241            used += w;
242            cursor += 1;
243        }
244        self.cursor = cursor.min(self.chars.len());
245    }
246
247    pub fn at_start(&self) -> bool {
248        self.cursor == 0
249    }
250
251    pub fn at_end(&self) -> bool {
252        self.cursor == self.chars.len()
253    }
254
255    /// Cuts everything after the cursor into a new input.
256    pub fn split_off_at_cursor(&mut self) -> Self {
257        self.clear_selection();
258        let tail: Vec<String> = self.chars.split_off(self.cursor);
259        Self {
260            chars: tail,
261            cursor: 0,
262            sel_anchor: None,
263            scroll: 0,
264            max_len: self.max_len,
265            max_bytes: self.max_bytes,
266        }
267    }
268
269    /// Appends another input's text, leaving the cursor at the join. Returns
270    /// `false` without changing either input when the full join would exceed
271    /// this field's grapheme or byte limit.
272    pub fn append(&mut self, other: &Self) -> bool {
273        let cursor = self.chars.len();
274        let left = self.value();
275        let right = other.value();
276        if left.len().saturating_add(right.len()) > self.max_bytes {
277            return false;
278        }
279        let joined = left + &right;
280        let chars: Vec<String> = joined.graphemes(true).map(str::to_string).collect();
281        if chars.len() > self.max_len {
282            return false;
283        }
284        self.clear_selection();
285        self.chars = chars;
286        self.cursor = cursor.min(self.chars.len());
287        self.scroll = self.scroll.min(self.cursor);
288        true
289    }
290
291    fn replace_range(&mut self, lo: usize, hi: usize, replacement: &str) {
292        let lo = lo.min(self.chars.len());
293        let hi = hi.max(lo).min(self.chars.len());
294        let prefix = self.chars[..lo].concat();
295        let suffix = self.chars[hi..].concat();
296        let before_cursor = format!("{prefix}{replacement}");
297        let cursor = before_cursor.graphemes(true).count();
298        let joined = before_cursor + &suffix;
299        self.chars = joined.graphemes(true).map(str::to_string).collect();
300        self.cursor = cursor.min(self.chars.len());
301        self.sel_anchor = None;
302        self.scroll = self.scroll.min(self.cursor);
303    }
304
305    pub fn insert(&mut self, c: char) {
306        self.insert_str(&c.to_string());
307    }
308
309    /// Inserts a run of text at the cursor. Tab/newline separators are
310    /// flattened, while terminal control bytes are discarded at the input
311    /// boundary so they can never reach the renderer.
312    pub fn insert_str(&mut self, text: &str) {
313        let (lo, hi) = self.selection_range().unwrap_or((self.cursor, self.cursor));
314        let prefix = self.chars[..lo].concat();
315        let suffix = self.chars[hi..].concat();
316        let available = self
317            .max_bytes
318            .saturating_sub(prefix.len().saturating_add(suffix.len()));
319        let mut flattened = String::with_capacity(text.len().min(available));
320        for character in text.chars().filter_map(|character| match character {
321            '\t' | '\n' | '\r' => Some(' '),
322            character if character.is_control() => None,
323            _ => Some(character),
324        }) {
325            if flattened.len().saturating_add(character.len_utf8()) > available {
326                break;
327            }
328            flattened.push(character);
329        }
330        if flattened.is_empty() {
331            return;
332        }
333        let joined = format!("{prefix}{flattened}{suffix}");
334        let accepted = if joined.graphemes(true).count() <= self.max_len {
335            flattened
336        } else {
337            // A candidate can merge with graphemes on either side (combining
338            // marks and ZWJ sequences), so preserve exact boundary behavior
339            // while finding the longest valid prefix.
340            let mut accepted = String::new();
341            for grapheme in flattened.graphemes(true) {
342                let candidate = format!("{prefix}{accepted}{grapheme}{suffix}");
343                if candidate.graphemes(true).count() > self.max_len {
344                    break;
345                }
346                accepted.push_str(grapheme);
347            }
348            accepted
349        };
350        if !accepted.is_empty() {
351            self.replace_range(lo, hi, &accepted);
352        }
353    }
354
355    pub fn backspace(&mut self) {
356        if self.delete_selection() {
357            return;
358        }
359        if self.cursor > 0 {
360            self.replace_range(self.cursor - 1, self.cursor, "");
361        }
362    }
363
364    pub fn delete(&mut self) {
365        if self.delete_selection() {
366            return;
367        }
368        if self.cursor < self.chars.len() {
369            self.replace_range(self.cursor, self.cursor + 1, "");
370        }
371    }
372
373    pub fn delete_to_start(&mut self) {
374        if self.delete_selection() {
375            return;
376        }
377        self.replace_range(0, self.cursor, "");
378    }
379
380    pub fn delete_to_end(&mut self) {
381        if self.delete_selection() {
382            return;
383        }
384        self.replace_range(self.cursor, self.chars.len(), "");
385    }
386
387    pub fn delete_word_left(&mut self) {
388        if self.delete_selection() {
389            return;
390        }
391        let target = self.word_left_index();
392        self.replace_range(target, self.cursor, "");
393    }
394
395    fn move_to(&mut self, pos: usize, extend: bool) {
396        if extend {
397            if self.sel_anchor.is_none() {
398                self.sel_anchor = Some(self.cursor);
399            }
400        } else {
401            self.sel_anchor = None;
402        }
403        self.cursor = pos.min(self.chars.len());
404    }
405
406    pub fn left(&mut self) {
407        self.move_to(self.cursor.saturating_sub(1), false);
408    }
409
410    pub fn right(&mut self) {
411        self.move_to((self.cursor + 1).min(self.chars.len()), false);
412    }
413
414    pub fn select_left(&mut self) {
415        self.move_to(self.cursor.saturating_sub(1), true);
416    }
417
418    pub fn select_right(&mut self) {
419        self.move_to((self.cursor + 1).min(self.chars.len()), true);
420    }
421
422    pub fn word_left(&mut self) {
423        self.move_to(self.word_left_index(), false);
424    }
425
426    pub fn word_right(&mut self) {
427        self.move_to(self.word_right_index(), false);
428    }
429
430    pub fn select_word_left(&mut self) {
431        self.move_to(self.word_left_index(), true);
432    }
433
434    pub fn select_word_right(&mut self) {
435        self.move_to(self.word_right_index(), true);
436    }
437
438    pub fn home(&mut self) {
439        self.move_to(0, false);
440    }
441
442    pub fn end(&mut self) {
443        self.move_to(self.chars.len(), false);
444    }
445
446    pub fn select_home(&mut self) {
447        self.move_to(0, true);
448    }
449
450    pub fn select_end(&mut self) {
451        self.move_to(self.chars.len(), true);
452    }
453
454    /// Where the caret lands moving one word left; the description editor uses
455    /// this to extend a selection across block boundaries.
456    pub fn word_left_index(&self) -> usize {
457        let mut i = self.cursor;
458        while i > 0 && is_whitespace(&self.chars[i - 1]) {
459            i -= 1;
460        }
461        while i > 0 && !is_whitespace(&self.chars[i - 1]) {
462            i -= 1;
463        }
464        i
465    }
466
467    /// Where the caret lands moving one word right.
468    pub fn word_right_index(&self) -> usize {
469        let mut i = self.cursor;
470        let n = self.chars.len();
471        while i < n && is_whitespace(&self.chars[i]) {
472            i += 1;
473        }
474        while i < n && !is_whitespace(&self.chars[i]) {
475            i += 1;
476        }
477        i
478    }
479
480    /// Soft-wrap into visual rows of at most `width` columns. Prefers
481    /// breaking after whitespace; hard-breaks when a single word is wider
482    /// than the field. Empty text yields one empty row.
483    pub fn wrap_breaks(&self, width: usize) -> Vec<(usize, usize)> {
484        wrap_breaks(&self.chars, width)
485    }
486
487    /// How many visual rows the text needs at `width` (at least 1).
488    pub fn wrap_height(&self, width: usize) -> usize {
489        self.wrap_breaks(width).len()
490    }
491
492    /// Cursor as `(visual_row, column)` from precomputed wrap breaks.
493    pub fn wrap_cursor_from_breaks(&self, breaks: &[(usize, usize)]) -> (usize, u16) {
494        for (row, &(start, end)) in breaks.iter().enumerate() {
495            if self.cursor < end || (self.cursor == end && row + 1 == breaks.len()) {
496                let col: usize = self.chars[start..self.cursor.min(end)]
497                    .iter()
498                    .map(|c| c.width())
499                    .sum();
500                // When the caret sits exactly at a soft-break and another
501                // row follows, show it at the start of the next row.
502                if self.cursor == end && row + 1 < breaks.len() && end < self.chars.len() {
503                    return (row + 1, 0);
504                }
505                return (row, col as u16);
506            }
507        }
508        let last = breaks.len().saturating_sub(1);
509        (last, 0)
510    }
511
512    /// Cursor as `(visual_row, column)` for a wrapped layout.
513    pub fn wrap_cursor(&self, width: usize) -> (usize, u16) {
514        self.wrap_cursor_from_breaks(&self.wrap_breaks(width))
515    }
516
517    /// Place the caret from a click on a wrapped row.
518    pub fn set_cursor_from_wrap(&mut self, width: usize, row: usize, col: usize) {
519        let breaks = self.wrap_breaks(width);
520        self.set_cursor_from_wrap_breaks(&breaks, row, col);
521    }
522
523    fn set_cursor_from_wrap_breaks(&mut self, breaks: &[(usize, usize)], row: usize, col: usize) {
524        self.clear_selection();
525        let row = row.min(breaks.len() - 1);
526        let (start, end) = breaks[row];
527        let mut used = 0usize;
528        let mut cursor = start;
529        for c in &self.chars[start..end] {
530            let w = c.width();
531            if used + w > col {
532                break;
533            }
534            used += w;
535            cursor += 1;
536        }
537        self.cursor = cursor.min(self.chars.len());
538    }
539
540    /// Move the caret up one visual row. Returns false when already on
541    /// the first row so the caller may leave the block.
542    pub fn wrap_up(&mut self, width: usize, prefer_col: u16) -> bool {
543        let breaks = self.wrap_breaks(width);
544        let (row, col) = self.wrap_cursor_from_breaks(&breaks);
545        let prefer = if prefer_col == u16::MAX {
546            col
547        } else {
548            prefer_col
549        };
550        if row == 0 {
551            return false;
552        }
553        self.set_cursor_from_wrap_breaks(&breaks, row - 1, prefer as usize);
554        true
555    }
556
557    /// Move the caret down one visual row. Returns false when already on
558    /// the last row so the caller may leave the block.
559    pub fn wrap_down(&mut self, width: usize, prefer_col: u16) -> bool {
560        let breaks = self.wrap_breaks(width);
561        let (row, col) = self.wrap_cursor_from_breaks(&breaks);
562        let prefer = if prefer_col == u16::MAX {
563            col
564        } else {
565            prefer_col
566        };
567        if row + 1 >= breaks.len() {
568            return false;
569        }
570        self.set_cursor_from_wrap_breaks(&breaks, row + 1, prefer as usize);
571        true
572    }
573
574    /// Full soft-wrapped view for painting a multi-line field.
575    pub fn wrapped(&self, width: usize) -> WrappedView {
576        self.wrapped_with_sel(width, self.selection_range())
577    }
578
579    /// Like [`Self::wrapped`], but uses an explicit grapheme-range selection
580    /// (for description-level multi-line selections).
581    pub fn wrapped_with_sel(&self, width: usize, sel: Option<(usize, usize)>) -> WrappedView {
582        self.wrapped_from_breaks(&self.wrap_breaks(width), sel)
583    }
584
585    /// Soft-wrapped view from precomputed breaks (one wrap pass per paint).
586    pub fn wrapped_from_breaks(
587        &self,
588        breaks: &[(usize, usize)],
589        sel: Option<(usize, usize)>,
590    ) -> WrappedView {
591        let (cursor_row, cursor_col) = self.wrap_cursor_from_breaks(breaks);
592        let lines = breaks
593            .iter()
594            .map(|&(start, end)| {
595                let text = self.chars[start..end].concat();
596                let sel_cols = sel.and_then(|(lo, hi)| {
597                    let vis_lo = lo.max(start);
598                    let vis_hi = hi.min(end);
599                    if vis_lo >= vis_hi {
600                        return None;
601                    }
602                    let col = |idx: usize| -> u16 {
603                        self.chars[start..idx]
604                            .iter()
605                            .map(|c| c.width())
606                            .sum::<usize>() as u16
607                    };
608                    Some((col(vis_lo), col(vis_hi)))
609                });
610                WrappedLine {
611                    text,
612                    sel_cols,
613                    start,
614                    end,
615                }
616            })
617            .collect();
618        WrappedView {
619            lines,
620            cursor_row: cursor_row as u16,
621            cursor_col,
622        }
623    }
624
625    /// Move the caret and drop this line's own selection: when the description
626    /// editor drives the caret, the multi-line selection is its to own.
627    pub fn place_cursor(&mut self, cursor: usize) {
628        self.cursor = cursor.min(self.chars.len());
629        self.sel_anchor = None;
630    }
631
632    /// Text to draw in a field `width` columns wide, the cursor's column
633    /// within that field, and the selection's column span when it overlaps.
634    /// Single-row fields (title, status) use this; description uses [`Self::wrapped`].
635    pub fn visible(&mut self, width: usize) -> View {
636        if width == 0 {
637            return View {
638                text: String::new(),
639                cursor_col: 0,
640                sel_cols: None,
641            };
642        }
643        if self.cursor < self.scroll {
644            self.scroll = self.cursor;
645        }
646        // Scroll forward until the cursor fits; it may rest on the column
647        // just past the field, right after the last character typed.
648        let mut cursor_width: usize = self.chars[self.scroll..self.cursor]
649            .iter()
650            .map(|c| c.width())
651            .sum();
652        while cursor_width > width && self.scroll < self.cursor {
653            cursor_width = cursor_width.saturating_sub(self.chars[self.scroll].width());
654            self.scroll += 1;
655        }
656        let mut text = String::new();
657        let mut used = 0usize;
658        let mut end_idx = self.scroll;
659        for c in &self.chars[self.scroll..] {
660            let w = c.width();
661            if used + w > width {
662                break;
663            }
664            used += w;
665            text.push_str(c);
666            end_idx += 1;
667        }
668        let sel_cols = self.selection_range().and_then(|(lo, hi)| {
669            let vis_lo = lo.max(self.scroll);
670            let vis_hi = hi.min(end_idx);
671            if vis_lo >= vis_hi {
672                return None;
673            }
674            let col = |idx: usize| -> u16 {
675                self.chars[self.scroll..idx]
676                    .iter()
677                    .map(|c| c.width())
678                    .sum::<usize>() as u16
679            };
680            Some((col(vis_lo), col(vis_hi)))
681        });
682
683        View {
684            text,
685            cursor_col: cursor_width.min(width) as u16,
686            sel_cols,
687        }
688    }
689}
690
691fn bounded_graphemes(value: &str, max_len: usize, max_bytes: usize) -> Vec<String> {
692    let mut result = Vec::with_capacity(value.len().min(max_len));
693    let mut bytes = 0usize;
694    for grapheme in value.graphemes(true).take(max_len) {
695        if bytes.saturating_add(grapheme.len()) > max_bytes {
696            break;
697        }
698        bytes += grapheme.len();
699        result.push(grapheme.to_string());
700    }
701    result
702}
703
704#[cfg(test)]
705mod tests {
706    use super::*;
707
708    fn input(text: &str) -> TextInput {
709        TextInput::new(text, 256)
710    }
711
712    #[test]
713    fn edits_at_the_cursor() {
714        let mut i = input("hello");
715        i.left();
716        i.insert('!');
717        assert_eq!(i.value(), "hell!o");
718        i.backspace();
719        assert_eq!(i.value(), "hello");
720        i.delete();
721        assert_eq!(i.value(), "hell");
722    }
723
724    #[test]
725    fn moves_and_deletes_by_word() {
726        let mut i = input("one two three");
727        i.word_left();
728        assert_eq!(i.cursor, 8);
729        i.word_left();
730        assert_eq!(i.cursor, 4);
731        i.delete_word_left();
732        assert_eq!(i.value(), "two three");
733        i.end();
734        i.delete_to_start();
735        assert!(i.is_empty());
736    }
737
738    #[test]
739    fn honours_the_length_limit() {
740        let mut i = TextInput::new("ab", 2);
741        i.insert('c');
742        assert_eq!(i.value(), "ab");
743    }
744
745    #[test]
746    fn combining_sequences_cannot_bypass_the_byte_limit() {
747        let mut typed = TextInput::new("", 1);
748        typed.insert('e');
749        for _ in 0..1_000 {
750            typed.insert('\u{301}');
751        }
752        assert_eq!(typed.len(), 1);
753        assert!(typed.value().len() <= crate::model::text_byte_limit(1));
754
755        let pasted = format!("e{}", "\u{301}".repeat(1_000));
756        let pasted = TextInput::new(&pasted, 1);
757        assert!(pasted.value().len() <= crate::model::text_byte_limit(1));
758    }
759
760    #[test]
761    fn appending_inputs_preserves_the_same_limits_as_typing() {
762        let mut input = TextInput::new("a", 1);
763        assert!(!input.append(&TextInput::new("b", 1)));
764        assert_eq!(input.value(), "a");
765    }
766
767    #[test]
768    fn scrolls_to_keep_the_cursor_in_view() {
769        let mut i = input("abcdefghij");
770        let view = i.visible(4);
771        assert_eq!(view.text, "ghij");
772        assert_eq!(view.cursor_col, 4);
773        i.home();
774        let view = i.visible(4);
775        assert_eq!(view.text, "abcd");
776        assert_eq!(view.cursor_col, 0);
777    }
778
779    #[test]
780    fn measures_wide_characters_in_columns() {
781        let mut i = input("买菜");
782        let view = i.visible(4);
783        assert_eq!(view.text, "买菜");
784        assert_eq!(view.cursor_col, 4);
785    }
786
787    #[test]
788    fn cursor_and_deletion_treat_combining_sequences_as_one_grapheme() {
789        let mut i = input("e\u{301}x");
790        assert_eq!(i.len(), 2);
791        i.left();
792        i.backspace();
793        assert_eq!(i.value(), "x");
794        assert_eq!(i.cursor(), 0);
795    }
796
797    #[test]
798    fn cursor_and_selection_do_not_split_zwj_emoji() {
799        let family = "👨‍👩‍👧‍👦";
800        let mut i = input(&format!("{family}!"));
801        assert_eq!(i.len(), 2);
802        i.home();
803        i.select_right();
804        assert_eq!(i.selected_text().as_deref(), Some(family));
805        i.delete_selection();
806        assert_eq!(i.value(), "!");
807    }
808
809    #[test]
810    fn paste_keeps_graphemes_but_strips_terminal_controls() {
811        let family = "👨‍👩‍👧‍👦";
812        let mut i = TextInput::new("", 64);
813        i.insert_str(&format!("e\u{301}\t{family}\u{1b}\u{7f}\u{85}!"));
814
815        assert_eq!(i.value(), format!("e\u{301} {family}!"));
816        assert_eq!(i.len(), 4);
817        assert!(!i.value().chars().any(char::is_control));
818    }
819
820    #[test]
821    fn separately_typed_combining_marks_merge_with_the_previous_grapheme() {
822        let mut i = TextInput::new("", 1);
823        i.insert('e');
824        i.insert('\u{301}');
825
826        assert_eq!(i.value(), "e\u{301}");
827        assert_eq!(i.len(), 1);
828    }
829
830    #[test]
831    fn select_word_picks_the_word_under_the_cursor() {
832        let mut i = input("one two three");
833        i.home();
834        i.word_right(); // after "one "
835        i.right(); // on 't' of two
836        i.select_word();
837        assert_eq!(i.selected_text().as_deref(), Some("two"));
838        assert_eq!(i.selection_range(), Some((4, 7)));
839    }
840
841    #[test]
842    fn shift_arrows_extend_the_selection() {
843        let mut i = input("hello");
844        i.home();
845        i.select_right();
846        i.select_right();
847        assert_eq!(i.selected_text().as_deref(), Some("he"));
848        i.delete_selection();
849        assert_eq!(i.value(), "llo");
850    }
851
852    #[test]
853    fn typing_replaces_the_selection() {
854        let mut i = input("hello");
855        i.home();
856        i.select_word();
857        i.insert('x');
858        assert_eq!(i.value(), "x");
859    }
860
861    #[test]
862    fn wraps_on_spaces_then_hard_breaks() {
863        let s: Vec<String> = "one two three"
864            .graphemes(true)
865            .map(str::to_string)
866            .collect();
867        let breaks = wrap_breaks(&s, 8);
868        let parts: Vec<String> = breaks.iter().map(|&(a, b)| s[a..b].concat()).collect();
869        // width 8 fits "one two " then "three"
870        assert!(parts.len() >= 2);
871        assert!(parts[0].starts_with("one"));
872        assert!(parts.iter().any(|p| p.contains("three")));
873    }
874
875    #[test]
876    fn wrap_ranges_preserve_every_space_and_accept_every_caret_position() {
877        let mut input = input("word   next");
878        let breaks = input.wrap_breaks(5);
879        assert_eq!(breaks.first().map(|range| range.0), Some(0));
880        assert_eq!(breaks.last().map(|range| range.1), Some(input.len()));
881        assert!(breaks.windows(2).all(|pair| pair[0].1 == pair[1].0));
882        assert_eq!(
883            breaks
884                .iter()
885                .map(|&(start, end)| input.slice(start, end))
886                .collect::<String>(),
887            input.value()
888        );
889
890        for cursor in 0..=input.len() {
891            input.set_cursor(cursor);
892            let _ = input.wrap_cursor_from_breaks(&breaks);
893        }
894    }
895
896    #[test]
897    fn wrap_height_is_at_least_one() {
898        let i = input("");
899        assert_eq!(i.wrap_height(10), 1);
900        let i = input("hello world again");
901        assert!(i.wrap_height(6) >= 2);
902    }
903
904    #[test]
905    fn wrap_cursor_tracks_rows() {
906        let mut i = input("aaaa bbbb cccc");
907        i.home();
908        // width 5 → "aaaa " then "bbbb " then "cccc"
909        let (row, col) = i.wrap_cursor(5);
910        assert_eq!((row, col), (0, 0));
911        i.end();
912        let (row, _) = i.wrap_cursor(5);
913        assert!(row >= 1);
914    }
915}