Skip to main content

rmut_front/
editor.rs

1//! The line prompt's editing, history and completion: what happens
2//! to the text when a key arrives, with no opinion about what the
3//! text is for. A front end owns a [`LineEdit`] per open prompt and
4//! a [`History`] for the session; the prompt's meaning stays with it.
5
6use std::collections::HashMap;
7use std::path::Path;
8
9use crate::key::{KeyCode, KeyEvent, KeyModifiers};
10
11/// Byte offset of the `cursor`-th char (the length when past the end).
12pub fn byte_at(buf: &str, cursor: usize) -> usize {
13    buf.char_indices()
14        .nth(cursor)
15        .map(|(i, _)| i)
16        .unwrap_or(buf.len())
17}
18
19/// A line prompt as it fits `width` columns: the label, then the text
20/// with the cursor marker (a low bar) in it. When that is wider than
21/// the line, the start is cut away behind a `<` so the cursor stays in
22/// view, with a few columns of what follows it.
23pub fn prompt_line(label: &str, edit: &LineEdit, width: usize) -> String {
24    use unicode_width::UnicodeWidthStr as _;
25    let i = byte_at(&edit.buf, edit.cursor);
26    let before = format!("{label}{}", &edit.buf[..i]);
27    let after = &edit.buf[i..];
28    let room = width.max(8).saturating_sub(1 + after.width().min(4));
29    if before.width() <= room {
30        return format!("{before}\u{2581}{after}");
31    }
32    let mut cut = before.as_str();
33    while 1 + cut.width() > room {
34        let mut chars = cut.chars();
35        chars.next();
36        cut = chars.as_str();
37    }
38    format!("<{cut}\u{2581}{after}")
39}
40
41/// Where the word before `cursor` starts (whitespace skipped first).
42fn word_start(buf: &str, cursor: usize) -> usize {
43    let chars: Vec<char> = buf.chars().collect();
44    let mut c = cursor.min(chars.len());
45    while c > 0 && chars[c - 1].is_whitespace() {
46        c -= 1;
47    }
48    while c > 0 && !chars[c - 1].is_whitespace() {
49        c -= 1;
50    }
51    c
52}
53
54/// Where the word at or after `cursor` ends (whitespace skipped first).
55fn word_end(buf: &str, cursor: usize) -> usize {
56    let chars: Vec<char> = buf.chars().collect();
57    let mut c = cursor.min(chars.len());
58    while c < chars.len() && chars[c].is_whitespace() {
59        c += 1;
60    }
61    while c < chars.len() && !chars[c].is_whitespace() {
62        c += 1;
63    }
64    c
65}
66
67/// What a key did to the line, or what it asks the front end for.
68#[derive(Clone, Copy, PartialEq, Eq, Debug)]
69pub enum Edit {
70    /// The line changed, or the cursor moved. Nothing to do but draw.
71    Edited,
72    /// Esc, or mutt's Ctrl+G.
73    Cancel,
74    /// Enter: the line is the answer.
75    Submit,
76    /// Tab: complete, if the prompt knows how.
77    Complete,
78    /// Up / Down: step the history (`true` is older).
79    History(bool),
80    /// Not an editing key.
81    Ignored,
82}
83
84/// The text of a line prompt and the cursor in it, as a char index.
85#[derive(Clone, Debug, Default)]
86pub struct LineEdit {
87    pub buf: String,
88    pub cursor: usize,
89    /// Index into the history while browsing with Up/Down.
90    hist_pos: Option<usize>,
91    /// The line being typed, restored when browsing steps back past
92    /// the newest history entry.
93    stash: String,
94}
95
96impl LineEdit {
97    /// The cursor at the end of the prefill.
98    pub fn new(prefill: String) -> LineEdit {
99        let cursor = prefill.chars().count();
100        LineEdit {
101            buf: prefill,
102            cursor,
103            hist_pos: None,
104            stash: String::new(),
105        }
106    }
107
108    /// Replace the line, cursor at the end.
109    pub fn set(&mut self, text: &str) {
110        self.buf = text.to_string();
111        self.cursor = self.buf.chars().count();
112    }
113
114    /// The line editor, mutt/readline style.
115    pub fn key(&mut self, key: KeyEvent) -> Edit {
116        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
117        let alt = key.modifiers.contains(KeyModifiers::ALT);
118        let buf = &mut self.buf;
119        let cursor = &mut self.cursor;
120        match key.code {
121            KeyCode::Esc => return Edit::Cancel,
122            KeyCode::Char('g') if ctrl => return Edit::Cancel,
123            // mutt's backward-word, forward-word and kill-eow. Words
124            // end at whitespace, as Ctrl+W's do.
125            KeyCode::Char('b') if alt => *cursor = word_start(buf, *cursor),
126            KeyCode::Char('f') if alt => *cursor = word_end(buf, *cursor),
127            KeyCode::Char('d') if alt => {
128                let end = word_end(buf, *cursor);
129                let (from, to) = (byte_at(buf, *cursor), byte_at(buf, end));
130                buf.replace_range(from..to, "");
131            }
132            KeyCode::Enter => return Edit::Submit,
133            KeyCode::Tab => return Edit::Complete,
134            KeyCode::Up => return Edit::History(true),
135            KeyCode::Down => return Edit::History(false),
136            KeyCode::Left => *cursor = cursor.saturating_sub(1),
137            KeyCode::Right => *cursor = (*cursor + 1).min(buf.chars().count()),
138            KeyCode::Home => *cursor = 0,
139            KeyCode::End => *cursor = buf.chars().count(),
140            KeyCode::Char('a') if ctrl => *cursor = 0,
141            KeyCode::Char('e') if ctrl => *cursor = buf.chars().count(),
142            KeyCode::Backspace => {
143                if *cursor > 0 {
144                    buf.remove(byte_at(buf, *cursor - 1));
145                    *cursor -= 1;
146                }
147            }
148            KeyCode::Delete => {
149                if *cursor < buf.chars().count() {
150                    buf.remove(byte_at(buf, *cursor));
151                }
152            }
153            KeyCode::Char('d') if ctrl => {
154                if *cursor < buf.chars().count() {
155                    buf.remove(byte_at(buf, *cursor));
156                }
157            }
158            KeyCode::Char('u') if ctrl => {
159                // Kill to the start of the line.
160                let i = byte_at(buf, *cursor);
161                buf.replace_range(..i, "");
162                *cursor = 0;
163            }
164            KeyCode::Char('k') if ctrl => {
165                let i = byte_at(buf, *cursor);
166                buf.truncate(i);
167            }
168            KeyCode::Char('w') if ctrl => {
169                // Kill the word before the cursor.
170                let c = word_start(buf, *cursor);
171                let (start, end) = (byte_at(buf, c), byte_at(buf, *cursor));
172                buf.replace_range(start..end, "");
173                *cursor = c;
174            }
175            // Any other Alt+letter is a key, not text to type.
176            KeyCode::Char(c) if !ctrl && !alt => {
177                buf.insert(byte_at(buf, *cursor), c);
178                *cursor += 1;
179            }
180            _ => return Edit::Ignored,
181        }
182        Edit::Edited
183    }
184
185    /// Up/Down at a line prompt: recall the bucket's history (newest
186    /// first); stepping back past the newest restores the line that
187    /// was being typed.
188    pub fn history_step(&mut self, bucket: &[String], older: bool) {
189        if bucket.is_empty() {
190            return;
191        }
192        let next = match (self.hist_pos, older) {
193            (None, true) => Some(0),
194            (None, false) => return,
195            (Some(p), true) => Some((p + 1).min(bucket.len() - 1)),
196            (Some(0), false) => None,
197            (Some(p), false) => Some(p - 1),
198        };
199        match next {
200            Some(p) => {
201                if self.hist_pos.is_none() {
202                    self.stash = self.buf.clone();
203                }
204                self.buf = bucket[p].clone();
205            }
206            None => self.buf = self.stash.clone(),
207        }
208        self.hist_pos = next;
209        self.cursor = self.buf.chars().count();
210    }
211}
212
213/// The history buckets, mutt-style: one shared list per input class,
214/// named so a persisted file maps back onto them.
215pub const KNOWN_BUCKETS: &[&str] = &[
216    "mailbox", "pattern", "address", "command", "other", "file", "notmuch",
217];
218
219/// Prompt history per bucket, newest first.
220#[derive(Default, Debug)]
221pub struct History {
222    buckets: HashMap<&'static str, Vec<String>>,
223}
224
225impl History {
226    pub fn get(&self, bucket: &str) -> &[String] {
227        self.buckets.get(bucket).map(Vec::as_slice).unwrap_or(&[])
228    }
229
230    /// Remember an answer: newest first, no duplicates, 100 deep.
231    pub fn push(&mut self, bucket: &'static str, entry: &str) {
232        let entry = entry.trim();
233        if entry.is_empty() {
234            return;
235        }
236        let list = self.buckets.entry(bucket).or_default();
237        list.retain(|e| e != entry);
238        list.insert(0, entry.to_string());
239        list.truncate(100);
240    }
241
242    /// Load persisted history: `bucket\tentry` lines, newest first
243    /// within each bucket, as [`History::save`] wrote them. Unknown
244    /// buckets are dropped.
245    pub fn load(&mut self, path: &Path) {
246        let Ok(text) = std::fs::read_to_string(path) else {
247            return;
248        };
249        for line in text.lines() {
250            if let Some((bucket, entry)) = line.split_once('\t')
251                && !entry.is_empty()
252                && let Some(known) = KNOWN_BUCKETS.iter().find(|b| **b == bucket)
253            {
254                let list = self.buckets.entry(known).or_default();
255                // As push keeps it: no duplicates, 100 deep, even when
256                // the file was edited by hand.
257                if list.len() < 100 && !list.iter().any(|e| e == entry) {
258                    list.push(entry.to_string());
259                }
260            }
261        }
262    }
263
264    /// Write the history back, at most `cap` entries per bucket, the
265    /// buckets in a fixed order. The file is replaced whole and kept
266    /// private: it holds addresses and searches.
267    pub fn save(&self, path: &Path, cap: usize) {
268        let mut out = String::new();
269        for bucket in KNOWN_BUCKETS {
270            let Some(entries) = self.buckets.get(bucket) else {
271                continue;
272            };
273            for entry in entries.iter().take(cap) {
274                // A tab or newline in an entry would corrupt the file;
275                // both are vanishingly rare in a prompt, and dropped.
276                if !entry.contains(['\t', '\n']) {
277                    out += &format!("{bucket}\t{entry}\n");
278                }
279            }
280        }
281        let _ = rmut_core::scratch::save_private(path, out.as_bytes());
282    }
283}
284
285/// Tab-completion state: candidates for the token at `start`, `tail`
286/// the text after the cursor that the completion leaves alone, and
287/// `expect` the whole line after the last insertion (an edit in
288/// between restarts the match).
289#[derive(Clone, Debug)]
290pub struct Complete {
291    start: usize,
292    tail: String,
293    candidates: Vec<String>,
294    index: usize,
295    expect: String,
296}
297
298impl Complete {
299    /// Where the token to complete begins, and the token: what stands
300    /// before the cursor, after the last comma before it at an
301    /// address prompt (mutt's address list) or from the start of the
302    /// line elsewhere, leading space skipped.
303    pub fn token(buf: &str, cursor: usize, address_list: bool) -> (usize, String) {
304        let head = &buf[..byte_at(buf, cursor)];
305        let after_comma = if address_list {
306            head.rfind(',').map(|i| i + 1).unwrap_or(0)
307        } else {
308            0
309        };
310        let start =
311            after_comma + head[after_comma..].len() - head[after_comma..].trim_start().len();
312        (start, head[start..].trim().to_string())
313    }
314
315    /// The line with this candidate in, and the cursor just after it.
316    fn fill(&self, buf: &str) -> (String, usize) {
317        let head = format!("{}{}", &buf[..self.start], self.candidates[self.index]);
318        let cursor = head.chars().count();
319        (head + &self.tail, cursor)
320    }
321
322    /// Another Tab on an unchanged line: the next candidate (the line
323    /// and the cursor), and the "match n/m" note. None when the line
324    /// was edited since, or there is only one candidate.
325    pub fn cycle(&mut self, buf: &str) -> Option<((String, usize), String)> {
326        if self.expect != buf || self.candidates.len() < 2 {
327            return None;
328        }
329        self.index = (self.index + 1) % self.candidates.len();
330        let next = self.fill(buf);
331        self.expect = next.0.clone();
332        let note = format!("match {}/{}", self.index + 1, self.candidates.len());
333        Some((next, note))
334    }
335
336    /// A fresh match for the token [`Complete::token`] found before
337    /// `cursor`: the line with the first candidate in and the cursor
338    /// after it, the note when there are more, and the state for the
339    /// next Tab. What follows the cursor stays. `candidates` is not
340    /// empty.
341    pub fn first(
342        buf: &str,
343        cursor: usize,
344        start: usize,
345        candidates: Vec<String>,
346    ) -> (Complete, (String, usize), Option<String>) {
347        let mut state = Complete {
348            start,
349            tail: buf[byte_at(buf, cursor)..].to_string(),
350            candidates,
351            index: 0,
352            expect: String::new(),
353        };
354        let next = state.fill(buf);
355        state.expect = next.0.clone();
356        let note = (state.candidates.len() > 1)
357            .then(|| format!("match 1/{} (Tab cycles)", state.candidates.len()));
358        (state, next, note)
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    fn press(edit: &mut LineEdit, code: KeyCode) -> Edit {
367        edit.key(KeyEvent::new(code, KeyModifiers::NONE))
368    }
369
370    fn ctrl(edit: &mut LineEdit, c: char) -> Edit {
371        edit.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL))
372    }
373
374    #[test]
375    fn a_long_prompt_scrolls_to_keep_the_cursor_in_view() {
376        let mut e = LineEdit::new("short".into());
377        assert_eq!(prompt_line("To: ", &e, 40), "To: short\u{2581}");
378        e.set(&"x".repeat(50));
379        let line = prompt_line("To: ", &e, 20);
380        assert!(line.starts_with('<'), "{line}");
381        assert!(line.ends_with('\u{2581}'), "the cursor is in view: {line}");
382        assert_eq!(line.chars().count(), 20);
383        // The cursor at the start: nothing to cut, the tail runs off.
384        e.cursor = 0;
385        assert!(prompt_line("To: ", &e, 20).starts_with("To: \u{2581}xxx"));
386    }
387
388    #[test]
389    fn ctrl_g_cancels_and_alt_moves_by_words() {
390        let alt =
391            |e: &mut LineEdit, c: char| e.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::ALT));
392        let mut e = LineEdit::new("one two  three".into());
393        assert_eq!(ctrl(&mut e, 'g'), Edit::Cancel);
394        alt(&mut e, 'b');
395        assert_eq!(e.cursor, 9);
396        alt(&mut e, 'b');
397        assert_eq!(e.cursor, 4);
398        alt(&mut e, 'f');
399        assert_eq!(e.cursor, 7);
400        alt(&mut e, 'd');
401        assert_eq!(e.buf, "one two");
402        e.cursor = 0;
403        alt(&mut e, 'd');
404        assert_eq!(e.buf, " two");
405        // Another Alt+letter types nothing.
406        assert_eq!(alt(&mut e, 'x'), Edit::Ignored);
407        assert_eq!(e.buf, " two");
408    }
409
410    #[test]
411    fn editing_keys_move_insert_and_kill() {
412        let mut e = LineEdit::new("ab".into());
413        assert_eq!(press(&mut e, KeyCode::Char('c')), Edit::Edited);
414        assert_eq!(e.buf, "abc");
415        press(&mut e, KeyCode::Left);
416        press(&mut e, KeyCode::Char('x'));
417        assert_eq!(e.buf, "abxc");
418        press(&mut e, KeyCode::Backspace);
419        assert_eq!(e.buf, "abc");
420        ctrl(&mut e, 'a');
421        press(&mut e, KeyCode::Delete);
422        assert_eq!(e.buf, "bc");
423        ctrl(&mut e, 'e');
424        ctrl(&mut e, 'u');
425        assert_eq!(e.buf, "");
426        e.set("two words here");
427        ctrl(&mut e, 'w');
428        assert_eq!(e.buf, "two words ");
429        press(&mut e, KeyCode::Home);
430        ctrl(&mut e, 'k');
431        assert_eq!(e.buf, "");
432        assert_eq!(press(&mut e, KeyCode::Enter), Edit::Submit);
433        assert_eq!(press(&mut e, KeyCode::Esc), Edit::Cancel);
434        assert_eq!(press(&mut e, KeyCode::Tab), Edit::Complete);
435        assert_eq!(press(&mut e, KeyCode::Up), Edit::History(true));
436        assert_eq!(press(&mut e, KeyCode::PageUp), Edit::Ignored);
437    }
438
439    #[test]
440    fn cursor_is_in_chars_not_bytes() {
441        let mut e = LineEdit::new("héllo".into());
442        press(&mut e, KeyCode::Left);
443        press(&mut e, KeyCode::Left);
444        press(&mut e, KeyCode::Backspace);
445        assert_eq!(e.buf, "hélo");
446        assert_eq!(byte_at("héllo", 2), 3);
447    }
448
449    #[test]
450    fn history_steps_and_restores_the_stash() {
451        let bucket = vec!["newest".to_string(), "older".to_string()];
452        let mut e = LineEdit::new("typing".into());
453        e.history_step(&bucket, true);
454        assert_eq!(e.buf, "newest");
455        e.history_step(&bucket, true);
456        assert_eq!(e.buf, "older");
457        e.history_step(&bucket, true);
458        assert_eq!(e.buf, "older", "stays at the oldest");
459        e.history_step(&bucket, false);
460        e.history_step(&bucket, false);
461        assert_eq!(e.buf, "typing", "back past the newest restores the line");
462        e.history_step(&[], true);
463        assert_eq!(e.buf, "typing");
464    }
465
466    #[test]
467    fn history_dedupes_and_round_trips_through_a_file() {
468        let mut h = History::default();
469        h.push("pattern", "~f jane");
470        h.push("pattern", "~N");
471        h.push("pattern", "~f jane");
472        h.push("pattern", "  ");
473        assert_eq!(h.get("pattern"), ["~f jane", "~N"]);
474        let dir = tempfile::tempdir().unwrap();
475        let path = dir.path().join("sub").join("history");
476        h.push("command", "set beep");
477        h.save(&path, 1);
478        let mut back = History::default();
479        back.load(&path);
480        assert_eq!(back.get("pattern"), ["~f jane"], "capped at 1");
481        assert_eq!(back.get("command"), ["set beep"]);
482        assert!(back.get("bogus").is_empty());
483    }
484
485    #[test]
486    fn completion_tokens_and_cycling() {
487        assert_eq!(Complete::token("jane, bo", 8, true), (6, "bo".into()));
488        assert_eq!(Complete::token("  =arch", 7, false), (2, "=arch".into()));
489        let (mut c, line, note) = Complete::first(
490            "jane, bo",
491            8,
492            6,
493            vec!["bob@example.com".into(), "bonnie@example.com".into()],
494        );
495        assert_eq!(line, ("jane, bob@example.com".to_string(), 21));
496        assert_eq!(note.as_deref(), Some("match 1/2 (Tab cycles)"));
497        let (line, note) = c.cycle(&line.0).unwrap();
498        assert_eq!(line.0, "jane, bonnie@example.com");
499        assert_eq!(note, "match 2/2");
500        assert!(c.cycle("edited since").is_none());
501        let (mut one, line, note) = Complete::first("x", 1, 0, vec!["xy".into()]);
502        assert_eq!((line.0.as_str(), note), ("xy", None));
503        assert!(one.cycle("xy").is_none());
504    }
505
506    #[test]
507    fn completion_works_on_the_token_before_the_cursor() {
508        // The cursor back on the first address of a list: that one
509        // completes, and the rest of the line stays as it was.
510        let buf = "ja, carol@example.com";
511        let (start, word) = Complete::token(buf, 2, true);
512        assert_eq!((start, word.as_str()), (0, "ja"));
513        let (_, (line, cursor), _) =
514            Complete::first(buf, 2, start, vec!["jane@example.com".into()]);
515        assert_eq!(line, "jane@example.com, carol@example.com");
516        assert_eq!(cursor, 16);
517    }
518}