Skip to main content

sql_cli/ui/key_handling/
mapper.rs

1// Maps keyboard input to actions
2// This will gradually replace direct key handling in TUI
3
4use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
5use std::collections::HashMap;
6
7use crate::buffer::AppMode;
8use crate::ui::input::actions::{Action, ActionContext, CursorPosition, NavigateAction};
9
10/// Maps keyboard input to actions based on context
11pub struct KeyMapper {
12    /// Static mappings that don't depend on mode
13    global_mappings: HashMap<(KeyCode, KeyModifiers), Action>,
14
15    /// Mode-specific mappings
16    mode_mappings: HashMap<AppMode, HashMap<(KeyCode, KeyModifiers), Action>>,
17
18    /// Vim-style count buffer for motions
19    count_buffer: String,
20
21    /// Buffer for multi-character vim commands (e.g., 'wa', 'oa')
22    vim_command_buffer: String,
23}
24
25impl KeyMapper {
26    #[must_use]
27    pub fn new() -> Self {
28        let mut mapper = Self {
29            global_mappings: HashMap::new(),
30            mode_mappings: HashMap::new(),
31            count_buffer: String::new(),
32            vim_command_buffer: String::new(),
33        };
34
35        mapper.init_global_mappings();
36        mapper.init_mode_mappings();
37        mapper
38    }
39
40    /// Initialize mappings that work regardless of mode
41    fn init_global_mappings(&mut self) {
42        use KeyCode::{Char, F};
43        use KeyModifiers as Mod;
44
45        // Function keys that work in any mode
46        self.global_mappings
47            .insert((F(1), Mod::NONE), Action::ShowHelp);
48        self.global_mappings
49            .insert((F(3), Mod::NONE), Action::ShowPrettyQuery);
50        self.global_mappings
51            .insert((F(5), Mod::NONE), Action::ShowDebugInfo);
52        self.global_mappings
53            .insert((F(6), Mod::NONE), Action::ToggleRowNumbers);
54        self.global_mappings
55            .insert((F(7), Mod::NONE), Action::ToggleCompactMode);
56        self.global_mappings
57            .insert((F(8), Mod::NONE), Action::ToggleCaseInsensitive);
58        self.global_mappings
59            .insert((F(9), Mod::NONE), Action::KillLine);
60        self.global_mappings
61            .insert((F(10), Mod::NONE), Action::KillLineBackward);
62        self.global_mappings
63            .insert((F(12), Mod::NONE), Action::ToggleKeyIndicator);
64
65        // Force quit
66        self.global_mappings
67            .insert((Char('c'), Mod::CONTROL), Action::ForceQuit);
68        self.global_mappings
69            .insert((Char('C'), Mod::CONTROL), Action::ForceQuit);
70    }
71
72    /// Initialize mode-specific mappings
73    fn init_mode_mappings(&mut self) {
74        self.init_results_mappings();
75        self.init_command_mappings();
76        // Add other modes as we migrate them
77    }
78
79    /// Initialize Results mode mappings
80    fn init_results_mappings(&mut self) {
81        use crate::buffer::AppMode;
82        use KeyCode::{Char, Down, End, Esc, Home, Left, PageDown, PageUp, Right, Up, F};
83        use KeyModifiers as Mod;
84
85        let mut mappings = HashMap::new();
86
87        // Basic navigation (will be extracted in Phase 2)
88        mappings.insert((Up, Mod::NONE), Action::Navigate(NavigateAction::Up(1)));
89        mappings.insert((Down, Mod::NONE), Action::Navigate(NavigateAction::Down(1)));
90        mappings.insert((Left, Mod::NONE), Action::Navigate(NavigateAction::Left(1)));
91        mappings.insert(
92            (Right, Mod::NONE),
93            Action::Navigate(NavigateAction::Right(1)),
94        );
95
96        mappings.insert(
97            (PageUp, Mod::NONE),
98            Action::Navigate(NavigateAction::PageUp),
99        );
100        mappings.insert(
101            (PageDown, Mod::NONE),
102            Action::Navigate(NavigateAction::PageDown),
103        );
104
105        // Ctrl+F/B for page navigation (vim style)
106        mappings.insert(
107            (Char('f'), Mod::CONTROL),
108            Action::Navigate(NavigateAction::PageDown),
109        );
110        mappings.insert(
111            (Char('b'), Mod::CONTROL),
112            Action::Navigate(NavigateAction::PageUp),
113        );
114
115        mappings.insert((Home, Mod::NONE), Action::Navigate(NavigateAction::Home));
116        mappings.insert((End, Mod::NONE), Action::Navigate(NavigateAction::End));
117
118        // Vim navigation
119        mappings.insert(
120            (Char('h'), Mod::NONE),
121            Action::Navigate(NavigateAction::Left(1)),
122        );
123        mappings.insert(
124            (Char('j'), Mod::NONE),
125            Action::Navigate(NavigateAction::Down(1)),
126        );
127        mappings.insert(
128            (Char('k'), Mod::NONE),
129            Action::Navigate(NavigateAction::Up(1)),
130        );
131        mappings.insert(
132            (Char('l'), Mod::NONE),
133            Action::Navigate(NavigateAction::Right(1)),
134        );
135
136        // Arrow keys (same as vim navigation - no mode switching)
137        mappings.insert((Left, Mod::NONE), Action::Navigate(NavigateAction::Left(1)));
138        mappings.insert(
139            (Right, Mod::NONE),
140            Action::Navigate(NavigateAction::Right(1)),
141        );
142        mappings.insert((Down, Mod::NONE), Action::Navigate(NavigateAction::Down(1)));
143        mappings.insert((Up, Mod::NONE), Action::Navigate(NavigateAction::Up(1))); // Up navigates up, bounded at row 0
144
145        // Page navigation
146        mappings.insert(
147            (PageUp, Mod::NONE),
148            Action::Navigate(NavigateAction::PageUp),
149        );
150        mappings.insert(
151            (PageDown, Mod::NONE),
152            Action::Navigate(NavigateAction::PageDown),
153        );
154
155        // Home/End navigation (using traditional vim gg/G pattern)
156        // Note: Single 'g' is reserved for vim command sequences like 'ga'
157        // Use 'gg' for go to top (handled in vim command sequences)
158        mappings.insert(
159            (Char('G'), Mod::SHIFT),
160            Action::Navigate(NavigateAction::End),
161        );
162
163        // First/Last column navigation
164        mappings.insert(
165            (Char('0'), Mod::NONE),
166            Action::Navigate(NavigateAction::FirstColumn),
167        );
168        mappings.insert(
169            (Char('^'), Mod::NONE),
170            Action::Navigate(NavigateAction::FirstColumn),
171        );
172        mappings.insert(
173            (Char('$'), Mod::NONE),
174            Action::Navigate(NavigateAction::LastColumn),
175        );
176
177        // Viewport navigation (H/M/L like vim)
178        mappings.insert((Char('H'), Mod::SHIFT), Action::NavigateToViewportTop);
179        mappings.insert((Char('M'), Mod::SHIFT), Action::NavigateToViewportMiddle);
180        mappings.insert((Char('L'), Mod::SHIFT), Action::NavigateToViewportBottom);
181
182        // Mode switching
183        mappings.insert((Esc, Mod::NONE), Action::ExitCurrentMode);
184        mappings.insert((Char('q'), Mod::NONE), Action::Quit);
185        mappings.insert((Char('c'), Mod::CONTROL), Action::Quit); // Ctrl+C to quit
186
187        // F2 to switch to Command mode
188        mappings.insert((F(2), Mod::NONE), Action::SwitchMode(AppMode::Command));
189
190        // Vim-style 'i' for insert/input mode (switch to Command at current position)
191        mappings.insert(
192            (Char('i'), Mod::NONE),
193            Action::SwitchModeWithCursor(AppMode::Command, CursorPosition::Current),
194        );
195
196        // Vim-style 'a' for append mode (switch to Command at end)
197        mappings.insert(
198            (Char('a'), Mod::NONE),
199            Action::SwitchModeWithCursor(AppMode::Command, CursorPosition::End),
200        );
201
202        // Column operations
203        mappings.insert((Char('p'), Mod::NONE), Action::ToggleColumnPin);
204        mappings.insert((Char('-'), Mod::NONE), Action::HideColumn); // '-' to hide column
205        mappings.insert(
206            (Char('H'), Mod::CONTROL | Mod::SHIFT),
207            Action::UnhideAllColumns,
208        );
209        mappings.insert((Char('+'), Mod::NONE), Action::UnhideAllColumns); // '+' to unhide all
210        mappings.insert((Char('='), Mod::NONE), Action::UnhideAllColumns); // '=' to unhide all (easier than shift+= for +)
211                                                                           // Handle both lowercase and uppercase 'e' for hide empty columns
212        mappings.insert((Char('e'), Mod::NONE), Action::HideEmptyColumns);
213        mappings.insert((Char('E'), Mod::SHIFT), Action::HideEmptyColumns);
214        mappings.insert((Left, Mod::SHIFT), Action::MoveColumnLeft);
215        mappings.insert((Right, Mod::SHIFT), Action::MoveColumnRight);
216        // Also support < and > characters for column movement (more intuitive)
217        mappings.insert((Char('<'), Mod::NONE), Action::MoveColumnLeft);
218        mappings.insert((Char('>'), Mod::NONE), Action::MoveColumnRight);
219        // Search and filter operations
220        mappings.insert((Char('/'), Mod::NONE), Action::StartSearch);
221        mappings.insert((Char('\\'), Mod::NONE), Action::StartColumnSearch);
222        mappings.insert((Char('f'), Mod::NONE), Action::StartFilter);
223        mappings.insert((Char('F'), Mod::SHIFT), Action::StartFuzzyFilter);
224
225        // Sorting
226        mappings.insert((Char('s'), Mod::NONE), Action::Sort(None));
227
228        // View toggles
229        mappings.insert((Char('N'), Mod::NONE), Action::ToggleRowNumbers);
230        mappings.insert((Char('C'), Mod::NONE), Action::ToggleCompactMode);
231
232        // Export operations
233        mappings.insert((Char('x'), Mod::CONTROL), Action::ExportToCsv);
234        mappings.insert((Char('j'), Mod::CONTROL), Action::ExportToJson);
235
236        // Clear filter (when filter is active)
237        mappings.insert((Char('C'), Mod::SHIFT), Action::ClearFilter);
238
239        // Jump to row
240        mappings.insert((Char(':'), Mod::NONE), Action::StartJumpToRow);
241
242        // Search navigation
243        mappings.insert((Char('n'), Mod::NONE), Action::NextSearchMatch);
244        mappings.insert((Char('N'), Mod::SHIFT), Action::PreviousSearchMatch);
245
246        // Selection mode toggle (v key like vim visual mode)
247        mappings.insert((Char('v'), Mod::NONE), Action::ToggleSelectionMode);
248
249        // Column statistics
250        mappings.insert((Char('S'), Mod::SHIFT), Action::ShowColumnStatistics);
251
252        // Column packing mode
253        mappings.insert((Char('s'), Mod::ALT), Action::CycleColumnPacking);
254
255        // Viewport/cursor lock operations
256        mappings.insert((Char(' '), Mod::NONE), Action::ToggleViewportLock);
257        mappings.insert((Char('x'), Mod::NONE), Action::ToggleCursorLock);
258        mappings.insert((Char('X'), Mod::SHIFT), Action::ToggleCursorLock);
259        mappings.insert((Char(' '), Mod::CONTROL), Action::ToggleViewportLock);
260
261        // Additional help key
262        mappings.insert((Char('?'), Mod::NONE), Action::ShowHelp); // ? also shows help
263                                                                   // F-key actions are now handled globally
264
265        // Clear pins
266        mappings.insert((Char('P'), Mod::SHIFT), Action::ClearAllPins);
267
268        // History search
269        mappings.insert((Char('r'), Mod::CONTROL), Action::StartHistorySearch);
270
271        self.mode_mappings.insert(AppMode::Results, mappings);
272    }
273
274    /// Initialize Command mode mappings
275    fn init_command_mappings(&mut self) {
276        use crate::buffer::AppMode;
277        use KeyCode::{Backspace, Char, Delete, Down, End, Enter, Home, Left, Right, Up, F};
278        use KeyModifiers as Mod;
279
280        let mut mappings = HashMap::new();
281
282        // Execute query
283        mappings.insert((Enter, Mod::NONE), Action::ExecuteQuery);
284
285        // F2 to switch back to Results mode (if results exist)
286        mappings.insert((F(2), Mod::NONE), Action::SwitchMode(AppMode::Results));
287
288        // Clear line
289        mappings.insert((Char('u'), Mod::CONTROL), Action::ClearLine);
290
291        // Undo/Redo
292        mappings.insert((Char('z'), Mod::CONTROL), Action::Undo);
293        mappings.insert((Char('y'), Mod::CONTROL), Action::Redo);
294
295        // Cursor movement
296        mappings.insert((Left, Mod::NONE), Action::MoveCursorLeft);
297        mappings.insert((Right, Mod::NONE), Action::MoveCursorRight);
298        mappings.insert((Down, Mod::NONE), Action::SwitchMode(AppMode::Results)); // Down enters Results mode
299        mappings.insert((Home, Mod::NONE), Action::MoveCursorHome);
300        mappings.insert((End, Mod::NONE), Action::MoveCursorEnd);
301        mappings.insert((Char('a'), Mod::CONTROL), Action::MoveCursorHome);
302        mappings.insert((Char('e'), Mod::CONTROL), Action::MoveCursorEnd);
303        mappings.insert((Left, Mod::CONTROL), Action::MoveCursorWordLeft);
304        mappings.insert((Right, Mod::CONTROL), Action::MoveCursorWordRight);
305        mappings.insert((Char('b'), Mod::ALT), Action::MoveCursorWordLeft);
306        mappings.insert((Char('f'), Mod::ALT), Action::MoveCursorWordRight);
307        mappings.insert((Char('['), Mod::ALT), Action::JumpToPrevToken);
308        mappings.insert((Char(']'), Mod::ALT), Action::JumpToNextToken);
309        // Terminal-safe aliases (Alt+[ is swallowed by CSI prefix in most terminals)
310        mappings.insert((Char(','), Mod::ALT), Action::JumpToPrevToken);
311        mappings.insert((Char('.'), Mod::ALT), Action::JumpToNextToken);
312
313        // Text editing
314        mappings.insert((Backspace, Mod::NONE), Action::Backspace);
315        mappings.insert((Delete, Mod::NONE), Action::Delete);
316        mappings.insert((Char('w'), Mod::CONTROL), Action::DeleteWordBackward);
317        mappings.insert((Char('d'), Mod::ALT), Action::DeleteWordForward);
318        mappings.insert((Char('k'), Mod::CONTROL), Action::KillLine);
319        // F9 and F10 are now handled globally
320
321        // Clipboard operations
322        mappings.insert((Char('v'), Mod::CONTROL), Action::Paste);
323
324        // History navigation
325        mappings.insert((Char('p'), Mod::CONTROL), Action::PreviousHistoryCommand);
326        mappings.insert((Char('n'), Mod::CONTROL), Action::NextHistoryCommand);
327        mappings.insert((Up, Mod::ALT), Action::PreviousHistoryCommand);
328        mappings.insert((Down, Mod::ALT), Action::NextHistoryCommand);
329
330        // SQL expansion operations
331        mappings.insert((Char('*'), Mod::CONTROL), Action::ExpandAsterisk);
332        mappings.insert((Char('*'), Mod::ALT), Action::ExpandAsteriskVisible);
333
334        // F-key actions are now handled globally
335
336        self.mode_mappings.insert(AppMode::Command, mappings);
337    }
338
339    /// Map a key event to an action based on current context
340    pub fn map_key(&mut self, key: KeyEvent, context: &ActionContext) -> Option<Action> {
341        // Handle vim-style counts and commands in Results mode
342        if context.mode == AppMode::Results {
343            if let KeyCode::Char(c) = key.code {
344                if key.modifiers.is_empty() {
345                    // Check if we're building a vim command (only for 'gg' now, others moved to chords)
346                    if !self.vim_command_buffer.is_empty() {
347                        // We have a pending command, check for valid combinations
348                        let command = format!("{}{}", self.vim_command_buffer, c);
349                        let action = if command.as_str() == "gg" {
350                            // Go to top (vim-style)
351                            self.vim_command_buffer.clear();
352                            Some(Action::Navigate(NavigateAction::Home))
353                        } else {
354                            // Invalid command, clear buffer
355                            self.vim_command_buffer.clear();
356                            None
357                        };
358
359                        if action.is_some() {
360                            return action;
361                        }
362                    }
363
364                    // Check for digits (vim counts)
365                    if c.is_ascii_digit() {
366                        self.count_buffer.push(c);
367                        return None; // Collecting count, no action yet
368                    }
369
370                    // Check if this starts a vim command sequence, but only if no standalone mapping exists
371                    // Only 'g' is used for vim commands now (gg = go to top)
372                    // SQL clause navigation moved to chord handler (cw, cs, etc.)
373                    if c == 'g' {
374                        let key_combo = (key.code, key.modifiers);
375                        if let Some(mode_mappings) = self.mode_mappings.get(&context.mode) {
376                            if mode_mappings.contains_key(&key_combo) {
377                                // This key has a standalone mapping, let it fall through to normal mapping
378                                // Don't treat it as a vim command starter
379                                tracing::debug!(
380                                    "Key '{}' has standalone mapping, not treating as vim command",
381                                    c
382                                );
383                            } else {
384                                // No standalone mapping, treat as vim command starter
385                                self.vim_command_buffer.push(c);
386                                tracing::debug!("Starting vim command buffer with '{}'", c);
387                                return None; // Collecting command, no action yet
388                            }
389                        }
390                    }
391                }
392            }
393        }
394
395        // Check for action with count
396        let action = self.map_key_internal(key, context);
397
398        // Apply count if we have one
399        if !self.count_buffer.is_empty() {
400            if let Some(mut action) = action {
401                if let Ok(count) = self.count_buffer.parse::<usize>() {
402                    action = self.apply_count_to_action(action, count);
403                }
404                self.count_buffer.clear();
405                return Some(action);
406            }
407            // If no action, clear count buffer
408            self.count_buffer.clear();
409        }
410
411        action
412    }
413
414    /// Internal key mapping without count handling
415    fn map_key_internal(&self, key: KeyEvent, context: &ActionContext) -> Option<Action> {
416        let key_combo = (key.code, key.modifiers);
417
418        // Check global mappings first
419        if let Some(action) = self.global_mappings.get(&key_combo) {
420            return Some(action.clone());
421        }
422
423        // Check mode-specific mappings
424        if let Some(mode_mappings) = self.mode_mappings.get(&context.mode) {
425            if let Some(action) = mode_mappings.get(&key_combo) {
426                return Some(action.clone());
427            }
428        }
429
430        // Handle regular character input in Command mode
431        if context.mode == AppMode::Command {
432            if let KeyCode::Char(c) = key.code {
433                if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT {
434                    // Regular character input
435                    return Some(Action::InsertChar(c));
436                }
437            }
438        }
439
440        // No mapping found
441        None
442    }
443
444    /// Apply a count to an action (for vim-style motions)
445    fn apply_count_to_action(&self, action: Action, count: usize) -> Action {
446        match action {
447            Action::Navigate(NavigateAction::Up(_)) => Action::Navigate(NavigateAction::Up(count)),
448            Action::Navigate(NavigateAction::Down(_)) => {
449                Action::Navigate(NavigateAction::Down(count))
450            }
451            Action::Navigate(NavigateAction::Left(_)) => {
452                Action::Navigate(NavigateAction::Left(count))
453            }
454            Action::Navigate(NavigateAction::Right(_)) => {
455                Action::Navigate(NavigateAction::Right(count))
456            }
457            // Other actions don't support counts yet
458            _ => action,
459        }
460    }
461
462    /// Clear any pending state (like count buffer and vim command buffer)
463    pub fn clear_pending(&mut self) {
464        self.count_buffer.clear();
465        self.vim_command_buffer.clear();
466    }
467
468    /// Check if we're collecting a count
469    #[must_use]
470    pub fn is_collecting_count(&self) -> bool {
471        !self.count_buffer.is_empty()
472    }
473
474    /// Get the current count buffer for display
475    #[must_use]
476    pub fn get_count_buffer(&self) -> &str {
477        &self.count_buffer
478    }
479}
480
481impl Default for KeyMapper {
482    fn default() -> Self {
483        Self::new()
484    }
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490    use crate::app_state_container::SelectionMode;
491
492    #[test]
493    fn test_basic_navigation_mapping() {
494        let mut mapper = KeyMapper::new();
495        let context = ActionContext {
496            mode: AppMode::Results,
497            selection_mode: SelectionMode::Row,
498            has_results: true,
499            has_filter: false,
500            has_search: false,
501            row_count: 100,
502            column_count: 10,
503            current_row: 0,
504            current_column: 0,
505        };
506
507        // Test arrow down
508        let key = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
509        let action = mapper.map_key(key, &context);
510        assert_eq!(action, Some(Action::Navigate(NavigateAction::Down(1))));
511
512        // Test vim j
513        let key = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
514        let action = mapper.map_key(key, &context);
515        assert_eq!(action, Some(Action::Navigate(NavigateAction::Down(1))));
516    }
517
518    #[test]
519    fn test_vim_count_motion() {
520        let mut mapper = KeyMapper::new();
521        let context = ActionContext {
522            mode: AppMode::Results,
523            selection_mode: SelectionMode::Row,
524            has_results: true,
525            has_filter: false,
526            has_search: false,
527            row_count: 100,
528            column_count: 10,
529            current_row: 0,
530            current_column: 0,
531        };
532
533        // Type "5"
534        let key = KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE);
535        let action = mapper.map_key(key, &context);
536        assert_eq!(action, None); // No action yet, collecting count
537        assert_eq!(mapper.get_count_buffer(), "5");
538
539        // Type "j"
540        let key = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
541        let action = mapper.map_key(key, &context);
542        assert_eq!(action, Some(Action::Navigate(NavigateAction::Down(5))));
543        assert_eq!(mapper.get_count_buffer(), ""); // Buffer cleared
544    }
545
546    #[test]
547    fn test_global_mapping_override() {
548        let mut mapper = KeyMapper::new();
549        let context = ActionContext {
550            mode: AppMode::Results,
551            selection_mode: SelectionMode::Row,
552            has_results: true,
553            has_filter: false,
554            has_search: false,
555            row_count: 100,
556            column_count: 10,
557            current_row: 0,
558            current_column: 0,
559        };
560
561        // F1 should work in any mode
562        let key = KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE);
563        let action = mapper.map_key(key, &context);
564        assert_eq!(action, Some(Action::ShowHelp));
565    }
566
567    #[test]
568    fn test_command_mode_editing_actions() {
569        let mut mapper = KeyMapper::new();
570        let context = ActionContext {
571            mode: AppMode::Command,
572            selection_mode: SelectionMode::Row,
573            has_results: false,
574            has_filter: false,
575            has_search: false,
576            row_count: 0,
577            column_count: 0,
578            current_row: 0,
579            current_column: 0,
580        };
581
582        // Test character input
583        let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
584        let action = mapper.map_key(key, &context);
585        assert_eq!(action, Some(Action::InsertChar('a')));
586
587        // Test uppercase character
588        let key = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);
589        let action = mapper.map_key(key, &context);
590        assert_eq!(action, Some(Action::InsertChar('A')));
591
592        // Test backspace
593        let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
594        let action = mapper.map_key(key, &context);
595        assert_eq!(action, Some(Action::Backspace));
596
597        // Test delete
598        let key = KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE);
599        let action = mapper.map_key(key, &context);
600        assert_eq!(action, Some(Action::Delete));
601
602        // Test cursor movement - left
603        let key = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);
604        let action = mapper.map_key(key, &context);
605        assert_eq!(action, Some(Action::MoveCursorLeft));
606
607        // Test cursor movement - right
608        let key = KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
609        let action = mapper.map_key(key, &context);
610        assert_eq!(action, Some(Action::MoveCursorRight));
611
612        // Test Ctrl+A (home)
613        let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL);
614        let action = mapper.map_key(key, &context);
615        assert_eq!(action, Some(Action::MoveCursorHome));
616
617        // Test Ctrl+E (end)
618        let key = KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL);
619        let action = mapper.map_key(key, &context);
620        assert_eq!(action, Some(Action::MoveCursorEnd));
621
622        // Test Ctrl+U (clear line)
623        let key = KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL);
624        let action = mapper.map_key(key, &context);
625        assert_eq!(action, Some(Action::ClearLine));
626
627        // Test Ctrl+W (delete word backward)
628        let key = KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL);
629        let action = mapper.map_key(key, &context);
630        assert_eq!(action, Some(Action::DeleteWordBackward));
631
632        // Test Ctrl+Z (undo)
633        let key = KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL);
634        let action = mapper.map_key(key, &context);
635        assert_eq!(action, Some(Action::Undo));
636
637        // Test Enter (execute query)
638        let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
639        let action = mapper.map_key(key, &context);
640        assert_eq!(action, Some(Action::ExecuteQuery));
641    }
642
643    #[test]
644    fn test_vim_style_append_modes() {
645        let mut mapper = KeyMapper::new();
646        let context = ActionContext {
647            mode: AppMode::Results,
648            selection_mode: SelectionMode::Row,
649            has_results: true,
650            has_filter: false,
651            has_search: false,
652            row_count: 100,
653            column_count: 10,
654            current_row: 0,
655            current_column: 0,
656        };
657
658        // Test 'i' for insert at current
659        let key = KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE);
660        let action = mapper.map_key(key, &context);
661        assert_eq!(
662            action,
663            Some(Action::SwitchModeWithCursor(
664                AppMode::Command,
665                CursorPosition::Current
666            ))
667        );
668
669        // Test 'a' for append at end
670        let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
671        let action = mapper.map_key(key, &context);
672        assert_eq!(
673            action,
674            Some(Action::SwitchModeWithCursor(
675                AppMode::Command,
676                CursorPosition::End
677            ))
678        );
679
680        // Note: SQL clause navigation (wa, oa, etc.) has been moved to the KeyChordHandler
681        // and is now accessed via chord sequences like 'cw' for WHERE, 'co' for ORDER BY.
682        // These are tested separately in the chord handler tests.
683    }
684
685    #[test]
686    fn test_sort_key_mapping() {
687        let mut mapper = KeyMapper::new();
688        let context = ActionContext {
689            mode: AppMode::Results,
690            selection_mode: SelectionMode::Row,
691            has_results: true,
692            has_filter: false,
693            has_search: false,
694            row_count: 100,
695            column_count: 10,
696            current_row: 0,
697            current_column: 0,
698        };
699
700        // Test 's' for standalone sort action (this was the original issue)
701        let key = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE);
702        let action = mapper.map_key(key, &context);
703        assert_eq!(action, Some(Action::Sort(None)));
704    }
705
706    #[test]
707    fn test_vim_go_to_top() {
708        let mut mapper = KeyMapper::new();
709        let context = ActionContext {
710            mode: AppMode::Results,
711            selection_mode: SelectionMode::Row,
712            has_results: true,
713            has_filter: false,
714            has_search: false,
715            row_count: 100,
716            column_count: 10,
717            current_row: 0,
718            current_column: 0,
719        };
720
721        // Test 'gg' for go to top (vim-style)
722        let key_g1 = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE);
723        let action_g1 = mapper.map_key(key_g1, &context);
724        assert_eq!(action_g1, None); // First 'g' starts collecting command
725
726        let key_g2 = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE);
727        let action_gg = mapper.map_key(key_g2, &context);
728        assert_eq!(action_gg, Some(Action::Navigate(NavigateAction::Home)));
729    }
730
731    #[test]
732    fn test_bug_reproduction_s_key_not_found() {
733        // This test reproduces the original bug where 's' key mapping wasn't found
734        let mut mapper = KeyMapper::new();
735        let context = ActionContext {
736            mode: AppMode::Results,
737            selection_mode: SelectionMode::Row,
738            has_results: true,
739            has_filter: false,
740            has_search: false,
741            row_count: 100,
742            column_count: 10,
743            current_row: 0,
744            current_column: 0,
745        };
746
747        // Before the fix, this would return None because 's' was being intercepted
748        // by vim command logic. After the fix, it should return Sort action.
749        let key = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE);
750        let action = mapper.map_key(key, &context);
751
752        // This should NOT be None - the bug was that map_key returned None
753        assert!(
754            action.is_some(),
755            "Bug reproduction: 's' key should map to an action, not return None"
756        );
757        assert_eq!(
758            action,
759            Some(Action::Sort(None)),
760            "Bug reproduction: 's' key should map to Sort action"
761        );
762    }
763}