Skip to main content

teamctl_ui/
compose.rs

1//! Send-mail compose modal — multi-line vim-style editor + CLI send.
2//!
3//! Two abstractions live here, mirroring the PR-UI-4 approvals
4//! split:
5//!
6//! - `Editor` — pure-state vim-style multi-line buffer. `apply_key`
7//!   takes a `KeyEvent` and returns an `EditorAction` so the
8//!   surrounding App can react to commands like "send" / "cancel"
9//!   without the editor itself knowing about the message bus.
10//! - `MessageSender` — write side. `CliMessageSender` shells out to
11//!   `teamctl send <agent> "<body>"` (DM) or
12//!   `teamctl broadcast #<channel> "<body>"` (broadcast), the same
13//!   architectural discipline as PR-UI-4's `CliApprovalDecider`. A
14//!   direct `INSERT INTO messages …` from the UI would silently
15//!   sidestep the channel-ACL + ratelimit + delivery hooks the CLI
16//!   already runs through.
17//!
18//! Vim keybindings shipped in PR-UI-5: insert mode (`i`/`a`/`o`,
19//! Esc back to Normal), Normal motions (`h`/`j`/`k`/`l`, arrows,
20//! `0`/`$`), ex command shim (`:w`/`:q`/`:wq`), Ctrl+Enter to send,
21//! Esc-Esc to cancel. Word motions (`w`/`b`) and line ops
22//! (`dd`/`yy`/`p`) deferred to the PR-UI-7 polish cycle — flagged
23//! in the PR description.
24
25use std::path::Path;
26use std::process::Command;
27
28use anyhow::{Context, Result};
29use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum ComposeTarget {
33    /// DM to a specific agent. `agent_id` is `<project>:<agent>`.
34    Dm {
35        agent_id: String,
36        project_id: String,
37    },
38    /// Broadcast to a channel. `channel_id` is `<project>:<name>`,
39    /// rendered as `#<name>` in the modal title.
40    Broadcast {
41        channel_id: String,
42        project_id: String,
43    },
44}
45
46impl ComposeTarget {
47    pub fn title(&self) -> String {
48        match self {
49            ComposeTarget::Dm { agent_id, .. } => format!("→ {agent_id}"),
50            ComposeTarget::Broadcast { channel_id, .. } => {
51                let short = channel_id
52                    .rsplit_once(':')
53                    .map(|(_, n)| n)
54                    .unwrap_or(channel_id);
55                format!("→ #{short}")
56            }
57        }
58    }
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum VimMode {
63    Normal,
64    Insert,
65    /// Awaiting an ex-command after `:`. `ex_buffer` accumulates
66    /// the typed string; Enter dispatches it.
67    Ex,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct Editor {
72    pub lines: Vec<String>,
73    pub cursor_row: usize,
74    pub cursor_col: usize,
75    pub mode: VimMode,
76    pub ex_buffer: String,
77    /// Tracks whether the previous keypress was `Esc`. Two Escs in
78    /// a row from any mode cancel the surrounding modal — same
79    /// shape SPEC §4 specifies for "close all modals."
80    pub esc_armed: bool,
81    /// Single-register yank buffer for `yy` / `dd` → `p`. Only
82    /// holds full lines; word-level yanks aren't in PR-UI-7 scope.
83    /// Empty `Vec` means "nothing to paste."
84    pub yank: Vec<String>,
85    /// Tracks whether the previous Normal-mode key was `d` or `y`.
86    /// `dd` deletes the line, `yy` yanks it. The pending-op state
87    /// clears on any non-matching key so `dx` doesn't leave the
88    /// editor in a half-operation.
89    pub pending_op: Option<char>,
90}
91
92impl Default for Editor {
93    fn default() -> Self {
94        Self {
95            lines: vec![String::new()],
96            cursor_row: 0,
97            cursor_col: 0,
98            // Compose modals open in Insert because typing is the
99            // central UX — operators expect their first keystroke
100            // to land in the buffer.
101            mode: VimMode::Insert,
102            ex_buffer: String::new(),
103            esc_armed: false,
104            yank: Vec::new(),
105            pending_op: None,
106        }
107    }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum EditorAction {
112    /// Keep the modal open; editor consumed the key.
113    Continue,
114    /// Operator hit `Ctrl+Enter` or `:wq` — send + close.
115    Send,
116    /// Operator hit Esc-Esc or `:q` — close without send.
117    Cancel,
118}
119
120impl Editor {
121    /// Final body for sending. Joins lines with `\n`; trailing
122    /// blank lines are stripped so a single newline at the bottom
123    /// doesn't sneak past the operator's intent.
124    pub fn body(&self) -> String {
125        let mut out = self.lines.join("\n");
126        while out.ends_with('\n') {
127            out.pop();
128        }
129        out
130    }
131
132    pub fn is_empty(&self) -> bool {
133        self.lines.iter().all(|l| l.is_empty())
134    }
135
136    /// Apply a keypress and return what the surrounding modal
137    /// should do. Held under `&mut self` so tests can drive the
138    /// editor deterministically without a `Terminal`.
139    pub fn apply_key(&mut self, k: KeyEvent) -> EditorAction {
140        if k.kind != KeyEventKind::Press {
141            return EditorAction::Continue;
142        }
143
144        // Send chord — Alt+Enter is the terminal-universal send
145        // (xterm / Terminal.app / tmux deliver it as Enter+ALT in
146        // their default modes). Ctrl+Enter is kept for terminals
147        // running the kitty keyboard protocol or modifyOtherKeys,
148        // where it does decode distinctly. Either chord fires send
149        // from any editor mode.
150        if k.code == KeyCode::Enter
151            && (k.modifiers.contains(KeyModifiers::ALT)
152                || k.modifiers.contains(KeyModifiers::CONTROL))
153        {
154            return EditorAction::Send;
155        }
156
157        // Esc-Esc handling spans modes: a single Esc out of Insert
158        // / Ex arms the second-Esc; from Normal the first Esc is
159        // the arming press. Any non-Esc key clears the arm.
160        if k.code == KeyCode::Esc {
161            return self.handle_esc();
162        }
163        self.esc_armed = false;
164
165        match self.mode {
166            VimMode::Insert => self.apply_insert(k),
167            VimMode::Normal => self.apply_normal(k),
168            VimMode::Ex => self.apply_ex(k),
169        }
170    }
171
172    fn handle_esc(&mut self) -> EditorAction {
173        // Two Escs in a row → cancel the modal regardless of mode.
174        if self.esc_armed {
175            return EditorAction::Cancel;
176        }
177        self.esc_armed = true;
178        match self.mode {
179            VimMode::Insert | VimMode::Ex => {
180                self.mode = VimMode::Normal;
181                self.ex_buffer.clear();
182            }
183            VimMode::Normal => {
184                // Already Normal — Esc just arms the second-Esc.
185            }
186        }
187        EditorAction::Continue
188    }
189
190    fn apply_insert(&mut self, k: KeyEvent) -> EditorAction {
191        match k.code {
192            KeyCode::Char(c) => {
193                let line = &mut self.lines[self.cursor_row];
194                let col = self.cursor_col.min(line.len());
195                line.insert(col, c);
196                self.cursor_col = col + 1;
197            }
198            KeyCode::Enter => {
199                let line = &mut self.lines[self.cursor_row];
200                let col = self.cursor_col.min(line.len());
201                let tail = line.split_off(col);
202                self.cursor_row += 1;
203                self.lines.insert(self.cursor_row, tail);
204                self.cursor_col = 0;
205            }
206            KeyCode::Backspace => {
207                if self.cursor_col > 0 {
208                    let line = &mut self.lines[self.cursor_row];
209                    let col = self.cursor_col.min(line.len());
210                    line.remove(col - 1);
211                    self.cursor_col = col - 1;
212                } else if self.cursor_row > 0 {
213                    let removed = self.lines.remove(self.cursor_row);
214                    self.cursor_row -= 1;
215                    let prev_len = self.lines[self.cursor_row].len();
216                    self.lines[self.cursor_row].push_str(&removed);
217                    self.cursor_col = prev_len;
218                }
219            }
220            KeyCode::Left => self.move_left(),
221            KeyCode::Right => self.move_right(),
222            KeyCode::Up => self.move_up(),
223            KeyCode::Down => self.move_down(),
224            _ => {}
225        }
226        EditorAction::Continue
227    }
228
229    fn apply_normal(&mut self, k: KeyEvent) -> EditorAction {
230        // Pending-op (dd/yy) machine: when the previous press was
231        // `d` or `y`, the next key either completes the op
232        // (`d`/`y`) or aborts it. Any non-d/y/p key clears the
233        // pending-op so we don't leave the editor stuck.
234        if let Some(op) = self.pending_op {
235            self.pending_op = None;
236            match (op, k.code) {
237                ('d', KeyCode::Char('d')) => {
238                    self.delete_line();
239                    return EditorAction::Continue;
240                }
241                ('y', KeyCode::Char('y')) => {
242                    self.yank_line();
243                    return EditorAction::Continue;
244                }
245                _ => {} // fall through and re-dispatch the key
246            }
247        }
248        match k.code {
249            KeyCode::Char('i') => self.mode = VimMode::Insert,
250            KeyCode::Char('a') => {
251                self.move_right_or_eol();
252                self.mode = VimMode::Insert;
253            }
254            KeyCode::Char('o') => {
255                self.cursor_row += 1;
256                self.lines.insert(self.cursor_row, String::new());
257                self.cursor_col = 0;
258                self.mode = VimMode::Insert;
259            }
260            KeyCode::Char('h') | KeyCode::Left => self.move_left(),
261            KeyCode::Char('l') | KeyCode::Right => self.move_right(),
262            KeyCode::Char('j') | KeyCode::Down => self.move_down(),
263            KeyCode::Char('k') | KeyCode::Up => self.move_up(),
264            KeyCode::Char('0') => self.cursor_col = 0,
265            KeyCode::Char('$') => {
266                self.cursor_col = self.lines[self.cursor_row].len();
267            }
268            KeyCode::Char(':') => {
269                self.mode = VimMode::Ex;
270                self.ex_buffer.clear();
271            }
272            // PR-UI-7 word motions — `w` next word, `b` prev
273            // word, `e` end of word. ASCII-word semantics
274            // (alphanumeric + `_`); good enough for prose +
275            // single-line code we'd compose into mailbox messages.
276            KeyCode::Char('w') => self.move_word_forward(),
277            KeyCode::Char('b') => self.move_word_back(),
278            KeyCode::Char('e') => self.move_word_end(),
279            // PR-UI-7 line ops — first press arms the op, second
280            // press completes. `p` pastes the yank register
281            // below the current line.
282            KeyCode::Char('d') => self.pending_op = Some('d'),
283            KeyCode::Char('y') => self.pending_op = Some('y'),
284            KeyCode::Char('p') => self.paste_below(),
285            _ => {}
286        }
287        EditorAction::Continue
288    }
289
290    fn apply_ex(&mut self, k: KeyEvent) -> EditorAction {
291        match k.code {
292            KeyCode::Char(c) => {
293                self.ex_buffer.push(c);
294                EditorAction::Continue
295            }
296            KeyCode::Backspace => {
297                self.ex_buffer.pop();
298                EditorAction::Continue
299            }
300            KeyCode::Enter => {
301                let cmd = std::mem::take(&mut self.ex_buffer);
302                self.mode = VimMode::Normal;
303                match cmd.trim() {
304                    "wq" | "x" => EditorAction::Send,
305                    "q" | "q!" => EditorAction::Cancel,
306                    "w" => EditorAction::Continue,
307                    _ => EditorAction::Continue,
308                }
309            }
310            _ => EditorAction::Continue,
311        }
312    }
313
314    fn move_left(&mut self) {
315        if self.cursor_col > 0 {
316            self.cursor_col -= 1;
317        }
318    }
319    fn move_right(&mut self) {
320        let len = self.lines[self.cursor_row].len();
321        if self.cursor_col < len {
322            self.cursor_col += 1;
323        }
324    }
325    fn move_right_or_eol(&mut self) {
326        // `a` in vim moves one past the cursor, clamped at EOL.
327        let len = self.lines[self.cursor_row].len();
328        self.cursor_col = (self.cursor_col + 1).min(len);
329    }
330    fn move_word_forward(&mut self) {
331        let line = self.lines[self.cursor_row].as_bytes();
332        let mut i = self.cursor_col;
333        // Skip current word.
334        while i < line.len() && is_word_byte(line[i]) {
335            i += 1;
336        }
337        // Skip whitespace / non-word.
338        while i < line.len() && !is_word_byte(line[i]) {
339            i += 1;
340        }
341        if i == self.cursor_col && self.cursor_row + 1 < self.lines.len() {
342            // At EOL with no further word — advance to next line's start.
343            self.cursor_row += 1;
344            self.cursor_col = 0;
345        } else {
346            self.cursor_col = i;
347        }
348    }
349    fn move_word_back(&mut self) {
350        if self.cursor_col == 0 {
351            if self.cursor_row > 0 {
352                self.cursor_row -= 1;
353                self.cursor_col = self.lines[self.cursor_row].len();
354            }
355            return;
356        }
357        let line = self.lines[self.cursor_row].as_bytes();
358        let mut i = self.cursor_col;
359        // Step back over whitespace.
360        while i > 0 && !is_word_byte(line[i - 1]) {
361            i -= 1;
362        }
363        // Step back to start of word.
364        while i > 0 && is_word_byte(line[i - 1]) {
365            i -= 1;
366        }
367        self.cursor_col = i;
368    }
369    fn move_word_end(&mut self) {
370        let line = self.lines[self.cursor_row].as_bytes();
371        let mut i = self.cursor_col;
372        // If currently in a word, move to its end; if not, find
373        // the next word and move to its end.
374        if i < line.len() && !is_word_byte(line[i]) {
375            while i < line.len() && !is_word_byte(line[i]) {
376                i += 1;
377            }
378        } else if i < line.len()
379            && is_word_byte(line[i])
380            && (i + 1 >= line.len() || !is_word_byte(line[i + 1]))
381        {
382            // Already at EOW — skip past this word's terminator
383            // and find the next word's end.
384            i += 1;
385            while i < line.len() && !is_word_byte(line[i]) {
386                i += 1;
387            }
388        }
389        while i + 1 < line.len() && is_word_byte(line[i + 1]) {
390            i += 1;
391        }
392        if i < line.len() {
393            self.cursor_col = i;
394        }
395    }
396
397    fn delete_line(&mut self) {
398        if self.lines.is_empty() {
399            return;
400        }
401        let removed = self.lines.remove(self.cursor_row);
402        self.yank = vec![removed];
403        if self.lines.is_empty() {
404            self.lines.push(String::new());
405        }
406        if self.cursor_row >= self.lines.len() {
407            self.cursor_row = self.lines.len() - 1;
408        }
409        self.cursor_col = self.cursor_col.min(self.lines[self.cursor_row].len());
410    }
411    fn yank_line(&mut self) {
412        if let Some(line) = self.lines.get(self.cursor_row) {
413            self.yank = vec![line.clone()];
414        }
415    }
416    fn paste_below(&mut self) {
417        if self.yank.is_empty() {
418            return;
419        }
420        let yanked = self.yank.clone();
421        for (offset, line) in yanked.into_iter().enumerate() {
422            self.lines.insert(self.cursor_row + 1 + offset, line);
423        }
424        self.cursor_row += 1;
425        self.cursor_col = 0;
426    }
427
428    fn move_up(&mut self) {
429        if self.cursor_row > 0 {
430            self.cursor_row -= 1;
431            self.cursor_col = self.cursor_col.min(self.lines[self.cursor_row].len());
432        }
433    }
434    fn move_down(&mut self) {
435        if self.cursor_row + 1 < self.lines.len() {
436            self.cursor_row += 1;
437            self.cursor_col = self.cursor_col.min(self.lines[self.cursor_row].len());
438        }
439    }
440}
441
442/// ASCII word-boundary classifier — alphanumeric + `_` is a word
443/// byte, everything else is whitespace / punctuation. Used by the
444/// vim word-motion `w` / `b` / `e` keys.
445fn is_word_byte(b: u8) -> bool {
446    b.is_ascii_alphanumeric() || b == b'_'
447}
448
449pub trait MessageSender: Send + Sync {
450    fn send_dm(&self, root: &Path, agent_id: &str, body: &str) -> Result<()>;
451    fn broadcast(&self, root: &Path, channel_id: &str, body: &str) -> Result<()>;
452}
453
454#[derive(Debug, Default, Clone, Copy)]
455pub struct CliMessageSender;
456
457impl MessageSender for CliMessageSender {
458    fn send_dm(&self, root: &Path, agent_id: &str, body: &str) -> Result<()> {
459        let status = Command::new("teamctl")
460            .arg("--root")
461            .arg(root)
462            .args(["send", agent_id, body])
463            .status()
464            .with_context(|| format!("invoke teamctl send {agent_id}"))?;
465        if !status.success() {
466            anyhow::bail!("teamctl send {agent_id} exited {status}");
467        }
468        Ok(())
469    }
470
471    fn broadcast(&self, root: &Path, channel_id: &str, body: &str) -> Result<()> {
472        // `teamctl broadcast` takes a `#<name>` argument scoped to
473        // the project's compose root. We pass the short name (after
474        // the last `:`); the CLI resolves to the project's channel.
475        let short = channel_id
476            .rsplit_once(':')
477            .map(|(_, n)| n)
478            .unwrap_or(channel_id);
479        let target = format!("#{short}");
480        let status = Command::new("teamctl")
481            .arg("--root")
482            .arg(root)
483            .args(["broadcast", &target, body])
484            .status()
485            .with_context(|| format!("invoke teamctl broadcast {target}"))?;
486        if !status.success() {
487            anyhow::bail!("teamctl broadcast {target} exited {status}");
488        }
489        Ok(())
490    }
491}
492
493pub mod test_support {
494    use super::*;
495    use std::sync::Mutex;
496
497    #[derive(Default)]
498    pub struct MockMessageSender {
499        pub dm_calls: Mutex<Vec<(String, String)>>,
500        pub broadcast_calls: Mutex<Vec<(String, String)>>,
501        /// When set, the next call returns an error of this text.
502        /// Reset after firing so subsequent calls succeed (the
503        /// modal's error-then-success path is a real flow).
504        pub fail_next: Mutex<Option<String>>,
505    }
506
507    impl MessageSender for MockMessageSender {
508        fn send_dm(&self, _root: &Path, agent_id: &str, body: &str) -> Result<()> {
509            if let Some(err) = self.fail_next.lock().unwrap().take() {
510                anyhow::bail!(err);
511            }
512            self.dm_calls
513                .lock()
514                .unwrap()
515                .push((agent_id.into(), body.into()));
516            Ok(())
517        }
518        fn broadcast(&self, _root: &Path, channel_id: &str, body: &str) -> Result<()> {
519            if let Some(err) = self.fail_next.lock().unwrap().take() {
520                anyhow::bail!(err);
521            }
522            self.broadcast_calls
523                .lock()
524                .unwrap()
525                .push((channel_id.into(), body.into()));
526            Ok(())
527        }
528    }
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534
535    fn k(code: KeyCode) -> KeyEvent {
536        KeyEvent::new(code, KeyModifiers::NONE)
537    }
538
539    fn k_ctrl(code: KeyCode) -> KeyEvent {
540        KeyEvent::new(code, KeyModifiers::CONTROL)
541    }
542
543    #[test]
544    fn dm_target_title_renders_as_arrow_agent() {
545        let t = ComposeTarget::Dm {
546            agent_id: "writing:dev1".into(),
547            project_id: "writing".into(),
548        };
549        assert_eq!(t.title(), "→ writing:dev1");
550    }
551
552    #[test]
553    fn broadcast_target_title_strips_project_prefix() {
554        let t = ComposeTarget::Broadcast {
555            channel_id: "writing:editorial".into(),
556            project_id: "writing".into(),
557        };
558        assert_eq!(t.title(), "→ #editorial");
559    }
560
561    #[test]
562    fn editor_starts_in_insert_mode() {
563        let e = Editor::default();
564        assert_eq!(e.mode, VimMode::Insert);
565        assert!(e.is_empty());
566    }
567
568    #[test]
569    fn typing_chars_appends_to_line() {
570        let mut e = Editor::default();
571        for c in "hello".chars() {
572            e.apply_key(k(KeyCode::Char(c)));
573        }
574        assert_eq!(e.lines, vec!["hello"]);
575        assert_eq!(e.cursor_col, 5);
576        assert_eq!(e.body(), "hello");
577    }
578
579    #[test]
580    fn enter_splits_line() {
581        let mut e = Editor::default();
582        for c in "hi".chars() {
583            e.apply_key(k(KeyCode::Char(c)));
584        }
585        e.apply_key(k(KeyCode::Enter));
586        for c in "yo".chars() {
587            e.apply_key(k(KeyCode::Char(c)));
588        }
589        assert_eq!(e.lines, vec!["hi", "yo"]);
590        assert_eq!(e.body(), "hi\nyo");
591    }
592
593    #[test]
594    fn backspace_at_line_start_joins_with_previous() {
595        let mut e = Editor::default();
596        for c in "ab".chars() {
597            e.apply_key(k(KeyCode::Char(c)));
598        }
599        e.apply_key(k(KeyCode::Enter));
600        for c in "cd".chars() {
601            e.apply_key(k(KeyCode::Char(c)));
602        }
603        // Cursor at start of line 2 → Backspace joins.
604        e.cursor_col = 0;
605        e.apply_key(k(KeyCode::Backspace));
606        assert_eq!(e.lines, vec!["abcd"]);
607        assert_eq!(e.cursor_row, 0);
608        assert_eq!(e.cursor_col, 2);
609    }
610
611    #[test]
612    fn esc_from_insert_drops_to_normal() {
613        let mut e = Editor::default();
614        e.apply_key(k(KeyCode::Esc));
615        assert_eq!(e.mode, VimMode::Normal);
616        assert!(e.esc_armed);
617    }
618
619    #[test]
620    fn second_esc_cancels_from_any_mode() {
621        let mut e = Editor::default();
622        // From Insert: first Esc → Normal+armed; second Esc → Cancel.
623        e.apply_key(k(KeyCode::Esc));
624        assert_eq!(e.apply_key(k(KeyCode::Esc)), EditorAction::Cancel);
625
626        // From Normal: first Esc arms; second Esc cancels.
627        let mut e = Editor {
628            mode: VimMode::Normal,
629            ..Editor::default()
630        };
631        assert_eq!(e.apply_key(k(KeyCode::Esc)), EditorAction::Continue);
632        assert_eq!(e.apply_key(k(KeyCode::Esc)), EditorAction::Cancel);
633    }
634
635    #[test]
636    fn ctrl_enter_sends_from_any_mode() {
637        let mut e = Editor::default();
638        for c in "hi".chars() {
639            e.apply_key(k(KeyCode::Char(c)));
640        }
641        assert_eq!(e.apply_key(k_ctrl(KeyCode::Enter)), EditorAction::Send);
642    }
643
644    #[test]
645    fn ex_wq_sends() {
646        let mut e = Editor::default();
647        for c in "hi".chars() {
648            e.apply_key(k(KeyCode::Char(c)));
649        }
650        // Esc → Normal, then `:wq` → Send.
651        e.apply_key(k(KeyCode::Esc));
652        e.apply_key(k(KeyCode::Char(':')));
653        for c in "wq".chars() {
654            e.apply_key(k(KeyCode::Char(c)));
655        }
656        assert_eq!(e.apply_key(k(KeyCode::Enter)), EditorAction::Send);
657    }
658
659    #[test]
660    fn ex_q_cancels() {
661        let mut e = Editor::default();
662        e.apply_key(k(KeyCode::Esc));
663        e.apply_key(k(KeyCode::Char(':')));
664        e.apply_key(k(KeyCode::Char('q')));
665        assert_eq!(e.apply_key(k(KeyCode::Enter)), EditorAction::Cancel);
666    }
667
668    #[test]
669    fn normal_i_re_enters_insert() {
670        let mut e = Editor::default();
671        e.apply_key(k(KeyCode::Esc));
672        // Non-Esc key clears the arm.
673        e.apply_key(k(KeyCode::Char('i')));
674        assert_eq!(e.mode, VimMode::Insert);
675        assert!(!e.esc_armed);
676    }
677
678    #[test]
679    fn hjkl_navigate_in_normal_mode() {
680        let mut e = Editor::default();
681        for c in "abc".chars() {
682            e.apply_key(k(KeyCode::Char(c)));
683        }
684        e.apply_key(k(KeyCode::Esc));
685        e.apply_key(k(KeyCode::Char('0')));
686        assert_eq!(e.cursor_col, 0);
687        e.apply_key(k(KeyCode::Char('l')));
688        e.apply_key(k(KeyCode::Char('l')));
689        assert_eq!(e.cursor_col, 2);
690        e.apply_key(k(KeyCode::Char('h')));
691        assert_eq!(e.cursor_col, 1);
692    }
693
694    #[test]
695    fn body_strips_trailing_blank_lines() {
696        let mut e = Editor::default();
697        for c in "x".chars() {
698            e.apply_key(k(KeyCode::Char(c)));
699        }
700        e.apply_key(k(KeyCode::Enter));
701        e.apply_key(k(KeyCode::Enter));
702        // body is `x\n\n` — strip both trailing newlines.
703        assert_eq!(e.body(), "x");
704    }
705}