1use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
5
6use crate::error::{Error, Result};
7use crate::keymap::Command;
8use crate::query::FindSpec;
9use crate::state::{self, AppState, Level, Mode};
10
11#[derive(Default)]
12pub struct CmdLine {
13 pub buffer: String,
14 pub cursor: usize,
16 history: Vec<String>,
18 hist_pos: Option<usize>,
20 stash: String,
22}
23
24impl CmdLine {
25 pub fn start(&mut self, prefill: &str) {
26 self.buffer = prefill.to_string();
27 self.cursor = prefill.chars().count();
28 self.hist_pos = None;
29 }
30
31 fn remember(&mut self, line: &str) {
33 if self.history.last().map(String::as_str) != Some(line) {
34 self.history.push(line.to_string());
35 }
36 self.hist_pos = None;
37 }
38
39 fn history_up(&mut self) {
40 let next = match self.hist_pos {
41 None if self.history.is_empty() => return,
42 None => {
43 self.stash = std::mem::take(&mut self.buffer);
44 self.history.len() - 1
45 }
46 Some(0) => 0,
47 Some(i) => i - 1,
48 };
49 self.hist_pos = Some(next);
50 self.buffer = self.history[next].clone();
51 self.cursor = self.len();
52 }
53
54 fn history_down(&mut self) {
55 match self.hist_pos {
56 None => {}
57 Some(i) if i + 1 < self.history.len() => {
58 self.hist_pos = Some(i + 1);
59 self.buffer = self.history[i + 1].clone();
60 self.cursor = self.len();
61 }
62 Some(_) => {
63 self.hist_pos = None;
64 self.buffer = std::mem::take(&mut self.stash);
65 self.cursor = self.len();
66 }
67 }
68 }
69
70 fn byte_of(&self, char_idx: usize) -> usize {
71 self.buffer
72 .char_indices()
73 .nth(char_idx)
74 .map(|(b, _)| b)
75 .unwrap_or(self.buffer.len())
76 }
77
78 fn insert(&mut self, c: char) {
79 let b = self.byte_of(self.cursor);
80 self.buffer.insert(b, c);
81 self.cursor += 1;
82 }
83
84 fn backspace(&mut self) {
85 if self.cursor > 0 {
86 let b = self.byte_of(self.cursor - 1);
87 self.buffer.remove(b);
88 self.cursor -= 1;
89 }
90 }
91
92 fn kill_to_start(&mut self) {
93 let b = self.byte_of(self.cursor);
94 self.buffer.drain(..b);
95 self.cursor = 0;
96 }
97
98 fn len(&self) -> usize {
99 self.buffer.chars().count()
100 }
101}
102
103pub fn handle_key(state: &mut AppState, key: &KeyEvent) {
106 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
107 match key.code {
108 KeyCode::Esc => {
109 state.mode = Mode::Normal;
110 state.cmdline.start("");
111 }
112 KeyCode::Enter => {
113 let line = std::mem::take(&mut state.cmdline.buffer);
114 state.cmdline.cursor = 0;
115 state.mode = Mode::Normal;
116 if !line.trim().is_empty() {
117 state.cmdline.remember(&line);
118 match parse(&line) {
119 Ok(ex) => state::apply(state, Command::Ex(ex)),
120 Err(e) => state.set_message(Level::Error, e.to_string()),
121 }
122 }
123 }
124 KeyCode::Up => state.cmdline.history_up(),
125 KeyCode::Down => state.cmdline.history_down(),
126 KeyCode::Char('u') if ctrl => state.cmdline.kill_to_start(),
127 KeyCode::Char(c) if !ctrl => state.cmdline.insert(c),
128 KeyCode::Backspace => state.cmdline.backspace(),
129 KeyCode::Left => state.cmdline.cursor = state.cmdline.cursor.saturating_sub(1),
130 KeyCode::Right => {
131 state.cmdline.cursor = (state.cmdline.cursor + 1).min(state.cmdline.len())
132 }
133 KeyCode::Home => state.cmdline.cursor = 0,
134 KeyCode::End => state.cmdline.cursor = state.cmdline.len(),
135 _ => {}
136 }
137}
138
139#[derive(Clone, Debug, PartialEq)]
141pub enum ExCommand {
142 Quit,
143 Help,
144 Reload,
145 Profile(String),
146 Find(FindSpec),
147 Search(String),
148 Goto(cuj::RefArg),
149 Backlinks(Option<cuj::RefArg>),
150}
151
152pub fn parse(line: &str) -> Result<ExCommand> {
153 let line = line.trim();
154 let (cmd, rest) = match line.split_once(char::is_whitespace) {
155 Some((c, r)) => (c, r.trim()),
156 None => (line, ""),
157 };
158 let no_args = |ex: ExCommand| {
159 if rest.is_empty() {
160 Ok(ex)
161 } else {
162 Err(Error::Usage(format!("{cmd} takes no arguments")))
163 }
164 };
165 match cmd {
166 "q" | "quit" => no_args(ExCommand::Quit),
167 "help" => no_args(ExCommand::Help),
168 "reload" => no_args(ExCommand::Reload),
169 "profile" => {
170 if rest.is_empty() || rest.contains(char::is_whitespace) {
171 Err(Error::Usage("profile <name|all>".into()))
172 } else {
173 Ok(ExCommand::Profile(rest.to_string()))
174 }
175 }
176 "search" => {
177 if rest.is_empty() {
178 Err(Error::Usage("search <words>".into()))
179 } else {
180 Ok(ExCommand::Search(rest.to_string()))
181 }
182 }
183 "find" => Ok(ExCommand::Find(parse_find(rest)?)),
184 "goto" => match cuj::app::parse_ref(rest) {
185 Some(r) => Ok(ExCommand::Goto(r)),
186 None => Err(Error::Usage(format!("goto <ref> (bad reference {rest:?})"))),
187 },
188 "backlinks" => {
189 if rest.is_empty() {
190 Ok(ExCommand::Backlinks(None))
191 } else {
192 match cuj::app::parse_ref(rest) {
193 Some(r) => Ok(ExCommand::Backlinks(Some(r))),
194 None => Err(Error::Usage(format!(
195 "backlinks [<ref>] (bad reference {rest:?})"
196 ))),
197 }
198 }
199 }
200 "" => Err(Error::Usage("empty command".into())),
201 other => Err(Error::Usage(format!("unknown command {other:?}"))),
202 }
203}
204
205fn parse_find(rest: &str) -> Result<FindSpec> {
209 let mut spec = FindSpec {
210 raw: rest.to_string(),
211 ..FindSpec::default()
212 };
213 let mut toks = rest.split_whitespace();
214 while let Some(t) = toks.next() {
215 if let Some(v) = t.strip_prefix("..") {
216 if v.is_empty() {
217 return Err(Error::Usage("empty tag".into()));
218 }
219 spec.tags.push(v.to_string());
220 } else if let Some(v) = t.strip_prefix("::") {
221 if v.is_empty() {
222 return Err(Error::Usage("empty category".into()));
223 }
224 spec.categories.push(v.to_string());
225 } else if t == "--todo" {
226 spec.todo = true;
227 } else if t == "--since" {
228 spec.since = Some(
229 toks.next()
230 .ok_or_else(|| Error::Usage("--since needs a date".into()))?
231 .to_string(),
232 );
233 } else if t == "--until" {
234 spec.until = Some(
235 toks.next()
236 .ok_or_else(|| Error::Usage("--until needs a date".into()))?
237 .to_string(),
238 );
239 } else if t == "--filter" {
240 let expr: Vec<&str> = toks.collect();
241 if expr.is_empty() {
242 return Err(Error::Usage("--filter needs an expression".into()));
243 }
244 spec.filter = Some(expr.join(" "));
245 break;
246 } else {
247 return Err(Error::Usage(format!("unexpected find token {t:?}")));
248 }
249 }
250 if spec.is_empty() {
251 return Err(Error::Usage(
252 "find needs at least one filter (tag, category, date, --todo, --filter)".into(),
253 ));
254 }
255 Ok(spec)
256}