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