ghostscope_ui/components/command_panel/
input_handler.rs

1use 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
7/// Handles input processing for the command panel
8pub struct InputHandler;
9
10impl InputHandler {
11    /// Handle special key events for history and suggestions (only handles specific keys)
12    pub fn handle_key_event(state: &mut CommandPanelState, key: KeyEvent) -> Vec<Action> {
13        // Priority 1: History search mode handling (handles ALL keys when in search mode)
14        if state.is_in_history_search() {
15            return Self::handle_history_search_keys(state, key);
16        }
17
18        // Priority 2: Input mode special keys (only specific keys)
19        if state.mode == InteractionMode::Input {
20            match (key.code, key.modifiers) {
21                // Ctrl+R: Start history search
22                (KeyCode::Char('r'), KeyModifiers::CONTROL) => {
23                    state.start_history_search();
24                    // Return NoOp action to prevent fallback character processing
25                    return vec![Action::NoOp];
26                }
27                // Tab: Command and file completion
28                (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                        // File completion needed
43                        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                        // Regular command completion
60                        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                        // Insert the completion at cursor position (same logic for both command and file completion)
70                        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                        // Update auto suggestion after completion
75                        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                // Ctrl+E: Accept auto suggestion if available, otherwise jump to end of line
88                (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 NoOp action to prevent fallback processing
101                    return vec![Action::NoOp];
102                }
103                // Right Arrow: Accept auto suggestion if available, otherwise move cursor
104                (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 NoOp action to prevent fallback processing
109                        return vec![Action::NoOp];
110                    } else {
111                        // No suggestion available, let right arrow fall through to normal cursor movement
112                        tracing::debug!(
113                            "Right Arrow - no suggestion, allowing normal cursor movement"
114                        );
115                        // Return empty vector to allow fallback processing (cursor movement)
116                    }
117                }
118                // Other keys are not handled by this function
119                _ => {}
120            }
121        }
122
123        // Return empty vector for keys we don't handle
124        Vec::new()
125    }
126
127    /// Handle key events during history search mode
128    fn handle_history_search_keys(state: &mut CommandPanelState, key: KeyEvent) -> Vec<Action> {
129        match (key.code, key.modifiers) {
130            // ESC: Exit search mode, set matched command as new input text
131            (KeyCode::Esc, _) => {
132                // Use the matched command if available, otherwise keep search query
133                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                // Don't add empty response - would overwrite previous command's response
144                vec![]
145            }
146            // Ctrl+C: Exit search mode and clear input
147            (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
148                state.exit_history_search();
149                state.input_text.clear();
150                state.cursor_position = 0;
151                // Don't add empty response - would overwrite previous command's response
152                vec![]
153            }
154            // Enter: Execute the current search result
155            (KeyCode::Enter, _) => {
156                // Get the command to execute (matched command if available, otherwise search query)
157                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                    // Return empty action list instead of adding an empty response
174                    // This prevents creating unnecessary history entries when user
175                    // exits history search without executing a command
176                    vec![]
177                }
178            }
179            // Ctrl+R: Next search result
180            (KeyCode::Char('r'), KeyModifiers::CONTROL) => {
181                state.next_history_match();
182                // No action needed - just move to next match
183                vec![]
184            }
185            // Backspace: Remove character from search query
186            (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                    // Update input_text to match the search query and cursor position
193                    state.input_text = query.clone();
194                    state.cursor_position = query.len();
195                } else {
196                    state.exit_history_search();
197                }
198                // Return NoOp action to prevent fallback to regular input handling
199                vec![Action::NoOp]
200            }
201            // Regular characters: Add to search query
202            (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                // Update input_text to match the search query and cursor position
208                state.input_text = query.clone();
209                state.cursor_position = query.len();
210
211                // Return NoOp action to prevent fallback to regular input handling
212                vec![Action::NoOp]
213            }
214            // Other keys: Ignore during search but prevent fallback
215            _ => vec![Action::NoOp],
216        }
217    }
218
219    /// Insert a character at the current cursor position
220    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                // Handle jk escape sequence for vim-like mode switching
226                let jk_result = Self::handle_jk_escape_sequence(state, c);
227                match jk_result {
228                    JkEscapeResult::Continue => {
229                        // Reset history navigation when user starts typing new content
230                        if state.history_index.is_some() {
231                            state.history_index = None;
232                            state.unsent_input_backup = None;
233                        }
234
235                        // Normal character insertion
236                        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                        // Update auto suggestion after character insertion
242                        state.update_auto_suggestion();
243                        // Note: No action is pushed here. Character insertion is a local
244                        // editing operation that doesn't require a response or command execution
245                    }
246                    JkEscapeResult::WaitForK => {
247                        // Don't insert 'j' yet, just wait for potential 'k'
248                        // No action needed, the timer is already set
249                    }
250                    JkEscapeResult::InsertPreviousJ => {
251                        // Insert the previous 'j' that was held
252                        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                        // Then insert the current character
258                        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                        // Update auto suggestion after character insertion
264                        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                // In command mode, characters might trigger navigation
276                // This would be handled by command navigation logic
277            }
278        }
279
280        actions
281    }
282
283    /// Delete character before cursor
284    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                        // Find the end of the character to remove
293                        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                    // Update auto suggestion after character deletion
302                    state.update_auto_suggestion();
303                }
304            }
305            InteractionMode::ScriptEditor => {
306                Self::delete_char_in_script(state);
307            }
308            InteractionMode::Command => {
309                // Command mode doesn't support deletion
310            }
311        }
312        Vec::new()
313    }
314
315    /// Move cursor in specified direction
316    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    /// Handle jk escape sequence for vim-like mode switching
337    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    /// Check and handle jk timeout (should be called periodically)
362    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                    // Timeout occurred, need to insert pending 'j'
369                    state.jk_escape_state = JkEscapeState::None;
370                    state.jk_timer = None;
371
372                    // Insert the pending 'j' character
373                    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                        // Update auto suggestion after timeout insertion
379                        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                // First time accessing history, backup current input
409                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        // Load the selected history item
422        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 => (), // Not in history mode
433            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                    // Load the selected history item
438                    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                    // Reached the end of history, restore unsent input or clear
444                    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    // Script editing functions
457    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                    // Adjust column if new line is shorter
545                    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                    // Adjust column if new line is shorter
555                    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    // Utility functions
573    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    /// Delete the previous word from the cursor position
584    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            // Skip whitespace
594            while new_cursor > 0 && chars[new_cursor - 1].is_whitespace() {
595                new_cursor -= 1;
596            }
597
598            // Delete word characters
599            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            // Update auto suggestion after word deletion
608            state.update_auto_suggestion();
609        }
610
611        Vec::new()
612    }
613
614    /// Delete from cursor to end of line
615    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        // Update auto suggestion after deletion to end
623        state.update_auto_suggestion();
624
625        Vec::new()
626    }
627
628    /// Delete from cursor to beginning of line
629    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        // Update auto suggestion after deletion to beginning
639        state.update_auto_suggestion();
640
641        Vec::new()
642    }
643
644    /// Move cursor to beginning of line
645    fn move_cursor_to_beginning(state: &mut CommandPanelState) {
646        state.cursor_position = 0;
647    }
648
649    /// Move cursor to end of line
650    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}