tenseleyflow/fackr / ddc3697

Browse files

test fixes for arch linux builds

Authored by espadonne
SHA
ddc3697a8314e3fd20a14d7c0a21085cb51dd63e
Parents
798b461
Tree
5990a95

5 changed files

StatusFile+-
M Cargo.toml 5 1
M fackr.spec 29 1
A src/bin/keytest.rs 62 0
M src/editor/state.rs 274 1
M src/render/screen.rs 209 0
Cargo.tomlmodified
@@ -7,7 +7,7 @@ authors = ["Matthew Forrester Wolffe"]
77
 
88
 [dependencies]
99
 # Terminal
10
-crossterm = "0.28"
10
+crossterm = { version = "0.28", features = ["libc"] }
1111
 
1212
 # Text handling
1313
 ropey = "1.6"
@@ -39,6 +39,10 @@ path = "src/main.rs"
3939
 name = "fac"
4040
 path = "src/main.rs"
4141
 
42
+[[bin]]
43
+name = "keytest"
44
+path = "src/bin/keytest.rs"
45
+
4246
 [profile.release]
4347
 opt-level = 3
4448
 lto = true
fackr.specmodified
@@ -1,5 +1,5 @@
11
 Name:           fackr
2
-Version:        0.4.0
2
+Version:        0.9.6
33
 Release:        1%{?dist}
44
 Summary:        Terminal text editor written in Rust
55
 
@@ -49,6 +49,34 @@ install -Dm644 README.md %{buildroot}%{_docdir}/%{name}/README.md 2>/dev/null ||
4949
 %{_bindir}/fac
5050
 
5151
 %changelog
52
+* Wed Dec 11 2024 mfw <espadon@outlook.com> - 0.9.6-1
53
+- Fix command palette char input
54
+- Create new file from CLI
55
+
56
+* Wed Dec 11 2024 mfw <espadon@outlook.com> - 0.9.5-1
57
+- Command palette (Ctrl+P) with fuzzy search
58
+
59
+* Wed Dec 11 2024 mfw <espadon@outlook.com> - 0.9.4-1
60
+- Ctrl+D scrolls viewport to show newly added cursor
61
+
62
+* Tue Dec 10 2024 mfw <espadon@outlook.com> - 0.9.0-1
63
+- Add Ctrl+/ toggle line comment
64
+- Performance optimizations
65
+
66
+* Mon Dec 09 2024 mfw <espadon@outlook.com> - 0.8.0-1
67
+- Add Ctrl+G/F5 goto line with line:col syntax
68
+- Add Ctrl+O fortress file browser
69
+- Fix multi-cursor same-line edits
70
+
71
+* Mon Dec 09 2024 mfw <espadon@outlook.com> - 0.7.0-1
72
+- Add Ctrl+F/Ctrl+R find and replace with regex support
73
+- Fuzzy filter improvements and references panel visual fix
74
+
75
+* Sun Dec 08 2024 mfw <espadon@outlook.com> - 0.6.0-1
76
+- Add Shift+F12 references panel with filtering
77
+- Syntax highlighting for 30+ languages
78
+- LSP support with auto-completion and diagnostics
79
+
5280
 * Sat Dec 07 2024 mfw <espadon@outlook.com> - 0.4.0-1
5381
 - Initial RPM release of fackr
5482
 - Terminal text editor written in Rust
src/bin/keytest.rsadded
@@ -0,0 +1,62 @@
1
+// Simple key event debugger
2
+// Run with: cargo run --bin keytest
3
+
4
+use crossterm::{
5
+    event::{self, Event, KeyCode, KeyEvent, KeyModifiers, PushKeyboardEnhancementFlags, PopKeyboardEnhancementFlags, KeyboardEnhancementFlags},
6
+    terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
7
+    execute,
8
+};
9
+use std::io::{self, Write};
10
+
11
+fn main() -> io::Result<()> {
12
+    terminal::enable_raw_mode()?;
13
+    execute!(io::stdout(), EnterAlternateScreen)?;
14
+
15
+    // Try to enable keyboard enhancement
16
+    let enhanced = execute!(
17
+        io::stdout(),
18
+        PushKeyboardEnhancementFlags(
19
+            KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
20
+                | KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES
21
+        )
22
+    ).is_ok();
23
+
24
+    println!("Keyboard enhancement: {}\r", if enhanced { "enabled" } else { "disabled" });
25
+    println!("Press keys to see events (Ctrl+C to quit)\r\n");
26
+
27
+    loop {
28
+        if event::poll(std::time::Duration::from_millis(100))? {
29
+            match event::read()? {
30
+                Event::Key(KeyEvent { code, modifiers, kind, state }) => {
31
+                    let ctrl = modifiers.contains(KeyModifiers::CONTROL);
32
+                    let alt = modifiers.contains(KeyModifiers::ALT);
33
+                    let shift = modifiers.contains(KeyModifiers::SHIFT);
34
+
35
+                    println!(
36
+                        "Key: {:?} | Modifiers: ctrl={} alt={} shift={} | Kind: {:?} | State: {:?}\r",
37
+                        code, ctrl, alt, shift, kind, state
38
+                    );
39
+
40
+                    // Quit on Ctrl+C
41
+                    if let KeyCode::Char('c') = code {
42
+                        if ctrl {
43
+                            break;
44
+                        }
45
+                    }
46
+                }
47
+                Event::Resize(w, h) => {
48
+                    println!("Resize: {}x{}\r", w, h);
49
+                }
50
+                _ => {}
51
+            }
52
+        }
53
+    }
54
+
55
+    if enhanced {
56
+        let _ = execute!(io::stdout(), PopKeyboardEnhancementFlags);
57
+    }
58
+    execute!(io::stdout(), LeaveAlternateScreen)?;
59
+    terminal::disable_raw_mode()?;
60
+
61
+    Ok(())
62
+}
src/editor/state.rsmodified
@@ -128,6 +128,134 @@ const ALL_COMMANDS: &[PaletteCommand] = &[
128128
 
129129
     // Help
130130
     PaletteCommand::new("Command Palette", "Ctrl+P", "Help", "command-palette"),
131
+    PaletteCommand::new("Help / Keybindings", "Shift+F1", "Help", "help"),
132
+];
133
+
134
+/// A keybinding entry for the help menu
135
+#[derive(Debug, Clone, PartialEq)]
136
+struct HelpKeybind {
137
+    /// Keyboard shortcut (e.g., "Ctrl+S")
138
+    shortcut: &'static str,
139
+    /// Description of what the keybind does
140
+    description: &'static str,
141
+    /// Category for grouping
142
+    category: &'static str,
143
+}
144
+
145
+impl HelpKeybind {
146
+    const fn new(shortcut: &'static str, description: &'static str, category: &'static str) -> Self {
147
+        Self { shortcut, description, category }
148
+    }
149
+}
150
+
151
+/// All keybindings for the help menu - comprehensive list
152
+const ALL_KEYBINDS: &[HelpKeybind] = &[
153
+    // File Operations
154
+    HelpKeybind::new("Ctrl+S", "Save file", "File"),
155
+    HelpKeybind::new("Ctrl+O", "Open file browser (Fortress)", "File"),
156
+    HelpKeybind::new("Ctrl+Q", "Quit editor", "File"),
157
+    HelpKeybind::new("Ctrl+B / F3", "Toggle file explorer", "File"),
158
+
159
+    // Tabs
160
+    HelpKeybind::new("Alt+T", "New tab", "Tabs"),
161
+    HelpKeybind::new("Alt+Q", "Close tab/pane", "Tabs"),
162
+    HelpKeybind::new("Alt+.", "Next tab", "Tabs"),
163
+    HelpKeybind::new("Alt+,", "Previous tab", "Tabs"),
164
+    HelpKeybind::new("Alt+1-9", "Switch to tab 1-9", "Tabs"),
165
+
166
+    // Panes
167
+    HelpKeybind::new("Alt+V", "Split vertical", "Panes"),
168
+    HelpKeybind::new("Alt+S", "Split horizontal", "Panes"),
169
+    HelpKeybind::new("Alt+H/J/K/L", "Navigate panes (vim-style)", "Panes"),
170
+    HelpKeybind::new("Alt+N", "Next pane", "Panes"),
171
+    HelpKeybind::new("Alt+P", "Previous pane", "Panes"),
172
+
173
+    // Editing
174
+    HelpKeybind::new("Ctrl+Z", "Undo", "Edit"),
175
+    HelpKeybind::new("Ctrl+Shift+Z", "Redo", "Edit"),
176
+    HelpKeybind::new("Ctrl+C", "Copy", "Edit"),
177
+    HelpKeybind::new("Ctrl+X", "Cut", "Edit"),
178
+    HelpKeybind::new("Ctrl+V", "Paste", "Edit"),
179
+    HelpKeybind::new("Ctrl+J", "Join lines", "Edit"),
180
+    HelpKeybind::new("Ctrl+/", "Toggle line comment", "Edit"),
181
+    HelpKeybind::new("Ctrl+T", "Transpose characters", "Edit"),
182
+    HelpKeybind::new("Tab", "Indent", "Edit"),
183
+    HelpKeybind::new("Shift+Tab", "Outdent", "Edit"),
184
+    HelpKeybind::new("Backspace", "Delete backward", "Edit"),
185
+    HelpKeybind::new("Delete", "Delete forward", "Edit"),
186
+    HelpKeybind::new("Ctrl+W", "Delete word backward", "Edit"),
187
+    HelpKeybind::new("Alt+D", "Delete word forward", "Edit"),
188
+    HelpKeybind::new("Alt+Backspace", "Delete word backward", "Edit"),
189
+
190
+    // Line Operations
191
+    HelpKeybind::new("Alt+Up", "Move line up", "Lines"),
192
+    HelpKeybind::new("Alt+Down", "Move line down", "Lines"),
193
+    HelpKeybind::new("Alt+Shift+Up", "Duplicate line up", "Lines"),
194
+    HelpKeybind::new("Alt+Shift+Down", "Duplicate line down", "Lines"),
195
+
196
+    // Movement
197
+    HelpKeybind::new("Arrow keys", "Move cursor", "Movement"),
198
+    HelpKeybind::new("Home / Ctrl+A", "Go to line start (smart)", "Movement"),
199
+    HelpKeybind::new("End / Ctrl+E", "Go to line end", "Movement"),
200
+    HelpKeybind::new("Alt+Left / Alt+B", "Move word left", "Movement"),
201
+    HelpKeybind::new("Alt+Right / Alt+F", "Move word right", "Movement"),
202
+    HelpKeybind::new("PageUp", "Page up", "Movement"),
203
+    HelpKeybind::new("PageDown", "Page down", "Movement"),
204
+    HelpKeybind::new("Ctrl+G / F5", "Go to line", "Movement"),
205
+
206
+    // Selection
207
+    HelpKeybind::new("Shift+Arrow", "Extend selection", "Selection"),
208
+    HelpKeybind::new("Ctrl+L", "Select line", "Selection"),
209
+    HelpKeybind::new("Ctrl+D", "Select word / next occurrence", "Selection"),
210
+    HelpKeybind::new("Escape", "Clear selection / collapse cursors", "Selection"),
211
+    HelpKeybind::new("Ctrl+Alt+Up", "Add cursor above", "Selection"),
212
+    HelpKeybind::new("Ctrl+Alt+Down", "Add cursor below", "Selection"),
213
+
214
+    // Search
215
+    HelpKeybind::new("Ctrl+F", "Find", "Search"),
216
+    HelpKeybind::new("Ctrl+R", "Find and replace", "Search"),
217
+    HelpKeybind::new("F3", "Find next", "Search"),
218
+    HelpKeybind::new("Shift+F3", "Find previous", "Search"),
219
+    HelpKeybind::new("F4", "Search in files", "Search"),
220
+    HelpKeybind::new("Alt+I", "Toggle case sensitivity (in find)", "Search"),
221
+    HelpKeybind::new("Alt+X", "Toggle regex mode (in find)", "Search"),
222
+    HelpKeybind::new("Alt+Enter", "Replace all (in find)", "Search"),
223
+
224
+    // Brackets & Quotes
225
+    HelpKeybind::new("Alt+[ / Alt+]", "Jump to matching bracket", "Brackets"),
226
+    HelpKeybind::new("Alt+'", "Cycle quote type (\"/'/`)", "Brackets"),
227
+    HelpKeybind::new("Alt+\"", "Remove surrounding quotes", "Brackets"),
228
+    HelpKeybind::new("Alt+(", "Cycle bracket type (/{/[)", "Brackets"),
229
+    HelpKeybind::new("Alt+)", "Remove surrounding brackets", "Brackets"),
230
+
231
+    // LSP / Code Intelligence
232
+    HelpKeybind::new("F1", "Show hover info", "LSP"),
233
+    HelpKeybind::new("F2", "Rename symbol", "LSP"),
234
+    HelpKeybind::new("F12", "Go to definition", "LSP"),
235
+    HelpKeybind::new("Shift+F12", "Find references", "LSP"),
236
+    HelpKeybind::new("Ctrl+N", "Trigger completion", "LSP"),
237
+    HelpKeybind::new("Alt+M", "LSP server manager", "LSP"),
238
+
239
+    // Help & Commands
240
+    HelpKeybind::new("Ctrl+P", "Command palette", "Help"),
241
+    HelpKeybind::new("Shift+F1", "Help / keybindings", "Help"),
242
+
243
+    // File Explorer (Fortress/Fuss mode)
244
+    HelpKeybind::new("Up/Down", "Navigate files", "Explorer"),
245
+    HelpKeybind::new("Enter", "Open file/directory", "Explorer"),
246
+    HelpKeybind::new("Right", "Expand directory", "Explorer"),
247
+    HelpKeybind::new("Left", "Collapse / go to parent", "Explorer"),
248
+    HelpKeybind::new("Space", "Toggle selection", "Explorer"),
249
+    HelpKeybind::new("a", "Add file", "Explorer"),
250
+    HelpKeybind::new("d", "Delete selected", "Explorer"),
251
+    HelpKeybind::new("m", "Move/rename selected", "Explorer"),
252
+    HelpKeybind::new("p", "Paste", "Explorer"),
253
+    HelpKeybind::new("u", "Undo last action", "Explorer"),
254
+    HelpKeybind::new("f", "Create folder", "Explorer"),
255
+    HelpKeybind::new("t", "Open in new tab", "Explorer"),
256
+    HelpKeybind::new("l", "Open in vertical split", "Explorer"),
257
+    HelpKeybind::new("Alt+G", "Git status", "Explorer"),
258
+    HelpKeybind::new("Alt+.", "Toggle hidden files", "Explorer"),
131259
 ];
132260
 
133261
 /// Prompt state for quit confirmation
@@ -206,6 +334,17 @@ enum PromptState {
206334
         /// Scroll offset for long lists
207335
         scroll_offset: usize,
208336
     },
337
+    /// Help menu (Shift+F1)
338
+    HelpMenu {
339
+        /// Search/filter query
340
+        query: String,
341
+        /// Filtered keybinds matching query
342
+        filtered: Vec<HelpKeybind>,
343
+        /// Currently selected index
344
+        selected_index: usize,
345
+        /// Scroll offset for long lists
346
+        scroll_offset: usize,
347
+    },
209348
 }
210349
 
211350
 /// A single result from multi-file search
@@ -1588,6 +1727,27 @@ impl Editor {
15881727
                 return Ok(()); // Modal handles cursor
15891728
             }
15901729
 
1730
+            // Render help menu if active
1731
+            if let PromptState::HelpMenu {
1732
+                ref query,
1733
+                ref filtered,
1734
+                selected_index,
1735
+                scroll_offset,
1736
+            } = self.prompt {
1737
+                // Convert keybinds to tuple format for render function
1738
+                let keybinds_tuples: Vec<(String, String, String)> = filtered
1739
+                    .iter()
1740
+                    .map(|kb| (kb.shortcut.to_string(), kb.description.to_string(), kb.category.to_string()))
1741
+                    .collect();
1742
+                self.screen.render_help_menu(
1743
+                    query,
1744
+                    &keybinds_tuples,
1745
+                    selected_index,
1746
+                    scroll_offset,
1747
+                )?;
1748
+                return Ok(()); // Modal handles cursor
1749
+            }
1750
+
15911751
             // Render find/replace bar if active (replaces status bar)
15921752
             if let PromptState::FindReplace {
15931753
                 ref find_query,
@@ -1964,7 +2124,7 @@ impl Editor {
19642124
             // Find references: Shift+F12
19652125
             (Key::F(12), Modifiers { shift: true, .. }) => self.lsp_find_references(),
19662126
             // Hover info: F1
1967
-            (Key::F(1), _) => self.lsp_hover(),
2127
+            (Key::F(1), Modifiers { shift: false, .. }) => self.lsp_hover(),
19682128
             // Code completion: Ctrl+N (vim-style)
19692129
             (Key::Char('n'), Modifiers { ctrl: true, .. }) => self.lsp_complete(),
19702130
             // Rename: F2
@@ -1972,6 +2132,10 @@ impl Editor {
19722132
             // Server manager: Alt+M
19732133
             (Key::Char('m'), Modifiers { alt: true, .. }) => self.toggle_server_manager(),
19742134
 
2135
+            // === Help ===
2136
+            // Help / keybindings: Shift+F1
2137
+            (Key::F(1), Modifiers { shift: true, .. }) => self.open_help_menu(),
2138
+
19752139
             _ => {}
19762140
         }
19772141
 
@@ -4616,6 +4780,72 @@ impl Editor {
46164780
                     _ => {}
46174781
                 }
46184782
             }
4783
+            PromptState::HelpMenu {
4784
+                ref mut query,
4785
+                ref mut filtered,
4786
+                ref mut selected_index,
4787
+                ref mut scroll_offset,
4788
+            } => {
4789
+                match key {
4790
+                    Key::Escape | Key::Enter => {
4791
+                        self.prompt = PromptState::None;
4792
+                    }
4793
+                    Key::Up => {
4794
+                        if *selected_index > 0 {
4795
+                            *selected_index -= 1;
4796
+                            if *selected_index < *scroll_offset {
4797
+                                *scroll_offset = *selected_index;
4798
+                            }
4799
+                        }
4800
+                    }
4801
+                    Key::Down => {
4802
+                        if *selected_index + 1 < filtered.len() {
4803
+                            *selected_index += 1;
4804
+                            let visible_rows = 18;
4805
+                            if *selected_index >= *scroll_offset + visible_rows {
4806
+                                *scroll_offset = selected_index.saturating_sub(visible_rows - 1);
4807
+                            }
4808
+                        }
4809
+                    }
4810
+                    Key::PageUp => {
4811
+                        *selected_index = selected_index.saturating_sub(10);
4812
+                        if *selected_index < *scroll_offset {
4813
+                            *scroll_offset = *selected_index;
4814
+                        }
4815
+                    }
4816
+                    Key::PageDown => {
4817
+                        *selected_index = (*selected_index + 10).min(filtered.len().saturating_sub(1));
4818
+                        let visible_rows = 18;
4819
+                        if *selected_index >= *scroll_offset + visible_rows {
4820
+                            *scroll_offset = selected_index.saturating_sub(visible_rows - 1);
4821
+                        }
4822
+                    }
4823
+                    Key::Home => {
4824
+                        *selected_index = 0;
4825
+                        *scroll_offset = 0;
4826
+                    }
4827
+                    Key::End => {
4828
+                        *selected_index = filtered.len().saturating_sub(1);
4829
+                        let visible_rows = 18;
4830
+                        *scroll_offset = selected_index.saturating_sub(visible_rows - 1);
4831
+                    }
4832
+                    Key::Backspace => {
4833
+                        if !query.is_empty() {
4834
+                            query.pop();
4835
+                            *filtered = filter_keybinds(query);
4836
+                            *selected_index = 0;
4837
+                            *scroll_offset = 0;
4838
+                        }
4839
+                    }
4840
+                    Key::Char(c) => {
4841
+                        query.push(c);
4842
+                        *filtered = filter_keybinds(query);
4843
+                        *selected_index = 0;
4844
+                        *scroll_offset = 0;
4845
+                    }
4846
+                    _ => {}
4847
+                }
4848
+            }
46194849
             PromptState::None => {}
46204850
         }
46214851
         Ok(())
@@ -5472,12 +5702,26 @@ impl Editor {
54725702
 
54735703
             // Help
54745704
             "command-palette" => {} // Already open
5705
+            "help" => self.open_help_menu(),
54755706
 
54765707
             _ => {
54775708
                 self.message = Some(format!("Unknown command: {}", command_id));
54785709
             }
54795710
         }
54805711
     }
5712
+
5713
+    // === Help Menu ===
5714
+
5715
+    /// Open the help menu with keybindings
5716
+    fn open_help_menu(&mut self) {
5717
+        let filtered = filter_keybinds("");
5718
+        self.prompt = PromptState::HelpMenu {
5719
+            query: String::new(),
5720
+            filtered,
5721
+            selected_index: 0,
5722
+            scroll_offset: 0,
5723
+        };
5724
+    }
54815725
 }
54825726
 
54835727
 /// Fuzzy match scoring for command palette
@@ -5558,6 +5802,35 @@ fn filter_commands(query: &str) -> Vec<PaletteCommand> {
55585802
     filtered
55595803
 }
55605804
 
5805
+/// Filter keybinds by fuzzy match (for help menu)
5806
+fn filter_keybinds(query: &str) -> Vec<HelpKeybind> {
5807
+    if query.is_empty() {
5808
+        // Return all keybinds in original order (grouped by category)
5809
+        return ALL_KEYBINDS.to_vec();
5810
+    }
5811
+
5812
+    let mut filtered: Vec<(HelpKeybind, i32)> = ALL_KEYBINDS
5813
+        .iter()
5814
+        .filter_map(|kb| {
5815
+            // Match against shortcut, description, or category
5816
+            let shortcut_score = fuzzy_match_score(kb.shortcut, query);
5817
+            let desc_score = fuzzy_match_score(kb.description, query);
5818
+            let category_score = fuzzy_match_score(kb.category, query) / 2;
5819
+
5820
+            let score = shortcut_score.max(desc_score).max(category_score);
5821
+            if score > 0 {
5822
+                Some((kb.clone(), score))
5823
+            } else {
5824
+                None
5825
+            }
5826
+        })
5827
+        .collect();
5828
+
5829
+    // Sort by score descending
5830
+    filtered.sort_by(|a, b| b.1.cmp(&a.1));
5831
+    filtered.into_iter().map(|(kb, _)| kb).collect()
5832
+}
5833
+
55615834
 impl Drop for Editor {
55625835
     fn drop(&mut self) {
55635836
         let _ = self.screen.leave_raw_mode();
src/render/screen.rsmodified
@@ -2710,6 +2710,215 @@ impl Screen {
27102710
         Ok(())
27112711
     }
27122712
 
2713
+    /// Render the help menu modal (Shift+F1)
2714
+    pub fn render_help_menu(
2715
+        &mut self,
2716
+        query: &str,
2717
+        keybinds: &[(String, String, String)], // (shortcut, description, category)
2718
+        selected_index: usize,
2719
+        scroll_offset: usize,
2720
+    ) -> Result<()> {
2721
+        let (width, height) = (self.cols as usize, self.rows as usize);
2722
+
2723
+        // Modal dimensions - larger to show keybindings comfortably
2724
+        let modal_width = 70.min(width - 4);
2725
+        let modal_height = 24.min(height - 4);
2726
+        let start_col = (width.saturating_sub(modal_width)) / 2;
2727
+        let start_row = 1; // Near top of screen
2728
+
2729
+        // Colors - sleek dark theme matching command palette
2730
+        let bg = Color::AnsiValue(236);
2731
+        let border_color = Color::AnsiValue(240);
2732
+        let title_color = Color::Cyan;
2733
+        let category_color = Color::AnsiValue(243);
2734
+        let shortcut_color = Color::Yellow;
2735
+        let desc_color = Color::White;
2736
+        let selected_bg = Color::AnsiValue(24); // Blue highlight
2737
+        let input_bg = Color::AnsiValue(238);
2738
+
2739
+        // Draw top border with title
2740
+        let title = " Keybindings ";
2741
+        let title_padding = (modal_width.saturating_sub(title.len() + 2)) / 2;
2742
+        execute!(
2743
+            self.stdout,
2744
+            MoveTo(start_col as u16, start_row as u16),
2745
+            SetBackgroundColor(bg),
2746
+            SetForegroundColor(border_color),
2747
+            Print("╭"),
2748
+            Print(format!("{:─<width$}", "", width = title_padding)),
2749
+            SetForegroundColor(title_color),
2750
+            SetAttribute(crossterm::style::Attribute::Bold),
2751
+            Print(title),
2752
+            SetAttribute(crossterm::style::Attribute::Reset),
2753
+            SetForegroundColor(border_color),
2754
+            Print(format!("{:─<width$}", "", width = modal_width.saturating_sub(title_padding + title.len() + 2))),
2755
+            Print("╮"),
2756
+            ResetColor,
2757
+        )?;
2758
+
2759
+        // Draw search input row
2760
+        let display_query = if query.is_empty() { "Type to filter..." } else { query };
2761
+        let input_display_width = modal_width.saturating_sub(6);
2762
+        let placeholder_color = if query.is_empty() { Color::AnsiValue(243) } else { Color::White };
2763
+        execute!(
2764
+            self.stdout,
2765
+            MoveTo(start_col as u16, (start_row + 1) as u16),
2766
+            SetBackgroundColor(bg),
2767
+            SetForegroundColor(border_color),
2768
+            Print("│ "),
2769
+            SetBackgroundColor(input_bg),
2770
+            SetForegroundColor(placeholder_color),
2771
+            Print(format!(" {:<width$}", display_query, width = input_display_width)),
2772
+            SetBackgroundColor(bg),
2773
+            SetForegroundColor(border_color),
2774
+            Print(" │"),
2775
+            ResetColor,
2776
+        )?;
2777
+
2778
+        // Draw separator
2779
+        execute!(
2780
+            self.stdout,
2781
+            MoveTo(start_col as u16, (start_row + 2) as u16),
2782
+            SetBackgroundColor(bg),
2783
+            SetForegroundColor(border_color),
2784
+            Print(format!("├{:─<width$}┤", "", width = modal_width.saturating_sub(2))),
2785
+            ResetColor,
2786
+        )?;
2787
+
2788
+        // Calculate visible range
2789
+        let visible_rows = modal_height.saturating_sub(5);
2790
+
2791
+        // Adjust scroll offset for visibility
2792
+        let scroll = if selected_index < scroll_offset {
2793
+            selected_index
2794
+        } else if selected_index >= scroll_offset + visible_rows {
2795
+            selected_index - visible_rows + 1
2796
+        } else {
2797
+            scroll_offset
2798
+        };
2799
+
2800
+        // Track current category for headers
2801
+        let mut last_category = String::new();
2802
+
2803
+        // Draw keybindings
2804
+        let mut row_offset = 0;
2805
+        for (idx, (shortcut, description, category)) in keybinds.iter().enumerate().skip(scroll) {
2806
+            if row_offset >= visible_rows {
2807
+                break;
2808
+            }
2809
+
2810
+            let row = (start_row + 3 + row_offset) as u16;
2811
+            let is_selected = idx == selected_index;
2812
+
2813
+            // Check if we're starting a new category (only when not filtering)
2814
+            if query.is_empty() && category != &last_category {
2815
+                last_category = category.clone();
2816
+            }
2817
+
2818
+            let item_bg = if is_selected { selected_bg } else { bg };
2819
+
2820
+            // Format: Shortcut (fixed width)   Description
2821
+            let shortcut_width = 20;
2822
+            let desc_width = modal_width.saturating_sub(shortcut_width + 8);
2823
+
2824
+            // Truncate description if needed
2825
+            let display_desc = if description.len() > desc_width {
2826
+                format!("{}…", &description[..desc_width.saturating_sub(1)])
2827
+            } else {
2828
+                description.clone()
2829
+            };
2830
+
2831
+            // Truncate shortcut if needed
2832
+            let display_shortcut = if shortcut.len() > shortcut_width {
2833
+                format!("{}…", &shortcut[..shortcut_width.saturating_sub(1)])
2834
+            } else {
2835
+                shortcut.clone()
2836
+            };
2837
+
2838
+            execute!(
2839
+                self.stdout,
2840
+                MoveTo(start_col as u16, row),
2841
+                SetBackgroundColor(item_bg),
2842
+                SetForegroundColor(border_color),
2843
+                Print("│ "),
2844
+                SetForegroundColor(shortcut_color),
2845
+                SetAttribute(crossterm::style::Attribute::Bold),
2846
+                Print(format!("{:<width$}", display_shortcut, width = shortcut_width)),
2847
+                SetAttribute(crossterm::style::Attribute::Reset),
2848
+                SetBackgroundColor(item_bg),
2849
+                SetForegroundColor(desc_color),
2850
+                Print(format!(" {:<width$}", display_desc, width = desc_width)),
2851
+                SetForegroundColor(category_color),
2852
+                Print(format!(" {:>6}", if row_offset == 0 || query.is_empty() && keybinds.get(idx.wrapping_sub(1)).map(|(_, _, c)| c != category).unwrap_or(true) { category.as_str() } else { "" })),
2853
+                SetForegroundColor(border_color),
2854
+                Print(" │"),
2855
+                ResetColor,
2856
+            )?;
2857
+
2858
+            row_offset += 1;
2859
+        }
2860
+
2861
+        // Fill remaining rows
2862
+        for i in row_offset..visible_rows {
2863
+            let row = (start_row + 3 + i) as u16;
2864
+            execute!(
2865
+                self.stdout,
2866
+                MoveTo(start_col as u16, row),
2867
+                SetBackgroundColor(bg),
2868
+                SetForegroundColor(border_color),
2869
+                Print(format!("│{:width$}│", "", width = modal_width.saturating_sub(2))),
2870
+                ResetColor,
2871
+            )?;
2872
+        }
2873
+
2874
+        // Draw info row
2875
+        let info_row = (start_row + 3 + visible_rows) as u16;
2876
+        let result_count = if keybinds.is_empty() {
2877
+            "No matches".to_string()
2878
+        } else {
2879
+            format!("{} keybinds", keybinds.len())
2880
+        };
2881
+        execute!(
2882
+            self.stdout,
2883
+            MoveTo(start_col as u16, info_row),
2884
+            SetBackgroundColor(bg),
2885
+            SetForegroundColor(border_color),
2886
+            Print("├"),
2887
+            SetForegroundColor(Color::AnsiValue(243)),
2888
+            Print(format!(" {} ", result_count)),
2889
+            SetForegroundColor(border_color),
2890
+            Print(format!("{:─<width$}", "", width = modal_width.saturating_sub(result_count.len() + 4))),
2891
+            Print("┤"),
2892
+            ResetColor,
2893
+        )?;
2894
+
2895
+        // Draw bottom border
2896
+        execute!(
2897
+            self.stdout,
2898
+            MoveTo(start_col as u16, info_row + 1),
2899
+            SetBackgroundColor(bg),
2900
+            SetForegroundColor(border_color),
2901
+            Print(format!("╰{:─<width$}╯", "", width = modal_width.saturating_sub(2))),
2902
+            ResetColor,
2903
+        )?;
2904
+
2905
+        // Show help text below
2906
+        let help_text = "↑↓:scroll  PgUp/PgDn:page  Home/End:jump  Esc:close";
2907
+        execute!(
2908
+            self.stdout,
2909
+            MoveTo(start_col as u16, info_row + 2),
2910
+            SetForegroundColor(Color::AnsiValue(243)),
2911
+            Print(format!("{:^width$}", help_text, width = modal_width)),
2912
+            ResetColor,
2913
+        )?;
2914
+
2915
+        // Hide cursor when in modal
2916
+        execute!(self.stdout, Hide)?;
2917
+
2918
+        self.stdout.flush()?;
2919
+        Ok(())
2920
+    }
2921
+
27132922
     /// Render the LSP references panel (sidebar style)
27142923
     pub fn render_references_panel(
27152924
         &mut self,