cuj-tui 0.1.0

cui — a read-only TUI browser for cuj vaults
Documentation
//! The ':' command line: a minimal insert-at-cursor editor plus
//! the ex-command parser.

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,
    /// Cursor as a char offset into `buffer`.
    pub cursor: usize,
    /// Submitted lines, oldest first.
    history: Vec<String>,
    /// Some(index into history) while browsing.
    hist_pos: Option<usize>,
    /// The in-progress line stashed while browsing history.
    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;
    }

    /// Record a submitted line (consecutive duplicates collapse).
    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()
    }
}

/// Handle one key press in Command mode: edit the buffer, cancel,
/// or submit (parse and apply).
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(),
        _ => {}
    }
}

/// A parsed ':' command.
#[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:?}"))),
    }
}

/// `:find` argument grammar, mirroring `cuj find`: `..tag`,
/// `::category`, `--todo`, `--since <date>`, `--until <date>`,
/// `--filter <expr…>` (consumes the rest of the line).
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)
}