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