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