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())
}
#[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 buf = &mut self.buf;
let cursor = &mut self.cursor;
match key.code {
KeyCode::Esc => return Edit::Cancel,
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 chars: Vec<char> = buf.chars().collect();
let mut c = *cursor;
while c > 0 && chars[c - 1].is_whitespace() {
c -= 1;
}
while c > 0 && !chars[c - 1].is_whitespace() {
c -= 1;
}
let (start, end) = (byte_at(buf, c), byte_at(buf, *cursor));
buf.replace_range(start..end, "");
*cursor = c;
}
KeyCode::Char(c) if !ctrl => {
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)
{
self.buckets
.entry(known)
.or_default()
.push(entry.to_string());
}
}
}
pub fn save(&self, path: &Path, cap: usize) {
let mut out = String::new();
for (bucket, entries) in &self.buckets {
for entry in entries.iter().take(cap) {
if !entry.contains(['\t', '\n']) {
out += &format!("{bucket}\t{entry}\n");
}
}
}
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let _ = std::fs::write(path, out);
}
}
#[derive(Clone, Debug)]
pub struct Complete {
start: usize,
candidates: Vec<String>,
index: usize,
expect: String,
}
impl Complete {
pub fn token(buf: &str, address_list: bool) -> (usize, String) {
let after_comma = if address_list {
buf.rfind(',').map(|i| i + 1).unwrap_or(0)
} else {
0
};
let start = after_comma + buf[after_comma..].len() - buf[after_comma..].trim_start().len();
(start, buf[start..].trim().to_string())
}
pub fn cycle(&mut self, buf: &str) -> Option<(String, String)> {
if self.expect != buf || self.candidates.len() < 2 {
return None;
}
self.index = (self.index + 1) % self.candidates.len();
let next = format!("{}{}", &buf[..self.start], self.candidates[self.index]);
self.expect = next.clone();
let note = format!("match {}/{}", self.index + 1, self.candidates.len());
Some((next, note))
}
pub fn first(
buf: &str,
start: usize,
candidates: Vec<String>,
) -> (Complete, String, Option<String>) {
let next = format!("{}{}", &buf[..start], candidates[0]);
let note =
(candidates.len() > 1).then(|| format!("match 1/{} (Tab cycles)", candidates.len()));
(
Complete {
start,
candidates,
index: 0,
expect: next.clone(),
},
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 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", true), (6, "bo".into()));
assert_eq!(Complete::token(" =arch", false), (2, "=arch".into()));
let (mut c, line, note) = Complete::first(
"jane, bo",
6,
vec!["bob@example.com".into(), "bonnie@example.com".into()],
);
assert_eq!(line, "jane, bob@example.com");
assert_eq!(note.as_deref(), Some("match 1/2 (Tab cycles)"));
let (line, note) = c.cycle(&line).unwrap();
assert_eq!(line, "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", 0, vec!["xy".into()]);
assert_eq!((line.as_str(), note), ("xy", None));
assert!(one.cycle("xy").is_none());
}
}