use std::collections::HashMap;
use std::path::Path;
use crate::key::{KeyCode, KeyEvent, KeyModifiers};
pub fn byte_at(buf: &str, cursor: usize) -> usize {
buf.char_indices()
.nth(cursor)
.map(|(i, _)| i)
.unwrap_or(buf.len())
}
fn word_start(buf: &str, cursor: usize) -> usize {
let chars: Vec<char> = buf.chars().collect();
let mut c = cursor.min(chars.len());
while c > 0 && chars[c - 1].is_whitespace() {
c -= 1;
}
while c > 0 && !chars[c - 1].is_whitespace() {
c -= 1;
}
c
}
fn word_end(buf: &str, cursor: usize) -> usize {
let chars: Vec<char> = buf.chars().collect();
let mut c = cursor.min(chars.len());
while c < chars.len() && chars[c].is_whitespace() {
c += 1;
}
while c < chars.len() && !chars[c].is_whitespace() {
c += 1;
}
c
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Edit {
Edited,
Cancel,
Submit,
Complete,
History(bool),
Ignored,
}
#[derive(Clone, Debug, Default)]
pub struct LineEdit {
pub buf: String,
pub cursor: usize,
hist_pos: Option<usize>,
stash: String,
}
impl LineEdit {
pub fn new(prefill: String) -> LineEdit {
let cursor = prefill.chars().count();
LineEdit {
buf: prefill,
cursor,
hist_pos: None,
stash: String::new(),
}
}
pub fn set(&mut self, text: &str) {
self.buf = text.to_string();
self.cursor = self.buf.chars().count();
}
pub fn key(&mut self, key: KeyEvent) -> Edit {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
let buf = &mut self.buf;
let cursor = &mut self.cursor;
match key.code {
KeyCode::Esc => return Edit::Cancel,
KeyCode::Char('g') if ctrl => return Edit::Cancel,
KeyCode::Char('b') if alt => *cursor = word_start(buf, *cursor),
KeyCode::Char('f') if alt => *cursor = word_end(buf, *cursor),
KeyCode::Char('d') if alt => {
let end = word_end(buf, *cursor);
let (from, to) = (byte_at(buf, *cursor), byte_at(buf, end));
buf.replace_range(from..to, "");
}
KeyCode::Enter => return Edit::Submit,
KeyCode::Tab => return Edit::Complete,
KeyCode::Up => return Edit::History(true),
KeyCode::Down => return Edit::History(false),
KeyCode::Left => *cursor = cursor.saturating_sub(1),
KeyCode::Right => *cursor = (*cursor + 1).min(buf.chars().count()),
KeyCode::Home => *cursor = 0,
KeyCode::End => *cursor = buf.chars().count(),
KeyCode::Char('a') if ctrl => *cursor = 0,
KeyCode::Char('e') if ctrl => *cursor = buf.chars().count(),
KeyCode::Backspace => {
if *cursor > 0 {
buf.remove(byte_at(buf, *cursor - 1));
*cursor -= 1;
}
}
KeyCode::Delete => {
if *cursor < buf.chars().count() {
buf.remove(byte_at(buf, *cursor));
}
}
KeyCode::Char('d') if ctrl => {
if *cursor < buf.chars().count() {
buf.remove(byte_at(buf, *cursor));
}
}
KeyCode::Char('u') if ctrl => {
let i = byte_at(buf, *cursor);
buf.replace_range(..i, "");
*cursor = 0;
}
KeyCode::Char('k') if ctrl => {
let i = byte_at(buf, *cursor);
buf.truncate(i);
}
KeyCode::Char('w') if ctrl => {
let c = word_start(buf, *cursor);
let (start, end) = (byte_at(buf, c), byte_at(buf, *cursor));
buf.replace_range(start..end, "");
*cursor = c;
}
KeyCode::Char(c) if !ctrl && !alt => {
buf.insert(byte_at(buf, *cursor), c);
*cursor += 1;
}
_ => return Edit::Ignored,
}
Edit::Edited
}
pub fn history_step(&mut self, bucket: &[String], older: bool) {
if bucket.is_empty() {
return;
}
let next = match (self.hist_pos, older) {
(None, true) => Some(0),
(None, false) => return,
(Some(p), true) => Some((p + 1).min(bucket.len() - 1)),
(Some(0), false) => None,
(Some(p), false) => Some(p - 1),
};
match next {
Some(p) => {
if self.hist_pos.is_none() {
self.stash = self.buf.clone();
}
self.buf = bucket[p].clone();
}
None => self.buf = self.stash.clone(),
}
self.hist_pos = next;
self.cursor = self.buf.chars().count();
}
}
pub const KNOWN_BUCKETS: &[&str] = &[
"mailbox", "pattern", "address", "command", "other", "file", "notmuch",
];
#[derive(Default, Debug)]
pub struct History {
buckets: HashMap<&'static str, Vec<String>>,
}
impl History {
pub fn get(&self, bucket: &str) -> &[String] {
self.buckets.get(bucket).map(Vec::as_slice).unwrap_or(&[])
}
pub fn push(&mut self, bucket: &'static str, entry: &str) {
let entry = entry.trim();
if entry.is_empty() {
return;
}
let list = self.buckets.entry(bucket).or_default();
list.retain(|e| e != entry);
list.insert(0, entry.to_string());
list.truncate(100);
}
pub fn load(&mut self, path: &Path) {
let Ok(text) = std::fs::read_to_string(path) else {
return;
};
for line in text.lines() {
if let Some((bucket, entry)) = line.split_once('\t')
&& !entry.is_empty()
&& let Some(known) = KNOWN_BUCKETS.iter().find(|b| **b == bucket)
{
let list = self.buckets.entry(known).or_default();
if list.len() < 100 && !list.iter().any(|e| e == entry) {
list.push(entry.to_string());
}
}
}
}
pub fn save(&self, path: &Path, cap: usize) {
let mut out = String::new();
for bucket in KNOWN_BUCKETS {
let Some(entries) = self.buckets.get(bucket) else {
continue;
};
for entry in entries.iter().take(cap) {
if !entry.contains(['\t', '\n']) {
out += &format!("{bucket}\t{entry}\n");
}
}
}
let _ = rmut_core::scratch::save_private(path, out.as_bytes());
}
}
#[derive(Clone, Debug)]
pub struct Complete {
start: usize,
tail: String,
candidates: Vec<String>,
index: usize,
expect: String,
}
impl Complete {
pub fn token(buf: &str, cursor: usize, address_list: bool) -> (usize, String) {
let head = &buf[..byte_at(buf, cursor)];
let after_comma = if address_list {
head.rfind(',').map(|i| i + 1).unwrap_or(0)
} else {
0
};
let start =
after_comma + head[after_comma..].len() - head[after_comma..].trim_start().len();
(start, head[start..].trim().to_string())
}
fn fill(&self, buf: &str) -> (String, usize) {
let head = format!("{}{}", &buf[..self.start], self.candidates[self.index]);
let cursor = head.chars().count();
(head + &self.tail, cursor)
}
pub fn cycle(&mut self, buf: &str) -> Option<((String, usize), String)> {
if self.expect != buf || self.candidates.len() < 2 {
return None;
}
self.index = (self.index + 1) % self.candidates.len();
let next = self.fill(buf);
self.expect = next.0.clone();
let note = format!("match {}/{}", self.index + 1, self.candidates.len());
Some((next, note))
}
pub fn first(
buf: &str,
cursor: usize,
start: usize,
candidates: Vec<String>,
) -> (Complete, (String, usize), Option<String>) {
let mut state = Complete {
start,
tail: buf[byte_at(buf, cursor)..].to_string(),
candidates,
index: 0,
expect: String::new(),
};
let next = state.fill(buf);
state.expect = next.0.clone();
let note = (state.candidates.len() > 1)
.then(|| format!("match 1/{} (Tab cycles)", state.candidates.len()));
(state, next, note)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn press(edit: &mut LineEdit, code: KeyCode) -> Edit {
edit.key(KeyEvent::new(code, KeyModifiers::NONE))
}
fn ctrl(edit: &mut LineEdit, c: char) -> Edit {
edit.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL))
}
#[test]
fn ctrl_g_cancels_and_alt_moves_by_words() {
let alt =
|e: &mut LineEdit, c: char| e.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::ALT));
let mut e = LineEdit::new("one two three".into());
assert_eq!(ctrl(&mut e, 'g'), Edit::Cancel);
alt(&mut e, 'b');
assert_eq!(e.cursor, 9);
alt(&mut e, 'b');
assert_eq!(e.cursor, 4);
alt(&mut e, 'f');
assert_eq!(e.cursor, 7);
alt(&mut e, 'd');
assert_eq!(e.buf, "one two");
e.cursor = 0;
alt(&mut e, 'd');
assert_eq!(e.buf, " two");
assert_eq!(alt(&mut e, 'x'), Edit::Ignored);
assert_eq!(e.buf, " two");
}
#[test]
fn editing_keys_move_insert_and_kill() {
let mut e = LineEdit::new("ab".into());
assert_eq!(press(&mut e, KeyCode::Char('c')), Edit::Edited);
assert_eq!(e.buf, "abc");
press(&mut e, KeyCode::Left);
press(&mut e, KeyCode::Char('x'));
assert_eq!(e.buf, "abxc");
press(&mut e, KeyCode::Backspace);
assert_eq!(e.buf, "abc");
ctrl(&mut e, 'a');
press(&mut e, KeyCode::Delete);
assert_eq!(e.buf, "bc");
ctrl(&mut e, 'e');
ctrl(&mut e, 'u');
assert_eq!(e.buf, "");
e.set("two words here");
ctrl(&mut e, 'w');
assert_eq!(e.buf, "two words ");
press(&mut e, KeyCode::Home);
ctrl(&mut e, 'k');
assert_eq!(e.buf, "");
assert_eq!(press(&mut e, KeyCode::Enter), Edit::Submit);
assert_eq!(press(&mut e, KeyCode::Esc), Edit::Cancel);
assert_eq!(press(&mut e, KeyCode::Tab), Edit::Complete);
assert_eq!(press(&mut e, KeyCode::Up), Edit::History(true));
assert_eq!(press(&mut e, KeyCode::PageUp), Edit::Ignored);
}
#[test]
fn cursor_is_in_chars_not_bytes() {
let mut e = LineEdit::new("héllo".into());
press(&mut e, KeyCode::Left);
press(&mut e, KeyCode::Left);
press(&mut e, KeyCode::Backspace);
assert_eq!(e.buf, "hélo");
assert_eq!(byte_at("héllo", 2), 3);
}
#[test]
fn history_steps_and_restores_the_stash() {
let bucket = vec!["newest".to_string(), "older".to_string()];
let mut e = LineEdit::new("typing".into());
e.history_step(&bucket, true);
assert_eq!(e.buf, "newest");
e.history_step(&bucket, true);
assert_eq!(e.buf, "older");
e.history_step(&bucket, true);
assert_eq!(e.buf, "older", "stays at the oldest");
e.history_step(&bucket, false);
e.history_step(&bucket, false);
assert_eq!(e.buf, "typing", "back past the newest restores the line");
e.history_step(&[], true);
assert_eq!(e.buf, "typing");
}
#[test]
fn history_dedupes_and_round_trips_through_a_file() {
let mut h = History::default();
h.push("pattern", "~f jane");
h.push("pattern", "~N");
h.push("pattern", "~f jane");
h.push("pattern", " ");
assert_eq!(h.get("pattern"), ["~f jane", "~N"]);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sub").join("history");
h.push("command", "set beep");
h.save(&path, 1);
let mut back = History::default();
back.load(&path);
assert_eq!(back.get("pattern"), ["~f jane"], "capped at 1");
assert_eq!(back.get("command"), ["set beep"]);
assert!(back.get("bogus").is_empty());
}
#[test]
fn completion_tokens_and_cycling() {
assert_eq!(Complete::token("jane, bo", 8, true), (6, "bo".into()));
assert_eq!(Complete::token(" =arch", 7, false), (2, "=arch".into()));
let (mut c, line, note) = Complete::first(
"jane, bo",
8,
6,
vec!["bob@example.com".into(), "bonnie@example.com".into()],
);
assert_eq!(line, ("jane, bob@example.com".to_string(), 21));
assert_eq!(note.as_deref(), Some("match 1/2 (Tab cycles)"));
let (line, note) = c.cycle(&line.0).unwrap();
assert_eq!(line.0, "jane, bonnie@example.com");
assert_eq!(note, "match 2/2");
assert!(c.cycle("edited since").is_none());
let (mut one, line, note) = Complete::first("x", 1, 0, vec!["xy".into()]);
assert_eq!((line.0.as_str(), note), ("xy", None));
assert!(one.cycle("xy").is_none());
}
#[test]
fn completion_works_on_the_token_before_the_cursor() {
let buf = "ja, carol@example.com";
let (start, word) = Complete::token(buf, 2, true);
assert_eq!((start, word.as_str()), (0, "ja"));
let (_, (line, cursor), _) =
Complete::first(buf, 2, start, vec!["jane@example.com".into()]);
assert_eq!(line, "jane@example.com, carol@example.com");
assert_eq!(cursor, 16);
}
}