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