1use std::io::Stdout;
2use anyhow::Result;
3use crossterm::{
4 event::{self, Event, KeyCode, KeyModifiers, KeyEvent},
5 execute,
6 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
7};
8use ratatui::{
9 backend::CrosstermBackend,
10 layout::{Constraint, Direction, Layout, Rect},
11 style::{Color, Style},
12 widgets::{Block, Borders, Paragraph, Clear},
13 Terminal,
14};
15
16pub type CommandResult = Option<(String, Vec<String>, Option<i32>)>;
18
19#[derive(Default)]
20pub struct AddCommandApp {
21 pub command: String,
23 pub tags: Vec<String>,
25 pub current_tag: String,
27 pub command_cursor: usize,
29 pub command_line: usize,
31 pub input_mode: InputMode,
33 pub suggested_tags: Vec<String>,
35 pub previous_mode: InputMode,
37}
38
39#[derive(Debug, Clone, PartialEq, Default)]
40pub enum InputMode {
41 #[default]
42 Command,
43 Tag,
44 Confirm,
45 Help,
46}
47
48impl AddCommandApp {
49 pub fn new() -> Self {
50 Self::default()
51 }
52
53 pub fn run(&mut self) -> Result<CommandResult> {
54 let mut terminal = setup_terminal()?;
55 let result = self.run_app(&mut terminal);
56 restore_terminal(&mut terminal)?;
57 result
58 }
59
60 fn run_app(&mut self, terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<CommandResult> {
61 loop {
62 terminal.draw(|f| self.ui(f))?;
63
64 if let Event::Key(key) = event::read()? {
65 match self.input_mode {
66 InputMode::Help => match key.code {
67 KeyCode::Char('?') | KeyCode::Esc => {
68 self.input_mode = self.previous_mode.clone();
69 }
70 _ => {}
71 },
72 _ => match key.code {
73 KeyCode::Char('?') => {
74 eprintln!("Debug: ? key pressed, switching to help mode");
75 self.previous_mode = self.input_mode.clone();
76 self.input_mode = InputMode::Help;
77 eprintln!("Debug: Input mode is now Help");
78 }
79 _ => match self.input_mode {
80 InputMode::Command => match key.code {
81 KeyCode::Enter => {
82 if key.modifiers.contains(KeyModifiers::SHIFT) {
83 self.command.insert(self.command_cursor, '\n');
85 self.command_cursor += 1;
86 self.command_line += 1;
87 } else {
88 if !self.command.is_empty() {
89 self.suggest_tags();
90 self.input_mode = InputMode::Tag;
91 }
92 }
93 }
94 KeyCode::Char(c) => {
95 self.command.insert(self.command_cursor, c);
96 self.command_cursor += 1;
97 }
98 KeyCode::Backspace => {
99 if self.command_cursor > 0 {
100 self.command.remove(self.command_cursor - 1);
101 self.command_cursor -= 1;
102 if self.command_cursor > 0 && self.command.chars().nth(self.command_cursor - 1) == Some('\n') {
103 self.command_line -= 1;
104 }
105 }
106 }
107 KeyCode::Left => {
108 if self.command_cursor > 0 {
109 self.command_cursor -= 1;
110 if self.command_cursor > 0 && self.command.chars().nth(self.command_cursor - 1) == Some('\n') {
111 self.command_line -= 1;
112 }
113 }
114 }
115 KeyCode::Right => {
116 if self.command_cursor < self.command.len() {
117 if self.command.chars().nth(self.command_cursor) == Some('\n') {
118 self.command_line += 1;
119 }
120 self.command_cursor += 1;
121 }
122 }
123 KeyCode::Up => {
124 let current_line_start = self.command[..self.command_cursor]
126 .rfind('\n')
127 .map(|pos| pos + 1)
128 .unwrap_or(0);
129 if let Some(prev_line_start) = self.command[..current_line_start.saturating_sub(1)]
130 .rfind('\n')
131 .map(|pos| pos + 1) {
132 let column = self.command_cursor - current_line_start;
133 self.command_cursor = prev_line_start + column.min(
134 self.command[prev_line_start..current_line_start.saturating_sub(1)]
135 .chars()
136 .count(),
137 );
138 self.command_line -= 1;
139 }
140 }
141 KeyCode::Down => {
142 let current_line_start = self.command[..self.command_cursor]
144 .rfind('\n')
145 .map(|pos| pos + 1)
146 .unwrap_or(0);
147 if let Some(next_line_start) = self.command[self.command_cursor..]
148 .find('\n')
149 .map(|pos| self.command_cursor + pos + 1) {
150 let column = self.command_cursor - current_line_start;
151 let next_line_end = self.command[next_line_start..]
152 .find('\n')
153 .map(|pos| next_line_start + pos)
154 .unwrap_or_else(|| self.command.len());
155 self.command_cursor = next_line_start + column.min(next_line_end - next_line_start);
156 self.command_line += 1;
157 }
158 }
159 KeyCode::Esc => {
160 return Ok(None);
161 }
162 _ => {}
163 },
164 InputMode::Tag => {
165 match key.code {
166 KeyCode::Enter => {
167 if !self.current_tag.is_empty() {
168 self.tags.push(self.current_tag.clone());
169 self.current_tag.clear();
170 } else {
171 self.input_mode = InputMode::Confirm;
172 }
173 }
174 KeyCode::Char(c) => {
175 self.current_tag.push(c);
176 }
177 KeyCode::Backspace => {
178 self.current_tag.pop();
179 }
180 KeyCode::Tab => {
181 if !self.suggested_tags.is_empty() {
182 self.tags.push(self.suggested_tags[0].clone());
183 self.suggested_tags.remove(0);
184 }
185 }
186 KeyCode::Esc => {
187 self.input_mode = InputMode::Command;
188 }
189 _ => {}
190 }
191 }
192 InputMode::Confirm => {
193 match key.code {
194 KeyCode::Char('y') => {
195 return Ok(Some((
196 self.command.clone(),
197 self.tags.clone(),
198 None,
199 )));
200 }
201 KeyCode::Char('n') | KeyCode::Esc => {
202 return Ok(None);
203 }
204 _ => {}
205 }
206 }
207 _ => {}
208 }
209 }
210 }
211 }
212 }
213 }
214
215 pub fn set_command(&mut self, command: String) {
216 self.command = command;
217 self.command_cursor = self.command.len();
218 }
219
220 pub fn set_tags(&mut self, tags: Vec<String>) {
221 self.tags = tags;
222 }
223
224 fn ui(&self, f: &mut ratatui::Frame) {
225 match self.input_mode {
226 InputMode::Help => {
227 let help_text = vec![
228 "Command Vault Help",
229 "",
230 "Global Commands:",
231 " ? - Toggle this help screen",
232 " Esc - Go back / Cancel",
233 "",
234 "Command Input Mode:",
235 " Enter - Continue to tag input",
236 " Shift+Enter - Add new line",
237 " ←/→ - Move cursor",
238 " ↑/↓ - Navigate between lines",
239 "",
240 "Tag Input Mode:",
241 " Enter - Add tag",
242 " Tab - Show tag suggestions",
243 "",
244 "Confirmation Mode:",
245 " y/Y - Save command",
246 " n/N - Cancel",
247 ];
248
249 let help_paragraph = Paragraph::new(help_text.join("\n"))
250 .style(Style::default().fg(Color::White))
251 .block(Block::default().borders(Borders::ALL).title("Help (press ? or Esc to close)"));
252
253 let area = centered_rect(60, 80, f.size());
255 f.render_widget(Clear, area); f.render_widget(help_paragraph, area);
257 }
258 _ => {
259 let chunks = Layout::default()
260 .direction(Direction::Vertical)
261 .margin(1)
262 .constraints([
263 Constraint::Length(3), Constraint::Min(5), Constraint::Length(3), Constraint::Min(0), ])
268 .split(f.size());
269
270 let title = Paragraph::new("Add Command")
272 .style(Style::default().fg(Color::Cyan))
273 .block(Block::default().borders(Borders::ALL));
274 f.render_widget(title, chunks[0]);
275
276 let mut command_text = self.command.clone();
278 if self.input_mode == InputMode::Command {
279 command_text.insert(self.command_cursor, '│'); }
281 let command_input = Paragraph::new(command_text)
282 .style(Style::default().fg(if self.input_mode == InputMode::Command {
283 Color::Yellow
284 } else {
285 Color::Gray
286 }))
287 .block(Block::default().borders(Borders::ALL).title("Command (Shift+Enter for new line)"))
288 .wrap(ratatui::widgets::Wrap { trim: false });
289 f.render_widget(command_input, chunks[1]);
290
291 let mut tags_text = self.tags.join(", ");
293 if !tags_text.is_empty() {
294 tags_text.push_str(", ");
295 }
296 tags_text.push_str(&self.current_tag);
297 if self.input_mode == InputMode::Tag {
298 tags_text.push('│');
299 }
300 let tags_input = Paragraph::new(tags_text)
301 .style(Style::default().fg(if self.input_mode == InputMode::Tag {
302 Color::Yellow
303 } else {
304 Color::Gray
305 }))
306 .block(Block::default().borders(Borders::ALL).title("Tags"));
307 f.render_widget(tags_input, chunks[2]);
308
309 let help_text = match self.input_mode {
311 InputMode::Command => "Press ? for help",
312 InputMode::Tag => "Press ? for help",
313 InputMode::Confirm => "Save command? (y/n)",
314 InputMode::Help => unreachable!(),
315 };
316 let help = Paragraph::new(help_text)
317 .style(Style::default().fg(Color::White))
318 .block(Block::default().borders(Borders::ALL));
319 f.render_widget(help, chunks[3]);
320 }
321 }
322 }
323
324 fn suggest_tags(&mut self) {
325 self.suggested_tags.clear();
326
327 let command = self.command.to_lowercase();
329
330 if command.contains("git") {
331 self.suggested_tags.push("git".to_string());
332 if command.contains("push") {
333 self.suggested_tags.push("push".to_string());
334 }
335 if command.contains("pull") {
336 self.suggested_tags.push("pull".to_string());
337 }
338 }
339
340 if command.contains("docker") {
341 self.suggested_tags.push("docker".to_string());
342 }
343
344 if command.contains("cargo") {
345 self.suggested_tags.push("rust".to_string());
346 self.suggested_tags.push("cargo".to_string());
347 }
348
349 if command.contains("npm") || command.contains("yarn") {
350 self.suggested_tags.push("javascript".to_string());
351 self.suggested_tags.push("node".to_string());
352 }
353 }
354
355 pub fn handle_key_event(&mut self, key: KeyEvent) {
356 match self.input_mode {
357 InputMode::Help => match key.code {
358 KeyCode::Char('?') | KeyCode::Esc => {
359 self.input_mode = self.previous_mode.clone();
360 }
361 _ => {}
362 },
363 _ => match key.code {
364 KeyCode::Char('?') => {
365 self.previous_mode = self.input_mode.clone();
366 self.input_mode = InputMode::Help;
367 }
368 _ => match self.input_mode {
369 InputMode::Command => match key.code {
370 KeyCode::Char(c) => {
371 self.command.insert(self.command_cursor, c);
372 self.command_cursor += 1;
373 }
374 KeyCode::Backspace => {
375 if self.command_cursor > 0 {
376 self.command.remove(self.command_cursor - 1);
377 self.command_cursor -= 1;
378 }
379 }
380 KeyCode::Left => {
381 if self.command_cursor > 0 {
382 self.command_cursor -= 1;
383 }
384 }
385 KeyCode::Right => {
386 if self.command_cursor < self.command.len() {
387 self.command_cursor += 1;
388 }
389 }
390 KeyCode::Enter => {
391 if key.modifiers.contains(KeyModifiers::SHIFT) {
392 self.command.insert(self.command_cursor, '\n');
393 self.command_cursor += 1;
394 self.command_line += 1;
395 } else if !self.command.is_empty() {
396 self.input_mode = InputMode::Tag;
397 }
398 }
399 _ => {}
400 },
401 InputMode::Tag => match key.code {
402 KeyCode::Char(c) => {
403 self.current_tag.push(c);
404 }
405 KeyCode::Enter => {
406 if !self.current_tag.is_empty() {
407 self.tags.push(self.current_tag.clone());
408 self.current_tag.clear();
409 }
410 }
411 _ => {}
412 },
413 _ => {}
414 }
415 }
416 }
417 }
418}
419
420fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
421 enable_raw_mode()?;
422 let mut stdout = std::io::stdout();
423 execute!(stdout, EnterAlternateScreen)?;
424 let backend = CrosstermBackend::new(stdout);
425 let mut terminal = Terminal::new(backend)?;
426 terminal.hide_cursor()?;
427 Ok(terminal)
428}
429
430fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
431 terminal.show_cursor()?;
432 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
433 disable_raw_mode()?;
434 Ok(())
435}
436
437fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
438 let popup_layout = Layout::default()
439 .direction(Direction::Vertical)
440 .constraints([
441 Constraint::Percentage((100 - percent_y) / 2),
442 Constraint::Percentage(percent_y),
443 Constraint::Percentage((100 - percent_y) / 2),
444 ])
445 .split(r);
446
447 Layout::default()
448 .direction(Direction::Horizontal)
449 .constraints([
450 Constraint::Percentage((100 - percent_x) / 2),
451 Constraint::Percentage(percent_x),
452 Constraint::Percentage((100 - percent_x) / 2),
453 ])
454 .split(popup_layout[1])[1]
455}