Skip to main content

heatsync_core/
edit.rs

1//! a vi line editor for the composer. insert and normal sub-modes, motions,
2//! operators, registers and history — the same muscle memory as everywhere else,
3//! rather than a handful of emacs control chords.
4//!
5//! text is held as `Vec<char>` and the cursor is a CHAR index. chat is full of
6//! emoji and CJK, and byte offsets would put motions and deletes half-way
7//! through a glyph; at composer length the extra allocation is irrelevant.
8//!
9//! no rendering and no app state here — main.rs asks what a key did and draws
10//! the result, which is what lets every motion below be tested directly.
11
12use crate::key::{KeyCode, KeyEvent, KeyModifiers};
13
14#[derive(Clone, Copy, PartialEq, Debug)]
15pub enum Mode {
16    Insert,
17    Normal,
18}
19
20/// what the caller should do after a key.
21#[derive(PartialEq, Debug)]
22pub enum Act {
23    Continue,
24    /// enter — send what's in the line.
25    Send,
26    /// escape from normal mode — put the composer away, keeping the draft.
27    Leave,
28}
29
30/// what a half-typed multi-key command is still waiting for.
31#[derive(Clone, Copy, PartialEq)]
32enum Await {
33    /// f F t T — jump to a character
34    Find { back: bool, till: bool },
35    /// r — replace the character under the cursor
36    Replace,
37}
38
39/// character classes, vi's rules: word characters, punctuation, blanks. `w` and
40/// `b` stop at every class change; `W` and `B` only at blanks.
41fn class(c: char) -> u8 {
42    if c.is_whitespace() {
43        0
44    } else if c.is_alphanumeric() || c == '_' {
45        1
46    } else {
47        2
48    }
49}
50
51/// sent lines kept (in memory and on disk).
52pub const HISTORY_CAP: usize = 200;
53
54pub struct Line {
55    text: Vec<char>,
56    cursor: usize,
57    mode: Mode,
58    /// pending operator: d, c or y.
59    op: Option<char>,
60    count: usize,
61    awaiting: Option<Await>,
62    /// unnamed register, for p / P.
63    reg: Vec<char>,
64    /// last f/F/t/T, for ; and ,
65    last_find: Option<(char, bool, bool)>,
66    undo: Vec<(Vec<char>, usize)>,
67    history: Vec<String>,
68    /// bumps whenever a line lands in history, so the face knows when to persist.
69    hist_rev: u32,
70    /// where we are walking history; None means "on the live draft".
71    hist: Option<usize>,
72    /// the draft we set aside when history walking started.
73    stash: Vec<char>,
74}
75
76impl Default for Line {
77    fn default() -> Self {
78        Line {
79            text: Vec::new(),
80            cursor: 0,
81            mode: Mode::Insert,
82            op: None,
83            count: 0,
84            awaiting: None,
85            reg: Vec::new(),
86            last_find: None,
87            undo: Vec::new(),
88            history: Vec::new(),
89            hist_rev: 0,
90            hist: None,
91            stash: Vec::new(),
92        }
93    }
94}
95
96impl Line {
97    pub fn text(&self) -> String {
98        self.text.iter().collect()
99    }
100
101    pub fn is_empty(&self) -> bool {
102        self.text.is_empty()
103    }
104
105    pub fn mode(&self) -> Mode {
106        self.mode
107    }
108
109    /// cursor position as a char index, for drawing the block.
110    pub fn cursor(&self) -> usize {
111        self.cursor
112    }
113
114    /// the pending command, echoed in the footer the way vi shows one.
115    pub fn pending(&self) -> String {
116        let mut s = String::new();
117        if self.count > 0 {
118            s.push_str(&self.count.to_string());
119        }
120        if let Some(op) = self.op {
121            s.push(op);
122        }
123        match self.awaiting {
124            Some(Await::Find { back, till }) => s.push(match (back, till) {
125                (false, false) => 'f',
126                (true, false) => 'F',
127                (false, true) => 't',
128                (true, true) => 'T',
129            }),
130            Some(Await::Replace) => s.push('r'),
131            None => {}
132        }
133        s
134    }
135
136    /// accept the line: record it in history and start a fresh one in insert
137    /// mode, the way hitting enter in any chat client does.
138    pub fn accept(&mut self) -> String {
139        let out: String = self.text.iter().collect();
140        if !out.trim().is_empty() && self.history.last().map(String::as_str) != Some(out.as_str()) {
141            self.history.push(out.clone());
142            if self.history.len() > HISTORY_CAP {
143                self.history.remove(0);
144            }
145            self.hist_rev = self.hist_rev.wrapping_add(1);
146        }
147        self.reset();
148        out
149    }
150
151    /// sent lines, oldest first — what the face persists.
152    pub fn history(&self) -> &[String] {
153        &self.history
154    }
155
156    /// changes every time `accept` records a line.
157    pub fn history_rev(&self) -> u32 {
158        self.hist_rev
159    }
160
161    /// seed history from disk: blanks and repeats dropped, only the newest
162    /// `HISTORY_CAP` kept.
163    pub fn set_history(&mut self, lines: impl IntoIterator<Item = String>) {
164        self.history.clear();
165        for l in lines {
166            if !l.trim().is_empty() && self.history.last() != Some(&l) {
167                self.history.push(l);
168            }
169        }
170        let extra = self.history.len().saturating_sub(HISTORY_CAP);
171        self.history.drain(..extra);
172        self.hist = None;
173    }
174
175    /// throw the draft away and return to insert mode.
176    pub fn reset(&mut self) {
177        self.text.clear();
178        self.cursor = 0;
179        self.mode = Mode::Insert;
180        self.op = None;
181        self.count = 0;
182        self.awaiting = None;
183        self.hist = None;
184        self.stash.clear();
185        self.undo.clear();
186    }
187
188    /// entering the composer always starts in insert mode — you came here to
189    /// type, and the draft you left behind is still there.
190    pub fn focus(&mut self) {
191        self.mode = Mode::Insert;
192        self.op = None;
193        self.count = 0;
194        self.awaiting = None;
195    }
196
197    /// replace `lo..hi` (char indices) with `s`, leaving the cursor after the
198    /// inserted text. undoable. this is the completion hook: tab swaps the word
199    /// under the cursor for a candidate without the editor knowing why.
200    pub fn replace_range(&mut self, lo: usize, hi: usize, s: &str) {
201        let hi = hi.min(self.text.len());
202        let lo = lo.min(hi);
203        self.snapshot();
204        let chars: Vec<char> = s.chars().collect();
205        let n = chars.len();
206        self.text.splice(lo..hi, chars);
207        self.cursor = lo + n;
208        self.clamp();
209    }
210
211    fn snapshot(&mut self) {
212        self.undo.push((self.text.clone(), self.cursor));
213        if self.undo.len() > 100 {
214            self.undo.remove(0);
215        }
216    }
217
218    /// in normal mode the cursor sits ON a character, so it stops one short of
219    /// the end; in insert mode it may sit past the last one.
220    fn clamp(&mut self) {
221        let max = match self.mode {
222            Mode::Insert => self.text.len(),
223            Mode::Normal => self.text.len().saturating_sub(1),
224        };
225        self.cursor = self.cursor.min(max);
226    }
227
228    fn take_count(&mut self) -> usize {
229        let n = self.count.max(1);
230        self.count = 0;
231        n
232    }
233
234    pub fn key(&mut self, k: KeyEvent) -> Act {
235        match self.mode {
236            Mode::Insert => self.insert_key(k),
237            Mode::Normal => self.normal_key(k),
238        }
239    }
240
241    fn insert_key(&mut self, k: KeyEvent) -> Act {
242        match k.code {
243            KeyCode::Esc => {
244                self.mode = Mode::Normal;
245                // vi steps left when leaving insert
246                self.cursor = self.cursor.saturating_sub(1);
247                self.clamp();
248            }
249            KeyCode::Enter => return Act::Send,
250            KeyCode::Backspace => {
251                if self.cursor > 0 {
252                    self.snapshot();
253                    self.cursor -= 1;
254                    self.text.remove(self.cursor);
255                }
256            }
257            KeyCode::Delete => {
258                if self.cursor < self.text.len() {
259                    self.snapshot();
260                    self.text.remove(self.cursor);
261                }
262            }
263            KeyCode::Left => self.cursor = self.cursor.saturating_sub(1),
264            KeyCode::Right => self.cursor = (self.cursor + 1).min(self.text.len()),
265            KeyCode::Home => self.cursor = 0,
266            KeyCode::End => self.cursor = self.text.len(),
267            KeyCode::Char(c) if !k.modifiers.contains(KeyModifiers::CONTROL) => {
268                self.snapshot();
269                self.text.insert(self.cursor, c);
270                self.cursor += 1;
271            }
272            _ => {}
273        }
274        Act::Continue
275    }
276
277    fn normal_key(&mut self, k: KeyEvent) -> Act {
278        let KeyCode::Char(c) = k.code else {
279            return self.normal_special(k);
280        };
281        if k.modifiers.contains(KeyModifiers::CONTROL) {
282            return Act::Continue;
283        }
284
285        // a pending f/F/t/T or r consumes the very next character, whatever it is
286        if let Some(a) = self.awaiting.take() {
287            match a {
288                Await::Find { back, till } => {
289                    self.last_find = Some((c, back, till));
290                    let n = self.take_count();
291                    if let Some(t) = self.find_target(c, back, till, n) {
292                        self.apply_motion(t, !back);
293                    } else {
294                        self.op = None;
295                    }
296                }
297                Await::Replace => {
298                    if self.cursor < self.text.len() {
299                        self.snapshot();
300                        self.text[self.cursor] = c;
301                    }
302                }
303            }
304            return Act::Continue;
305        }
306
307        // counts
308        if c.is_ascii_digit() && !(c == '0' && self.count == 0) {
309            self.count = (self.count.saturating_mul(10))
310                .saturating_add(c.to_digit(10).unwrap_or(0) as usize)
311                .min(100_000);
312            return Act::Continue;
313        }
314
315        // an operator repeated is the linewise form: dd, cc, yy
316        if let Some(op) = self.op {
317            if c == op {
318                self.op = None;
319                self.count = 0;
320                return self.whole_line(op);
321            }
322        }
323
324        match c {
325            // ---- motions ------------------------------------------------
326            'h' => {
327                let n = self.take_count();
328                self.apply_motion(self.cursor.saturating_sub(n), false)
329            }
330            'l' | ' ' => {
331                let n = self.take_count();
332                self.apply_motion((self.cursor + n).min(self.text.len()), false)
333            }
334            '0' => self.apply_motion(0, false),
335            '^' => {
336                let t = self.first_non_blank();
337                self.apply_motion(t, false)
338            }
339            '$' => self.apply_motion(self.text.len(), false),
340            'w' | 'W' => {
341                let n = self.take_count();
342                let t = self.word_fwd(n, c == 'W');
343                self.apply_motion(t, false)
344            }
345            'b' | 'B' => {
346                let n = self.take_count();
347                let t = self.word_back(n, c == 'B');
348                self.apply_motion(t, false)
349            }
350            'e' | 'E' => {
351                let n = self.take_count();
352                let t = self.word_end(n, c == 'E');
353                self.apply_motion(t, true)
354            }
355            'f' | 'F' | 't' | 'T' => {
356                self.awaiting = Some(Await::Find {
357                    back: c == 'F' || c == 'T',
358                    till: c == 't' || c == 'T',
359                });
360            }
361            ';' | ',' => {
362                if let Some((ch, back, till)) = self.last_find {
363                    let back = if c == ',' { !back } else { back };
364                    let n = self.take_count();
365                    if let Some(t) = self.find_target(ch, back, till, n) {
366                        self.apply_motion(t, !back);
367                    }
368                }
369            }
370            // ---- operators ----------------------------------------------
371            'd' | 'c' | 'y' => self.op = Some(c),
372            'D' => return self.op_to_end('d'),
373            'C' => return self.op_to_end('c'),
374            'Y' => return self.whole_line('y'),
375            // ---- edits ----------------------------------------------------
376            'x' => {
377                let n = self.take_count();
378                let end = (self.cursor + n).min(self.text.len());
379                self.cut(self.cursor, end, false);
380            }
381            'X' => {
382                let n = self.take_count();
383                let start = self.cursor.saturating_sub(n);
384                self.cut(start, self.cursor, false);
385            }
386            'r' => self.awaiting = Some(Await::Replace),
387            'i' => self.mode = Mode::Insert,
388            'a' => {
389                self.cursor = (self.cursor + 1).min(self.text.len());
390                self.mode = Mode::Insert;
391            }
392            'I' => {
393                self.cursor = self.first_non_blank();
394                self.mode = Mode::Insert;
395            }
396            'A' => {
397                self.cursor = self.text.len();
398                self.mode = Mode::Insert;
399            }
400            'p' | 'P' => {
401                if !self.reg.is_empty() {
402                    self.snapshot();
403                    let at = if c == 'p' {
404                        (self.cursor + 1).min(self.text.len())
405                    } else {
406                        self.cursor
407                    };
408                    let reg = self.reg.clone();
409                    let n = reg.len();
410                    self.text.splice(at..at, reg);
411                    self.cursor = at + n - 1;
412                    self.clamp();
413                }
414            }
415            'u' => {
416                if let Some((t, cur)) = self.undo.pop() {
417                    self.text = t;
418                    self.cursor = cur;
419                    self.clamp();
420                }
421            }
422            // ---- history --------------------------------------------------
423            'k' => self.history_step(-1),
424            'j' => self.history_step(1),
425            _ => {}
426        }
427        Act::Continue
428    }
429
430    fn normal_special(&mut self, k: KeyEvent) -> Act {
431        match k.code {
432            KeyCode::Enter => Act::Send,
433            KeyCode::Esc => {
434                if self.op.is_some() || self.count > 0 || self.awaiting.is_some() {
435                    // first escape cancels a half-typed command, as in vi
436                    self.op = None;
437                    self.count = 0;
438                    self.awaiting = None;
439                    Act::Continue
440                } else {
441                    Act::Leave
442                }
443            }
444            KeyCode::Left => {
445                self.cursor = self.cursor.saturating_sub(1);
446                Act::Continue
447            }
448            KeyCode::Right => {
449                self.cursor += 1;
450                self.clamp();
451                Act::Continue
452            }
453            KeyCode::Up => {
454                self.history_step(-1);
455                Act::Continue
456            }
457            KeyCode::Down => {
458                self.history_step(1);
459                Act::Continue
460            }
461            KeyCode::Home => {
462                self.cursor = 0;
463                Act::Continue
464            }
465            KeyCode::End => {
466                self.cursor = self.text.len();
467                self.clamp();
468                Act::Continue
469            }
470            KeyCode::Backspace => {
471                self.cursor = self.cursor.saturating_sub(1);
472                Act::Continue
473            }
474            _ => Act::Continue,
475        }
476    }
477
478    /// move, or apply the pending operator across the span just travelled.
479    /// `inclusive` motions (e, f, t) cover the character they land on.
480    fn apply_motion(&mut self, target: usize, inclusive: bool) {
481        let target = target.min(self.text.len());
482        match self.op.take() {
483            None => {
484                self.cursor = target;
485                self.clamp();
486            }
487            Some(op) => {
488                let (lo, hi) = if target >= self.cursor {
489                    (
490                        self.cursor,
491                        if inclusive {
492                            (target + 1).min(self.text.len())
493                        } else {
494                            target
495                        },
496                    )
497                } else {
498                    (target, self.cursor)
499                };
500                match op {
501                    'y' => self.reg = self.text[lo..hi].to_vec(),
502                    'd' => self.cut(lo, hi, false),
503                    'c' => self.cut(lo, hi, true),
504                    _ => {}
505                }
506            }
507        }
508    }
509
510    fn op_to_end(&mut self, op: char) -> Act {
511        self.op = Some(op);
512        self.apply_motion(self.text.len(), false);
513        Act::Continue
514    }
515
516    fn whole_line(&mut self, op: char) -> Act {
517        match op {
518            'y' => self.reg = self.text.clone(),
519            'd' => self.cut(0, self.text.len(), false),
520            'c' => self.cut(0, self.text.len(), true),
521            _ => {}
522        }
523        Act::Continue
524    }
525
526    /// remove `lo..hi` into the register. `into_insert` is the `c` behaviour.
527    fn cut(&mut self, lo: usize, hi: usize, into_insert: bool) {
528        let hi = hi.min(self.text.len());
529        if lo < hi {
530            self.snapshot();
531            self.reg = self.text[lo..hi].to_vec();
532            self.text.drain(lo..hi);
533        }
534        self.cursor = lo;
535        if into_insert {
536            self.mode = Mode::Insert;
537        }
538        self.clamp();
539    }
540
541    fn first_non_blank(&self) -> usize {
542        self.text
543            .iter()
544            .position(|c| !c.is_whitespace())
545            .unwrap_or(0)
546    }
547
548    fn find_target(&self, ch: char, back: bool, till: bool, n: usize) -> Option<usize> {
549        let mut at = self.cursor;
550        for _ in 0..n {
551            at = if back {
552                self.text[..at.min(self.text.len())]
553                    .iter()
554                    .rposition(|&c| c == ch)?
555            } else {
556                let from = at + 1;
557                from + self.text.get(from..)?.iter().position(|&c| c == ch)?
558            };
559        }
560        Some(match (back, till) {
561            (false, true) => at.saturating_sub(1),
562            (true, true) => at + 1,
563            _ => at,
564        })
565    }
566
567    fn word_fwd(&self, n: usize, big: bool) -> usize {
568        let mut i = self.cursor;
569        let len = self.text.len();
570        for _ in 0..n {
571            if i >= len {
572                break;
573            }
574            let start = if big {
575                1.min(class(self.text[i]))
576            } else {
577                class(self.text[i])
578            };
579            // step off the current run…
580            while i < len && cls(self.text[i], big) == start && start != 0 {
581                i += 1;
582            }
583            // …then over any blanks
584            while i < len && self.text[i].is_whitespace() {
585                i += 1;
586            }
587        }
588        i
589    }
590
591    fn word_back(&self, n: usize, big: bool) -> usize {
592        let mut i = self.cursor;
593        for _ in 0..n {
594            if i == 0 {
595                break;
596            }
597            i -= 1;
598            while i > 0 && self.text[i].is_whitespace() {
599                i -= 1;
600            }
601            let c = cls(self.text[i], big);
602            while i > 0 && cls(self.text[i - 1], big) == c && c != 0 {
603                i -= 1;
604            }
605        }
606        i
607    }
608
609    fn word_end(&self, n: usize, big: bool) -> usize {
610        let mut i = self.cursor;
611        let len = self.text.len();
612        for _ in 0..n {
613            if i + 1 >= len {
614                break;
615            }
616            i += 1;
617            while i < len && self.text[i].is_whitespace() {
618                i += 1;
619            }
620            if i >= len {
621                break;
622            }
623            let c = cls(self.text[i], big);
624            while i + 1 < len && cls(self.text[i + 1], big) == c {
625                i += 1;
626            }
627        }
628        i.min(len.saturating_sub(1))
629    }
630
631    /// walk sent history. -1 is older (vi's `k`), 1 is newer.
632    fn history_step(&mut self, dir: i32) {
633        if self.history.is_empty() {
634            return;
635        }
636        let last = self.history.len() - 1;
637        let next = match (self.hist, dir) {
638            (None, -1) => {
639                self.stash = self.text.clone();
640                Some(last)
641            }
642            (Some(i), -1) => Some(i.saturating_sub(1)),
643            (Some(i), 1) if i >= last => None,
644            (Some(i), 1) => Some(i + 1),
645            _ => return,
646        };
647        self.text = match next {
648            Some(i) => self.history[i].chars().collect(),
649            None => std::mem::take(&mut self.stash),
650        };
651        self.hist = next;
652        self.cursor = self.text.len();
653        self.clamp();
654    }
655}
656
657/// class, collapsing word and punctuation for the WORD motions.
658fn cls(c: char, big: bool) -> u8 {
659    let k = class(c);
660    if big && k == 2 {
661        1
662    } else {
663        k
664    }
665}
666
667#[cfg(test)]
668mod tests {
669    use super::*;
670
671    fn key(c: char) -> KeyEvent {
672        KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
673    }
674    fn special(k: KeyCode) -> KeyEvent {
675        KeyEvent::new(k, KeyModifiers::NONE)
676    }
677
678    /// drive a sequence; `<esc>` and `<cr>` are spelled out.
679    fn run(l: &mut Line, keys: &str) -> Act {
680        let mut act = Act::Continue;
681        let mut it = keys.chars().peekable();
682        while let Some(c) = it.next() {
683            if c == '<' {
684                let tag: String = it.by_ref().take_while(|&c| c != '>').collect();
685                act = l.key(special(match tag.as_str() {
686                    "esc" => KeyCode::Esc,
687                    "cr" => KeyCode::Enter,
688                    "bs" => KeyCode::Backspace,
689                    "up" => KeyCode::Up,
690                    "down" => KeyCode::Down,
691                    other => panic!("unknown key <{other}>"),
692                }));
693            } else {
694                act = l.key(key(c));
695            }
696        }
697        act
698    }
699
700    fn line(s: &str) -> Line {
701        let mut l = Line::default();
702        run(&mut l, s);
703        l
704    }
705
706    #[test]
707    fn typing_inserts_and_starts_in_insert_mode() {
708        let l = line("hello");
709        assert_eq!(l.text(), "hello");
710        assert_eq!(l.mode(), Mode::Insert);
711        assert_eq!(l.cursor(), 5);
712    }
713
714    #[test]
715    fn escape_enters_normal_mode_and_steps_left() {
716        let l = line("hello<esc>");
717        assert_eq!(l.mode(), Mode::Normal);
718        assert_eq!(
719            l.cursor(),
720            4,
721            "vi puts the cursor on the last char, not past it"
722        );
723    }
724
725    #[test]
726    fn escape_from_normal_leaves_the_composer_keeping_the_draft() {
727        let mut l = line("wip<esc>");
728        assert_eq!(l.key(special(KeyCode::Esc)), Act::Leave);
729        assert_eq!(l.text(), "wip", "the draft survives");
730    }
731
732    #[test]
733    fn escape_first_cancels_a_half_typed_command() {
734        let mut l = line("hello world<esc>0");
735        run(&mut l, "2d");
736        assert_eq!(
737            l.key(special(KeyCode::Esc)),
738            Act::Continue,
739            "cancels the pending d"
740        );
741        assert_eq!(l.text(), "hello world");
742        assert_eq!(
743            l.key(special(KeyCode::Esc)),
744            Act::Leave,
745            "second one leaves"
746        );
747    }
748
749    #[test]
750    fn enter_sends_from_either_mode() {
751        let mut l = line("hi");
752        assert_eq!(l.key(special(KeyCode::Enter)), Act::Send);
753        let mut l = line("hi<esc>");
754        assert_eq!(l.key(special(KeyCode::Enter)), Act::Send);
755    }
756
757    // ---- motions ---------------------------------------------------------
758
759    #[test]
760    fn hl_and_line_ends() {
761        let mut l = line("hello world<esc>");
762        run(&mut l, "0");
763        assert_eq!(l.cursor(), 0);
764        run(&mut l, "$");
765        assert_eq!(l.cursor(), 10);
766        run(&mut l, "5h");
767        assert_eq!(l.cursor(), 5);
768        run(&mut l, "2l");
769        assert_eq!(l.cursor(), 7);
770    }
771
772    #[test]
773    fn caret_goes_to_the_first_non_blank() {
774        let mut l = line("   hi<esc>");
775        run(&mut l, "^");
776        assert_eq!(l.cursor(), 3);
777    }
778
779    #[test]
780    fn word_motions_stop_at_class_changes() {
781        let mut l = line("foo.bar baz<esc>0");
782        run(&mut l, "w");
783        assert_eq!(l.cursor(), 3, "w stops at the punctuation");
784        run(&mut l, "w");
785        assert_eq!(l.cursor(), 4);
786        run(&mut l, "w");
787        assert_eq!(l.cursor(), 8);
788    }
789
790    #[test]
791    fn big_word_motions_only_stop_at_blanks() {
792        let mut l = line("foo.bar baz<esc>0");
793        run(&mut l, "W");
794        assert_eq!(l.cursor(), 8, "W treats foo.bar as one WORD");
795        run(&mut l, "B");
796        assert_eq!(l.cursor(), 0);
797    }
798
799    #[test]
800    fn e_lands_on_the_last_char_of_a_word() {
801        let mut l = line("hello world<esc>0");
802        run(&mut l, "e");
803        assert_eq!(l.cursor(), 4);
804        run(&mut l, "e");
805        assert_eq!(l.cursor(), 10);
806    }
807
808    #[test]
809    fn find_and_till_with_repeat() {
810        let mut l = line("a,b,c<esc>0");
811        run(&mut l, "f,");
812        assert_eq!(l.cursor(), 1);
813        run(&mut l, ";");
814        assert_eq!(l.cursor(), 3);
815        run(&mut l, "0t,");
816        assert_eq!(l.cursor(), 0, "t stops before the target");
817        run(&mut l, "$F,");
818        assert_eq!(l.cursor(), 3);
819    }
820
821    // ---- operators -------------------------------------------------------
822
823    #[test]
824    fn dw_deletes_a_word() {
825        let mut l = line("hello world<esc>0");
826        run(&mut l, "dw");
827        assert_eq!(l.text(), "world");
828    }
829
830    #[test]
831    fn counts_apply_to_operators() {
832        let mut l = line("one two three four<esc>0");
833        run(&mut l, "3dw");
834        assert_eq!(l.text(), "four");
835    }
836
837    #[test]
838    fn dd_and_cc_take_the_whole_line() {
839        let mut l = line("throw this away<esc>");
840        run(&mut l, "dd");
841        assert_eq!(l.text(), "");
842        let mut l = line("replace me<esc>");
843        run(&mut l, "cc");
844        assert_eq!(l.text(), "");
845        assert_eq!(l.mode(), Mode::Insert, "c leaves you typing");
846    }
847
848    #[test]
849    fn shift_d_and_c_run_to_end_of_line() {
850        let mut l = line("keep this drop that<esc>0");
851        run(&mut l, "wwD");
852        assert_eq!(l.text(), "keep this ");
853        let mut l = line("keep drop<esc>0");
854        run(&mut l, "wC");
855        assert_eq!(l.text(), "keep ");
856        assert_eq!(l.mode(), Mode::Insert);
857    }
858
859    #[test]
860    fn ci_style_change_then_type() {
861        let mut l = line("hello world<esc>0");
862        run(&mut l, "cwbye ");
863        assert_eq!(l.text(), "bye world");
864    }
865
866    #[test]
867    fn df_deletes_through_the_target() {
868        let mut l = line("a,b,c<esc>0");
869        run(&mut l, "df,");
870        assert_eq!(l.text(), "b,c", "f is inclusive");
871    }
872
873    #[test]
874    fn backwards_operator_deletes_the_span_behind() {
875        let mut l = line("hello world<esc>$");
876        run(&mut l, "db");
877        assert_eq!(l.text(), "hello d");
878    }
879
880    // ---- edits -----------------------------------------------------------
881
882    #[test]
883    fn x_deletes_forward_and_yanks_to_the_register() {
884        let mut l = line("abcdef<esc>0");
885        run(&mut l, "3x");
886        assert_eq!(l.text(), "def");
887        run(&mut l, "$p");
888        assert_eq!(l.text(), "defabc", "p pastes after the cursor");
889    }
890
891    #[test]
892    fn shift_p_pastes_before() {
893        let mut l = line("abcdef<esc>0");
894        run(&mut l, "3x0P");
895        assert_eq!(l.text(), "abcdef");
896    }
897
898    #[test]
899    fn r_replaces_one_char() {
900        let mut l = line("cat<esc>0");
901        run(&mut l, "rb");
902        assert_eq!(l.text(), "bat");
903    }
904
905    #[test]
906    fn insert_entry_points_land_in_the_right_place() {
907        let mut l = line("bc<esc>0");
908        run(&mut l, "ia");
909        assert_eq!(l.text(), "abc");
910        let mut l = line("ab<esc>");
911        run(&mut l, "Ac");
912        assert_eq!(l.text(), "abc");
913        let mut l = line("ac<esc>0");
914        run(&mut l, "ab");
915        assert_eq!(l.text(), "abc", "a appends after the cursor");
916        let mut l = line("  bc<esc>");
917        run(&mut l, "Ia");
918        assert_eq!(l.text(), "  abc", "I goes to the first non-blank");
919    }
920
921    #[test]
922    fn u_undoes_the_last_change() {
923        let mut l = line("hello world<esc>0");
924        run(&mut l, "dw");
925        assert_eq!(l.text(), "world");
926        run(&mut l, "u");
927        assert_eq!(l.text(), "hello world");
928    }
929
930    #[test]
931    fn yank_then_paste_leaves_the_text_intact() {
932        let mut l = line("word<esc>0");
933        run(&mut l, "yw$p");
934        assert_eq!(l.text(), "wordword");
935    }
936
937    // ---- utf-8 -----------------------------------------------------------
938
939    /// chat is full of CJK and emoji; a byte-indexed editor would slice glyphs.
940    #[test]
941    fn motions_and_deletes_are_char_safe() {
942        let mut l = line("キツネス pog<esc>0");
943        run(&mut l, "x");
944        assert_eq!(l.text(), "ツネス pog");
945        run(&mut l, "dw");
946        assert_eq!(l.text(), "pog");
947    }
948
949    #[test]
950    fn emoji_are_single_positions() {
951        let mut l = line("a🔥b<esc>0");
952        run(&mut l, "l");
953        assert_eq!(l.cursor(), 1);
954        run(&mut l, "x");
955        assert_eq!(l.text(), "ab");
956    }
957
958    // ---- history ---------------------------------------------------------
959
960    #[test]
961    fn accept_records_history_and_clears_the_line() {
962        let mut l = line("first");
963        assert_eq!(l.accept(), "first");
964        assert_eq!(l.text(), "");
965        assert_eq!(l.mode(), Mode::Insert, "ready to type the next one");
966    }
967
968    #[test]
969    fn k_and_j_walk_sent_history() {
970        let mut l = Line::default();
971        run(&mut l, "one");
972        l.accept();
973        run(&mut l, "two");
974        l.accept();
975        run(&mut l, "draft<esc>");
976        run(&mut l, "k");
977        assert_eq!(l.text(), "two");
978        run(&mut l, "k");
979        assert_eq!(l.text(), "one");
980        run(&mut l, "j");
981        assert_eq!(l.text(), "two");
982        run(&mut l, "j");
983        assert_eq!(l.text(), "draft", "coming back restores the draft");
984    }
985
986    #[test]
987    fn history_ignores_blanks_and_immediate_repeats() {
988        let mut l = Line::default();
989        run(&mut l, "   ");
990        l.accept();
991        run(&mut l, "same");
992        l.accept();
993        run(&mut l, "same");
994        l.accept();
995        run(&mut l, "<esc>k");
996        assert_eq!(l.text(), "same");
997        run(&mut l, "k");
998        assert_eq!(l.text(), "same", "only one entry, nothing older");
999    }
1000
1001    #[test]
1002    fn history_is_bounded_seedable_and_reports_changes() {
1003        let mut l = Line::default();
1004        let r0 = l.history_rev();
1005        run(&mut l, "a");
1006        l.accept();
1007        assert_ne!(l.history_rev(), r0);
1008        let r1 = l.history_rev();
1009        l.accept(); // blank: nothing recorded
1010        assert_eq!(l.history_rev(), r1);
1011        l.set_history((0..HISTORY_CAP + 50).map(|i| format!("m{i}")));
1012        assert_eq!(l.history().len(), HISTORY_CAP);
1013        assert_eq!(l.history()[0], "m50", "oldest dropped");
1014        run(&mut l, "new");
1015        l.accept();
1016        assert_eq!(l.history().len(), HISTORY_CAP);
1017        assert_eq!(l.history().last().unwrap(), "new");
1018        run(&mut l, "<esc>k");
1019        assert_eq!(l.text(), "new", "seeded history walks like live history");
1020        l.set_history(["x".into(), "".into(), "x".into(), "y".into()]);
1021        assert_eq!(l.history(), ["x", "y"]);
1022    }
1023
1024    // ---- guards ----------------------------------------------------------
1025
1026    #[test]
1027    fn every_motion_survives_an_empty_line() {
1028        let mut l = Line::default();
1029        l.key(special(KeyCode::Esc));
1030        for keys in [
1031            "h", "l", "w", "b", "e", "$", "0", "^", "x", "X", "dw", "dd", "p", "u", "D",
1032        ] {
1033            run(&mut l, keys);
1034            assert_eq!(l.text(), "");
1035            assert_eq!(l.cursor(), 0, "{keys} moved the cursor off an empty line");
1036        }
1037    }
1038
1039    #[test]
1040    fn counts_cannot_run_off_the_end() {
1041        let mut l = line("ab<esc>0");
1042        run(&mut l, "999l");
1043        assert_eq!(l.cursor(), 1, "normal mode stops on the last char");
1044        run(&mut l, "999x");
1045        assert_eq!(l.text(), "a");
1046    }
1047}