use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::error::{Error, Result};
use crate::keymap::Command;
use crate::query::FindSpec;
use crate::state::{self, AppState, Level, Mode};
#[derive(Default)]
pub struct CmdLine {
pub buffer: String,
pub cursor: usize,
history: Vec<String>,
hist_pos: Option<usize>,
stash: String,
}
impl CmdLine {
pub fn start(&mut self, prefill: &str) {
self.buffer = prefill.to_string();
self.cursor = prefill.chars().count();
self.hist_pos = None;
}
fn remember(&mut self, line: &str) {
if self.history.last().map(String::as_str) != Some(line) {
self.history.push(line.to_string());
}
self.hist_pos = None;
}
fn history_up(&mut self) {
let next = match self.hist_pos {
None if self.history.is_empty() => return,
None => {
self.stash = std::mem::take(&mut self.buffer);
self.history.len() - 1
}
Some(0) => 0,
Some(i) => i - 1,
};
self.hist_pos = Some(next);
self.buffer = self.history[next].clone();
self.cursor = self.len();
}
fn history_down(&mut self) {
match self.hist_pos {
None => {}
Some(i) if i + 1 < self.history.len() => {
self.hist_pos = Some(i + 1);
self.buffer = self.history[i + 1].clone();
self.cursor = self.len();
}
Some(_) => {
self.hist_pos = None;
self.buffer = std::mem::take(&mut self.stash);
self.cursor = self.len();
}
}
}
fn byte_of(&self, char_idx: usize) -> usize {
self.buffer
.char_indices()
.nth(char_idx)
.map(|(b, _)| b)
.unwrap_or(self.buffer.len())
}
fn insert(&mut self, c: char) {
let b = self.byte_of(self.cursor);
self.buffer.insert(b, c);
self.cursor += 1;
}
fn backspace(&mut self) {
if self.cursor > 0 {
let b = self.byte_of(self.cursor - 1);
self.buffer.remove(b);
self.cursor -= 1;
}
}
fn kill_to_start(&mut self) {
let b = self.byte_of(self.cursor);
self.buffer.drain(..b);
self.cursor = 0;
}
fn len(&self) -> usize {
self.buffer.chars().count()
}
}
pub fn handle_key(state: &mut AppState, key: &KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Esc => {
state.mode = Mode::Normal;
state.cmdline.start("");
}
KeyCode::Enter => {
let line = std::mem::take(&mut state.cmdline.buffer);
state.cmdline.cursor = 0;
state.mode = Mode::Normal;
if !line.trim().is_empty() {
state.cmdline.remember(&line);
match parse(&line) {
Ok(ex) => state::apply(state, Command::Ex(ex)),
Err(e) => state.set_message(Level::Error, e.to_string()),
}
}
}
KeyCode::Up => state.cmdline.history_up(),
KeyCode::Down => state.cmdline.history_down(),
KeyCode::Char('u') if ctrl => state.cmdline.kill_to_start(),
KeyCode::Char(c) if !ctrl => state.cmdline.insert(c),
KeyCode::Backspace => state.cmdline.backspace(),
KeyCode::Left => state.cmdline.cursor = state.cmdline.cursor.saturating_sub(1),
KeyCode::Right => {
state.cmdline.cursor = (state.cmdline.cursor + 1).min(state.cmdline.len())
}
KeyCode::Home => state.cmdline.cursor = 0,
KeyCode::End => state.cmdline.cursor = state.cmdline.len(),
_ => {}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum ExCommand {
Quit,
Help,
Reload,
Profile(String),
Find(FindSpec),
Search(String),
Goto(cuj::RefArg),
Backlinks(Option<cuj::RefArg>),
}
pub fn parse(line: &str) -> Result<ExCommand> {
let line = line.trim();
let (cmd, rest) = match line.split_once(char::is_whitespace) {
Some((c, r)) => (c, r.trim()),
None => (line, ""),
};
let no_args = |ex: ExCommand| {
if rest.is_empty() {
Ok(ex)
} else {
Err(Error::Usage(format!("{cmd} takes no arguments")))
}
};
match cmd {
"q" | "quit" => no_args(ExCommand::Quit),
"help" => no_args(ExCommand::Help),
"reload" => no_args(ExCommand::Reload),
"profile" => {
if rest.is_empty() || rest.contains(char::is_whitespace) {
Err(Error::Usage("profile <name|all>".into()))
} else {
Ok(ExCommand::Profile(rest.to_string()))
}
}
"search" => {
if rest.is_empty() {
Err(Error::Usage("search <words>".into()))
} else {
Ok(ExCommand::Search(rest.to_string()))
}
}
"find" => Ok(ExCommand::Find(parse_find(rest)?)),
"goto" => match cuj::app::parse_ref(rest) {
Some(r) => Ok(ExCommand::Goto(r)),
None => Err(Error::Usage(format!("goto <ref> (bad reference {rest:?})"))),
},
"backlinks" => {
if rest.is_empty() {
Ok(ExCommand::Backlinks(None))
} else {
match cuj::app::parse_ref(rest) {
Some(r) => Ok(ExCommand::Backlinks(Some(r))),
None => Err(Error::Usage(format!(
"backlinks [<ref>] (bad reference {rest:?})"
))),
}
}
}
"" => Err(Error::Usage("empty command".into())),
other => Err(Error::Usage(format!("unknown command {other:?}"))),
}
}
fn parse_find(rest: &str) -> Result<FindSpec> {
let mut spec = FindSpec {
raw: rest.to_string(),
..FindSpec::default()
};
let mut toks = rest.split_whitespace();
while let Some(t) = toks.next() {
if let Some(v) = t.strip_prefix("..") {
if v.is_empty() {
return Err(Error::Usage("empty tag".into()));
}
spec.tags.push(v.to_string());
} else if let Some(v) = t.strip_prefix("::") {
if v.is_empty() {
return Err(Error::Usage("empty category".into()));
}
spec.categories.push(v.to_string());
} else if t == "--todo" {
spec.todo = true;
} else if t == "--since" {
spec.since = Some(
toks.next()
.ok_or_else(|| Error::Usage("--since needs a date".into()))?
.to_string(),
);
} else if t == "--until" {
spec.until = Some(
toks.next()
.ok_or_else(|| Error::Usage("--until needs a date".into()))?
.to_string(),
);
} else if t == "--filter" {
let expr: Vec<&str> = toks.collect();
if expr.is_empty() {
return Err(Error::Usage("--filter needs an expression".into()));
}
spec.filter = Some(expr.join(" "));
break;
} else {
return Err(Error::Usage(format!("unexpected find token {t:?}")));
}
}
if spec.is_empty() {
return Err(Error::Usage(
"find needs at least one filter (tag, category, date, --todo, --filter)".into(),
));
}
Ok(spec)
}