ghostscope_ui/components/command_panel/
input_handler.rs1use crate::action::{Action, CursorDirection};
2use crate::model::panel_state::{CommandPanelState, InputState, InteractionMode, JkEscapeState};
3use crossterm::event::{KeyCode, KeyModifiers};
4use ratatui::crossterm::event::KeyEvent;
5use std::time::Instant;
6
7pub struct InputHandler;
9
10impl InputHandler {
11 pub fn handle_key_event(state: &mut CommandPanelState, key: KeyEvent) -> Vec<Action> {
13 if state.is_in_history_search() {
15 return Self::handle_history_search_keys(state, key);
16 }
17
18 if state.mode == InteractionMode::Input {
20 match (key.code, key.modifiers) {
21 (KeyCode::Char('r'), KeyModifiers::CONTROL) => {
23 state.start_history_search();
24 return vec![Action::NoOp];
26 }
27 (KeyCode::Tab, KeyModifiers::NONE) => {
29 tracing::debug!("Tab pressed for completion, input: '{}'", state.input_text);
30
31 let needs_file_comp =
32 crate::components::command_panel::file_completion::needs_file_completion(
33 &state.input_text,
34 );
35 tracing::debug!(
36 "Needs file completion for '{}': {}",
37 state.input_text,
38 needs_file_comp
39 );
40
41 let completion = if needs_file_comp {
42 tracing::debug!(
44 "Attempting file completion, cache available: {}",
45 state.file_completion_cache.is_some()
46 );
47
48 if let Some(cache) = &mut state.file_completion_cache {
49 let result = cache.get_file_completion(&state.input_text);
50 tracing::debug!("File completion result: {:?}", result);
51 result
52 } else {
53 tracing::debug!("File completion cache not available, falling back to command completion");
54 crate::components::command_panel::CommandParser::get_command_completion(
55 &state.input_text,
56 )
57 }
58 } else {
59 tracing::debug!("Using command completion");
61 crate::components::command_panel::CommandParser::get_command_completion(
62 &state.input_text,
63 )
64 };
65
66 if let Some(completion_text) = completion {
67 tracing::debug!("Found completion: '{}'", completion_text);
68
69 let cursor_pos = state.cursor_position.min(state.input_text.len());
71 state.input_text.insert_str(cursor_pos, &completion_text);
72 state.cursor_position += completion_text.len();
73
74 state.update_auto_suggestion();
76 tracing::debug!(
77 "After completion: '{}', cursor at {}",
78 state.input_text,
79 state.cursor_position
80 );
81 } else {
82 tracing::debug!("No completion found for input: '{}'", state.input_text);
83 }
84
85 return vec![Action::NoOp];
86 }
87 (KeyCode::Char('e'), KeyModifiers::CONTROL) => {
89 tracing::debug!(
90 "Ctrl+E pressed, suggestion available: {}",
91 state.get_suggestion_text().is_some()
92 );
93 if let Some(suggestion_text) = state.get_suggestion_text() {
94 tracing::debug!("Accepting auto suggestion: '{}'", suggestion_text);
95 state.accept_auto_suggestion();
96 } else {
97 tracing::debug!("No suggestion available, jumping to end of line");
98 state.cursor_position = state.input_text.chars().count();
99 }
100 return vec![Action::NoOp];
102 }
103 (KeyCode::Right, KeyModifiers::NONE) => {
105 if let Some(_suggestion_text) = state.get_suggestion_text() {
106 tracing::debug!("Right Arrow accepting auto suggestion");
107 state.accept_auto_suggestion();
108 return vec![Action::NoOp];
110 } else {
111 tracing::debug!(
113 "Right Arrow - no suggestion, allowing normal cursor movement"
114 );
115 }
117 }
118 _ => {}
120 }
121 }
122
123 Vec::new()
125 }
126
127 fn handle_history_search_keys(state: &mut CommandPanelState, key: KeyEvent) -> Vec<Action> {
129 match (key.code, key.modifiers) {
130 (KeyCode::Esc, _) => {
132 let selected_command = if let Some(matched_command) = state
134 .history_search
135 .current_match(&state.command_history_manager)
136 {
137 matched_command.to_string()
138 } else {
139 state.get_history_search_query().to_string()
140 };
141
142 state.exit_history_search_with_selection(&selected_command);
143 vec![]
145 }
146 (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
148 state.exit_history_search();
149 state.input_text.clear();
150 state.cursor_position = 0;
151 vec![]
153 }
154 (KeyCode::Enter, _) => {
156 let command_to_execute = if let Some(matched_command) = state
158 .history_search
159 .current_match(&state.command_history_manager)
160 {
161 matched_command.to_string()
162 } else {
163 state.get_history_search_query().to_string()
164 };
165
166 state.exit_history_search();
167
168 if !command_to_execute.trim().is_empty() {
169 vec![Action::SubmitCommandWithText {
170 command: command_to_execute,
171 }]
172 } else {
173 vec![]
177 }
178 }
179 (KeyCode::Char('r'), KeyModifiers::CONTROL) => {
181 state.next_history_match();
182 vec![]
184 }
185 (KeyCode::Backspace, _) => {
187 let mut query = state.get_history_search_query().to_string();
188 if !query.is_empty() {
189 query.pop();
190 state.update_history_search(query.clone());
191
192 state.input_text = query.clone();
194 state.cursor_position = query.len();
195 } else {
196 state.exit_history_search();
197 }
198 vec![Action::NoOp]
200 }
201 (KeyCode::Char(c), KeyModifiers::NONE) => {
203 let mut query = state.get_history_search_query().to_string();
204 query.push(c);
205 state.update_history_search(query.clone());
206
207 state.input_text = query.clone();
209 state.cursor_position = query.len();
210
211 vec![Action::NoOp]
213 }
214 _ => vec![Action::NoOp],
216 }
217 }
218
219 pub fn insert_char(state: &mut CommandPanelState, c: char) -> Vec<Action> {
221 let mut actions = Vec::new();
222
223 match state.mode {
224 InteractionMode::Input => {
225 let jk_result = Self::handle_jk_escape_sequence(state, c);
227 match jk_result {
228 JkEscapeResult::Continue => {
229 if state.history_index.is_some() {
231 state.history_index = None;
232 state.unsent_input_backup = None;
233 }
234
235 let byte_pos =
237 Self::char_pos_to_byte_pos(&state.input_text, state.cursor_position);
238 state.input_text.insert(byte_pos, c);
239 state.cursor_position += 1;
240
241 state.update_auto_suggestion();
243 }
246 JkEscapeResult::WaitForK => {
247 }
250 JkEscapeResult::InsertPreviousJ => {
251 let byte_pos =
253 Self::char_pos_to_byte_pos(&state.input_text, state.cursor_position);
254 state.input_text.insert(byte_pos, 'j');
255 state.cursor_position += 1;
256
257 let byte_pos =
259 Self::char_pos_to_byte_pos(&state.input_text, state.cursor_position);
260 state.input_text.insert(byte_pos, c);
261 state.cursor_position += 1;
262
263 state.update_auto_suggestion();
265 }
266 JkEscapeResult::SwitchToCommand => {
267 actions.push(Action::EnterCommandMode);
268 }
269 }
270 }
271 InteractionMode::ScriptEditor => {
272 Self::insert_char_in_script(state, c);
273 }
274 InteractionMode::Command => {
275 }
278 }
279
280 actions
281 }
282
283 pub fn delete_char(state: &mut CommandPanelState) -> Vec<Action> {
285 match state.mode {
286 InteractionMode::Input => {
287 if state.cursor_position > 0 {
288 state.cursor_position -= 1;
289 let byte_pos =
290 Self::char_pos_to_byte_pos(&state.input_text, state.cursor_position);
291 if byte_pos < state.input_text.len() {
292 let mut end_pos = byte_pos + 1;
294 while end_pos < state.input_text.len()
295 && !state.input_text.is_char_boundary(end_pos)
296 {
297 end_pos += 1;
298 }
299 state.input_text.drain(byte_pos..end_pos);
300 }
301 state.update_auto_suggestion();
303 }
304 }
305 InteractionMode::ScriptEditor => {
306 Self::delete_char_in_script(state);
307 }
308 InteractionMode::Command => {
309 }
311 }
312 Vec::new()
313 }
314
315 pub fn move_cursor(state: &mut CommandPanelState, direction: CursorDirection) -> Vec<Action> {
317 match state.mode {
318 InteractionMode::Input => match direction {
319 CursorDirection::Left => Self::move_cursor_left(state),
320 CursorDirection::Right => Self::move_cursor_right(state),
321 CursorDirection::Up => Self::history_up(state),
322 CursorDirection::Down => Self::history_down(state),
323 CursorDirection::Home => Self::move_cursor_to_beginning(state),
324 CursorDirection::End => Self::move_cursor_to_end(state),
325 },
326 InteractionMode::ScriptEditor => {
327 Self::move_cursor_in_script(state, direction);
328 }
329 InteractionMode::Command => {
330 Self::move_cursor_in_command_mode(state, direction);
331 }
332 }
333 Vec::new()
334 }
335
336 fn handle_jk_escape_sequence(state: &mut CommandPanelState, c: char) -> JkEscapeResult {
338 match state.jk_escape_state {
339 JkEscapeState::None => {
340 if c == 'j' {
341 state.jk_escape_state = JkEscapeState::J;
342 state.jk_timer = Some(Instant::now());
343 JkEscapeResult::WaitForK
344 } else {
345 JkEscapeResult::Continue
346 }
347 }
348 JkEscapeState::J => {
349 state.jk_escape_state = JkEscapeState::None;
350 state.jk_timer = None;
351
352 if c == 'k' {
353 JkEscapeResult::SwitchToCommand
354 } else {
355 JkEscapeResult::InsertPreviousJ
356 }
357 }
358 }
359 }
360
361 pub fn check_jk_timeout(state: &mut CommandPanelState) -> bool {
363 const JK_TIMEOUT_MS: u64 = 100;
364
365 if let JkEscapeState::J = state.jk_escape_state {
366 if let Some(timer) = state.jk_timer {
367 if timer.elapsed().as_millis() > JK_TIMEOUT_MS as u128 {
368 state.jk_escape_state = JkEscapeState::None;
370 state.jk_timer = None;
371
372 if Self::should_show_input_prompt(state) {
374 let byte_pos =
375 Self::char_pos_to_byte_pos(&state.input_text, state.cursor_position);
376 state.input_text.insert(byte_pos, 'j');
377 state.cursor_position += 1;
378 state.update_auto_suggestion();
380 }
381 return true;
382 }
383 }
384 }
385 false
386 }
387
388 fn move_cursor_left(state: &mut CommandPanelState) {
389 if state.cursor_position > 0 {
390 state.cursor_position -= 1;
391 }
392 }
393
394 fn move_cursor_right(state: &mut CommandPanelState) {
395 let input_len = state.input_text.chars().count();
396 if state.cursor_position < input_len {
397 state.cursor_position += 1;
398 }
399 }
400
401 fn history_up(state: &mut CommandPanelState) {
402 if state.command_history.is_empty() {
403 return;
404 }
405
406 match state.history_index {
407 None => {
408 if !state.input_text.is_empty() {
410 state.unsent_input_backup = Some(state.input_text.clone());
411 }
412 state.history_index = Some(state.command_history.len() - 1);
413 }
414 Some(current_index) => {
415 if current_index > 0 {
416 state.history_index = Some(current_index - 1);
417 }
418 }
419 }
420
421 if let Some(index) = state.history_index {
423 if let Some(item) = state.command_history.get(index) {
424 state.input_text = item.command.clone();
425 state.cursor_position = state.input_text.chars().count();
426 }
427 }
428 }
429
430 fn history_down(state: &mut CommandPanelState) {
431 match state.history_index {
432 None => (), Some(current_index) => {
434 let max_index = state.command_history.len() - 1;
435 if current_index < max_index {
436 state.history_index = Some(current_index + 1);
437 if let Some(item) = state.command_history.get(current_index + 1) {
439 state.input_text = item.command.clone();
440 state.cursor_position = state.input_text.chars().count();
441 }
442 } else {
443 state.history_index = None;
445 if let Some(backup) = state.unsent_input_backup.take() {
446 state.input_text = backup;
447 } else {
448 state.input_text.clear();
449 }
450 state.cursor_position = state.input_text.chars().count();
451 }
452 }
453 }
454 }
455
456 fn insert_char_in_script(state: &mut CommandPanelState, c: char) {
458 if let Some(ref mut script) = state.script_cache {
459 if script.cursor_line < script.lines.len() {
460 let line = &mut script.lines[script.cursor_line];
461 let byte_pos = Self::char_pos_to_byte_pos(line, script.cursor_col);
462 line.insert(byte_pos, c);
463 script.cursor_col += 1;
464 }
465 }
466 }
467
468 fn delete_char_in_script(state: &mut CommandPanelState) {
469 if let Some(ref mut script) = state.script_cache {
470 if script.cursor_line < script.lines.len() && script.cursor_col > 0 {
471 let line = &mut script.lines[script.cursor_line];
472 script.cursor_col -= 1;
473 let byte_pos = Self::char_pos_to_byte_pos(line, script.cursor_col);
474 if byte_pos < line.len() {
475 let mut end_pos = byte_pos + 1;
476 while end_pos < line.len() && !line.is_char_boundary(end_pos) {
477 end_pos += 1;
478 }
479 line.drain(byte_pos..end_pos);
480 }
481 }
482 }
483 }
484
485 fn move_cursor_in_script(state: &mut CommandPanelState, direction: CursorDirection) {
486 if let Some(ref mut script) = state.script_cache {
487 match direction {
488 CursorDirection::Left => {
489 if script.cursor_col > 0 {
490 script.cursor_col -= 1;
491 }
492 }
493 CursorDirection::Right => {
494 if script.cursor_line < script.lines.len() {
495 let line_len = script.lines[script.cursor_line].chars().count();
496 if script.cursor_col < line_len {
497 script.cursor_col += 1;
498 }
499 }
500 }
501 CursorDirection::Up => {
502 if script.cursor_line > 0 {
503 script.cursor_line -= 1;
504 let line_len = script.lines[script.cursor_line].chars().count();
505 script.cursor_col = script.cursor_col.min(line_len);
506 }
507 }
508 CursorDirection::Down => {
509 if script.cursor_line + 1 < script.lines.len() {
510 script.cursor_line += 1;
511 let line_len = script.lines[script.cursor_line].chars().count();
512 script.cursor_col = script.cursor_col.min(line_len);
513 }
514 }
515 CursorDirection::Home => {
516 script.cursor_col = 0;
517 }
518 CursorDirection::End => {
519 if script.cursor_line < script.lines.len() {
520 script.cursor_col = script.lines[script.cursor_line].chars().count();
521 }
522 }
523 }
524 }
525 }
526
527 fn move_cursor_in_command_mode(state: &mut CommandPanelState, direction: CursorDirection) {
528 match direction {
529 CursorDirection::Left => {
530 if state.command_cursor_column > 0 {
531 state.command_cursor_column -= 1;
532 }
533 }
534 CursorDirection::Right => {
535 if let Some(line) = state.static_lines.get(state.command_cursor_line) {
536 if state.command_cursor_column < line.content.len() {
537 state.command_cursor_column += 1;
538 }
539 }
540 }
541 CursorDirection::Up => {
542 if state.command_cursor_line > 0 {
543 state.command_cursor_line -= 1;
544 if let Some(line) = state.static_lines.get(state.command_cursor_line) {
546 state.command_cursor_column =
547 state.command_cursor_column.min(line.content.len());
548 }
549 }
550 }
551 CursorDirection::Down => {
552 if state.command_cursor_line + 1 < state.static_lines.len() {
553 state.command_cursor_line += 1;
554 if let Some(line) = state.static_lines.get(state.command_cursor_line) {
556 state.command_cursor_column =
557 state.command_cursor_column.min(line.content.len());
558 }
559 }
560 }
561 CursorDirection::Home => {
562 state.command_cursor_column = 0;
563 }
564 CursorDirection::End => {
565 if let Some(line) = state.static_lines.get(state.command_cursor_line) {
566 state.command_cursor_column = line.content.len();
567 }
568 }
569 }
570 }
571
572 fn char_pos_to_byte_pos(text: &str, char_pos: usize) -> usize {
574 text.char_indices()
575 .nth(char_pos)
576 .map_or(text.len(), |(pos, _)| pos)
577 }
578
579 fn should_show_input_prompt(state: &CommandPanelState) -> bool {
580 matches!(state.input_state, InputState::Ready)
581 }
582
583 pub fn delete_previous_word(state: &mut CommandPanelState) -> Vec<Action> {
585 if state.mode != InteractionMode::Input {
586 return Vec::new();
587 }
588
589 let chars: Vec<char> = state.input_text.chars().collect();
590 if state.cursor_position > 0 && !chars.is_empty() {
591 let mut new_cursor = state.cursor_position;
592
593 while new_cursor > 0 && chars[new_cursor - 1].is_whitespace() {
595 new_cursor -= 1;
596 }
597
598 while new_cursor > 0 && !chars[new_cursor - 1].is_whitespace() {
600 new_cursor -= 1;
601 }
602
603 let start_byte = Self::char_pos_to_byte_pos(&state.input_text, new_cursor);
604 let end_byte = Self::char_pos_to_byte_pos(&state.input_text, state.cursor_position);
605 state.input_text.drain(start_byte..end_byte);
606 state.cursor_position = new_cursor;
607 state.update_auto_suggestion();
609 }
610
611 Vec::new()
612 }
613
614 pub fn delete_to_end(state: &mut CommandPanelState) -> Vec<Action> {
616 if state.mode != InteractionMode::Input {
617 return Vec::new();
618 }
619
620 let byte_pos = Self::char_pos_to_byte_pos(&state.input_text, state.cursor_position);
621 state.input_text.truncate(byte_pos);
622 state.update_auto_suggestion();
624
625 Vec::new()
626 }
627
628 pub fn delete_to_beginning(state: &mut CommandPanelState) -> Vec<Action> {
630 if state.mode != InteractionMode::Input {
631 return Vec::new();
632 }
633
634 let byte_pos = Self::char_pos_to_byte_pos(&state.input_text, state.cursor_position);
635 let remaining = state.input_text[byte_pos..].to_string();
636 state.input_text = remaining;
637 state.cursor_position = 0;
638 state.update_auto_suggestion();
640
641 Vec::new()
642 }
643
644 fn move_cursor_to_beginning(state: &mut CommandPanelState) {
646 state.cursor_position = 0;
647 }
648
649 fn move_cursor_to_end(state: &mut CommandPanelState) {
651 state.cursor_position = state.input_text.chars().count();
652 }
653}
654
655#[derive(Debug, PartialEq)]
656enum JkEscapeResult {
657 Continue,
658 WaitForK,
659 InsertPreviousJ,
660 SwitchToCommand,
661}