Skip to main content

fresh/view/
keybinding_editor.rs

1//! Keybinding Editor rendering and input handling
2//!
3//! Renders the keybinding editor modal and handles input events.
4
5use crate::app::keybinding_editor::{
6    BindingSource, ContextFilter, DeleteResult, DisplayRow, EditMode, KeybindingEditor, SearchMode,
7    SourceFilter,
8};
9use crate::input::keybindings::{format_keybinding, KeybindingResolver};
10use crate::view::dimming::apply_dimming;
11use crate::view::theme::Theme;
12use crate::view::ui::scrollbar::{render_scrollbar, ScrollbarColors};
13use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
14use ratatui::{
15    layout::{Constraint, Layout, Rect},
16    style::{Modifier, Style},
17    text::{Line, Span},
18    widgets::{Block, Borders, Clear, Paragraph},
19    Frame,
20};
21use rust_i18n::t;
22
23/// Compute the centred modal rectangle for the keybinding editor within
24/// `area`.
25///
26/// The result is offset by `area.x`/`area.y` so the modal centres within
27/// the area it was handed — not the whole screen. When the orchestrator
28/// left dock is open the editor passes the post-dock-split `chrome_area`
29/// (whose `x` starts to the right of the dock); without these offsets the
30/// modal would be placed relative to column 0 and bleed left under the
31/// dock, leaving it partially obscured and off-centre.
32fn keybinding_modal_area(area: Rect) -> Rect {
33    // Modal dimensions: 90% width, 90% height
34    let modal_width = (area.width as f32 * 0.90).min(120.0) as u16;
35    let modal_height = (area.height as f32 * 0.90) as u16;
36    let modal_width = modal_width.max(60).min(area.width.saturating_sub(2));
37    let modal_height = modal_height.max(20).min(area.height.saturating_sub(2));
38
39    let x = area.x + (area.width.saturating_sub(modal_width)) / 2;
40    let y = area.y + (area.height.saturating_sub(modal_height)) / 2;
41
42    Rect {
43        x,
44        y,
45        width: modal_width,
46        height: modal_height,
47    }
48}
49
50/// Render the keybinding editor modal
51pub fn render_keybinding_editor(
52    frame: &mut Frame,
53    area: Rect,
54    editor: &mut KeybindingEditor,
55    theme: &Theme,
56) {
57    let modal_area = keybinding_modal_area(area);
58
59    // Clear background
60    frame.render_widget(Clear, modal_area);
61
62    // Border
63    let title = format!(
64        " {} \u{2500} [{}] ",
65        t!("keybinding_editor.title"),
66        editor.active_keymap
67    );
68    let block = Block::default()
69        .title(title)
70        .borders(Borders::ALL)
71        .border_style(Style::default().fg(theme.popup_border_fg))
72        .style(Style::default().bg(theme.popup_bg).fg(theme.popup_text_fg));
73
74    let inner = block.inner(modal_area);
75    frame.render_widget(block, modal_area);
76
77    // Layout: header (3-4 lines) | table | footer (1 line)
78    let chunks = Layout::vertical([
79        Constraint::Length(3), // Header: config path + search + filters
80        Constraint::Min(5),    // Table
81        Constraint::Length(1), // Footer hints
82    ])
83    .split(inner);
84
85    // Store layout for mouse hit testing
86    editor.layout.modal_area = modal_area;
87    editor.layout.table_area = chunks[1];
88    editor.layout.table_first_row_y = chunks[1].y + 2; // +2 for header + separator
89    editor.layout.search_bar = Some(Rect {
90        x: inner.x,
91        y: inner.y + 1, // second row of header
92        width: inner.width,
93        height: 1,
94    });
95    // Reset dialog layouts (will be set if dialogs are rendered)
96    editor.layout.dialog_buttons = None;
97    editor.layout.dialog_key_field = None;
98    editor.layout.dialog_action_field = None;
99    editor.layout.dialog_context_field = None;
100    editor.layout.confirm_buttons = None;
101    editor.layout.table_scrollbar = None;
102
103    render_header(frame, chunks[0], editor, theme);
104    render_table(frame, chunks[1], editor, theme);
105    render_footer(frame, chunks[2], editor, theme);
106
107    // Render dialogs on top
108    if editor.showing_help {
109        render_help_overlay(frame, inner, theme);
110    }
111
112    // Need to temporarily take dialog to avoid borrow conflict
113    if let Some(dialog) = editor.edit_dialog.take() {
114        apply_dimming(frame, modal_area);
115        render_edit_dialog(frame, inner, &dialog, editor, theme);
116        editor.edit_dialog = Some(dialog);
117    }
118
119    if editor.showing_confirm_dialog {
120        apply_dimming(frame, modal_area);
121        render_confirm_dialog(frame, inner, editor, theme);
122    }
123}
124
125/// Render the header section (config path, search, filters)
126fn render_header(frame: &mut Frame, area: Rect, editor: &KeybindingEditor, theme: &Theme) {
127    let chunks = Layout::vertical([
128        Constraint::Length(1), // Config path + keymap info
129        Constraint::Length(1), // Search bar
130        Constraint::Length(1), // Filters
131    ])
132    .split(area);
133
134    // Line 1: Config file path and keymap names
135    let mut path_spans = vec![
136        Span::styled(
137            format!(" {} ", t!("keybinding_editor.label_config")),
138            Style::default().fg(theme.popup_text_fg),
139        ),
140        Span::styled(
141            &editor.config_file_path,
142            Style::default().fg(theme.diagnostic_info_fg),
143        ),
144    ];
145    if !editor.keymap_names.is_empty() {
146        path_spans.push(Span::styled(
147            format!("  {} ", t!("keybinding_editor.label_maps")),
148            Style::default().fg(theme.popup_text_fg),
149        ));
150        path_spans.push(Span::styled(
151            editor.keymap_names.join(", "),
152            Style::default().fg(theme.popup_text_fg),
153        ));
154    }
155    frame.render_widget(Paragraph::new(Line::from(path_spans)), chunks[0]);
156
157    // Line 2: Search bar
158    if editor.search_active {
159        let search_spans = match editor.search_mode {
160            SearchMode::Text => {
161                let mut spans = vec![
162                    Span::styled(
163                        format!(" {} ", t!("keybinding_editor.label_search")),
164                        Style::default()
165                            .fg(theme.help_key_fg)
166                            .add_modifier(Modifier::BOLD),
167                    ),
168                    Span::styled(
169                        &editor.search_query,
170                        Style::default().fg(theme.popup_text_fg),
171                    ),
172                ];
173                if editor.search_focused {
174                    spans.push(Span::styled("_", Style::default().fg(theme.cursor)));
175                    spans.push(Span::styled(
176                        format!("  {}", t!("keybinding_editor.search_text_hint")),
177                        Style::default().fg(theme.popup_text_fg),
178                    ));
179                }
180                spans
181            }
182            SearchMode::RecordKey => {
183                let key_text = if editor.search_key_display.is_empty() {
184                    t!("keybinding_editor.press_a_key").to_string()
185                } else {
186                    editor.search_key_display.clone()
187                };
188                vec![
189                    Span::styled(
190                        format!(" {} ", t!("keybinding_editor.label_record_key")),
191                        Style::default()
192                            .fg(theme.diagnostic_warning_fg)
193                            .add_modifier(Modifier::BOLD),
194                    ),
195                    Span::styled(key_text, Style::default().fg(theme.popup_text_fg)),
196                    Span::styled(
197                        format!("  {}", t!("keybinding_editor.search_record_hint")),
198                        Style::default().fg(theme.popup_text_fg),
199                    ),
200                ]
201            }
202        };
203        frame.render_widget(Paragraph::new(Line::from(search_spans)), chunks[1]);
204    } else {
205        let hint = Line::from(vec![
206            Span::styled(" ", Style::default()),
207            Span::styled(
208                t!("keybinding_editor.search_hint").to_string(),
209                Style::default().fg(theme.popup_text_fg),
210            ),
211        ]);
212        frame.render_widget(Paragraph::new(hint), chunks[1]);
213    }
214
215    // Line 3: Filters and counts
216    let total = editor.bindings.len();
217    let filtered = editor.filtered_indices.len();
218    let count_str = if filtered == total {
219        t!("keybinding_editor.bindings_count", count = total).to_string()
220    } else {
221        t!(
222            "keybinding_editor.bindings_filtered",
223            filtered = filtered,
224            total = total
225        )
226        .to_string()
227    };
228
229    let filter_spans = vec![
230        Span::styled(
231            format!(" {} ", t!("keybinding_editor.label_context")),
232            Style::default().fg(theme.popup_text_fg),
233        ),
234        Span::styled(
235            format!("[{}]", editor.context_filter_display()),
236            Style::default().fg(if editor.context_filter == ContextFilter::All {
237                theme.popup_text_fg
238            } else {
239                theme.diagnostic_info_fg
240            }),
241        ),
242        Span::styled(
243            format!("  {} ", t!("keybinding_editor.label_source")),
244            Style::default().fg(theme.popup_text_fg),
245        ),
246        Span::styled(
247            format!("[{}]", editor.source_filter_display()),
248            Style::default().fg(if editor.source_filter == SourceFilter::All {
249                theme.popup_text_fg
250            } else {
251                theme.diagnostic_info_fg
252            }),
253        ),
254        Span::styled(
255            format!("  {}", count_str),
256            Style::default().fg(theme.popup_text_fg),
257        ),
258        Span::styled(
259            if editor.has_changes {
260                format!("  {}", t!("keybinding_editor.modified"))
261            } else {
262                String::new()
263            },
264            Style::default().fg(theme.diagnostic_warning_fg),
265        ),
266    ];
267    frame.render_widget(Paragraph::new(Line::from(filter_spans)), chunks[2]);
268}
269
270/// Render the keybinding table
271fn render_table(frame: &mut Frame, area: Rect, editor: &mut KeybindingEditor, theme: &Theme) {
272    if area.height < 2 {
273        return;
274    }
275
276    let inner_width = area.width.saturating_sub(2); // Leave room for scrollbar
277
278    // Column widths (adaptive): Key | Action Name | Description | Context | Source
279    let key_col_width = (inner_width as f32 * 0.16).min(20.0) as u16;
280    let action_name_col_width = (inner_width as f32 * 0.22).min(28.0) as u16;
281    let context_col_width = (inner_width as f32 * 0.18).clamp(14.0, 30.0) as u16;
282    let source_col_width = 8u16;
283    let fixed_cols =
284        key_col_width + action_name_col_width + context_col_width + source_col_width + 5; // +5 for spacers
285    let description_col_width = inner_width.saturating_sub(fixed_cols);
286
287    // Header line
288    let header = Line::from(vec![
289        Span::styled(" ", Style::default()),
290        Span::styled(
291            pad_right(&t!("keybinding_editor.header_key"), key_col_width as usize),
292            Style::default()
293                .fg(theme.help_key_fg)
294                .add_modifier(Modifier::BOLD),
295        ),
296        Span::styled(" ", Style::default()),
297        Span::styled(
298            pad_right(
299                &t!("keybinding_editor.header_action"),
300                action_name_col_width as usize,
301            ),
302            Style::default()
303                .fg(theme.help_key_fg)
304                .add_modifier(Modifier::BOLD),
305        ),
306        Span::styled(" ", Style::default()),
307        Span::styled(
308            pad_right(
309                &t!("keybinding_editor.header_description"),
310                description_col_width as usize,
311            ),
312            Style::default()
313                .fg(theme.help_key_fg)
314                .add_modifier(Modifier::BOLD),
315        ),
316        Span::styled(" ", Style::default()),
317        Span::styled(
318            pad_right(
319                &t!("keybinding_editor.header_context"),
320                context_col_width as usize,
321            ),
322            Style::default()
323                .fg(theme.help_key_fg)
324                .add_modifier(Modifier::BOLD),
325        ),
326        Span::styled(" ", Style::default()),
327        Span::styled(
328            pad_right(
329                &t!("keybinding_editor.header_source"),
330                source_col_width as usize,
331            ),
332            Style::default()
333                .fg(theme.help_key_fg)
334                .add_modifier(Modifier::BOLD),
335        ),
336    ]);
337    frame.render_widget(Paragraph::new(header), Rect { height: 1, ..area });
338
339    // Separator
340    if area.height > 1 {
341        let sep = "\u{2500}".repeat(inner_width as usize);
342        frame.render_widget(
343            Paragraph::new(Line::from(Span::styled(
344                format!(" {}", sep),
345                Style::default().fg(theme.popup_text_fg),
346            ))),
347            Rect {
348                y: area.y + 1,
349                height: 1,
350                ..area
351            },
352        );
353    }
354
355    // Table rows
356    let table_area = Rect {
357        y: area.y + 2,
358        height: area.height.saturating_sub(2),
359        ..area
360    };
361
362    // Sync scroll state with actual viewport dimensions
363    editor.scroll.set_viewport(table_area.height);
364    editor
365        .scroll
366        .set_content_height(editor.display_rows.len() as u16);
367
368    let visible_rows = table_area.height as usize;
369    let scroll_offset = editor.scroll.offset as usize;
370
371    for (display_idx, display_row) in editor
372        .display_rows
373        .iter()
374        .skip(scroll_offset)
375        .take(visible_rows)
376        .enumerate()
377    {
378        let row_y = table_area.y + display_idx as u16;
379        if row_y >= table_area.y + table_area.height {
380            break;
381        }
382
383        let is_selected = scroll_offset + display_idx == editor.selected;
384        let row_area = Rect {
385            y: row_y,
386            height: 1,
387            ..table_area
388        };
389
390        match display_row {
391            DisplayRow::SectionHeader {
392                plugin_name,
393                collapsed,
394                binding_count,
395            } => {
396                let (row_bg, row_fg) = if is_selected {
397                    (theme.popup_selection_bg, theme.popup_text_fg)
398                } else {
399                    (theme.popup_bg, theme.help_key_fg)
400                };
401
402                let chevron = if *collapsed { "\u{25b6}" } else { "\u{25bc}" };
403                let label = match plugin_name {
404                    Some(name) => name.as_str(),
405                    None => "Builtin",
406                };
407
408                let header_text = format!("{} {} ({})", chevron, label, binding_count);
409                let header_style = Style::default()
410                    .fg(row_fg)
411                    .bg(row_bg)
412                    .add_modifier(Modifier::BOLD);
413
414                let indicator = if is_selected { ">" } else { " " };
415                let row = Line::from(vec![
416                    Span::styled(indicator, Style::default().fg(theme.help_key_fg).bg(row_bg)),
417                    Span::styled(header_text, header_style),
418                ]);
419
420                frame.render_widget(
421                    Paragraph::new("").style(Style::default().bg(row_bg)),
422                    row_area,
423                );
424                frame.render_widget(Paragraph::new(row), row_area);
425            }
426            DisplayRow::Binding(binding_idx) => {
427                let binding = &editor.bindings[*binding_idx];
428
429                let (row_bg, row_fg) = if is_selected {
430                    (theme.popup_selection_bg, theme.popup_text_fg)
431                } else {
432                    (theme.popup_bg, theme.popup_text_fg)
433                };
434
435                let key_style = Style::default()
436                    .fg(if is_selected {
437                        theme.popup_text_fg
438                    } else {
439                        theme.help_key_fg
440                    })
441                    .bg(row_bg);
442                let action_name_style = Style::default()
443                    .fg(if is_selected {
444                        theme.popup_text_fg
445                    } else {
446                        theme.diagnostic_info_fg
447                    })
448                    .bg(row_bg);
449                let action_style = Style::default().fg(row_fg).bg(row_bg);
450                let context_style = Style::default()
451                    .fg(if is_selected {
452                        row_fg
453                    } else {
454                        theme.popup_text_fg
455                    })
456                    .bg(row_bg);
457                let source_style = Style::default()
458                    .fg(
459                        if binding.source == BindingSource::Custom
460                            || binding.source == BindingSource::Plugin
461                        {
462                            if is_selected {
463                                theme.popup_text_fg
464                            } else {
465                                theme.diagnostic_info_fg
466                            }
467                        } else {
468                            context_style.fg.unwrap_or(theme.popup_text_fg)
469                        },
470                    )
471                    .bg(row_bg);
472
473                let indicator = if is_selected { ">" } else { " " };
474
475                let row = Line::from(vec![
476                    Span::styled(indicator, Style::default().fg(theme.help_key_fg).bg(row_bg)),
477                    Span::styled(
478                        pad_right(&binding.key_display, key_col_width as usize),
479                        key_style,
480                    ),
481                    Span::styled(" ", action_name_style),
482                    Span::styled(
483                        pad_right(&binding.action, action_name_col_width as usize),
484                        action_name_style,
485                    ),
486                    Span::styled(" ", action_style),
487                    Span::styled(
488                        pad_right(&binding.action_display, description_col_width as usize),
489                        action_style,
490                    ),
491                    Span::styled(" ", context_style),
492                    Span::styled(
493                        pad_right(&binding.context, context_col_width as usize),
494                        context_style,
495                    ),
496                    Span::styled(" ", source_style),
497                    Span::styled(
498                        pad_right(
499                            &match binding.source {
500                                BindingSource::Custom => {
501                                    t!("keybinding_editor.source_custom").to_string()
502                                }
503                                BindingSource::Keymap => {
504                                    t!("keybinding_editor.source_keymap").to_string()
505                                }
506                                BindingSource::Plugin => {
507                                    t!("keybinding_editor.source_plugin", default = "Plugin")
508                                        .to_string()
509                                }
510                                BindingSource::Unbound => String::new(),
511                            },
512                            source_col_width as usize,
513                        ),
514                        source_style,
515                    ),
516                ]);
517
518                frame.render_widget(
519                    Paragraph::new("").style(Style::default().bg(row_bg)),
520                    row_area,
521                );
522                frame.render_widget(Paragraph::new(row), row_area);
523            }
524        }
525    }
526
527    // Scrollbar
528    if editor.scroll.needs_scrollbar() {
529        let sb_area = Rect::new(
530            table_area.x + table_area.width.saturating_sub(1),
531            table_area.y,
532            1,
533            table_area.height,
534        );
535        let sb_state = editor.scroll.to_scrollbar_state();
536        let sb_colors = ScrollbarColors::from_theme(theme);
537        render_scrollbar(frame, sb_area, &sb_state, &sb_colors);
538        editor.layout.table_scrollbar = Some(sb_area);
539    }
540}
541
542/// Render the footer with key hints
543fn render_footer(frame: &mut Frame, area: Rect, editor: &KeybindingEditor, theme: &Theme) {
544    let hints = if editor.search_active && editor.search_focused {
545        vec![
546            Span::styled(" Esc", Style::default().fg(theme.help_key_fg)),
547            Span::styled(
548                format!(":{}  ", t!("keybinding_editor.footer_cancel")),
549                Style::default().fg(theme.popup_text_fg),
550            ),
551            Span::styled("Tab", Style::default().fg(theme.help_key_fg)),
552            Span::styled(
553                format!(":{}  ", t!("keybinding_editor.footer_toggle_mode")),
554                Style::default().fg(theme.popup_text_fg),
555            ),
556            Span::styled("Enter", Style::default().fg(theme.help_key_fg)),
557            Span::styled(
558                format!(":{}", t!("keybinding_editor.footer_confirm")),
559                Style::default().fg(theme.popup_text_fg),
560            ),
561        ]
562    } else {
563        vec![
564            Span::styled(" Enter", Style::default().fg(theme.help_key_fg)),
565            Span::styled(
566                format!(":{}  ", t!("keybinding_editor.footer_edit")),
567                Style::default().fg(theme.popup_text_fg),
568            ),
569            Span::styled("a", Style::default().fg(theme.help_key_fg)),
570            Span::styled(
571                format!(":{}  ", t!("keybinding_editor.footer_add")),
572                Style::default().fg(theme.popup_text_fg),
573            ),
574            Span::styled("d", Style::default().fg(theme.help_key_fg)),
575            Span::styled(
576                format!(":{}  ", t!("keybinding_editor.footer_delete")),
577                Style::default().fg(theme.popup_text_fg),
578            ),
579            Span::styled("/", Style::default().fg(theme.help_key_fg)),
580            Span::styled(
581                format!(":{}  ", t!("keybinding_editor.footer_search")),
582                Style::default().fg(theme.popup_text_fg),
583            ),
584            Span::styled("r", Style::default().fg(theme.help_key_fg)),
585            Span::styled(
586                format!(":{}  ", t!("keybinding_editor.footer_record_key")),
587                Style::default().fg(theme.popup_text_fg),
588            ),
589            Span::styled("c", Style::default().fg(theme.help_key_fg)),
590            Span::styled(
591                format!(":{}  ", t!("keybinding_editor.footer_context")),
592                Style::default().fg(theme.popup_text_fg),
593            ),
594            Span::styled("s", Style::default().fg(theme.help_key_fg)),
595            Span::styled(
596                format!(":{}  ", t!("keybinding_editor.footer_source")),
597                Style::default().fg(theme.popup_text_fg),
598            ),
599            Span::styled("?", Style::default().fg(theme.help_key_fg)),
600            Span::styled(
601                format!(":{}  ", t!("keybinding_editor.footer_help")),
602                Style::default().fg(theme.popup_text_fg),
603            ),
604            Span::styled("Ctrl+S", Style::default().fg(theme.help_key_fg)),
605            Span::styled(
606                format!(":{}  ", t!("keybinding_editor.footer_save")),
607                Style::default().fg(theme.popup_text_fg),
608            ),
609            Span::styled("Esc", Style::default().fg(theme.help_key_fg)),
610            Span::styled(
611                format!(":{}", t!("keybinding_editor.footer_close")),
612                Style::default().fg(theme.popup_text_fg),
613            ),
614        ]
615    };
616
617    frame.render_widget(Paragraph::new(Line::from(hints)), area);
618}
619
620/// Render the help overlay
621fn render_help_overlay(frame: &mut Frame, area: Rect, theme: &Theme) {
622    let width = 52u16.min(area.width.saturating_sub(4));
623    let height = 22u16.min(area.height.saturating_sub(4));
624    let x = area.x + (area.width.saturating_sub(width)) / 2;
625    let y = area.y + (area.height.saturating_sub(height)) / 2;
626
627    let dialog_area = Rect {
628        x,
629        y,
630        width,
631        height,
632    };
633    frame.render_widget(Clear, dialog_area);
634
635    let block = Block::default()
636        .title(format!(" {} ", t!("keybinding_editor.help_title")))
637        .borders(Borders::ALL)
638        .border_style(Style::default().fg(theme.popup_border_fg))
639        .style(Style::default().bg(theme.popup_bg).fg(theme.popup_text_fg));
640    let inner = block.inner(dialog_area);
641    frame.render_widget(block, dialog_area);
642
643    let h_nav = t!("keybinding_editor.help_navigation").to_string();
644    let h_move = t!("keybinding_editor.help_move_up_down").to_string();
645    let h_page = t!("keybinding_editor.help_page_up_down").to_string();
646    let h_first = t!("keybinding_editor.help_first_last").to_string();
647    let h_search = t!("keybinding_editor.help_search").to_string();
648    let h_by_name = t!("keybinding_editor.help_search_by_name").to_string();
649    let h_by_key = t!("keybinding_editor.help_search_by_key").to_string();
650    let h_toggle = t!("keybinding_editor.help_toggle_search").to_string();
651    let h_cancel = t!("keybinding_editor.help_cancel_search").to_string();
652    let h_editing = t!("keybinding_editor.help_editing").to_string();
653    let h_edit = t!("keybinding_editor.help_edit_binding").to_string();
654    let h_add = t!("keybinding_editor.help_add_binding").to_string();
655    let h_del = t!("keybinding_editor.help_delete_binding").to_string();
656    let h_filters = t!("keybinding_editor.help_filters").to_string();
657    let h_ctx = t!("keybinding_editor.help_cycle_context").to_string();
658    let h_src = t!("keybinding_editor.help_cycle_source").to_string();
659    let h_save = t!("keybinding_editor.help_save_changes").to_string();
660    let h_close = t!("keybinding_editor.help_close_help").to_string();
661
662    let help_lines = vec![
663        help_line(&h_nav, "", theme, true),
664        help_line("  \u{2191} / \u{2193}", &h_move, theme, false),
665        help_line("  PgUp / PgDn", &h_page, theme, false),
666        help_line("  Home / End", &h_first, theme, false),
667        help_line("", "", theme, false),
668        help_line(&h_search, "", theme, true),
669        help_line("  /", &h_by_name, theme, false),
670        help_line("  r", &h_by_key, theme, false),
671        help_line("  Tab", &h_toggle, theme, false),
672        help_line("  Esc", &h_cancel, theme, false),
673        help_line("", "", theme, false),
674        help_line(&h_editing, "", theme, true),
675        help_line("  Enter", &h_edit, theme, false),
676        help_line("  a", &h_add, theme, false),
677        help_line("  d / Delete", &h_del, theme, false),
678        help_line("", "", theme, false),
679        help_line(&h_filters, "", theme, true),
680        help_line("  c", &h_ctx, theme, false),
681        help_line("  s", &h_src, theme, false),
682        help_line("", "", theme, false),
683        help_line("  Ctrl+S", &h_save, theme, false),
684        help_line("  Esc / ?", &h_close, theme, false),
685    ];
686
687    let para = Paragraph::new(help_lines);
688    frame.render_widget(para, inner);
689}
690
691fn help_line<'a>(key: &'a str, desc: &'a str, theme: &Theme, is_header: bool) -> Line<'a> {
692    if is_header {
693        Line::from(vec![Span::styled(
694            key,
695            Style::default()
696                .fg(theme.popup_text_fg)
697                .add_modifier(Modifier::BOLD),
698        )])
699    } else {
700        Line::from(vec![
701            Span::styled(
702                format!("{:16}", key),
703                Style::default()
704                    .fg(theme.help_key_fg)
705                    .add_modifier(Modifier::BOLD),
706            ),
707            Span::styled(desc, Style::default().fg(theme.popup_text_fg)),
708        ])
709    }
710}
711
712/// Maximum number of autocomplete suggestions to display
713const MAX_AUTOCOMPLETE_VISIBLE: usize = 8;
714
715/// Render the edit/add binding dialog
716fn render_edit_dialog(
717    frame: &mut Frame,
718    area: Rect,
719    dialog: &crate::app::keybinding_editor::EditBindingState,
720    editor: &mut KeybindingEditor,
721    theme: &Theme,
722) {
723    let width = 56u16.min(area.width.saturating_sub(4));
724    let height = 18u16.min(area.height.saturating_sub(4));
725    let x = area.x + (area.width.saturating_sub(width)) / 2;
726    let y = area.y + (area.height.saturating_sub(height)) / 2;
727
728    let dialog_area = Rect {
729        x,
730        y,
731        width,
732        height,
733    };
734    frame.render_widget(Clear, dialog_area);
735
736    let title = if dialog.editing_index.is_some() {
737        format!(" {} ", t!("keybinding_editor.dialog_edit_title"))
738    } else {
739        format!(" {} ", t!("keybinding_editor.dialog_add_title"))
740    };
741
742    let block = Block::default()
743        .title(title)
744        .borders(Borders::ALL)
745        .border_style(Style::default().fg(theme.popup_border_fg))
746        .style(Style::default().bg(theme.popup_bg).fg(theme.popup_text_fg));
747    let inner = block.inner(dialog_area);
748    frame.render_widget(block, dialog_area);
749
750    let chunks = Layout::vertical([
751        Constraint::Length(1), // Instructions
752        Constraint::Length(1), // Spacer
753        Constraint::Length(1), // Key field
754        Constraint::Length(1), // Action field
755        Constraint::Length(1), // Action description (read-only)
756        Constraint::Length(1), // Context field
757        Constraint::Length(1), // Spacer
758        Constraint::Min(3),    // Conflicts / error
759        Constraint::Length(1), // Buttons
760    ])
761    .split(inner);
762
763    // Instructions
764    let instr = if dialog.capturing_special && dialog.focus_area == 0 {
765        t!("keybinding_editor.instr_capturing_special").to_string()
766    } else {
767        match dialog.mode {
768            EditMode::RecordingKey => t!("keybinding_editor.instr_recording_key").to_string(),
769            EditMode::EditingAction => t!("keybinding_editor.instr_editing_action").to_string(),
770            EditMode::EditingContext => t!("keybinding_editor.instr_editing_context").to_string(),
771        }
772    };
773    frame.render_widget(
774        Paragraph::new(Line::from(Span::styled(
775            format!(" {}", instr),
776            Style::default().fg(theme.popup_text_fg),
777        ))),
778        chunks[0],
779    );
780
781    // Key field
782    let key_focused = dialog.focus_area == 0;
783    let key_none_text;
784    let key_recording_text;
785    let key_text = if dialog.key_display.is_empty() {
786        if dialog.mode == EditMode::RecordingKey {
787            key_recording_text = t!("keybinding_editor.key_recording").to_string();
788            &key_recording_text
789        } else {
790            key_none_text = t!("keybinding_editor.key_none").to_string();
791            &key_none_text
792        }
793    } else {
794        &dialog.key_display
795    };
796    let field_bg = if key_focused {
797        theme.popup_selection_bg
798    } else {
799        theme.popup_bg
800    };
801    let key_label_style = if key_focused {
802        Style::default()
803            .fg(theme.help_key_fg)
804            .bg(field_bg)
805            .add_modifier(Modifier::BOLD)
806    } else {
807        Style::default().fg(theme.popup_text_fg).bg(field_bg)
808    };
809    let key_value_style = Style::default().fg(theme.popup_text_fg).bg(field_bg);
810    if key_focused {
811        frame.render_widget(
812            Paragraph::new("").style(Style::default().bg(field_bg)),
813            chunks[2],
814        );
815    }
816    let mut key_spans = vec![
817        Span::styled(
818            format!("   {:9}", t!("keybinding_editor.label_key")),
819            key_label_style,
820        ),
821        Span::styled(key_text, key_value_style),
822    ];
823    if key_focused && dialog.capturing_special {
824        key_spans.push(Span::styled(
825            format!("  {}", t!("keybinding_editor.capture_any_key_hint")),
826            Style::default()
827                .fg(theme.diagnostic_warning_fg)
828                .bg(field_bg)
829                .add_modifier(Modifier::BOLD),
830        ));
831    } else if key_focused && !dialog.capturing_special {
832        key_spans.push(Span::styled(
833            format!("  {}", t!("keybinding_editor.capture_special_hint")),
834            Style::default().fg(theme.popup_text_fg).bg(field_bg),
835        ));
836    }
837    frame.render_widget(Paragraph::new(Line::from(key_spans)), chunks[2]);
838
839    // Action field
840    let action_focused = dialog.focus_area == 1;
841    let field_bg = if action_focused {
842        theme.popup_selection_bg
843    } else {
844        theme.popup_bg
845    };
846    let action_label_style = if action_focused {
847        Style::default()
848            .fg(theme.help_key_fg)
849            .bg(field_bg)
850            .add_modifier(Modifier::BOLD)
851    } else {
852        Style::default().fg(theme.popup_text_fg).bg(field_bg)
853    };
854    let has_error = dialog.action_error.is_some();
855    let action_value_style = if has_error {
856        Style::default().fg(theme.diagnostic_error_fg).bg(field_bg)
857    } else {
858        Style::default().fg(theme.popup_text_fg).bg(field_bg)
859    };
860    let action_placeholder;
861    let action_display = if dialog.action_text.is_empty() && dialog.mode != EditMode::EditingAction
862    {
863        action_placeholder = t!("keybinding_editor.action_placeholder").to_string();
864        &action_placeholder
865    } else {
866        &dialog.action_text
867    };
868    if action_focused {
869        frame.render_widget(
870            Paragraph::new("").style(Style::default().bg(field_bg)),
871            chunks[3],
872        );
873    }
874    let mut action_spans = vec![
875        Span::styled(
876            format!("   {:9}", t!("keybinding_editor.label_action")),
877            action_label_style,
878        ),
879        Span::styled(action_display, action_value_style),
880    ];
881    if action_focused && dialog.mode == EditMode::EditingAction {
882        action_spans.push(Span::styled(
883            "_",
884            Style::default().fg(theme.cursor).bg(field_bg),
885        ));
886    }
887    frame.render_widget(Paragraph::new(Line::from(action_spans)), chunks[3]);
888
889    // Action description (read-only, shown when action text is a valid action)
890    if !dialog.action_text.is_empty() {
891        let description = KeybindingResolver::format_action_from_str(&dialog.action_text);
892        // Only show if description differs from the raw action name
893        if description.to_lowercase() != dialog.action_text.replace('_', " ").to_lowercase() {
894            frame.render_widget(
895                Paragraph::new(Line::from(vec![
896                    Span::styled("            ", Style::default().fg(theme.popup_text_fg)),
897                    Span::styled(
898                        format!("\u{2192} {}", description),
899                        Style::default()
900                            .fg(theme.popup_text_fg)
901                            .add_modifier(Modifier::ITALIC),
902                    ),
903                ])),
904                chunks[4],
905            );
906        }
907    }
908
909    // Context field
910    let ctx_focused = dialog.focus_area == 2;
911    let field_bg = if ctx_focused {
912        theme.popup_selection_bg
913    } else {
914        theme.popup_bg
915    };
916    let ctx_label_style = if ctx_focused {
917        Style::default()
918            .fg(theme.help_key_fg)
919            .bg(field_bg)
920            .add_modifier(Modifier::BOLD)
921    } else {
922        Style::default().fg(theme.popup_text_fg).bg(field_bg)
923    };
924    if ctx_focused {
925        frame.render_widget(
926            Paragraph::new("").style(Style::default().bg(field_bg)),
927            chunks[5],
928        );
929    }
930    frame.render_widget(
931        Paragraph::new(Line::from(vec![
932            Span::styled(
933                format!("   {:9}", t!("keybinding_editor.label_context")),
934                ctx_label_style,
935            ),
936            Span::styled(
937                format!("[{}]", dialog.context),
938                Style::default().fg(theme.popup_text_fg).bg(field_bg),
939            ),
940            if ctx_focused {
941                Span::styled(
942                    format!("  {}", t!("keybinding_editor.context_change_hint")),
943                    Style::default().fg(theme.popup_text_fg).bg(field_bg),
944                )
945            } else {
946                Span::raw("")
947            },
948        ])),
949        chunks[5],
950    );
951
952    // Conflicts or error in the info area
953    let mut info_lines: Vec<Line> = Vec::new();
954    if let Some(ref err) = dialog.action_error {
955        info_lines.push(Line::from(Span::styled(
956            format!("   \u{2717} {}", err),
957            Style::default()
958                .fg(theme.diagnostic_error_fg)
959                .add_modifier(Modifier::BOLD),
960        )));
961    }
962    if !dialog.conflicts.is_empty() {
963        info_lines.push(Line::from(Span::styled(
964            format!("   {}", t!("keybinding_editor.conflicts_label")),
965            Style::default()
966                .fg(theme.diagnostic_warning_fg)
967                .add_modifier(Modifier::BOLD),
968        )));
969        for conflict in &dialog.conflicts {
970            info_lines.push(Line::from(Span::styled(
971                format!("     {}", conflict),
972                Style::default().fg(theme.diagnostic_warning_fg),
973            )));
974        }
975    }
976    if !info_lines.is_empty() {
977        frame.render_widget(Paragraph::new(info_lines), chunks[7]);
978    }
979
980    // Buttons
981    let btn_focused = dialog.focus_area == 3;
982    let save_style = if btn_focused && dialog.selected_button == 0 {
983        Style::default()
984            .fg(theme.popup_bg)
985            .bg(theme.help_key_fg)
986            .add_modifier(Modifier::BOLD)
987    } else {
988        Style::default().fg(theme.popup_text_fg)
989    };
990    let cancel_style = if btn_focused && dialog.selected_button == 1 {
991        Style::default()
992            .fg(theme.popup_bg)
993            .bg(theme.help_key_fg)
994            .add_modifier(Modifier::BOLD)
995    } else {
996        Style::default().fg(theme.popup_text_fg)
997    };
998    // Store field areas for mouse hit testing
999    editor.layout.dialog_key_field = Some(chunks[2]);
1000    editor.layout.dialog_action_field = Some(chunks[3]);
1001    editor.layout.dialog_context_field = Some(chunks[5]);
1002
1003    let save_text = format!(" {} ", t!("keybinding_editor.btn_save"));
1004    let cancel_text = format!(" {} ", t!("keybinding_editor.btn_cancel"));
1005    let save_x = chunks[8].x + 3;
1006    let cancel_x = save_x + save_text.len() as u16 + 2;
1007    editor.layout.dialog_buttons = Some((
1008        Rect {
1009            x: save_x,
1010            y: chunks[8].y,
1011            width: save_text.len() as u16,
1012            height: 1,
1013        },
1014        Rect {
1015            x: cancel_x,
1016            y: chunks[8].y,
1017            width: cancel_text.len() as u16,
1018            height: 1,
1019        },
1020    ));
1021
1022    frame.render_widget(
1023        Paragraph::new(Line::from(vec![
1024            Span::raw("   "),
1025            Span::styled(save_text, save_style),
1026            Span::raw("  "),
1027            Span::styled(cancel_text, cancel_style),
1028        ])),
1029        chunks[8],
1030    );
1031
1032    // Render autocomplete popup on top of everything if visible
1033    if dialog.autocomplete_visible && !dialog.autocomplete_suggestions.is_empty() {
1034        render_autocomplete_popup(frame, chunks[3], dialog, theme);
1035    }
1036}
1037
1038/// Render the autocomplete suggestions popup below the action field
1039fn render_autocomplete_popup(
1040    frame: &mut Frame,
1041    action_field_area: Rect,
1042    dialog: &crate::app::keybinding_editor::EditBindingState,
1043    theme: &Theme,
1044) {
1045    let suggestion_count = dialog
1046        .autocomplete_suggestions
1047        .len()
1048        .min(MAX_AUTOCOMPLETE_VISIBLE);
1049    if suggestion_count == 0 {
1050        return;
1051    }
1052
1053    // Position: below the action field, offset to align with text
1054    let popup_x = action_field_area.x + 12; // offset past "   Action:  "
1055    let popup_y = action_field_area.y + 1;
1056    let popup_width = 36u16.min(action_field_area.width.saturating_sub(12));
1057    let popup_height = (suggestion_count as u16) + 2; // +2 for border
1058
1059    let popup_area = Rect {
1060        x: popup_x,
1061        y: popup_y,
1062        width: popup_width,
1063        height: popup_height,
1064    };
1065
1066    frame.render_widget(Clear, popup_area);
1067
1068    let block = Block::default()
1069        .borders(Borders::ALL)
1070        .border_style(Style::default().fg(theme.popup_border_fg))
1071        .style(Style::default().bg(theme.popup_bg).fg(theme.popup_text_fg));
1072    let inner = block.inner(popup_area);
1073    frame.render_widget(block, popup_area);
1074
1075    // Determine scroll offset for autocomplete list
1076    let selected = dialog.autocomplete_selected.unwrap_or(0);
1077    let scroll_offset = if selected >= MAX_AUTOCOMPLETE_VISIBLE {
1078        selected - MAX_AUTOCOMPLETE_VISIBLE + 1
1079    } else {
1080        0
1081    };
1082
1083    let mut lines: Vec<Line> = Vec::new();
1084    for (i, suggestion) in dialog
1085        .autocomplete_suggestions
1086        .iter()
1087        .skip(scroll_offset)
1088        .take(MAX_AUTOCOMPLETE_VISIBLE)
1089        .enumerate()
1090    {
1091        let actual_idx = i + scroll_offset;
1092        let is_selected = Some(actual_idx) == dialog.autocomplete_selected;
1093
1094        let style = if is_selected {
1095            Style::default()
1096                .fg(theme.popup_bg)
1097                .bg(theme.help_key_fg)
1098                .add_modifier(Modifier::BOLD)
1099        } else {
1100            Style::default().fg(theme.popup_text_fg).bg(theme.popup_bg)
1101        };
1102
1103        // Pad the suggestion to fill the width
1104        let display = pad_right(suggestion, inner.width as usize);
1105        lines.push(Line::from(Span::styled(display, style)));
1106    }
1107
1108    frame.render_widget(Paragraph::new(lines), inner);
1109}
1110
1111/// Render the unsaved changes confirm dialog
1112fn render_confirm_dialog(
1113    frame: &mut Frame,
1114    area: Rect,
1115    editor: &mut KeybindingEditor,
1116    theme: &Theme,
1117) {
1118    let width = 44u16.min(area.width.saturating_sub(4));
1119    let height = 7u16.min(area.height.saturating_sub(4));
1120    let x = area.x + (area.width.saturating_sub(width)) / 2;
1121    let y = area.y + (area.height.saturating_sub(height)) / 2;
1122
1123    let dialog_area = Rect {
1124        x,
1125        y,
1126        width,
1127        height,
1128    };
1129    frame.render_widget(Clear, dialog_area);
1130
1131    let block = Block::default()
1132        .title(format!(" {} ", t!("keybinding_editor.confirm_title")))
1133        .borders(Borders::ALL)
1134        .border_style(Style::default().fg(theme.diagnostic_warning_fg))
1135        .style(Style::default().bg(theme.popup_bg).fg(theme.popup_text_fg));
1136    let inner = block.inner(dialog_area);
1137    frame.render_widget(block, dialog_area);
1138
1139    let chunks = Layout::vertical([
1140        Constraint::Length(2), // Message
1141        Constraint::Length(1), // Spacer
1142        Constraint::Length(1), // Buttons
1143    ])
1144    .split(inner);
1145
1146    frame.render_widget(
1147        Paragraph::new(Line::from(Span::styled(
1148            format!(" {}", t!("keybinding_editor.confirm_message")),
1149            Style::default().fg(theme.popup_text_fg),
1150        ))),
1151        chunks[0],
1152    );
1153
1154    let options = [
1155        t!("keybinding_editor.btn_save").to_string(),
1156        t!("keybinding_editor.btn_discard").to_string(),
1157        t!("keybinding_editor.btn_cancel").to_string(),
1158    ];
1159    // Compute button areas for mouse hit testing
1160    let mut x_offset = chunks[2].x + 1;
1161    let mut btn_rects = Vec::new();
1162    let mut spans = vec![Span::raw(" ")];
1163    for (i, opt) in options.iter().enumerate() {
1164        let style = if i == editor.confirm_selection {
1165            Style::default()
1166                .fg(theme.popup_bg)
1167                .bg(theme.help_key_fg)
1168                .add_modifier(Modifier::BOLD)
1169        } else {
1170            Style::default().fg(theme.popup_text_fg)
1171        };
1172        let text = format!(" {} ", opt);
1173        let text_len = text.len() as u16;
1174        btn_rects.push(Rect {
1175            x: x_offset,
1176            y: chunks[2].y,
1177            width: text_len,
1178            height: 1,
1179        });
1180        x_offset += text_len + 2; // +2 for spacing
1181        spans.push(Span::styled(text, style));
1182        spans.push(Span::raw("  "));
1183    }
1184    if btn_rects.len() == 3 {
1185        editor.layout.confirm_buttons = Some((btn_rects[0], btn_rects[1], btn_rects[2]));
1186    }
1187
1188    frame.render_widget(Paragraph::new(Line::from(spans)), chunks[2]);
1189}
1190
1191/// Right-pad a string to a given width (in chars), truncating if necessary
1192fn pad_right(s: &str, width: usize) -> String {
1193    let char_count = s.chars().count();
1194    if char_count >= width {
1195        s.chars().take(width).collect()
1196    } else {
1197        let padding = width - char_count;
1198        format!("{}{}", s, " ".repeat(padding))
1199    }
1200}
1201
1202// ==================== INPUT HANDLING ====================
1203
1204/// Handle input for the keybinding editor. Returns true if the editor should close.
1205pub fn handle_keybinding_editor_input(
1206    editor: &mut KeybindingEditor,
1207    event: &KeyEvent,
1208) -> KeybindingEditorAction {
1209    // Help overlay
1210    if editor.showing_help {
1211        match event.code {
1212            KeyCode::Esc | KeyCode::Char('?') | KeyCode::Enter => {
1213                editor.showing_help = false;
1214            }
1215            _ => {}
1216        }
1217        return KeybindingEditorAction::Consumed;
1218    }
1219
1220    // Confirm dialog
1221    if editor.showing_confirm_dialog {
1222        return handle_confirm_input(editor, event);
1223    }
1224
1225    // Edit dialog
1226    if editor.edit_dialog.is_some() {
1227        return handle_edit_dialog_input(editor, event);
1228    }
1229
1230    // Search mode (only when focused/accepting input)
1231    if editor.search_active && editor.search_focused {
1232        return handle_search_input(editor, event);
1233    }
1234
1235    // Main table navigation
1236    handle_main_input(editor, event)
1237}
1238
1239/// Actions that the keybinding editor can return to the parent
1240pub enum KeybindingEditorAction {
1241    /// Input was consumed, no further action needed
1242    Consumed,
1243    /// Close the editor (no save)
1244    Close,
1245    /// Save and close
1246    SaveAndClose,
1247    /// Status message to display
1248    StatusMessage(String),
1249}
1250
1251fn handle_main_input(editor: &mut KeybindingEditor, event: &KeyEvent) -> KeybindingEditorAction {
1252    match (event.code, event.modifiers) {
1253        // Close / clear search
1254        (KeyCode::Esc, KeyModifiers::NONE) => {
1255            if editor.search_active {
1256                // Search is visible but unfocused — clear it
1257                editor.cancel_search();
1258                KeybindingEditorAction::Consumed
1259            } else if editor.has_changes {
1260                editor.showing_confirm_dialog = true;
1261                editor.confirm_selection = 0;
1262                KeybindingEditorAction::Consumed
1263            } else {
1264                KeybindingEditorAction::Close
1265            }
1266        }
1267
1268        // Save
1269        (KeyCode::Char('s'), m) if m.contains(KeyModifiers::CONTROL) => {
1270            KeybindingEditorAction::SaveAndClose
1271        }
1272
1273        // Navigation
1274        (KeyCode::Up, KeyModifiers::NONE) | (KeyCode::Char('k'), KeyModifiers::NONE) => {
1275            editor.select_prev();
1276            KeybindingEditorAction::Consumed
1277        }
1278        (KeyCode::Down, KeyModifiers::NONE) | (KeyCode::Char('j'), KeyModifiers::NONE) => {
1279            editor.select_next();
1280            KeybindingEditorAction::Consumed
1281        }
1282        (KeyCode::PageUp, _) => {
1283            editor.page_up();
1284            KeybindingEditorAction::Consumed
1285        }
1286        (KeyCode::PageDown, _) => {
1287            editor.page_down();
1288            KeybindingEditorAction::Consumed
1289        }
1290        (KeyCode::Home, _) => {
1291            editor.selected = 0;
1292            editor.scroll.offset = 0;
1293            KeybindingEditorAction::Consumed
1294        }
1295        (KeyCode::End, _) => {
1296            editor.selected = editor.display_rows.len().saturating_sub(1);
1297            editor.ensure_visible_public();
1298            KeybindingEditorAction::Consumed
1299        }
1300
1301        // Search (re-focuses existing search if visible)
1302        (KeyCode::Char('/'), KeyModifiers::NONE) => {
1303            editor.start_search();
1304            KeybindingEditorAction::Consumed
1305        }
1306
1307        // Record key search
1308        (KeyCode::Char('r'), KeyModifiers::NONE) => {
1309            editor.start_record_key_search();
1310            KeybindingEditorAction::Consumed
1311        }
1312
1313        // Help
1314        (KeyCode::Char('?'), _) => {
1315            editor.showing_help = true;
1316            KeybindingEditorAction::Consumed
1317        }
1318
1319        // Add binding
1320        (KeyCode::Char('a'), KeyModifiers::NONE) => {
1321            editor.open_add_dialog();
1322            KeybindingEditorAction::Consumed
1323        }
1324
1325        // Enter: toggle section header or edit binding
1326        (KeyCode::Enter, KeyModifiers::NONE) => {
1327            if editor.selected_is_section_header() {
1328                editor.toggle_section_at_selected();
1329            } else {
1330                editor.open_edit_dialog();
1331            }
1332            KeybindingEditorAction::Consumed
1333        }
1334
1335        // Delete binding
1336        (KeyCode::Char('d'), KeyModifiers::NONE) | (KeyCode::Delete, _) => {
1337            match editor.delete_selected() {
1338                DeleteResult::CustomRemoved => KeybindingEditorAction::StatusMessage(
1339                    t!("keybinding_editor.status_binding_removed").to_string(),
1340                ),
1341                DeleteResult::KeymapOverridden => KeybindingEditorAction::StatusMessage(
1342                    t!("keybinding_editor.status_keymap_overridden").to_string(),
1343                ),
1344                DeleteResult::CannotDelete | DeleteResult::NothingSelected => {
1345                    KeybindingEditorAction::StatusMessage(
1346                        t!("keybinding_editor.status_cannot_delete").to_string(),
1347                    )
1348                }
1349            }
1350        }
1351
1352        // Context filter
1353        (KeyCode::Char('c'), KeyModifiers::NONE) => {
1354            editor.cycle_context_filter();
1355            KeybindingEditorAction::Consumed
1356        }
1357
1358        // Source filter
1359        (KeyCode::Char('s'), KeyModifiers::NONE) => {
1360            editor.cycle_source_filter();
1361            KeybindingEditorAction::Consumed
1362        }
1363
1364        _ => KeybindingEditorAction::Consumed,
1365    }
1366}
1367
1368fn handle_search_input(editor: &mut KeybindingEditor, event: &KeyEvent) -> KeybindingEditorAction {
1369    match editor.search_mode {
1370        SearchMode::Text => match (event.code, event.modifiers) {
1371            (KeyCode::Esc, _) => {
1372                editor.cancel_search();
1373                KeybindingEditorAction::Consumed
1374            }
1375            (KeyCode::Enter, _) | (KeyCode::Down, _) => {
1376                // Unfocus search, keep results visible, move to list
1377                editor.search_focused = false;
1378                KeybindingEditorAction::Consumed
1379            }
1380            (KeyCode::Up, _) => {
1381                // Unfocus search, move to list, select last item
1382                editor.search_focused = false;
1383                editor.selected = editor.filtered_indices.len().saturating_sub(1);
1384                editor.ensure_visible_public();
1385                KeybindingEditorAction::Consumed
1386            }
1387            (KeyCode::Tab, _) => {
1388                // Switch to record key mode
1389                editor.search_mode = SearchMode::RecordKey;
1390                editor.search_key_display.clear();
1391                editor.search_key_code = None;
1392                KeybindingEditorAction::Consumed
1393            }
1394            (KeyCode::Backspace, _) => {
1395                editor.search_query.pop();
1396                editor.apply_filters();
1397                KeybindingEditorAction::Consumed
1398            }
1399            (KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
1400                editor.search_query.push(c);
1401                editor.apply_filters();
1402                KeybindingEditorAction::Consumed
1403            }
1404            _ => KeybindingEditorAction::Consumed,
1405        },
1406        SearchMode::RecordKey => match (event.code, event.modifiers) {
1407            (KeyCode::Esc, KeyModifiers::NONE) => {
1408                editor.cancel_search();
1409                KeybindingEditorAction::Consumed
1410            }
1411            (KeyCode::Tab, KeyModifiers::NONE) => {
1412                // Switch to text mode, preserve query
1413                editor.search_mode = SearchMode::Text;
1414                editor.apply_filters();
1415                KeybindingEditorAction::Consumed
1416            }
1417            (KeyCode::Enter, KeyModifiers::NONE) => {
1418                // Unfocus search, keep results visible
1419                editor.search_focused = false;
1420                KeybindingEditorAction::Consumed
1421            }
1422            _ => {
1423                // Record the key
1424                editor.record_search_key(event);
1425                KeybindingEditorAction::Consumed
1426            }
1427        },
1428    }
1429}
1430
1431fn handle_edit_dialog_input(
1432    editor: &mut KeybindingEditor,
1433    event: &KeyEvent,
1434) -> KeybindingEditorAction {
1435    // Take the dialog out to avoid borrow conflicts
1436    let mut dialog = match editor.edit_dialog.take() {
1437        Some(d) => d,
1438        None => return KeybindingEditorAction::Consumed,
1439    };
1440
1441    // In special-capture mode on the key field, record the very next key
1442    // (including Esc, Tab, Enter) and exit capture mode.
1443    if dialog.capturing_special && dialog.focus_area == 0 {
1444        match event.code {
1445            KeyCode::Modifier(_) => {} // ignore bare modifier presses
1446            _ => {
1447                dialog.key_code = Some(event.code);
1448                dialog.modifiers = event.modifiers;
1449                dialog.key_display = format_keybinding(&event.code, &event.modifiers);
1450                dialog.conflicts =
1451                    editor.find_conflicts(event.code, event.modifiers, &dialog.context);
1452                dialog.capturing_special = false;
1453            }
1454        }
1455        editor.edit_dialog = Some(dialog);
1456        return KeybindingEditorAction::Consumed;
1457    }
1458
1459    // Close dialog on Esc
1460    if event.code == KeyCode::Esc && event.modifiers == KeyModifiers::NONE {
1461        // Don't put it back - it's closed
1462        return KeybindingEditorAction::Consumed;
1463    }
1464
1465    match dialog.focus_area {
1466        0 => {
1467            // Key recording area
1468            match (event.code, event.modifiers) {
1469                // Enter enters special-capture mode for the next keypress
1470                (KeyCode::Enter, KeyModifiers::NONE) => {
1471                    dialog.capturing_special = true;
1472                }
1473                (KeyCode::Tab | KeyCode::Down, KeyModifiers::NONE) => {
1474                    dialog.focus_area = 1;
1475                    dialog.mode = EditMode::EditingAction;
1476                }
1477                _ => {
1478                    // Keys are only recorded via capture mode (Enter then key).
1479                    // Ignore everything else in the key field.
1480                }
1481            }
1482        }
1483        1 => {
1484            // Action editing area with autocomplete
1485            match (event.code, event.modifiers) {
1486                (KeyCode::Tab, KeyModifiers::NONE) => {
1487                    // Accept selected autocomplete suggestion, or move to next field
1488                    if dialog.autocomplete_visible {
1489                        if let Some(sel) = dialog.autocomplete_selected {
1490                            if sel < dialog.autocomplete_suggestions.len() {
1491                                let suggestion = dialog.autocomplete_suggestions[sel].clone();
1492                                dialog.action_text = suggestion;
1493                                dialog.action_cursor = dialog.action_text.len();
1494                                dialog.autocomplete_visible = false;
1495                                dialog.autocomplete_selected = None;
1496                                dialog.action_error = None;
1497                            }
1498                        }
1499                    } else {
1500                        dialog.focus_area = 2;
1501                        dialog.mode = EditMode::EditingContext;
1502                    }
1503                }
1504                (KeyCode::BackTab, _) => {
1505                    dialog.autocomplete_visible = false;
1506                    dialog.focus_area = 0;
1507                    dialog.mode = EditMode::RecordingKey;
1508                }
1509                (KeyCode::Enter, KeyModifiers::NONE) => {
1510                    // Accept selected autocomplete suggestion, or move to buttons
1511                    if dialog.autocomplete_visible {
1512                        if let Some(sel) = dialog.autocomplete_selected {
1513                            if sel < dialog.autocomplete_suggestions.len() {
1514                                let suggestion = dialog.autocomplete_suggestions[sel].clone();
1515                                dialog.action_text = suggestion;
1516                                dialog.action_cursor = dialog.action_text.len();
1517                                dialog.autocomplete_visible = false;
1518                                dialog.autocomplete_selected = None;
1519                                dialog.action_error = None;
1520                            }
1521                        }
1522                    } else {
1523                        dialog.focus_area = 3;
1524                        dialog.selected_button = 0;
1525                        dialog.mode = EditMode::EditingContext;
1526                    }
1527                }
1528                (KeyCode::Up, _) if dialog.autocomplete_visible => {
1529                    // Navigate autocomplete up
1530                    if let Some(sel) = dialog.autocomplete_selected {
1531                        if sel > 0 {
1532                            dialog.autocomplete_selected = Some(sel - 1);
1533                        }
1534                    }
1535                }
1536                (KeyCode::Down, _) if dialog.autocomplete_visible => {
1537                    // Navigate autocomplete down
1538                    if let Some(sel) = dialog.autocomplete_selected {
1539                        let max = dialog.autocomplete_suggestions.len().saturating_sub(1);
1540                        if sel < max {
1541                            dialog.autocomplete_selected = Some(sel + 1);
1542                        }
1543                    }
1544                }
1545                (KeyCode::Up, KeyModifiers::NONE) => {
1546                    // Move to previous field (key)
1547                    dialog.autocomplete_visible = false;
1548                    dialog.focus_area = 0;
1549                    dialog.mode = EditMode::RecordingKey;
1550                }
1551                (KeyCode::Down, KeyModifiers::NONE) => {
1552                    // Move to next field (context)
1553                    dialog.focus_area = 2;
1554                    dialog.mode = EditMode::EditingContext;
1555                }
1556                (KeyCode::Esc, _) if dialog.autocomplete_visible => {
1557                    // Close autocomplete without closing dialog
1558                    dialog.autocomplete_visible = false;
1559                    dialog.autocomplete_selected = None;
1560                    // Put dialog back and return early (don't let outer Esc handler close dialog)
1561                    editor.edit_dialog = Some(dialog);
1562                    return KeybindingEditorAction::Consumed;
1563                }
1564                (KeyCode::Backspace, _) => {
1565                    if dialog.action_cursor > 0 {
1566                        dialog.action_cursor -= 1;
1567                        dialog.action_text.remove(dialog.action_cursor);
1568                        dialog.action_error = None;
1569                    }
1570                    // Put dialog back and update autocomplete
1571                    editor.edit_dialog = Some(dialog);
1572                    editor.update_autocomplete();
1573                    return KeybindingEditorAction::Consumed;
1574                }
1575                (KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
1576                    dialog.action_text.insert(dialog.action_cursor, c);
1577                    dialog.action_cursor += 1;
1578                    dialog.action_error = None;
1579                    // Put dialog back and update autocomplete
1580                    editor.edit_dialog = Some(dialog);
1581                    editor.update_autocomplete();
1582                    return KeybindingEditorAction::Consumed;
1583                }
1584                _ => {}
1585            }
1586        }
1587        2 => {
1588            // Context selection area
1589            match (event.code, event.modifiers) {
1590                (KeyCode::Tab | KeyCode::Down, KeyModifiers::NONE) => {
1591                    dialog.focus_area = 3;
1592                    dialog.selected_button = 0;
1593                }
1594                (KeyCode::BackTab, _) | (KeyCode::Up, KeyModifiers::NONE) => {
1595                    dialog.focus_area = 1;
1596                    dialog.mode = EditMode::EditingAction;
1597                }
1598                (KeyCode::Left, _) => {
1599                    if dialog.context_option_index > 0 {
1600                        dialog.context_option_index -= 1;
1601                        dialog.context =
1602                            dialog.context_options[dialog.context_option_index].clone();
1603                        // Update conflicts
1604                        if let Some(key_code) = dialog.key_code {
1605                            dialog.conflicts =
1606                                editor.find_conflicts(key_code, dialog.modifiers, &dialog.context);
1607                        }
1608                    }
1609                }
1610                (KeyCode::Right, _) => {
1611                    if dialog.context_option_index + 1 < dialog.context_options.len() {
1612                        dialog.context_option_index += 1;
1613                        dialog.context =
1614                            dialog.context_options[dialog.context_option_index].clone();
1615                        if let Some(key_code) = dialog.key_code {
1616                            dialog.conflicts =
1617                                editor.find_conflicts(key_code, dialog.modifiers, &dialog.context);
1618                        }
1619                    }
1620                }
1621                (KeyCode::Enter, _) => {
1622                    dialog.focus_area = 3;
1623                    dialog.selected_button = 0;
1624                }
1625                _ => {}
1626            }
1627        }
1628        3 => {
1629            // Buttons area
1630            match (event.code, event.modifiers) {
1631                (KeyCode::Tab, KeyModifiers::NONE) => {
1632                    if dialog.selected_button < 1 {
1633                        // Move from Save to Cancel
1634                        dialog.selected_button = 1;
1635                    } else {
1636                        // Wrap from Cancel to Key field
1637                        dialog.focus_area = 0;
1638                        dialog.mode = EditMode::RecordingKey;
1639                    }
1640                }
1641                (KeyCode::BackTab, _) => {
1642                    if dialog.selected_button > 0 {
1643                        // Move from Cancel to Save
1644                        dialog.selected_button = 0;
1645                    } else {
1646                        // Wrap from Save to Context field
1647                        dialog.focus_area = 2;
1648                        dialog.mode = EditMode::EditingContext;
1649                    }
1650                }
1651                (KeyCode::Up, KeyModifiers::NONE) => {
1652                    dialog.focus_area = 2;
1653                    dialog.mode = EditMode::EditingContext;
1654                }
1655                (KeyCode::Left, _) => {
1656                    if dialog.selected_button > 0 {
1657                        dialog.selected_button -= 1;
1658                    }
1659                }
1660                (KeyCode::Right, _) => {
1661                    if dialog.selected_button < 1 {
1662                        dialog.selected_button += 1;
1663                    }
1664                }
1665                (KeyCode::Enter, _) => {
1666                    if dialog.selected_button == 0 {
1667                        // Save - put the dialog back first so apply_edit_dialog can take it
1668                        editor.edit_dialog = Some(dialog);
1669                        if let Some(err) = editor.apply_edit_dialog() {
1670                            // Validation failed - dialog is still open with error
1671                            return KeybindingEditorAction::StatusMessage(err);
1672                        }
1673                        return KeybindingEditorAction::Consumed;
1674                    } else {
1675                        // Cancel - don't put dialog back
1676                        return KeybindingEditorAction::Consumed;
1677                    }
1678                }
1679                _ => {}
1680            }
1681        }
1682        _ => {}
1683    }
1684
1685    // Put the dialog back
1686    editor.edit_dialog = Some(dialog);
1687    KeybindingEditorAction::Consumed
1688}
1689
1690fn handle_confirm_input(editor: &mut KeybindingEditor, event: &KeyEvent) -> KeybindingEditorAction {
1691    match (event.code, event.modifiers) {
1692        (KeyCode::Left, _) => {
1693            if editor.confirm_selection > 0 {
1694                editor.confirm_selection -= 1;
1695            }
1696            KeybindingEditorAction::Consumed
1697        }
1698        (KeyCode::Right, _) => {
1699            if editor.confirm_selection < 2 {
1700                editor.confirm_selection += 1;
1701            }
1702            KeybindingEditorAction::Consumed
1703        }
1704        (KeyCode::Enter, _) => match editor.confirm_selection {
1705            0 => KeybindingEditorAction::SaveAndClose,
1706            1 => KeybindingEditorAction::Close, // Discard
1707            _ => {
1708                editor.showing_confirm_dialog = false;
1709                KeybindingEditorAction::Consumed
1710            }
1711        },
1712        (KeyCode::Esc, _) => {
1713            editor.showing_confirm_dialog = false;
1714            KeybindingEditorAction::Consumed
1715        }
1716        _ => KeybindingEditorAction::Consumed,
1717    }
1718}
1719
1720#[cfg(test)]
1721mod tests {
1722    use super::*;
1723
1724    /// The modal must stay fully inside the area it is handed and be
1725    /// horizontally centred within it. Regression test for the
1726    /// orchestrator-dock case where the editor passes the post-dock-split
1727    /// `chrome_area` (with a non-zero `x`): the modal used to be placed
1728    /// relative to column 0, bleeding left under the dock and ending up
1729    /// off-centre.
1730    #[test]
1731    fn modal_centres_within_offset_area_left_of_dock() {
1732        // Full screen 120x40 with a 34-column left dock carved off, so the
1733        // chrome area starts at x = 34.
1734        let chrome = Rect::new(34, 0, 120 - 34, 40);
1735        let modal = keybinding_modal_area(chrome);
1736
1737        // Fully contained within the chrome area — never bleeds under the
1738        // dock (left edge) or past the right/bottom edges.
1739        assert!(modal.x >= chrome.x, "modal bleeds left under the dock");
1740        assert!(
1741            modal.x + modal.width <= chrome.x + chrome.width,
1742            "modal overflows the right edge"
1743        );
1744        assert!(
1745            modal.y + modal.height <= chrome.y + chrome.height,
1746            "modal overflows the bottom edge"
1747        );
1748
1749        // Horizontally centred within the chrome area: left and right
1750        // margins are equal (within one column for odd remainders).
1751        let left_margin = modal.x - chrome.x;
1752        let right_margin = (chrome.x + chrome.width) - (modal.x + modal.width);
1753        assert!(
1754            left_margin.abs_diff(right_margin) <= 1,
1755            "modal not centred: left={left_margin} right={right_margin}"
1756        );
1757    }
1758
1759    /// With no dock (area at origin) the modal is still centred — the
1760    /// offsets must not regress the common full-screen case.
1761    #[test]
1762    fn modal_centres_within_full_screen() {
1763        let area = Rect::new(0, 0, 120, 40);
1764        let modal = keybinding_modal_area(area);
1765
1766        let left_margin = modal.x;
1767        let right_margin = area.width - (modal.x + modal.width);
1768        assert!(
1769            left_margin.abs_diff(right_margin) <= 1,
1770            "modal not centred on full screen: left={left_margin} right={right_margin}"
1771        );
1772        assert!(modal.x + modal.width <= area.width);
1773        assert!(modal.y + modal.height <= area.height);
1774    }
1775}