Skip to main content

fresh/view/settings/
render.rs

1//! Settings UI renderer
2//!
3//! Renders the settings modal with category navigation and setting controls.
4
5use rust_i18n::t;
6
7use crate::primitives::display_width::str_width;
8
9use super::items::SettingControl;
10use super::layout::{SettingsHit, SettingsLayout};
11use super::search::{DeepMatch, SearchResult};
12use super::state::SettingsState;
13use crate::view::controls::{
14    render_dropdown_aligned, render_dual_list_partial, render_number_input_aligned,
15    render_text_input_aligned, render_toggle_aligned, DropdownColors, DualListColors, MapColors,
16    NumberInputColors, TextInputColors, TextListColors, ToggleColors,
17};
18use crate::view::theme::Theme;
19use crate::view::ui::scrollbar::{render_scrollbar, ScrollbarColors, ScrollbarState};
20use ratatui::layout::{Constraint, Layout, Rect};
21use ratatui::style::{Color, Modifier, Style};
22use ratatui::text::{Line, Span};
23use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};
24use ratatui::Frame;
25
26/// Build spans for a text line with selection highlighting
27///
28/// Returns a vector of spans where selected portions are highlighted.
29#[allow(clippy::too_many_arguments)]
30fn build_selection_spans(
31    display_text: &str,
32    display_len: usize,
33    line_idx: usize,
34    start_row: usize,
35    start_col: usize,
36    end_row: usize,
37    end_col: usize,
38    text_color: Color,
39    selection_bg: Color,
40) -> Vec<Span<'static>> {
41    let chars: Vec<char> = display_text.chars().collect();
42    let char_count = chars.len();
43
44    // Determine selection range for this line
45    let (sel_start, sel_end) = if line_idx < start_row || line_idx > end_row {
46        // Line not in selection
47        (char_count, char_count)
48    } else if line_idx == start_row && line_idx == end_row {
49        // Selection within single line
50        let start = byte_to_char_idx(display_text, start_col).min(char_count);
51        let end = byte_to_char_idx(display_text, end_col).min(char_count);
52        (start, end)
53    } else if line_idx == start_row {
54        // Selection starts on this line
55        let start = byte_to_char_idx(display_text, start_col).min(char_count);
56        (start, char_count)
57    } else if line_idx == end_row {
58        // Selection ends on this line
59        let end = byte_to_char_idx(display_text, end_col).min(char_count);
60        (0, end)
61    } else {
62        // Entire line is selected
63        (0, char_count)
64    };
65
66    let mut spans = Vec::new();
67    let normal_style = Style::default().fg(text_color);
68    let selected_style = Style::default().fg(text_color).bg(selection_bg);
69
70    if sel_start >= sel_end || sel_start >= char_count {
71        // No selection on this line
72        let padded = format!("{:width$}", display_text, width = display_len);
73        spans.push(Span::styled(padded, normal_style));
74    } else {
75        // Before selection
76        if sel_start > 0 {
77            let before: String = chars[..sel_start].iter().collect();
78            spans.push(Span::styled(before, normal_style));
79        }
80
81        // Selection
82        let selected: String = chars[sel_start..sel_end].iter().collect();
83        spans.push(Span::styled(selected, selected_style));
84
85        // After selection
86        if sel_end < char_count {
87            let after: String = chars[sel_end..].iter().collect();
88            spans.push(Span::styled(after, normal_style));
89        }
90
91        // Pad to display_len
92        let current_len = char_count;
93        if current_len < display_len {
94            let padding = " ".repeat(display_len - current_len);
95            spans.push(Span::styled(padding, normal_style));
96        }
97    }
98
99    spans
100}
101
102/// Convert byte offset to char index in a string
103fn byte_to_char_idx(s: &str, byte_offset: usize) -> usize {
104    s.char_indices()
105        .take_while(|(i, _)| *i < byte_offset)
106        .count()
107}
108
109/// Truncate `s` to at most `max_chars` characters, appending `"..."` if it
110/// was actually shortened. Counts characters (not bytes) so non-ASCII
111/// inputs (CJK descriptions, emoji, etc.) don't byte-slice through a
112/// multi-byte UTF-8 sequence and panic — same class as #1718.
113fn truncate_chars_with_ellipsis(s: &str, max_chars: usize) -> String {
114    if s.chars().count() <= max_chars {
115        s.to_string()
116    } else {
117        let kept: String = s.chars().take(max_chars.saturating_sub(3)).collect();
118        format!("{}...", kept)
119    }
120}
121
122/// Render the settings modal
123pub fn render_settings(
124    frame: &mut Frame,
125    area: Rect,
126    state: &mut SettingsState,
127    theme: &Theme,
128) -> SettingsLayout {
129    // Minimum size guard — prevent panics from zero-sized layout arithmetic
130    if area.width < 40 || area.height < 10 {
131        let msg = "[Terminal too small for settings]";
132        let x = area.x + area.width.saturating_sub(msg.len() as u16) / 2;
133        let y = area.y + area.height / 2;
134        if area.width > 0 && area.height > 0 {
135            frame.render_widget(
136                Paragraph::new(msg).style(Style::default().fg(theme.diagnostic_warning_fg)),
137                Rect::new(x, y, msg.len() as u16, 1),
138            );
139        }
140        return SettingsLayout::new(Rect::ZERO);
141    }
142
143    // Calculate modal size (90% of screen width, 90% height to fill most of available space)
144    let modal_width = (area.width * 90 / 100).min(160);
145    let modal_height = area.height * 90 / 100;
146    let modal_x = (area.width.saturating_sub(modal_width)) / 2;
147    let modal_y = (area.height.saturating_sub(modal_height)) / 2;
148
149    let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
150
151    // Clear the modal area and draw border
152    frame.render_widget(Clear, modal_area);
153
154    let title = if state.has_changes() {
155        format!(" Settings [{}] • (modified) ", state.target_layer_name())
156    } else {
157        format!(" Settings [{}] ", state.target_layer_name())
158    };
159
160    let block = Block::default()
161        .title(title.as_str())
162        .borders(Borders::ALL)
163        .border_type(BorderType::Rounded)
164        .border_style(Style::default().fg(theme.popup_border_fg))
165        .style(Style::default().bg(theme.popup_bg));
166    frame.render_widget(block, modal_area);
167
168    // Inner area after border
169    let inner_area = Rect::new(
170        modal_area.x + 1,
171        modal_area.y + 1,
172        modal_area.width.saturating_sub(2),
173        modal_area.height.saturating_sub(2),
174    );
175
176    // Determine layout mode: vertical (narrow) vs horizontal (wide)
177    // Narrow mode when inner width < 60 columns
178    let narrow_mode = inner_area.width < 60;
179
180    // Always render search bar at the top (1 line height to avoid layout
181    // jump), with a 1-row blank gap below it so the bar reads as a header
182    // rather than running into the panels.
183    let search_area = Rect::new(inner_area.x, inner_area.y, inner_area.width, 1);
184    let search_header_height = 1u16;
185    let search_gap = 1u16;
186    if state.search_active {
187        render_search_header(frame, search_area, state, theme);
188    } else {
189        render_search_hint(frame, search_area, theme);
190    }
191
192    // Footer height: 2 lines for horizontal (separator + buttons), 7 for vertical
193    let footer_height = if narrow_mode { 7 } else { 2 };
194    let chrome_height = search_header_height + search_gap + footer_height;
195    let content_area = Rect::new(
196        inner_area.x,
197        inner_area.y + search_header_height + search_gap,
198        inner_area.width,
199        inner_area.height.saturating_sub(chrome_height),
200    );
201
202    // Create layout tracker
203    let mut layout = SettingsLayout::new(modal_area);
204
205    if narrow_mode {
206        // Vertical layout: categories on top, items below
207        render_vertical_layout(frame, content_area, modal_area, state, theme, &mut layout);
208    } else {
209        // Horizontal layout: categories left, items right
210        render_horizontal_layout(frame, content_area, modal_area, state, theme, &mut layout);
211    }
212
213    // Determine the topmost dialog layer and apply dimming to layers below
214    let has_confirm = state.showing_confirm_dialog;
215    let has_reset = state.showing_reset_dialog;
216    let has_entry = state.showing_entry_dialog();
217    let has_help = state.showing_help;
218
219    // Render confirmation dialog if showing
220    if has_confirm {
221        if !has_entry && !has_help {
222            crate::view::dimming::apply_dimming(frame, modal_area);
223        }
224        render_confirm_dialog(frame, modal_area, state, theme);
225    }
226
227    // Render reset confirmation dialog if showing
228    if has_reset {
229        if !has_confirm && !has_entry && !has_help {
230            crate::view::dimming::apply_dimming(frame, modal_area);
231        }
232        render_reset_dialog(frame, modal_area, state, theme);
233    }
234
235    // Render entry dialog stack — dim between each level
236    if has_entry {
237        let stack_depth = state.entry_dialog_stack.len();
238        for dialog_idx in 0..stack_depth {
239            if !has_help || dialog_idx < stack_depth - 1 {
240                crate::view::dimming::apply_dimming(frame, modal_area);
241            }
242            render_entry_dialog_at(frame, modal_area, state, theme, dialog_idx);
243        }
244    }
245
246    // Render entry-dialog discard-confirm prompt if showing, on top of
247    // the entry dialog stack. Dim first so the user can see the prompt
248    // is asking about the dialog underneath.
249    if state.showing_entry_discard_confirm {
250        crate::view::dimming::apply_dimming(frame, modal_area);
251        render_entry_discard_confirm(frame, modal_area, state, theme);
252    }
253
254    // Render entry-dialog delete-confirm prompt on top of everything
255    // else. Same chrome as the discard prompt but worded for
256    // destructive action.
257    if state.showing_entry_delete_confirm {
258        crate::view::dimming::apply_dimming(frame, modal_area);
259        render_entry_delete_confirm(frame, modal_area, state, theme);
260    }
261
262    // Render help overlay if showing
263    if has_help {
264        crate::view::dimming::apply_dimming(frame, modal_area);
265        render_help_overlay(frame, modal_area, theme);
266    }
267
268    layout
269}
270
271/// Render horizontal layout (wide mode): categories left, items right
272fn render_horizontal_layout(
273    frame: &mut Frame,
274    content_area: Rect,
275    modal_area: Rect,
276    state: &mut SettingsState,
277    theme: &Theme,
278    layout: &mut SettingsLayout,
279) {
280    // Layout: [left panel (categories)] | [right panel (settings)]
281    // 24 cols for categories, 1 col for the divider, the rest for settings.
282    let chunks = Layout::horizontal([
283        Constraint::Length(24),
284        Constraint::Length(1),
285        Constraint::Min(40),
286    ])
287    .split(content_area);
288
289    let categories_area = chunks[0];
290    let divider_area = chunks[1];
291    let settings_area = chunks[2];
292
293    // Render category list (left panel)
294    render_categories(frame, categories_area, state, theme, layout);
295
296    // Single straight vertical line dividing categories from settings.
297    let divider_style = Style::default().fg(theme.split_separator_fg);
298    for y in 0..divider_area.height {
299        frame.render_widget(
300            Paragraph::new("│").style(divider_style),
301            Rect::new(divider_area.x, divider_area.y + y, 1, 1),
302        );
303    }
304
305    // 1-col gutter on each side of the settings panel for breathing room.
306    let horizontal_padding = 1u16;
307    let settings_inner = Rect::new(
308        settings_area.x + horizontal_padding,
309        settings_area.y,
310        settings_area.width.saturating_sub(horizontal_padding * 2),
311        settings_area.height,
312    );
313
314    if state.search_active && !state.search_results.is_empty() {
315        render_search_results(frame, settings_inner, state, theme, layout);
316    } else {
317        render_settings_panel(frame, settings_inner, state, theme, layout);
318    }
319
320    // Render footer with buttons (horizontal layout)
321    render_footer(frame, modal_area, state, theme, layout, false);
322}
323
324/// Render vertical layout (narrow mode): categories on top, items below
325fn render_vertical_layout(
326    frame: &mut Frame,
327    content_area: Rect,
328    modal_area: Rect,
329    state: &mut SettingsState,
330    theme: &Theme,
331    layout: &mut SettingsLayout,
332) {
333    // Calculate footer height for vertical buttons (5 buttons + separators)
334    let footer_height = 7;
335
336    // Layout: [categories (3 lines)] / [separator] / [settings] / [footer]
337    let main_height = content_area.height.saturating_sub(footer_height);
338    let category_height = 3u16.min(main_height);
339    let settings_height = main_height.saturating_sub(category_height + 1); // +1 for separator
340
341    // Categories area (horizontal strip at top)
342    let categories_area = Rect::new(
343        content_area.x,
344        content_area.y,
345        content_area.width,
346        category_height,
347    );
348
349    // Separator line
350    let sep_y = content_area.y + category_height;
351
352    // Settings area
353    let settings_area = Rect::new(
354        content_area.x,
355        sep_y + 1,
356        content_area.width,
357        settings_height,
358    );
359
360    // Render horizontal category strip
361    render_categories_horizontal(frame, categories_area, state, theme, layout);
362
363    // Render horizontal separator
364    if sep_y < content_area.y + content_area.height {
365        let sep_line: String = "─".repeat(content_area.width as usize);
366        frame.render_widget(
367            Paragraph::new(sep_line).style(Style::default().fg(theme.split_separator_fg)),
368            Rect::new(content_area.x, sep_y, content_area.width, 1),
369        );
370    }
371
372    // Render settings panel
373    if state.search_active && !state.search_results.is_empty() {
374        render_search_results(frame, settings_area, state, theme, layout);
375    } else {
376        render_settings_panel(frame, settings_area, state, theme, layout);
377    }
378
379    // Render footer with buttons (vertical layout)
380    render_footer(frame, modal_area, state, theme, layout, true);
381}
382
383/// Render categories as a horizontal strip (for narrow mode)
384fn render_categories_horizontal(
385    frame: &mut Frame,
386    area: Rect,
387    state: &SettingsState,
388    theme: &Theme,
389    layout: &mut SettingsLayout,
390) {
391    use super::state::FocusPanel;
392
393    if area.height == 0 || area.width == 0 {
394        return;
395    }
396
397    let is_focused = state.focus_panel() == FocusPanel::Categories;
398
399    // Build category labels with indicators
400    let mut spans = Vec::new();
401    let mut total_width = 0u16;
402
403    for (i, page) in state.pages.iter().enumerate() {
404        let is_selected = i == state.selected_category;
405        let has_modified = page.items.iter().any(|item| item.modified);
406
407        let indicator = if has_modified { "● " } else { "  " };
408        let name = &page.name;
409
410        let style = if is_selected && is_focused {
411            Style::default()
412                .fg(theme.menu_highlight_fg)
413                .bg(theme.menu_highlight_bg)
414                .add_modifier(Modifier::BOLD)
415        } else if is_selected {
416            Style::default()
417                .fg(theme.menu_highlight_fg)
418                .add_modifier(Modifier::BOLD)
419        } else {
420            Style::default().fg(theme.popup_text_fg)
421        };
422
423        let indicator_style = if has_modified {
424            Style::default().fg(theme.menu_highlight_fg)
425        } else {
426            style
427        };
428
429        // Add separator between categories
430        if i > 0 {
431            spans.push(Span::styled(
432                " │ ",
433                Style::default().fg(theme.split_separator_fg),
434            ));
435            total_width += 3;
436        }
437
438        spans.push(Span::styled(indicator, indicator_style));
439        spans.push(Span::styled(name.as_str(), style));
440        total_width += (indicator.len() + name.len()) as u16;
441
442        // Track category rect for click handling (approximate)
443        let cat_x = area.x + total_width.saturating_sub((indicator.len() + name.len()) as u16);
444        let cat_width = (indicator.len() + name.len()) as u16;
445        layout
446            .categories
447            .push((i, Rect::new(cat_x, area.y, cat_width, 1)));
448    }
449
450    // Render the category line
451    let line = Line::from(spans);
452    frame.render_widget(Paragraph::new(line), area);
453
454    // Show navigation hint on line 2 if space
455    if area.height >= 2 {
456        let hint = "←→: Switch category";
457        let hint_style = Style::default().fg(theme.line_number_fg);
458        frame.render_widget(
459            Paragraph::new(hint).style(hint_style),
460            Rect::new(area.x, area.y + 1, area.width, 1),
461        );
462    }
463}
464
465/// Get an icon for a settings category name (Nerd Font icons)
466fn category_icon(name: &str) -> &'static str {
467    match name.to_lowercase().as_str() {
468        "general" => "\u{f013} ",       //
469        "editor" => "\u{f044} ",        //
470        "clipboard" => "\u{f328} ",     //
471        "file browser" => "\u{f07b} ",  //
472        "file explorer" => "\u{f07c} ", //
473        "packages" => "\u{f487} ",      //
474        "plugins" => "\u{f1e6} ",       //
475        "terminal" => "\u{f120} ",      //
476        "warnings" => "\u{f071} ",      //
477        "keybindings" => "\u{f11c} ",   //
478        _ => "\u{f111} ",               //  (dot circle as fallback)
479    }
480}
481
482/// Render the category tree (categories + expanded sections) in the left panel.
483///
484/// Rows are flattened by [`SettingsState::visible_tree`] and rendered through
485/// [`ScrollablePanel`], which handles partial-row clipping and the scrollbar.
486/// Per-row Rects are recorded on `layout` for hit-testing.
487fn render_categories(
488    frame: &mut Frame,
489    area: Rect,
490    state: &mut SettingsState,
491    theme: &Theme,
492    layout: &mut SettingsLayout,
493) {
494    use super::state::{FocusPanel, TreeRow};
495
496    layout.categories_panel_area = Some(area);
497
498    let rows = state.visible_tree();
499    state.categories_scroll.set_viewport(area.height);
500    state
501        .categories_scroll
502        .update_content_height(&rows, area.width);
503
504    let focus_panel = state.focus_panel();
505    let selected_category = state.selected_category;
506    // Where the keyboard cursor lives in the tree. `None` = on the
507    // category row; `Some(s_idx)` = on the s-th section row inside the
508    // currently-selected category. This is the single source of truth
509    // for the `>` indicator and the row-bg highlight.
510    let tree_cursor = state.tree_cursor_section;
511
512    // Snapshot the data each row needs so we don't hold a borrow on `state`
513    // through the render callback.
514    struct RowData {
515        chevron: &'static str,
516        is_expandable: bool,
517        is_selected: bool,
518        has_changes: bool,
519        indent_cols: u16,
520        is_category: bool,
521        cat_idx: Option<usize>,
522        section_idx: Option<usize>,
523        label: String,
524        icon: Option<&'static str>,
525    }
526    let row_data: Vec<RowData> = rows
527        .iter()
528        .map(|row| match *row {
529            TreeRow::Category {
530                idx,
531                expandable,
532                expanded,
533            } => {
534                let page = &state.pages[idx];
535                RowData {
536                    chevron: if expandable {
537                        if expanded {
538                            "▼"
539                        } else {
540                            "▶"
541                        }
542                    } else {
543                        " "
544                    },
545                    is_expandable: expandable,
546                    // Category row is "selected" iff the keyboard cursor
547                    // is sitting on it (no section is the cursor target).
548                    is_selected: idx == selected_category && tree_cursor.is_none(),
549                    has_changes: page.items.iter().any(|i| i.modified),
550                    indent_cols: 0,
551                    is_category: true,
552                    cat_idx: Some(idx),
553                    section_idx: None,
554                    label: page.name.clone(),
555                    icon: Some(category_icon(&page.name)),
556                }
557            }
558            TreeRow::Section {
559                cat_idx,
560                section_idx,
561            } => {
562                let section = &state.pages[cat_idx].sections[section_idx];
563                // Section row is "selected" iff the explicit tree cursor
564                // points at it. The cursor follows the user's keyboard
565                // navigation AND syncs to body scroll (handled by the
566                // sync-on-scroll path), so this single check covers
567                // both keyboard and wheel-driven highlight updates.
568                let is_current = cat_idx == selected_category && tree_cursor == Some(section_idx);
569                RowData {
570                    chevron: " ",
571                    is_expandable: false,
572                    is_selected: is_current,
573                    has_changes: false,
574                    indent_cols: 4,
575                    is_category: false,
576                    cat_idx: Some(cat_idx),
577                    section_idx: Some(section_idx),
578                    label: section.name.clone(),
579                    icon: None,
580                }
581            }
582        })
583        .collect();
584
585    // Render through ScrollablePanel so we get scrollbar + clipping.
586    let panel_layout = state.categories_scroll.render(
587        frame,
588        area,
589        &rows,
590        |frame, info, row| {
591            // Find this row's snapshot. `rows` and `row_data` are 1:1 by index.
592            let idx = info.index;
593            let data = &row_data[idx];
594            let row_area = info.area;
595
596            // Only the cursor row paints a bg — no separate hover-bg
597            // path. Hover bg in addition to the cursor bg produced two
598            // visually-highlighted rows simultaneously (with two
599            // *different* colors, since hover and selection use
600            // different theme keys), which violates the single-cursor
601            // invariant. The OS mouse cursor itself is the user's
602            // "where am I" indicator; we don't need an in-app one.
603            let row_bg = if data.is_selected {
604                if focus_panel == FocusPanel::Categories {
605                    Some(theme.menu_highlight_bg)
606                } else {
607                    Some(theme.selection_bg)
608                }
609            } else {
610                None
611            };
612            if let Some(bg) = row_bg {
613                frame.render_widget(
614                    Paragraph::new(" ".repeat(row_area.width as usize))
615                        .style(Style::default().bg(bg)),
616                    row_area,
617                );
618            }
619
620            let fg = if data.is_selected {
621                if focus_panel == FocusPanel::Categories {
622                    theme.menu_highlight_fg
623                } else {
624                    theme.menu_fg
625                }
626            } else {
627                theme.popup_text_fg
628            };
629            let bg = row_bg.unwrap_or(theme.popup_bg);
630            let style = Style::default().fg(fg).bg(bg);
631
632            let mut spans: Vec<Span> = Vec::with_capacity(8);
633            // Selection indicator (">" when this row is the focused one in
634            // the categories panel) lives in col 0 before any indentation.
635            // The category-selection-indicator-visible test asserts on this.
636            let selected_marker = if data.is_selected && focus_panel == FocusPanel::Categories {
637                ">"
638            } else {
639                " "
640            };
641            spans.push(Span::styled(selected_marker.to_string(), style));
642            if data.indent_cols > 0 {
643                spans.push(Span::styled(" ".repeat(data.indent_cols as usize), style));
644            }
645            // Chevron occupies one column; followed by a space for breathing room.
646            spans.push(Span::styled(format!("{} ", data.chevron), style));
647            if data.has_changes {
648                spans.push(Span::styled(
649                    "● ",
650                    Style::default().fg(theme.menu_highlight_fg).bg(bg),
651                ));
652            } else {
653                spans.push(Span::styled("  ", style));
654            }
655            if let Some(icon) = data.icon {
656                spans.push(Span::styled(
657                    icon.to_string(),
658                    Style::default().fg(theme.popup_border_fg).bg(bg),
659                ));
660            } else {
661                spans.push(Span::styled(" ", style));
662            }
663            spans.push(Span::styled(data.label.clone(), style));
664
665            frame.render_widget(Paragraph::new(Line::from(spans)), row_area);
666
667            // Hand back the row identity so we can register hit-test areas
668            // after rendering.
669            (
670                row_area,
671                data.is_category,
672                data.is_expandable,
673                data.cat_idx,
674                data.section_idx,
675                data.indent_cols,
676                *row,
677            )
678        },
679        theme,
680    );
681
682    // Translate per-row Rects into hit-test entries.
683    for layout_info in panel_layout.item_layouts.iter() {
684        let (row_area, is_category, is_expandable, cat_idx, section_idx, indent_cols, _row) =
685            layout_info.layout;
686        if is_category {
687            if let Some(idx) = cat_idx {
688                layout.add_category(idx, row_area);
689                if is_expandable {
690                    // Chevron sits one column after the selection-indicator
691                    // marker plus any indent for nested rows.
692                    let chevron_x = row_area.x.saturating_add(1 + indent_cols);
693                    let chevron_area = Rect::new(chevron_x, row_area.y, 1, 1);
694                    layout.add_category_disclosure(idx, chevron_area);
695                }
696            }
697        } else if let (Some(c), Some(s)) = (cat_idx, section_idx) {
698            layout.add_section(c, s, row_area);
699        }
700    }
701    if let Some(scrollbar) = panel_layout.scrollbar_area {
702        layout.categories_scrollbar_area = Some(scrollbar);
703    }
704}
705
706/// Context for rendering a setting item (extracted to avoid borrow issues)
707struct RenderContext {
708    selected_item: usize,
709    settings_focused: bool,
710    hover_hit: Option<SettingsHit>,
711}
712
713/// Render the settings panel for the current category
714fn render_settings_panel(
715    frame: &mut Frame,
716    area: Rect,
717    state: &mut SettingsState,
718    theme: &Theme,
719    layout: &mut SettingsLayout,
720) {
721    let page = match state.current_page() {
722        Some(p) => p,
723        None => return,
724    };
725
726    // Page description suppressed: it duplicated the category name visible
727    // in the sidebar and pushed the actual settings down without adding
728    // information. The category names + section headers carry enough
729    // context.
730    let mut y = area.y;
731    let header_start_y = y;
732
733    // "Clear" button for nullable categories (e.g., Option<LanguageConfig>)
734    if page.nullable && state.current_category_has_values() {
735        let btn_text = format!("[{}]", t!("settings.btn_clear_category"));
736        let btn_len = btn_text.len() as u16;
737        let is_hovered = matches!(state.hover_hit, Some(SettingsHit::ClearCategoryButton));
738        let btn_style = if is_hovered {
739            Style::default()
740                .fg(theme.menu_hover_fg)
741                .bg(theme.menu_hover_bg)
742        } else {
743            Style::default().fg(theme.line_number_fg)
744        };
745        let btn_area = Rect::new(area.x, y, btn_len, 1);
746        frame.render_widget(Paragraph::new(btn_text).style(btn_style), btn_area);
747        layout.clear_category_button = Some(btn_area);
748        y += 1;
749    } else {
750        layout.clear_category_button = None;
751    }
752
753    y += 1; // Blank line
754
755    let header_height = (y - header_start_y) as usize;
756    let items_start_y = y;
757
758    // Calculate available height for items
759    let available_height = area.height.saturating_sub(header_height as u16);
760
761    // The body panel width is the full width of the area allocated to items.
762    // Items size themselves against this width directly via the ScrollItem
763    // trait — there's no longer a cached per-item layout_width to keep in
764    // sync.
765    state.layout_width = area.width;
766
767    // Update scroll panel with current viewport and content
768    let page = state.pages.get(state.selected_category).unwrap();
769    state.scroll_panel.set_viewport(available_height);
770    state
771        .scroll_panel
772        .update_content_height(&page.items, area.width);
773
774    // Extract state needed for rendering (to avoid borrow issues with scroll_panel)
775    use super::state::FocusPanel;
776    let render_ctx = RenderContext {
777        selected_item: state.selected_item,
778        settings_focused: state.focus_panel() == FocusPanel::Settings,
779        hover_hit: state.hover_hit,
780    };
781
782    // Area for items (below header)
783    let items_area = Rect::new(area.x, items_start_y, area.width, available_height.max(1));
784
785    // Get items reference for rendering
786    let page = state.pages.get(state.selected_category).unwrap();
787
788    // Calculate max label width for column alignment (only for single-row controls)
789    let max_label_width = page
790        .items
791        .iter()
792        .filter_map(|item| {
793            // Only consider single-row controls for alignment
794            match &item.control {
795                SettingControl::Toggle(s) => Some(s.label.len() as u16),
796                SettingControl::Number(s) => Some(s.label.len() as u16),
797                SettingControl::Dropdown(s) => Some(s.label.len() as u16),
798                SettingControl::Text(s) => Some(s.label.len() as u16),
799                // Multi-row controls have their labels on separate lines
800                _ => None,
801            }
802        })
803        .max();
804
805    // Use ScrollablePanel to render items with automatic scroll handling
806    let panel_layout = state.scroll_panel.render(
807        frame,
808        items_area,
809        &page.items,
810        |frame, info, item| {
811            render_setting_item_pure(
812                frame,
813                info.area,
814                item,
815                info.index,
816                info.skip_top,
817                &render_ctx,
818                theme,
819                max_label_width,
820            )
821        },
822        theme,
823    );
824
825    // Transfer item layouts to SettingsLayout
826    let page = state.pages.get(state.selected_category).unwrap();
827    for item_info in panel_layout.item_layouts {
828        layout.add_item(
829            item_info.index,
830            page.items[item_info.index].path.clone(),
831            item_info.area,
832            item_info.layout.control,
833            item_info.layout.inherit_button,
834        );
835    }
836
837    // Track the settings panel area for scroll hit testing
838    layout.settings_panel_area = Some(panel_layout.content_area);
839
840    // Track scrollbar area for drag detection
841    if let Some(sb_area) = panel_layout.scrollbar_area {
842        layout.scrollbar_area = Some(sb_area);
843    }
844}
845
846/// Wrap text to fit within a given width
847fn wrap_text(text: &str, width: usize) -> Vec<String> {
848    if width == 0 || text.is_empty() {
849        return vec![text.to_string()];
850    }
851
852    let mut lines = Vec::new();
853    let mut current_line = String::new();
854    let mut current_len = 0;
855
856    for word in text.split_whitespace() {
857        let word_len = word.chars().count();
858
859        if current_len == 0 {
860            // First word on line
861            current_line = word.to_string();
862            current_len = word_len;
863        } else if current_len + 1 + word_len <= width {
864            // Word fits on current line
865            current_line.push(' ');
866            current_line.push_str(word);
867            current_len += 1 + word_len;
868        } else {
869            // Start new line
870            lines.push(current_line);
871            current_line = word.to_string();
872            current_len = word_len;
873        }
874    }
875
876    if !current_line.is_empty() {
877        lines.push(current_line);
878    }
879
880    if lines.is_empty() {
881        lines.push(String::new());
882    }
883
884    lines
885}
886
887/// Pure render function for a setting item (returns layout, doesn't modify external state)
888///
889/// Driven by `item.layout_box(area.width, &item.style)` — every y-offset comes
890/// from the resulting `ItemBox`, so adjusting card chrome (border, padding,
891/// section header height) happens by changing `ItemBoxStyle`, not by editing
892/// renderer arithmetic.
893///
894/// # Arguments
895/// * `skip_top` - Number of rows to skip at top of item (for partial visibility when scrolling)
896/// * `label_width` - Optional label width for column alignment
897#[allow(clippy::too_many_arguments)]
898fn render_setting_item_pure(
899    frame: &mut Frame,
900    area: Rect,
901    item: &super::items::SettingItem,
902    idx: usize,
903    skip_top: u16,
904    ctx: &RenderContext,
905    theme: &Theme,
906    label_width: Option<u16>,
907) -> SettingItemLayoutInfo {
908    let plan = item.layout_box(area.width, &item.style);
909    let style = item.style;
910    let viewport_end_logical = skip_top.saturating_add(area.height); // exclusive
911
912    // Translate a logical band [logical_y, logical_y + rows) to a physical
913    // sub-rectangle of `area`, accounting for `skip_top` clipping. Returns
914    // None when the band is entirely outside the visible viewport.
915    let band_rect = |logical_y: u16, rows: u16| -> Option<Rect> {
916        if rows == 0 {
917            return None;
918        }
919        let band_end = logical_y.saturating_add(rows);
920        if band_end <= skip_top || logical_y >= viewport_end_logical {
921            return None;
922        }
923        let visible_top_logical = logical_y.max(skip_top);
924        let visible_bottom_logical = band_end.min(viewport_end_logical);
925        let physical_y = area.y + (visible_top_logical - skip_top);
926        let visible_h = visible_bottom_logical - visible_top_logical;
927        Some(Rect::new(area.x, physical_y, area.width, visible_h))
928    };
929
930    // ── Section header band ────────────────────────────────────────────────
931    // Layout: blank gap on the leading rows, title on the last row of the
932    // band. This puts the breathing room above the heading and butts the
933    // title against the card it labels, which reads as "title belongs to
934    // what's below" rather than "title belongs to what's above".
935    if let (Some(section_name), Some(_header_rect)) = (
936        item.section.as_deref().filter(|_| item.is_section_start),
937        band_rect(0, plan.section_header_rows),
938    ) {
939        let title_logical_y = plan.section_header_rows.saturating_sub(1);
940        if let Some(title_rect) = band_rect(title_logical_y, 1) {
941            let header_style = Style::default()
942                .fg(theme.editor_fg)
943                .add_modifier(Modifier::BOLD);
944            frame.render_widget(
945                Paragraph::new(section_name).style(header_style),
946                Rect::new(title_rect.x, title_rect.y, title_rect.width, 1),
947            );
948        }
949    }
950
951    // ── Card box ───────────────────────────────────────────────────────────
952    // The card spans logical rows [card_top_y, total_rows). Render it with a
953    // single Block, choosing which edges to draw based on which logical rows
954    // are inside the visible viewport.
955    let card_logical_top = plan.card_top_y();
956    let card_logical_bottom = plan.total_rows();
957    if let Some(card_rect) = band_rect(
958        card_logical_top,
959        card_logical_bottom.saturating_sub(card_logical_top),
960    ) {
961        let mut borders = Borders::NONE;
962        if style.card_border_cols > 0 {
963            borders |= Borders::LEFT | Borders::RIGHT;
964        }
965        if style.card_border_rows > 0 {
966            // TOP edge is only visible when its logical row sits inside [skip_top, viewport_end).
967            if card_logical_top >= skip_top {
968                borders |= Borders::TOP;
969            }
970            // BOTTOM edge is the last logical row of the card.
971            let bottom_logical = card_logical_bottom.saturating_sub(1);
972            if bottom_logical >= skip_top && bottom_logical < viewport_end_logical {
973                borders |= Borders::BOTTOM;
974            }
975        }
976        if !borders.is_empty() {
977            // Subdued color for the card chrome — distinct from the
978            // panel/popup border around the modal so the cards read as
979            // secondary structure, not nested popups.
980            let block = Block::default()
981                .borders(borders)
982                .border_type(BorderType::Rounded)
983                .border_style(Style::default().fg(theme.split_separator_fg));
984            frame.render_widget(block, card_rect);
985        }
986    }
987
988    // ── Content area (control + description) ───────────────────────────────
989    let is_selected = ctx.settings_focused && idx == ctx.selected_item;
990    let is_item_hovered = matches!(
991        ctx.hover_hit,
992        Some(SettingsHit::Item(i))
993            | Some(SettingsHit::ControlToggle(i))
994            | Some(SettingsHit::ControlDecrement(i))
995            | Some(SettingsHit::ControlIncrement(i))
996            | Some(SettingsHit::ControlDropdown(i))
997            | Some(SettingsHit::ControlText(i))
998            | Some(SettingsHit::ControlTextListRow(i, _))
999            | Some(SettingsHit::ControlMapRow(i, _))
1000            | Some(SettingsHit::ControlInherit(i))
1001        if i == idx
1002    );
1003    let is_focused_or_hovered = is_selected || is_item_hovered;
1004
1005    // Inner area is the card minus the side borders. Y-axis is the union of
1006    // the control + description bands.
1007    let content_logical_top = plan.control_y();
1008    let content_logical_bottom = plan.bottom_border_y();
1009    let mut control_layout = ControlLayoutInfo::default();
1010    let mut inherit_button_area: Option<Rect> = None;
1011    if let Some(content_rect) = band_rect(
1012        content_logical_top,
1013        content_logical_bottom.saturating_sub(content_logical_top),
1014    ) {
1015        // Trim left/right by the card side borders.
1016        let inner_x = content_rect.x.saturating_add(style.card_border_cols);
1017        let inner_width = content_rect
1018            .width
1019            .saturating_sub(2 * style.card_border_cols);
1020        let inner_area = Rect::new(inner_x, content_rect.y, inner_width, content_rect.height);
1021
1022        // Highlight background for focused/hovered items. Limited to the
1023        // label row so chip / description text below stays on popup_bg
1024        // and remains legible regardless of how saturated the theme's
1025        // highlight bg is. The colors come from the theme's
1026        // `settings_selected_bg` (selected) and `menu_hover_bg` (hovered)
1027        // — each theme is responsible for picking values that contrast
1028        // with its own popup_bg AND don't collide with chip text colors.
1029        let label_visible = skip_top <= content_logical_top;
1030        if is_focused_or_hovered && inner_width > 0 && label_visible {
1031            let bg_style = if is_selected {
1032                Style::default().bg(theme.settings_selected_bg)
1033            } else {
1034                Style::default().bg(theme.menu_hover_bg)
1035            };
1036            let row_area = Rect::new(inner_area.x, inner_area.y, inner_area.width, 1);
1037            frame.render_widget(Paragraph::new("").style(bg_style), row_area);
1038        }
1039
1040        // skip_top relative to the start of the control band — used by
1041        // multi-row controls and by the description renderer to know how
1042        // many leading rows are off-screen.
1043        let content_skip_top = skip_top.saturating_sub(content_logical_top);
1044
1045        // Focus indicator (`>`) at column 0 of inner area, modified marker
1046        // (`●`) at column 1. Only paint them when the control's first row is
1047        // visible (i.e. nothing has been clipped off the top of the content).
1048        let label_row_visible = content_skip_top == 0 && inner_area.height > 0;
1049        if is_selected && label_row_visible {
1050            frame.render_widget(
1051                Paragraph::new(">").style(
1052                    Style::default()
1053                        .fg(theme.settings_selected_fg)
1054                        .add_modifier(Modifier::BOLD),
1055                ),
1056                Rect::new(inner_area.x, inner_area.y, 1, 1),
1057            );
1058        }
1059        if item.modified && label_row_visible && inner_area.width >= 2 {
1060            frame.render_widget(
1061                Paragraph::new("●").style(Style::default().fg(theme.settings_selected_fg)),
1062                Rect::new(inner_area.x + 1, inner_area.y, 1, 1),
1063            );
1064        }
1065
1066        // Control occupies its own band at the top of the content rect.
1067        let control_logical_rows = plan.control_rows;
1068        if let Some(control_rect) = band_rect(content_logical_top, control_logical_rows).map(|r| {
1069            let x =
1070                r.x.saturating_add(style.card_border_cols + style.focus_indicator_cols);
1071            let w = r
1072                .width
1073                .saturating_sub(2 * style.card_border_cols + style.focus_indicator_cols);
1074            Rect::new(x, r.y, w, r.height)
1075        }) {
1076            control_layout = render_control(
1077                frame,
1078                control_rect,
1079                &item.control,
1080                &item.name,
1081                content_skip_top,
1082                theme,
1083                label_width
1084                    .map(|w| w.saturating_sub(style.card_border_cols + style.focus_indicator_cols)),
1085                item.read_only,
1086                item.is_null,
1087            );
1088
1089            // (Inherited) badge / [Inherit] button: rendered on the same row
1090            // as the control's first line, at its right edge.
1091            if item.nullable && content_skip_top == 0 && control_rect.width > 0 {
1092                if item.is_null {
1093                    let badge_text = t!("settings.inherited_badge").to_string();
1094                    let badge_len = badge_text.len() as u16 + 1;
1095                    let badge_x = control_rect
1096                        .x
1097                        .saturating_add(control_rect.width)
1098                        .saturating_sub(badge_len);
1099                    if badge_x > control_rect.x {
1100                        frame.render_widget(
1101                            Paragraph::new(badge_text).style(
1102                                Style::default()
1103                                    .fg(theme.line_number_fg)
1104                                    .add_modifier(Modifier::ITALIC),
1105                            ),
1106                            Rect::new(badge_x, control_rect.y, badge_len, 1),
1107                        );
1108                    }
1109                } else {
1110                    let btn_text = format!("[{}]", t!("settings.btn_inherit"));
1111                    let btn_len = btn_text.len() as u16 + 1;
1112                    let btn_x = control_rect
1113                        .x
1114                        .saturating_add(control_rect.width)
1115                        .saturating_sub(btn_len);
1116                    if btn_x > control_rect.x {
1117                        let btn_area = Rect::new(btn_x, control_rect.y, btn_len, 1);
1118                        let is_hovered = matches!(
1119                            ctx.hover_hit,
1120                            Some(SettingsHit::ControlInherit(i)) if i == idx
1121                        );
1122                        let btn_style = if is_hovered {
1123                            Style::default()
1124                                .fg(theme.menu_hover_fg)
1125                                .bg(theme.menu_hover_bg)
1126                        } else {
1127                            Style::default().fg(theme.line_number_fg)
1128                        };
1129                        frame.render_widget(Paragraph::new(btn_text).style(btn_style), btn_area);
1130                        inherit_button_area = Some(btn_area);
1131                    }
1132                }
1133            }
1134        }
1135
1136        // Description band: below the control. Wraps to the inner text width
1137        // computed by the style, falling back to a layer label when there's
1138        // no description but we still need to show the source layer.
1139        let desc_logical_rows = plan.description_rows;
1140        let layer_label = match item.layer_source {
1141            crate::config_io::ConfigLayer::System => None,
1142            crate::config_io::ConfigLayer::User => Some("user"),
1143            crate::config_io::ConfigLayer::Project => Some("project"),
1144            crate::config_io::ConfigLayer::Session => Some("session"),
1145        };
1146
1147        if desc_logical_rows > 0 {
1148            if let Some(desc_rect) = band_rect(plan.description_y(), desc_logical_rows).map(|r| {
1149                let x =
1150                    r.x.saturating_add(style.card_border_cols + style.focus_indicator_cols);
1151                let w = r
1152                    .width
1153                    .saturating_sub(2 * style.card_border_cols + style.focus_indicator_cols);
1154                Rect::new(x, r.y, w, r.height)
1155            }) {
1156                let desc_skip = skip_top.saturating_sub(plan.description_y());
1157                let max_text_width = desc_rect
1158                    .width
1159                    .saturating_sub(style.description_right_padding_cols)
1160                    as usize;
1161                let mut lines = match item.description.as_deref() {
1162                    Some(d) if !d.is_empty() => wrap_text(d, max_text_width),
1163                    _ => Vec::new(),
1164                };
1165                if let Some(layer) = layer_label {
1166                    if let Some(last) = lines.last_mut() {
1167                        last.push_str(&format!(" ({})", layer));
1168                    } else {
1169                        lines.push(format!("({})", layer));
1170                    }
1171                }
1172                let desc_style = Style::default().fg(theme.line_number_fg);
1173                let take = desc_rect.height as usize;
1174                for (i, line) in lines.iter().skip(desc_skip as usize).take(take).enumerate() {
1175                    frame.render_widget(
1176                        Paragraph::new(line.as_str()).style(desc_style),
1177                        Rect::new(desc_rect.x, desc_rect.y + i as u16, desc_rect.width, 1),
1178                    );
1179                }
1180            }
1181        } else if let Some(layer) = layer_label {
1182            // No description, just a layer label on the row immediately
1183            // below the control.
1184            if let Some(layer_rect) = band_rect(plan.description_y(), 1).map(|r| {
1185                let x =
1186                    r.x.saturating_add(style.card_border_cols + style.focus_indicator_cols);
1187                let w = r
1188                    .width
1189                    .saturating_sub(2 * style.card_border_cols + style.focus_indicator_cols);
1190                Rect::new(x, r.y, w, r.height)
1191            }) {
1192                frame.render_widget(
1193                    Paragraph::new(format!("({})", layer))
1194                        .style(Style::default().fg(theme.line_number_fg)),
1195                    layer_rect,
1196                );
1197            }
1198        }
1199    }
1200
1201    SettingItemLayoutInfo {
1202        control: control_layout,
1203        inherit_button: inherit_button_area,
1204    }
1205}
1206
1207/// Render the appropriate control for a setting
1208///
1209/// # Arguments
1210/// * `name` - Setting name (for controls that render their own label)
1211/// * `skip_rows` - Number of rows to skip at top of control (for partial visibility)
1212/// * `label_width` - Optional label width for column alignment
1213/// * `read_only` - Whether this field is read-only (displays as plain text instead of input)
1214#[allow(clippy::too_many_arguments)]
1215fn render_control(
1216    frame: &mut Frame,
1217    area: Rect,
1218    control: &SettingControl,
1219    name: &str,
1220    skip_rows: u16,
1221    theme: &Theme,
1222    label_width: Option<u16>,
1223    read_only: bool,
1224    is_null: bool,
1225) -> ControlLayoutInfo {
1226    match control {
1227        // Single-row controls: only render if not skipped
1228        SettingControl::Toggle(state) => {
1229            if skip_rows > 0 {
1230                return ControlLayoutInfo::Toggle(Rect::default());
1231            }
1232            let colors = ToggleColors::from_theme(theme);
1233            let toggle_layout = render_toggle_aligned(frame, area, state, &colors, label_width);
1234            ControlLayoutInfo::Toggle(toggle_layout.full_area)
1235        }
1236
1237        SettingControl::Number(state) => {
1238            if skip_rows > 0 {
1239                return ControlLayoutInfo::Number {
1240                    decrement: Rect::default(),
1241                    increment: Rect::default(),
1242                    value: Rect::default(),
1243                };
1244            }
1245            let colors = NumberInputColors::from_theme(theme);
1246            let num_layout = render_number_input_aligned(frame, area, state, &colors, label_width);
1247            ControlLayoutInfo::Number {
1248                decrement: num_layout.decrement_area,
1249                increment: num_layout.increment_area,
1250                value: num_layout.value_area,
1251            }
1252        }
1253
1254        SettingControl::Dropdown(state) => {
1255            if skip_rows > 0 {
1256                return ControlLayoutInfo::Dropdown {
1257                    button_area: Rect::default(),
1258                    option_areas: Vec::new(),
1259                    scroll_offset: 0,
1260                };
1261            }
1262            let colors = DropdownColors::from_theme(theme);
1263            let drop_layout = render_dropdown_aligned(frame, area, state, &colors, label_width);
1264            ControlLayoutInfo::Dropdown {
1265                button_area: drop_layout.button_area,
1266                option_areas: drop_layout.option_areas,
1267                scroll_offset: drop_layout.scroll_offset,
1268            }
1269        }
1270
1271        SettingControl::Text(state) => {
1272            if skip_rows > 0 {
1273                return ControlLayoutInfo::Text(Rect::default());
1274            }
1275            if read_only {
1276                // Truly read-only fields (e.g., Key: in entry dialogs) render as plain text
1277                let label_w = label_width.unwrap_or(20);
1278                let label_style = Style::default().fg(theme.editor_fg);
1279                let value_style = Style::default().fg(theme.line_number_fg);
1280                let label = format!("{}: ", state.label);
1281                let value = &state.value;
1282
1283                let label_area = Rect::new(area.x, area.y, label_w, 1);
1284                let value_area = Rect::new(
1285                    area.x + label_w,
1286                    area.y,
1287                    area.width.saturating_sub(label_w),
1288                    1,
1289                );
1290
1291                frame.render_widget(Paragraph::new(label.clone()).style(label_style), label_area);
1292                frame.render_widget(
1293                    Paragraph::new(value.as_str()).style(value_style),
1294                    value_area,
1295                );
1296                ControlLayoutInfo::Text(Rect::default())
1297            } else if is_null {
1298                // Nullable-null fields render with dimmed brackets to indicate input presence
1299                let colors = TextInputColors::from_theme_disabled(theme);
1300                let text_layout =
1301                    render_text_input_aligned(frame, area, state, &colors, 30, label_width);
1302                ControlLayoutInfo::Text(text_layout.input_area)
1303            } else {
1304                let colors = TextInputColors::from_theme(theme);
1305                let text_layout =
1306                    render_text_input_aligned(frame, area, state, &colors, 30, label_width);
1307                ControlLayoutInfo::Text(text_layout.input_area)
1308            }
1309        }
1310
1311        // Multi-row controls: pass skip_rows to render partial view
1312        SettingControl::TextList(state) => {
1313            let colors = TextListColors::from_theme(theme);
1314            let list_layout = render_text_list_partial(frame, area, state, &colors, 30, skip_rows);
1315            ControlLayoutInfo::TextList {
1316                rows: list_layout
1317                    .rows
1318                    .iter()
1319                    .map(|r| (r.index, r.text_area))
1320                    .collect(),
1321            }
1322        }
1323
1324        SettingControl::DualList(state) => {
1325            let colors = DualListColors::from_theme(theme);
1326            let dual_layout = render_dual_list_partial(frame, area, state, &colors, skip_rows);
1327            ControlLayoutInfo::DualList(dual_layout)
1328        }
1329
1330        SettingControl::Map(state) => {
1331            let colors = MapColors::from_theme(theme);
1332            let map_layout = render_map_partial(frame, area, state, &colors, 20, skip_rows);
1333            ControlLayoutInfo::Map {
1334                entry_rows: map_layout
1335                    .entry_areas
1336                    .iter()
1337                    .map(|e| (e.index, e.row_area))
1338                    .collect(),
1339                add_row_area: map_layout.add_row_area,
1340            }
1341        }
1342
1343        SettingControl::ObjectArray(state) => {
1344            let colors = crate::view::controls::KeybindingListColors {
1345                label_fg: theme.editor_fg,
1346                key_fg: theme.help_key_fg,
1347                action_fg: theme.syntax_function,
1348                // Match the surrounding settings panel so unfocused rows
1349                // don't paint a `Color::Reset` strip that falls back to
1350                // the host terminal's default bg. See issue #2033.
1351                row_bg: theme.popup_bg,
1352                // Use settings colors for focused items in settings UI
1353                focused_bg: theme.settings_selected_bg,
1354                focused_fg: theme.settings_selected_fg,
1355                delete_fg: theme.diagnostic_error_fg,
1356                add_fg: theme.syntax_string,
1357            };
1358            let kb_layout = render_keybinding_list_partial(frame, area, state, &colors, skip_rows);
1359            ControlLayoutInfo::ObjectArray {
1360                entry_rows: kb_layout
1361                    .entry_rects
1362                    .iter()
1363                    .map(|&(idx, rect)| (idx, rect))
1364                    .collect(),
1365            }
1366        }
1367
1368        SettingControl::Json(state) => {
1369            render_json_control(frame, area, state, name, skip_rows, theme)
1370        }
1371
1372        SettingControl::Complex { type_name } => {
1373            if skip_rows > 0 {
1374                return ControlLayoutInfo::Complex;
1375            }
1376            // Render label (modified indicator is shown in the row indicator column)
1377            let label_style = Style::default().fg(theme.editor_fg);
1378            let value_style = Style::default().fg(theme.line_number_fg);
1379
1380            let label = Span::styled(format!("{}: ", name), label_style);
1381            let value = Span::styled(
1382                format!("<{} - edit in config.toml>", type_name),
1383                value_style,
1384            );
1385
1386            frame.render_widget(Paragraph::new(Line::from(vec![label, value])), area);
1387            ControlLayoutInfo::Complex
1388        }
1389    }
1390}
1391
1392/// Render a multiline JSON editor control
1393fn render_json_control(
1394    frame: &mut Frame,
1395    area: Rect,
1396    state: &super::items::JsonEditState,
1397    name: &str,
1398    skip_rows: u16,
1399    theme: &Theme,
1400) -> ControlLayoutInfo {
1401    use crate::view::controls::FocusState;
1402
1403    let empty_layout = ControlLayoutInfo::Json {
1404        edit_area: Rect::default(),
1405    };
1406
1407    if area.height == 0 || area.width < 10 {
1408        return empty_layout;
1409    }
1410
1411    let is_focused = state.focus == FocusState::Focused;
1412    let is_valid = state.is_valid();
1413
1414    let label_color = if is_focused {
1415        theme.menu_highlight_fg
1416    } else {
1417        theme.editor_fg
1418    };
1419
1420    let text_color = theme.editor_fg;
1421    let border_color = if !is_valid {
1422        theme.diagnostic_error_fg
1423    } else if is_focused {
1424        theme.menu_highlight_fg
1425    } else {
1426        theme.split_separator_fg
1427    };
1428
1429    let mut y = area.y;
1430    let mut content_row = 0u16;
1431
1432    // Row 0: label (modified indicator is shown in the row indicator column)
1433    if content_row >= skip_rows {
1434        let label_line = Line::from(vec![Span::styled(
1435            format!("{}:", name),
1436            Style::default().fg(label_color),
1437        )]);
1438        frame.render_widget(
1439            Paragraph::new(label_line),
1440            Rect::new(area.x, y, area.width, 1),
1441        );
1442        y += 1;
1443    }
1444    content_row += 1;
1445
1446    let indent = 2u16;
1447    let edit_width = area.width.saturating_sub(indent + 1);
1448    let edit_x = area.x + indent;
1449    let edit_start_y = y;
1450
1451    // Unset JSON values used to render as the literal text `null` inside
1452    // the editor frame, which read like a stray keyword. Replace it with
1453    // a muted "(not set — press Enter to add)" hint so the user can tell
1454    // the field is empty and editable. start_editing() wipes the `null`
1455    // text so typing doesn't concatenate onto it; the placeholder only
1456    // disappears once the user has actually started editing.
1457    if state.is_unset() && content_row >= skip_rows && y < area.y + area.height {
1458        let hint = "(not set — press Enter to add)";
1459        let hint_line = Line::from(vec![
1460            Span::raw(" ".repeat(indent as usize)),
1461            Span::styled(
1462                hint,
1463                Style::default()
1464                    .fg(theme.line_number_fg)
1465                    .add_modifier(Modifier::ITALIC),
1466            ),
1467        ]);
1468        frame.render_widget(
1469            Paragraph::new(hint_line),
1470            Rect::new(area.x, y, area.width, 1),
1471        );
1472        return ControlLayoutInfo::Json {
1473            edit_area: Rect::new(edit_x, edit_start_y, edit_width, 1),
1474        };
1475    }
1476
1477    // Render all lines (scrolling handled by entry dialog/scroll panel)
1478    let lines = state.lines();
1479    let total_lines = lines.len();
1480    for line_idx in 0..total_lines {
1481        let actual_line_idx = line_idx;
1482
1483        if content_row < skip_rows {
1484            content_row += 1;
1485            continue;
1486        }
1487
1488        if y >= area.y + area.height {
1489            break;
1490        }
1491
1492        let line_content = lines.get(actual_line_idx).map(|s| s.as_str()).unwrap_or("");
1493
1494        // Truncate line if too long
1495        let display_len = edit_width.saturating_sub(2) as usize;
1496        let display_text: String = line_content.chars().take(display_len).collect();
1497
1498        // Get selection range and cursor position
1499        let selection = state.selection_range();
1500        let (cursor_row, cursor_col) = state.cursor_pos();
1501
1502        // Build content spans with selection highlighting
1503        let content_spans = if is_focused {
1504            if let Some(((start_row, start_col), (end_row, end_col))) = selection {
1505                build_selection_spans(
1506                    &display_text,
1507                    display_len,
1508                    actual_line_idx,
1509                    start_row,
1510                    start_col,
1511                    end_row,
1512                    end_col,
1513                    text_color,
1514                    theme.selection_bg,
1515                )
1516            } else {
1517                vec![Span::styled(
1518                    format!("{:width$}", display_text, width = display_len),
1519                    Style::default().fg(text_color),
1520                )]
1521            }
1522        } else {
1523            vec![Span::styled(
1524                format!("{:width$}", display_text, width = display_len),
1525                Style::default().fg(text_color),
1526            )]
1527        };
1528
1529        // Build line with border
1530        let mut spans = vec![
1531            Span::raw(" ".repeat(indent as usize)),
1532            Span::styled("│", Style::default().fg(border_color)),
1533        ];
1534        spans.extend(content_spans);
1535        spans.push(Span::styled("│", Style::default().fg(border_color)));
1536        let line = Line::from(spans);
1537
1538        frame.render_widget(Paragraph::new(line), Rect::new(area.x, y, area.width, 1));
1539
1540        // Draw cursor if focused and on this line (overlays selection)
1541        if is_focused && actual_line_idx == cursor_row {
1542            let cursor_x = edit_x + 1 + cursor_col.min(display_len) as u16;
1543            if cursor_x < area.x + area.width - 1 {
1544                let cursor_char = line_content.chars().nth(cursor_col).unwrap_or(' ');
1545                let cursor_span = Span::styled(
1546                    cursor_char.to_string(),
1547                    Style::default()
1548                        .fg(theme.cursor)
1549                        .add_modifier(Modifier::REVERSED),
1550                );
1551                frame.render_widget(
1552                    Paragraph::new(Line::from(vec![cursor_span])),
1553                    Rect::new(cursor_x, y, 1, 1),
1554                );
1555            }
1556        }
1557
1558        y += 1;
1559        content_row += 1;
1560    }
1561
1562    // Show invalid JSON indicator
1563    if !is_valid && y < area.y + area.height {
1564        let warning = Span::styled(
1565            "  ⚠ Invalid JSON",
1566            Style::default().fg(theme.diagnostic_warning_fg),
1567        );
1568        frame.render_widget(
1569            Paragraph::new(Line::from(vec![warning])),
1570            Rect::new(area.x, y, area.width, 1),
1571        );
1572    }
1573
1574    let edit_height = y.saturating_sub(edit_start_y);
1575    ControlLayoutInfo::Json {
1576        edit_area: Rect::new(edit_x, edit_start_y, edit_width, edit_height),
1577    }
1578}
1579
1580/// Render TextList with partial visibility (skipping top rows)
1581fn render_text_list_partial(
1582    frame: &mut Frame,
1583    area: Rect,
1584    state: &crate::view::controls::TextListState,
1585    colors: &TextListColors,
1586    field_width: u16,
1587    skip_rows: u16,
1588) -> crate::view::controls::TextListLayout {
1589    use crate::view::controls::text_list::{TextListLayout, TextListRowLayout};
1590    use crate::view::controls::FocusState;
1591
1592    let empty_layout = TextListLayout {
1593        rows: Vec::new(),
1594        full_area: area,
1595    };
1596
1597    if area.height == 0 || area.width < 10 {
1598        return empty_layout;
1599    }
1600
1601    // Use focused_fg for label when focused (not focused, which is the bg color)
1602    let label_color = match state.focus {
1603        FocusState::Focused => colors.focused_fg,
1604        FocusState::Hovered => colors.focused_fg,
1605        FocusState::Disabled => colors.disabled,
1606        FocusState::Normal => colors.label,
1607    };
1608
1609    let mut rows = Vec::new();
1610    let mut y = area.y;
1611    let mut content_row = 0u16; // Which row of content we're at
1612
1613    // Row 0 is label
1614    if skip_rows == 0 {
1615        let label_line = Line::from(vec![
1616            Span::styled(&state.label, Style::default().fg(label_color)),
1617            Span::raw(":"),
1618        ]);
1619        frame.render_widget(
1620            Paragraph::new(label_line),
1621            Rect::new(area.x, y, area.width, 1),
1622        );
1623        y += 1;
1624    }
1625    content_row += 1;
1626
1627    let indent = 2u16;
1628    let actual_field_width = field_width.min(area.width.saturating_sub(indent + 5));
1629
1630    // Render existing items (rows 1 to N)
1631    for (idx, item) in state.items.iter().enumerate() {
1632        if y >= area.y + area.height {
1633            break;
1634        }
1635
1636        // Skip rows before skip_rows
1637        if content_row < skip_rows {
1638            content_row += 1;
1639            continue;
1640        }
1641
1642        let is_focused = state.focused_item == Some(idx) && state.focus == FocusState::Focused;
1643        let (border_color, text_color) = if is_focused {
1644            (colors.focused, colors.text)
1645        } else if state.focus == FocusState::Disabled {
1646            (colors.disabled, colors.disabled)
1647        } else {
1648            (colors.border, colors.text)
1649        };
1650
1651        let inner_width = actual_field_width.saturating_sub(2) as usize;
1652        let visible: String = item.chars().take(inner_width).collect();
1653        let padded = format!("{:width$}", visible, width = inner_width);
1654
1655        let mut spans = vec![
1656            Span::raw(" ".repeat(indent as usize)),
1657            Span::styled("[", Style::default().fg(border_color)),
1658            Span::styled(padded, Style::default().fg(text_color)),
1659            Span::styled("]", Style::default().fg(border_color)),
1660            Span::raw(" "),
1661            Span::styled("[x]", Style::default().fg(colors.remove_button)),
1662        ];
1663        // Inline hint on a focused committed row: tell the user
1664        // exactly how to remove it. Otherwise the `[x]` looks
1665        // clickable but offers no keyboard equivalent.
1666        if is_focused {
1667            spans.push(Span::styled(
1668                "  Del:remove  Enter:edit",
1669                Style::default()
1670                    .fg(colors.disabled)
1671                    .add_modifier(ratatui::style::Modifier::ITALIC),
1672            ));
1673        }
1674        let line = Line::from(spans);
1675
1676        let row_area = Rect::new(area.x, y, area.width, 1);
1677        frame.render_widget(Paragraph::new(line), row_area);
1678
1679        let text_area = Rect::new(area.x + indent, y, actual_field_width, 1);
1680        let button_area = Rect::new(area.x + indent + actual_field_width + 1, y, 3, 1);
1681        rows.push(TextListRowLayout {
1682            text_area,
1683            button_area,
1684            index: Some(idx),
1685        });
1686
1687        y += 1;
1688        content_row += 1;
1689    }
1690
1691    // Add-new row
1692    if y < area.y + area.height && content_row >= skip_rows {
1693        // Check if we're focused on the add-new input (focused_item is None and focused)
1694        let is_add_focused = state.focused_item.is_none() && state.focus == FocusState::Focused;
1695        // Only show the bracketed input box once the user has explicitly
1696        // started adding an item — keep `[+] Add new` visible on plain
1697        // focus so the row doesn't look like it disappeared.
1698        let show_input_box =
1699            is_add_focused && (state.pending_active || !state.new_item_text.is_empty());
1700
1701        if show_input_box {
1702            // Show input field. When empty (just-activated input), drop
1703            // a muted "type new item" placeholder INSIDE the box so the
1704            // empty bracket pair clearly reads as "this is an editable
1705            // input", not a generic empty cell.
1706            let inner_width = actual_field_width.saturating_sub(2) as usize;
1707            let (visible_text, text_style) = if state.new_item_text.is_empty() {
1708                let placeholder = "type new item";
1709                let truncated: String = placeholder.chars().take(inner_width).collect();
1710                (
1711                    truncated,
1712                    Style::default()
1713                        .fg(colors.disabled)
1714                        .add_modifier(ratatui::style::Modifier::ITALIC),
1715                )
1716            } else {
1717                let visible: String = state.new_item_text.chars().take(inner_width).collect();
1718                (visible, Style::default().fg(colors.text))
1719            };
1720            let padded = format!("{:width$}", visible_text, width = inner_width);
1721
1722            // Dimmed inline hint to the right of the input — so Enter /
1723            // Esc semantics are visible next to the row itself instead
1724            // of only on the bottom helper bar.
1725            let hint = "  Enter:add  Esc:cancel";
1726            let line = Line::from(vec![
1727                Span::raw(" ".repeat(indent as usize)),
1728                Span::styled(
1729                    "[",
1730                    Style::default()
1731                        .fg(colors.focused)
1732                        .add_modifier(ratatui::style::Modifier::BOLD),
1733                ),
1734                Span::styled(padded, text_style),
1735                Span::styled(
1736                    "]",
1737                    Style::default()
1738                        .fg(colors.focused)
1739                        .add_modifier(ratatui::style::Modifier::BOLD),
1740                ),
1741                Span::raw(" "),
1742                Span::styled("[+]", Style::default().fg(colors.add_button)),
1743                Span::styled(
1744                    hint,
1745                    Style::default()
1746                        .fg(colors.disabled)
1747                        .add_modifier(ratatui::style::Modifier::ITALIC),
1748                ),
1749            ]);
1750            let row_area = Rect::new(area.x, y, area.width, 1);
1751            frame.render_widget(Paragraph::new(line), row_area);
1752
1753            // Render cursor. Skip when showing the placeholder (empty
1754            // buffer) — the cursor block would otherwise eat the first
1755            // letter of "type new item" and confuse the placeholder.
1756            if !state.new_item_text.is_empty() && state.cursor <= inner_width {
1757                let cursor_x = area.x + indent + 1 + state.cursor as u16;
1758                let cursor_char = state.new_item_text.chars().nth(state.cursor).unwrap_or(' ');
1759                let cursor_area = Rect::new(cursor_x, y, 1, 1);
1760                let cursor_span = Span::styled(
1761                    cursor_char.to_string(),
1762                    Style::default()
1763                        .fg(colors.focused)
1764                        .add_modifier(ratatui::style::Modifier::REVERSED),
1765                );
1766                frame.render_widget(Paragraph::new(Line::from(vec![cursor_span])), cursor_area);
1767            }
1768
1769            rows.push(TextListRowLayout {
1770                text_area: Rect::new(area.x + indent, y, actual_field_width, 1),
1771                button_area: Rect::new(area.x + indent + actual_field_width + 1, y, 3, 1),
1772                index: None,
1773            });
1774        } else {
1775            // Show static "[+] Add new" label. When the trailing slot
1776            // has focus but isn't yet in input mode, mark the label as
1777            // focused (use the same focused fg the other focused rows
1778            // use) AND append a dimmed inline hint so the user sees
1779            // exactly what Enter will do — without having to look at
1780            // the bottom helper bar.
1781            let label_fg = if is_add_focused {
1782                colors.focused_fg
1783            } else {
1784                colors.add_button
1785            };
1786            let mut spans = vec![
1787                Span::raw(" ".repeat(indent as usize)),
1788                Span::styled("[+] Add new", Style::default().fg(label_fg)),
1789            ];
1790            if is_add_focused {
1791                spans.push(Span::styled(
1792                    "  press Enter (or type) to add a new item",
1793                    Style::default()
1794                        .fg(colors.disabled)
1795                        .add_modifier(ratatui::style::Modifier::ITALIC),
1796                ));
1797            }
1798            let add_line = Line::from(spans);
1799            let row_area = Rect::new(area.x, y, area.width, 1);
1800            frame.render_widget(Paragraph::new(add_line), row_area);
1801
1802            rows.push(TextListRowLayout {
1803                text_area: Rect::new(area.x + indent, y, 11, 1), // "[+] Add new"
1804                button_area: Rect::new(area.x + indent, y, 11, 1),
1805                index: None,
1806            });
1807        }
1808    }
1809
1810    TextListLayout {
1811        rows,
1812        full_area: area,
1813    }
1814}
1815
1816/// Render Map with partial visibility (skipping top rows)
1817fn render_map_partial(
1818    frame: &mut Frame,
1819    area: Rect,
1820    state: &crate::view::controls::MapState,
1821    colors: &MapColors,
1822    key_width: u16,
1823    skip_rows: u16,
1824) -> crate::view::controls::MapLayout {
1825    use crate::view::controls::map_input::{MapEntryLayout, MapLayout};
1826    use crate::view::controls::FocusState;
1827
1828    let empty_layout = MapLayout {
1829        entry_areas: Vec::new(),
1830        add_row_area: None,
1831        full_area: area,
1832    };
1833
1834    if area.height == 0 || area.width < 15 {
1835        return empty_layout;
1836    }
1837
1838    // Use focused_fg for label when focused (not focused, which is the bg color)
1839    let label_color = match state.focus {
1840        FocusState::Focused => colors.focused_fg,
1841        FocusState::Hovered => colors.focused_fg,
1842        FocusState::Disabled => colors.disabled,
1843        FocusState::Normal => colors.label,
1844    };
1845
1846    let mut entry_areas = Vec::new();
1847    let mut y = area.y;
1848    let mut content_row = 0u16;
1849
1850    // Row 0 is label
1851    if skip_rows == 0 {
1852        let label_line = Line::from(vec![
1853            Span::styled(&state.label, Style::default().fg(label_color)),
1854            Span::raw(":"),
1855        ]);
1856        frame.render_widget(
1857            Paragraph::new(label_line),
1858            Rect::new(area.x, y, area.width, 1),
1859        );
1860        y += 1;
1861    }
1862    content_row += 1;
1863
1864    let indent = 2u16;
1865
1866    // Row 1 is column headers (if display_field is set)
1867    if state.display_field.is_some() && y < area.y + area.height {
1868        if content_row >= skip_rows {
1869            // Derive header name from display_field (e.g., "/enabled" -> "Enabled")
1870            let value_header = state
1871                .display_field
1872                .as_ref()
1873                .map(|f| {
1874                    let name = f.trim_start_matches('/');
1875                    // Capitalize first letter
1876                    let mut chars = name.chars();
1877                    match chars.next() {
1878                        None => String::new(),
1879                        Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
1880                    }
1881                })
1882                .unwrap_or_else(|| "Value".to_string());
1883
1884            let header_style = Style::default()
1885                .fg(colors.label)
1886                .add_modifier(Modifier::DIM);
1887            let header_line = Line::from(vec![
1888                Span::styled(" ".repeat(indent as usize), header_style),
1889                Span::styled(
1890                    format!("{:width$}", "Name", width = key_width as usize),
1891                    header_style,
1892                ),
1893                Span::raw(" "),
1894                Span::styled(value_header, header_style),
1895            ]);
1896            frame.render_widget(
1897                Paragraph::new(header_line),
1898                Rect::new(area.x, y, area.width, 1),
1899            );
1900            y += 1;
1901        }
1902        content_row += 1;
1903    }
1904
1905    // Render entries
1906    for (idx, (key, value)) in state.entries.iter().enumerate() {
1907        if y >= area.y + area.height {
1908            break;
1909        }
1910
1911        if content_row < skip_rows {
1912            content_row += 1;
1913            continue;
1914        }
1915
1916        let is_focused = state.focused_entry == Some(idx) && state.focus == FocusState::Focused;
1917
1918        let row_area = Rect::new(area.x, y, area.width, 1);
1919
1920        // Full row background highlight for focused entry
1921        if is_focused {
1922            let highlight_style = Style::default().bg(colors.focused);
1923            let bg_line = Line::from(Span::styled(
1924                " ".repeat(area.width as usize),
1925                highlight_style,
1926            ));
1927            frame.render_widget(Paragraph::new(bg_line), row_area);
1928        }
1929
1930        let (key_color, value_color) = if is_focused {
1931            // Use focused_fg for text on the focused background
1932            (colors.focused_fg, colors.focused_fg)
1933        } else if state.focus == FocusState::Disabled {
1934            (colors.disabled, colors.disabled)
1935        } else {
1936            (colors.key, colors.value_preview)
1937        };
1938
1939        let base_style = if is_focused {
1940            Style::default().bg(colors.focused)
1941        } else {
1942            Style::default()
1943        };
1944
1945        // Get display value. `truncate_chars_with_ellipsis` counts
1946        // characters (not bytes) so a localized / CJK preview value
1947        // doesn't panic on truncation (same class as #1718).
1948        let value_preview = state.get_display_value(value);
1949        let value_preview = truncate_chars_with_ellipsis(&value_preview, 20);
1950
1951        let display_key: String = key.chars().take(key_width as usize).collect();
1952        let mut spans = vec![
1953            Span::styled(" ".repeat(indent as usize), base_style),
1954            Span::styled(
1955                format!("{:width$}", display_key, width = key_width as usize),
1956                base_style.fg(key_color),
1957            ),
1958            Span::raw(" "),
1959            Span::styled(value_preview, base_style.fg(value_color)),
1960        ];
1961
1962        // Add [Edit] hint for focused entry
1963        if is_focused {
1964            spans.push(Span::styled(
1965                "  [Enter to edit]",
1966                base_style.fg(colors.focused_fg).add_modifier(Modifier::DIM),
1967            ));
1968        }
1969
1970        frame.render_widget(Paragraph::new(Line::from(spans)), row_area);
1971
1972        entry_areas.push(MapEntryLayout {
1973            index: idx,
1974            row_area,
1975            expand_area: Rect::default(), // Not rendering expand button in partial view
1976            key_area: Rect::new(area.x + indent, y, key_width, 1),
1977            remove_area: Rect::new(area.x + indent + key_width + 1, y, 3, 1),
1978        });
1979
1980        y += 1;
1981        content_row += 1;
1982    }
1983
1984    // Add-new row (only show if adding is allowed)
1985    let add_row_area = if !state.no_add && y < area.y + area.height && content_row >= skip_rows {
1986        let row_area = Rect::new(area.x, y, area.width, 1);
1987        let is_focused = state.focused_entry.is_none() && state.focus == FocusState::Focused;
1988
1989        // Highlight row when focused
1990        if is_focused {
1991            let highlight_style = Style::default().bg(colors.focused);
1992            let bg_line = Line::from(Span::styled(
1993                " ".repeat(area.width as usize),
1994                highlight_style,
1995            ));
1996            frame.render_widget(Paragraph::new(bg_line), row_area);
1997        }
1998
1999        let base_style = if is_focused {
2000            Style::default().bg(colors.focused)
2001        } else {
2002            Style::default()
2003        };
2004
2005        let mut spans = vec![
2006            Span::styled(" ".repeat(indent as usize), base_style),
2007            Span::styled("[+] Add new", base_style.fg(colors.add_button)),
2008        ];
2009
2010        if is_focused {
2011            spans.push(Span::styled(
2012                "  [Enter to add]",
2013                base_style.fg(colors.focused_fg).add_modifier(Modifier::DIM),
2014            ));
2015        }
2016
2017        frame.render_widget(Paragraph::new(Line::from(spans)), row_area);
2018        Some(row_area)
2019    } else {
2020        None
2021    };
2022
2023    MapLayout {
2024        entry_areas,
2025        add_row_area,
2026        full_area: area,
2027    }
2028}
2029
2030/// Render KeybindingList with partial visibility
2031fn render_keybinding_list_partial(
2032    frame: &mut Frame,
2033    area: Rect,
2034    state: &crate::view::controls::KeybindingListState,
2035    colors: &crate::view::controls::KeybindingListColors,
2036    skip_rows: u16,
2037) -> crate::view::controls::KeybindingListLayout {
2038    use crate::view::controls::keybinding_list::format_key_combo;
2039    use crate::view::controls::FocusState;
2040    use ratatui::text::{Line, Span};
2041    use ratatui::widgets::Paragraph;
2042
2043    let empty_layout = crate::view::controls::KeybindingListLayout {
2044        entry_rects: Vec::new(),
2045        delete_rects: Vec::new(),
2046        add_rect: None,
2047    };
2048
2049    if area.height == 0 {
2050        return empty_layout;
2051    }
2052
2053    let indent = 2u16;
2054    let is_focused = state.focus == FocusState::Focused;
2055    let mut entry_rects = Vec::new();
2056    let mut delete_rects = Vec::new();
2057    let mut content_row = 0u16;
2058    let mut y = area.y;
2059
2060    // Render label (row 0) - modified indicator is shown in the row indicator column
2061    if content_row >= skip_rows {
2062        let label_line = Line::from(vec![Span::styled(
2063            format!("{}:", state.label),
2064            Style::default().fg(colors.label_fg),
2065        )]);
2066        frame.render_widget(
2067            Paragraph::new(label_line),
2068            Rect::new(area.x, y, area.width, 1),
2069        );
2070        y += 1;
2071    }
2072    content_row += 1;
2073
2074    // Render each keybinding entry
2075    for (idx, binding) in state.bindings.iter().enumerate() {
2076        if y >= area.y + area.height {
2077            break;
2078        }
2079
2080        if content_row >= skip_rows {
2081            let entry_area = Rect::new(area.x + indent, y, area.width.saturating_sub(indent), 1);
2082            entry_rects.push((idx, entry_area));
2083
2084            let is_entry_focused = is_focused && state.focused_index == Some(idx);
2085            let bg = if is_entry_focused {
2086                colors.focused_bg
2087            } else {
2088                colors.row_bg
2089            };
2090
2091            let key_combo = format_key_combo(binding);
2092            // Use display_field from state if available, otherwise default to "action"
2093            let field_name = state
2094                .display_field
2095                .as_ref()
2096                .and_then(|p| p.strip_prefix('/'))
2097                .unwrap_or("action");
2098            let action = binding
2099                .get(field_name)
2100                .and_then(|a| a.as_str())
2101                .unwrap_or("(no action)");
2102
2103            let indicator = if is_entry_focused { "> " } else { "  " };
2104            // Use focused_fg for all text when entry is focused for good contrast
2105            let (indicator_fg, key_fg, arrow_fg, action_fg, delete_fg) = if is_entry_focused {
2106                (
2107                    colors.focused_fg,
2108                    colors.focused_fg,
2109                    colors.focused_fg,
2110                    colors.focused_fg,
2111                    colors.focused_fg,
2112                )
2113            } else {
2114                (
2115                    colors.label_fg,
2116                    colors.key_fg,
2117                    colors.label_fg,
2118                    colors.action_fg,
2119                    colors.delete_fg,
2120                )
2121            };
2122            // The KeybindingList widget is reused for non-keybinding
2123            // ObjectArrays (e.g. LSP servers under a language), where the
2124            // `key_combo` column is always empty — a single LSP entry then
2125            // rendered as `                           → clangd [x]`, with
2126            // the empty Name column padding and an unexplained arrow. When
2127            // there's nothing to put left of the arrow, collapse to just
2128            // `action [x]` so the row reads like the form field it is.
2129            let line = if key_combo.trim().is_empty() {
2130                Line::from(vec![
2131                    Span::styled(indicator, Style::default().fg(indicator_fg).bg(bg)),
2132                    Span::styled(action, Style::default().fg(action_fg).bg(bg)),
2133                    Span::styled(" [x]", Style::default().fg(delete_fg).bg(bg)),
2134                ])
2135            } else {
2136                Line::from(vec![
2137                    Span::styled(indicator, Style::default().fg(indicator_fg).bg(bg)),
2138                    Span::styled(
2139                        format!("{:<20}", key_combo),
2140                        Style::default().fg(key_fg).bg(bg),
2141                    ),
2142                    Span::styled(" → ", Style::default().fg(arrow_fg).bg(bg)),
2143                    Span::styled(action, Style::default().fg(action_fg).bg(bg)),
2144                    Span::styled(" [x]", Style::default().fg(delete_fg).bg(bg)),
2145                ])
2146            };
2147            frame.render_widget(Paragraph::new(line), entry_area);
2148
2149            // Track delete button area
2150            let delete_x = entry_area.x + entry_area.width.saturating_sub(4);
2151            delete_rects.push(Rect::new(delete_x, y, 3, 1));
2152
2153            y += 1;
2154        }
2155        content_row += 1;
2156    }
2157
2158    // Render add-new row
2159    let add_rect = if y < area.y + area.height && content_row >= skip_rows {
2160        let is_add_focused = is_focused && state.focused_index.is_none();
2161        let bg = if is_add_focused {
2162            colors.focused_bg
2163        } else {
2164            colors.row_bg
2165        };
2166
2167        let indicator = if is_add_focused { "> " } else { "  " };
2168        // Use focused_fg for text when add row is focused
2169        let (indicator_fg, add_fg) = if is_add_focused {
2170            (colors.focused_fg, colors.focused_fg)
2171        } else {
2172            (colors.label_fg, colors.add_fg)
2173        };
2174        let line = Line::from(vec![
2175            Span::styled(indicator, Style::default().fg(indicator_fg).bg(bg)),
2176            Span::styled("[+] Add new", Style::default().fg(add_fg).bg(bg)),
2177        ]);
2178        let add_area = Rect::new(area.x + indent, y, area.width.saturating_sub(indent), 1);
2179        frame.render_widget(Paragraph::new(line), add_area);
2180        Some(add_area)
2181    } else {
2182        None
2183    };
2184
2185    crate::view::controls::KeybindingListLayout {
2186        entry_rects,
2187        delete_rects,
2188        add_rect,
2189    }
2190}
2191
2192/// Combined layout info for a setting item (control + inherit button)
2193#[derive(Debug, Clone, Default)]
2194pub struct SettingItemLayoutInfo {
2195    pub control: ControlLayoutInfo,
2196    pub inherit_button: Option<Rect>,
2197}
2198
2199/// Layout info for a control (for hit testing)
2200#[derive(Debug, Clone, Default)]
2201pub enum ControlLayoutInfo {
2202    Toggle(Rect),
2203    Number {
2204        decrement: Rect,
2205        increment: Rect,
2206        value: Rect,
2207    },
2208    Dropdown {
2209        button_area: Rect,
2210        option_areas: Vec<Rect>,
2211        scroll_offset: usize,
2212    },
2213    Text(Rect),
2214    TextList {
2215        /// (data_index, screen_area) - None index means "add new" row
2216        rows: Vec<(Option<usize>, Rect)>,
2217    },
2218    DualList(crate::view::controls::DualListLayout),
2219    Map {
2220        /// (data_index, screen_area)
2221        entry_rows: Vec<(usize, Rect)>,
2222        add_row_area: Option<Rect>,
2223    },
2224    ObjectArray {
2225        /// (data_index, screen_area)
2226        entry_rows: Vec<(usize, Rect)>,
2227    },
2228    Json {
2229        edit_area: Rect,
2230    },
2231    #[default]
2232    Complex,
2233}
2234
2235/// Render a single button with focus/hover states
2236#[allow(clippy::too_many_arguments)]
2237fn render_button(
2238    frame: &mut Frame,
2239    area: Rect,
2240    text: &str,
2241    focused_text: &str,
2242    is_focused: bool,
2243    is_hovered: bool,
2244    theme: &Theme,
2245    dimmed: bool,
2246) {
2247    if is_focused {
2248        let style = Style::default()
2249            .fg(theme.menu_highlight_fg)
2250            .bg(theme.menu_highlight_bg)
2251            .add_modifier(Modifier::BOLD);
2252        frame.render_widget(Paragraph::new(focused_text).style(style), area);
2253    } else if is_hovered {
2254        let style = Style::default()
2255            .fg(theme.menu_hover_fg)
2256            .bg(theme.menu_hover_bg);
2257        frame.render_widget(Paragraph::new(text).style(style), area);
2258    } else {
2259        let fg = if dimmed {
2260            theme.line_number_fg
2261        } else {
2262            theme.popup_text_fg
2263        };
2264        frame.render_widget(Paragraph::new(text).style(Style::default().fg(fg)), area);
2265    }
2266}
2267
2268/// Render footer with action buttons
2269/// When `vertical` is true, buttons are stacked vertically (for narrow mode)
2270fn render_footer(
2271    frame: &mut Frame,
2272    modal_area: Rect,
2273    state: &SettingsState,
2274    theme: &Theme,
2275    layout: &mut SettingsLayout,
2276    vertical: bool,
2277) {
2278    use super::layout::SettingsHit;
2279    use super::state::FocusPanel;
2280
2281    // Guard against too-small modal
2282    if modal_area.height < 4 || modal_area.width < 10 {
2283        return;
2284    }
2285
2286    if vertical {
2287        render_footer_vertical(frame, modal_area, state, theme, layout);
2288        return;
2289    }
2290
2291    let footer_y = modal_area.y + modal_area.height.saturating_sub(2);
2292    let footer_width = modal_area.width.saturating_sub(2);
2293    let footer_area = Rect::new(modal_area.x + 1, footer_y, footer_width, 1);
2294
2295    // Draw separator line (only if we have room above footer)
2296    if footer_y > modal_area.y {
2297        let sep_y = footer_y.saturating_sub(1);
2298        let sep_area = Rect::new(modal_area.x + 1, sep_y, footer_width, 1);
2299        let sep_line: String = "─".repeat(sep_area.width as usize);
2300        frame.render_widget(
2301            Paragraph::new(sep_line).style(Style::default().fg(theme.split_separator_fg)),
2302            sep_area,
2303        );
2304    }
2305
2306    // Check if footer has keyboard focus
2307    let footer_focused = state.focus_panel() == FocusPanel::Footer;
2308
2309    // Determine hover and keyboard focus states for buttons
2310    // Button indices: 0=Layer, 1=Reset, 2=Save, 3=Cancel, 4=Edit (on left, for advanced users)
2311    let layer_hovered = matches!(state.hover_hit, Some(SettingsHit::LayerButton));
2312    let reset_hovered = matches!(state.hover_hit, Some(SettingsHit::ResetButton));
2313    let save_hovered = matches!(state.hover_hit, Some(SettingsHit::SaveButton));
2314    let cancel_hovered = matches!(state.hover_hit, Some(SettingsHit::CancelButton));
2315    let edit_hovered = matches!(state.hover_hit, Some(SettingsHit::EditButton));
2316
2317    let layer_focused = footer_focused && state.footer_button_index == 0;
2318    let reset_focused = footer_focused && state.footer_button_index == 1;
2319    let save_focused = footer_focused && state.footer_button_index == 2;
2320    let cancel_focused = footer_focused && state.footer_button_index == 3;
2321    let edit_focused = footer_focused && state.footer_button_index == 4;
2322
2323    // Get translated button labels
2324    // Use "Inherit" label instead of "Reset" when current item is nullable and explicitly set
2325    let current_is_nullable_set = state
2326        .current_item()
2327        .map(|item| item.nullable && !item.is_null)
2328        .unwrap_or(false);
2329    let save_label = t!("settings.btn_save").to_string();
2330    let cancel_label = t!("settings.btn_cancel").to_string();
2331    let reset_label = if current_is_nullable_set {
2332        t!("settings.btn_inherit").to_string()
2333    } else {
2334        t!("settings.btn_reset").to_string()
2335    };
2336    let edit_label = t!("settings.btn_edit").to_string();
2337
2338    // Build button text with brackets (layer button uses layer name)
2339    let layer_text = format!("[ {} ]", state.target_layer_name());
2340    let layer_text_focused = format!(">[ {} ]", state.target_layer_name());
2341    let save_text = format!("[ {} ]", save_label);
2342    let save_text_focused = format!(">[ {} ]", save_label);
2343    let cancel_text = format!("[ {} ]", cancel_label);
2344    let cancel_text_focused = format!(">[ {} ]", cancel_label);
2345    let reset_text = format!("[ {} ]", reset_label);
2346    let reset_text_focused = format!(">[ {} ]", reset_label);
2347    let edit_text = format!("[ {} ]", edit_label);
2348    let edit_text_focused = format!(">[ {} ]", edit_label);
2349
2350    // Calculate button widths using display width (handles unicode)
2351    let cancel_width = str_width(if cancel_focused {
2352        &cancel_text_focused
2353    } else {
2354        &cancel_text
2355    }) as u16;
2356    let save_width = str_width(if save_focused {
2357        &save_text_focused
2358    } else {
2359        &save_text
2360    }) as u16;
2361    let reset_width = str_width(if reset_focused {
2362        &reset_text_focused
2363    } else {
2364        &reset_text
2365    }) as u16;
2366    let layer_width = str_width(if layer_focused {
2367        &layer_text_focused
2368    } else {
2369        &layer_text
2370    }) as u16;
2371    let edit_width = str_width(if edit_focused {
2372        &edit_text_focused
2373    } else {
2374        &edit_text
2375    }) as u16;
2376    let gap: u16 = 2;
2377
2378    // Calculate total width needed for all buttons
2379    // Minimum needed: Save + Cancel
2380    let min_buttons_width = save_width + gap + cancel_width;
2381    // Full buttons: Edit + Layer + Reset + Save + Cancel with gaps
2382    let all_buttons_width =
2383        edit_width + gap + layer_width + gap + reset_width + gap + save_width + gap + cancel_width;
2384
2385    // Determine which buttons to show based on available width
2386    let available = footer_area.width;
2387    let show_edit = available >= all_buttons_width;
2388    let show_layer = available >= (layer_width + gap + reset_width + gap + min_buttons_width);
2389    let show_reset = available >= (reset_width + gap + min_buttons_width);
2390
2391    // Calculate X positions using saturating_sub to prevent overflow
2392    let cancel_x = footer_area
2393        .x
2394        .saturating_add(footer_area.width.saturating_sub(cancel_width));
2395    let save_x = cancel_x.saturating_sub(save_width + gap);
2396    let reset_x = if show_reset {
2397        save_x.saturating_sub(reset_width + gap)
2398    } else {
2399        0
2400    };
2401    let layer_x = if show_layer {
2402        reset_x.saturating_sub(layer_width + gap)
2403    } else {
2404        0
2405    };
2406    let edit_x = footer_area.x; // Left-aligned
2407
2408    // Render buttons using helper function
2409    // Layer button (conditionally shown)
2410    if show_layer {
2411        let layer_area = Rect::new(layer_x, footer_y, layer_width, 1);
2412        render_button(
2413            frame,
2414            layer_area,
2415            &layer_text,
2416            &layer_text_focused,
2417            layer_focused,
2418            layer_hovered,
2419            theme,
2420            false,
2421        );
2422        layout.layer_button = Some(layer_area);
2423    }
2424
2425    // Reset button (conditionally shown)
2426    if show_reset {
2427        let reset_area = Rect::new(reset_x, footer_y, reset_width, 1);
2428        render_button(
2429            frame,
2430            reset_area,
2431            &reset_text,
2432            &reset_text_focused,
2433            reset_focused,
2434            reset_hovered,
2435            theme,
2436            false,
2437        );
2438        layout.reset_button = Some(reset_area);
2439    }
2440
2441    // Save button (always shown)
2442    let save_area = Rect::new(save_x, footer_y, save_width, 1);
2443    render_button(
2444        frame,
2445        save_area,
2446        &save_text,
2447        &save_text_focused,
2448        save_focused,
2449        save_hovered,
2450        theme,
2451        false,
2452    );
2453    layout.save_button = Some(save_area);
2454
2455    // Cancel button (always shown)
2456    let cancel_area = Rect::new(cancel_x, footer_y, cancel_width, 1);
2457    render_button(
2458        frame,
2459        cancel_area,
2460        &cancel_text,
2461        &cancel_text_focused,
2462        cancel_focused,
2463        cancel_hovered,
2464        theme,
2465        false,
2466    );
2467    layout.cancel_button = Some(cancel_area);
2468
2469    // Edit button (on left, for advanced users, conditionally shown)
2470    if show_edit {
2471        let edit_area = Rect::new(edit_x, footer_y, edit_width, 1);
2472        render_button(
2473            frame,
2474            edit_area,
2475            &edit_text,
2476            &edit_text_focused,
2477            edit_focused,
2478            edit_hovered,
2479            theme,
2480            true, // dimmed for advanced option
2481        );
2482        layout.edit_button = Some(edit_area);
2483    }
2484
2485    // Help text (between Edit button and main buttons)
2486    // Calculate position based on which buttons are visible
2487    let help_start_x = if show_edit {
2488        edit_x + edit_width + 2
2489    } else {
2490        footer_area.x
2491    };
2492    let help_end_x = if show_layer {
2493        layer_x
2494    } else if show_reset {
2495        reset_x
2496    } else {
2497        save_x
2498    };
2499    let help_width = help_end_x.saturating_sub(help_start_x + 1);
2500
2501    // Get translated help text
2502    let help = if state.search_active {
2503        t!("settings.help_search").to_string()
2504    } else if footer_focused {
2505        t!("settings.help_footer").to_string()
2506    } else {
2507        t!("settings.help_default").to_string()
2508    };
2509    // Render help text with reverse-video styling for key hints
2510    // Parse "Key:Action  Key:Action" format
2511    let help_line = build_keyhint_line(&help, theme);
2512    frame.render_widget(
2513        Paragraph::new(help_line),
2514        Rect::new(help_start_x, footer_y, help_width, 1),
2515    );
2516}
2517
2518/// Build a Line with reverse-video styled key hints from "Key:Action  Key:Action" format
2519fn build_keyhint_line<'a>(text: &str, theme: &Theme) -> Line<'a> {
2520    let key_style = Style::default()
2521        .fg(theme.popup_text_fg)
2522        .bg(theme.split_separator_fg);
2523    let desc_style = Style::default().fg(theme.line_number_fg);
2524    let sep_style = Style::default().fg(theme.line_number_fg);
2525
2526    let mut spans: Vec<Span<'a>> = Vec::new();
2527
2528    // Split by double-space to get individual key hints
2529    for (i, segment) in text.split("  ").enumerate() {
2530        let segment = segment.trim();
2531        if segment.is_empty() {
2532            continue;
2533        }
2534        if i > 0 {
2535            spans.push(Span::styled(" ", sep_style));
2536        }
2537        // Split by first ":" to separate key from description
2538        if let Some(colon_pos) = segment.find(':') {
2539            let key = &segment[..colon_pos];
2540            let action = &segment[colon_pos + 1..];
2541            spans.push(Span::styled(format!(" {} ", key), key_style));
2542            spans.push(Span::styled(action.to_string(), desc_style));
2543        } else {
2544            // No colon - just render as text
2545            spans.push(Span::styled(segment.to_string(), desc_style));
2546        }
2547    }
2548
2549    Line::from(spans)
2550}
2551
2552/// Render footer with buttons stacked vertically (for narrow mode)
2553fn render_footer_vertical(
2554    frame: &mut Frame,
2555    modal_area: Rect,
2556    state: &SettingsState,
2557    theme: &Theme,
2558    layout: &mut SettingsLayout,
2559) {
2560    use super::layout::SettingsHit;
2561    use super::state::FocusPanel;
2562
2563    // Footer takes bottom 7 lines: separator + 5 buttons + help
2564    let footer_height = 7u16;
2565    let footer_y = modal_area
2566        .y
2567        .saturating_add(modal_area.height.saturating_sub(footer_height));
2568    let footer_width = modal_area.width.saturating_sub(2);
2569
2570    // Draw top separator
2571    let sep_y = footer_y;
2572    if sep_y > modal_area.y {
2573        let sep_line: String = "─".repeat(footer_width as usize);
2574        frame.render_widget(
2575            Paragraph::new(sep_line).style(Style::default().fg(theme.split_separator_fg)),
2576            Rect::new(modal_area.x + 1, sep_y, footer_width, 1),
2577        );
2578    }
2579
2580    // Check if footer has keyboard focus
2581    let footer_focused = state.focus_panel() == FocusPanel::Footer;
2582
2583    // Determine hover and keyboard focus states for buttons
2584    let layer_hovered = matches!(state.hover_hit, Some(SettingsHit::LayerButton));
2585    let reset_hovered = matches!(state.hover_hit, Some(SettingsHit::ResetButton));
2586    let save_hovered = matches!(state.hover_hit, Some(SettingsHit::SaveButton));
2587    let cancel_hovered = matches!(state.hover_hit, Some(SettingsHit::CancelButton));
2588    let edit_hovered = matches!(state.hover_hit, Some(SettingsHit::EditButton));
2589
2590    let layer_focused = footer_focused && state.footer_button_index == 0;
2591    let reset_focused = footer_focused && state.footer_button_index == 1;
2592    let save_focused = footer_focused && state.footer_button_index == 2;
2593    let cancel_focused = footer_focused && state.footer_button_index == 3;
2594    let edit_focused = footer_focused && state.footer_button_index == 4;
2595
2596    // Get translated button labels
2597    // Use "Inherit" label instead of "Reset" when current item is nullable and explicitly set
2598    let current_is_nullable_set = state
2599        .current_item()
2600        .map(|item| item.nullable && !item.is_null)
2601        .unwrap_or(false);
2602    let save_label = t!("settings.btn_save").to_string();
2603    let cancel_label = t!("settings.btn_cancel").to_string();
2604    let reset_label = if current_is_nullable_set {
2605        t!("settings.btn_inherit").to_string()
2606    } else {
2607        t!("settings.btn_reset").to_string()
2608    };
2609    let edit_label = t!("settings.btn_edit").to_string();
2610
2611    // Build button text
2612    let layer_text = format!("[ {} ]", state.target_layer_name());
2613    let layer_text_focused = format!(">[ {} ]", state.target_layer_name());
2614    let save_text = format!("[ {} ]", save_label);
2615    let save_text_focused = format!(">[ {} ]", save_label);
2616    let cancel_text = format!("[ {} ]", cancel_label);
2617    let cancel_text_focused = format!(">[ {} ]", cancel_label);
2618    let reset_text = format!("[ {} ]", reset_label);
2619    let reset_text_focused = format!(">[ {} ]", reset_label);
2620    let edit_text = format!("[ {} ]", edit_label);
2621    let edit_text_focused = format!(">[ {} ]", edit_label);
2622
2623    // Render buttons vertically, centered
2624    let button_x = modal_area.x + 2;
2625    let mut y = sep_y + 1;
2626
2627    // Layer button
2628    let layer_width = str_width(if layer_focused {
2629        &layer_text_focused
2630    } else {
2631        &layer_text
2632    }) as u16;
2633    let layer_area = Rect::new(button_x, y, layer_width.min(footer_width), 1);
2634    render_button(
2635        frame,
2636        layer_area,
2637        &layer_text,
2638        &layer_text_focused,
2639        layer_focused,
2640        layer_hovered,
2641        theme,
2642        false,
2643    );
2644    layout.layer_button = Some(layer_area);
2645    y += 1;
2646
2647    // Save button
2648    let save_width = str_width(if save_focused {
2649        &save_text_focused
2650    } else {
2651        &save_text
2652    }) as u16;
2653    let save_area = Rect::new(button_x, y, save_width.min(footer_width), 1);
2654    render_button(
2655        frame,
2656        save_area,
2657        &save_text,
2658        &save_text_focused,
2659        save_focused,
2660        save_hovered,
2661        theme,
2662        false,
2663    );
2664    layout.save_button = Some(save_area);
2665    y += 1;
2666
2667    // Reset button
2668    let reset_width = str_width(if reset_focused {
2669        &reset_text_focused
2670    } else {
2671        &reset_text
2672    }) as u16;
2673    let reset_area = Rect::new(button_x, y, reset_width.min(footer_width), 1);
2674    render_button(
2675        frame,
2676        reset_area,
2677        &reset_text,
2678        &reset_text_focused,
2679        reset_focused,
2680        reset_hovered,
2681        theme,
2682        false,
2683    );
2684    layout.reset_button = Some(reset_area);
2685    y += 1;
2686
2687    // Cancel button
2688    let cancel_width = str_width(if cancel_focused {
2689        &cancel_text_focused
2690    } else {
2691        &cancel_text
2692    }) as u16;
2693    let cancel_area = Rect::new(button_x, y, cancel_width.min(footer_width), 1);
2694    render_button(
2695        frame,
2696        cancel_area,
2697        &cancel_text,
2698        &cancel_text_focused,
2699        cancel_focused,
2700        cancel_hovered,
2701        theme,
2702        false,
2703    );
2704    layout.cancel_button = Some(cancel_area);
2705    y += 1;
2706
2707    // Edit button
2708    let edit_width = str_width(if edit_focused {
2709        &edit_text_focused
2710    } else {
2711        &edit_text
2712    }) as u16;
2713    let edit_area = Rect::new(button_x, y, edit_width.min(footer_width), 1);
2714    render_button(
2715        frame,
2716        edit_area,
2717        &edit_text,
2718        &edit_text_focused,
2719        edit_focused,
2720        edit_hovered,
2721        theme,
2722        true, // dimmed
2723    );
2724    layout.edit_button = Some(edit_area);
2725}
2726
2727/// Render the search header with query input
2728fn render_search_header(frame: &mut Frame, area: Rect, state: &SettingsState, theme: &Theme) {
2729    let search_style = Style::default().fg(theme.settings_selected_fg);
2730    let cursor_style = Style::default()
2731        .fg(theme.settings_selected_fg)
2732        .add_modifier(Modifier::REVERSED);
2733
2734    // Show result count and scroll position inline after cursor
2735    let result_count = state.search_results.len();
2736    let count_text = if state.search_query.is_empty() {
2737        String::new()
2738    } else if result_count == 0 {
2739        " (no results)".to_string()
2740    } else if result_count == 1 {
2741        " (1 result)".to_string()
2742    } else if state.search_max_visible >= result_count {
2743        // All results visible, no need to show range
2744        format!(" ({} results)", result_count)
2745    } else {
2746        // Show current position in results
2747        let first = state.search_scroll_offset + 1;
2748        let last = (state.search_scroll_offset + state.search_max_visible).min(result_count);
2749        format!(" ({}-{} of {})", first, last, result_count)
2750    };
2751
2752    // Add scroll indicators
2753    let has_more_above = state.search_scroll_offset > 0;
2754    let has_more_below = state.search_scroll_offset + state.search_max_visible < result_count;
2755    let scroll_indicator = match (has_more_above, has_more_below) {
2756        (true, true) => " ↑↓",
2757        (true, false) => " ↑",
2758        (false, true) => " ↓",
2759        (false, false) => "",
2760    };
2761
2762    let count_style = Style::default().fg(theme.line_number_fg);
2763    let indicator_style = Style::default()
2764        .fg(theme.menu_active_fg)
2765        .add_modifier(Modifier::BOLD);
2766
2767    let spans = vec![
2768        Span::styled("> ", search_style),
2769        Span::styled(&state.search_query, search_style),
2770        Span::styled(" ", cursor_style), // Cursor
2771        Span::styled(count_text, count_style),
2772        Span::styled(scroll_indicator, indicator_style),
2773    ];
2774    let line = Line::from(spans);
2775    frame.render_widget(Paragraph::new(line), area);
2776}
2777
2778/// Render search hint when search is not active
2779fn render_search_hint(frame: &mut Frame, area: Rect, theme: &Theme) {
2780    let hint_style = Style::default().fg(theme.line_number_fg);
2781    let key_style = Style::default()
2782        .fg(theme.popup_text_fg)
2783        .bg(theme.split_separator_fg);
2784
2785    let spans = vec![
2786        Span::styled("Press ", hint_style),
2787        Span::styled(" / ", key_style),
2788        Span::styled(" to search settings...", hint_style),
2789    ];
2790    let line = Line::from(spans);
2791    frame.render_widget(Paragraph::new(line), area);
2792}
2793
2794/// Render search results with breadcrumbs
2795fn render_search_results(
2796    frame: &mut Frame,
2797    area: Rect,
2798    state: &mut SettingsState,
2799    theme: &Theme,
2800    layout: &mut SettingsLayout,
2801) {
2802    // Calculate max visible results (each result is 3 rows tall)
2803    let max_visible = (area.height.saturating_sub(3) / 3) as usize;
2804    state.search_max_visible = max_visible.max(1);
2805
2806    // Ensure scroll offset is valid
2807    if state.search_scroll_offset >= state.search_results.len() {
2808        state.search_scroll_offset = state.search_results.len().saturating_sub(1);
2809    }
2810
2811    // Determine if we need a scrollbar
2812    let needs_scrollbar = state.search_results.len() > state.search_max_visible;
2813    let scrollbar_width = if needs_scrollbar { 1 } else { 0 };
2814
2815    // Reserve space for scrollbar on the right
2816    let content_area = Rect::new(
2817        area.x,
2818        area.y,
2819        area.width.saturating_sub(scrollbar_width),
2820        area.height,
2821    );
2822
2823    let mut y = content_area.y;
2824
2825    for (idx, result) in state
2826        .search_results
2827        .iter()
2828        .enumerate()
2829        .skip(state.search_scroll_offset)
2830    {
2831        if y >= content_area.y + content_area.height.saturating_sub(3) {
2832            break;
2833        }
2834
2835        let is_selected = idx == state.selected_search_result;
2836        let is_hovered = matches!(state.hover_hit, Some(SettingsHit::SearchResult(i)) if i == idx);
2837        let item_area = Rect::new(content_area.x, y, content_area.width, 3);
2838
2839        render_search_result_item(
2840            frame,
2841            item_area,
2842            result,
2843            is_selected,
2844            is_hovered,
2845            theme,
2846            layout,
2847        );
2848        y += 3;
2849    }
2850
2851    // Track search results area in layout for mouse wheel support
2852    layout.search_results_area = Some(content_area);
2853
2854    // Render scrollbar if needed
2855    if needs_scrollbar {
2856        let scrollbar_area = Rect::new(
2857            area.x + area.width - 1,
2858            area.y,
2859            1,
2860            area.height.saturating_sub(3), // Leave space at bottom
2861        );
2862
2863        let scrollbar_state = ScrollbarState::new(
2864            state.search_results.len(),
2865            state.search_max_visible,
2866            state.search_scroll_offset,
2867        );
2868
2869        let colors = ScrollbarColors::from_theme(theme);
2870        render_scrollbar(frame, scrollbar_area, &scrollbar_state, &colors);
2871
2872        // Track scrollbar area in layout for click/drag support
2873        layout.search_scrollbar_area = Some(scrollbar_area);
2874    } else {
2875        layout.search_scrollbar_area = None;
2876    }
2877}
2878
2879/// Render a single search result with breadcrumb
2880fn render_search_result_item(
2881    frame: &mut Frame,
2882    area: Rect,
2883    result: &SearchResult,
2884    is_selected: bool,
2885    is_hovered: bool,
2886    theme: &Theme,
2887    layout: &mut SettingsLayout,
2888) {
2889    // Draw selection or hover highlight background
2890    if is_selected {
2891        // Use dedicated settings colors for selected items
2892        let bg_style = Style::default().bg(theme.settings_selected_bg);
2893        for row in 0..area.height.min(3) {
2894            let row_area = Rect::new(area.x, area.y + row, area.width, 1);
2895            frame.render_widget(Paragraph::new("").style(bg_style), row_area);
2896        }
2897    } else if is_hovered {
2898        // Subtle hover highlight using menu hover colors
2899        let bg_style = Style::default().bg(theme.menu_hover_bg);
2900        for row in 0..area.height.min(3) {
2901            let row_area = Rect::new(area.x, area.y + row, area.width, 1);
2902            frame.render_widget(Paragraph::new("").style(bg_style), row_area);
2903        }
2904    }
2905
2906    // Determine display name and description based on deep match
2907    let (display_name, display_desc) = match &result.deep_match {
2908        Some(DeepMatch::MapKey { key, .. }) => (key.clone(), Some(result.item.name.clone())),
2909        Some(DeepMatch::MapValue {
2910            matched_text, key, ..
2911        }) => (
2912            matched_text.clone(),
2913            Some(format!("{} > {}", result.item.name, key)),
2914        ),
2915        Some(DeepMatch::TextListItem { text, .. }) => {
2916            (text.clone(), Some(result.item.name.clone()))
2917        }
2918        None => (result.item.name.clone(), result.item.description.clone()),
2919    };
2920
2921    // First line: Setting name with highlighting
2922    let name_style = if is_selected {
2923        Style::default().fg(theme.settings_selected_fg)
2924    } else if is_hovered {
2925        Style::default().fg(theme.menu_hover_fg)
2926    } else {
2927        Style::default().fg(theme.popup_text_fg)
2928    };
2929
2930    // Build name with match highlighting, prefixed with selection indicator
2931    let indicator = if is_selected { "▸ " } else { "  " };
2932    let indicator_style = if is_selected {
2933        Style::default()
2934            .fg(theme.settings_selected_fg)
2935            .add_modifier(Modifier::BOLD)
2936    } else {
2937        name_style
2938    };
2939    let mut name_line = build_highlighted_text(
2940        &display_name,
2941        &result.name_matches,
2942        name_style,
2943        Style::default()
2944            .fg(theme.diagnostic_warning_fg)
2945            .add_modifier(Modifier::BOLD),
2946    );
2947    name_line
2948        .spans
2949        .insert(0, Span::styled(indicator, indicator_style));
2950    frame.render_widget(
2951        Paragraph::new(name_line),
2952        Rect::new(area.x, area.y, area.width, 1),
2953    );
2954
2955    // Second line: Breadcrumb
2956    let breadcrumb_style = Style::default()
2957        .fg(theme.line_number_fg)
2958        .add_modifier(Modifier::ITALIC);
2959    let breadcrumb = format!("  {} > {}", result.breadcrumb, result.item.path);
2960    let breadcrumb_line = Line::from(Span::styled(breadcrumb, breadcrumb_style));
2961    frame.render_widget(
2962        Paragraph::new(breadcrumb_line),
2963        Rect::new(area.x, area.y + 1, area.width, 1),
2964    );
2965
2966    // Third line: Description (if any). Counts characters (not bytes)
2967    // when checking and truncating: descriptions can be localized (e.g.
2968    // CJK translations) and a byte-based slice could land inside a
2969    // multi-byte UTF-8 sequence and panic — same class as #1718.
2970    if let Some(ref desc) = display_desc {
2971        let desc_style = Style::default().fg(theme.line_number_fg);
2972        let max_chars = (area.width as usize).saturating_sub(2);
2973        let truncated_desc = format!("  {}", truncate_chars_with_ellipsis(desc, max_chars));
2974        frame.render_widget(
2975            Paragraph::new(truncated_desc).style(desc_style),
2976            Rect::new(area.x, area.y + 2, area.width, 1),
2977        );
2978    }
2979
2980    // Track this item in layout
2981    layout.add_search_result(result.page_index, result.item_index, area);
2982}
2983
2984/// Build a line with highlighted match positions
2985fn build_highlighted_text(
2986    text: &str,
2987    matches: &[usize],
2988    normal_style: Style,
2989    highlight_style: Style,
2990) -> Line<'static> {
2991    if matches.is_empty() {
2992        return Line::from(Span::styled(text.to_string(), normal_style));
2993    }
2994
2995    let chars: Vec<char> = text.chars().collect();
2996    let mut spans = Vec::new();
2997    let mut current = String::new();
2998    let mut in_highlight = false;
2999
3000    for (idx, ch) in chars.iter().enumerate() {
3001        let should_highlight = matches.contains(&idx);
3002
3003        if should_highlight != in_highlight {
3004            if !current.is_empty() {
3005                let style = if in_highlight {
3006                    highlight_style
3007                } else {
3008                    normal_style
3009                };
3010                spans.push(Span::styled(current, style));
3011                current = String::new();
3012            }
3013            in_highlight = should_highlight;
3014        }
3015
3016        current.push(*ch);
3017    }
3018
3019    // Push remaining
3020    if !current.is_empty() {
3021        let style = if in_highlight {
3022            highlight_style
3023        } else {
3024            normal_style
3025        };
3026        spans.push(Span::styled(current, style));
3027    }
3028
3029    Line::from(spans)
3030}
3031
3032/// Render the unsaved changes confirmation dialog
3033fn render_confirm_dialog(
3034    frame: &mut Frame,
3035    parent_area: Rect,
3036    state: &SettingsState,
3037    theme: &Theme,
3038) {
3039    // Calculate dialog size
3040    let changes = state.get_change_descriptions();
3041    let dialog_width = 50.min(parent_area.width.saturating_sub(4));
3042    // Base height: 2 borders + 2 prompt lines + 1 separator + 1 buttons + 1 help = 7
3043    // Plus one line per change
3044    let dialog_height = (7 + changes.len() as u16)
3045        .min(20)
3046        .min(parent_area.height.saturating_sub(4));
3047
3048    // Center the dialog
3049    let dialog_x = parent_area.x + (parent_area.width.saturating_sub(dialog_width)) / 2;
3050    let dialog_y = parent_area.y + (parent_area.height.saturating_sub(dialog_height)) / 2;
3051    let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
3052
3053    // Clear and draw border
3054    frame.render_widget(Clear, dialog_area);
3055
3056    let title = format!(" {} ", t!("confirm.unsaved_changes_title"));
3057    let block = Block::default()
3058        .title(title)
3059        .borders(Borders::ALL)
3060        .border_type(BorderType::Rounded)
3061        .border_style(Style::default().fg(theme.diagnostic_warning_fg))
3062        .style(Style::default().bg(theme.popup_bg));
3063    frame.render_widget(block, dialog_area);
3064
3065    // Inner area
3066    let inner = Rect::new(
3067        dialog_area.x + 2,
3068        dialog_area.y + 1,
3069        dialog_area.width.saturating_sub(4),
3070        dialog_area.height.saturating_sub(2),
3071    );
3072
3073    let mut y = inner.y;
3074
3075    // Prompt text
3076    let prompt = t!("confirm.unsaved_changes_prompt").to_string();
3077    let prompt_style = Style::default().fg(theme.popup_text_fg);
3078    frame.render_widget(
3079        Paragraph::new(prompt).style(prompt_style),
3080        Rect::new(inner.x, y, inner.width, 1),
3081    );
3082    y += 2;
3083
3084    // List changes. Character-based truncation here (rather than byte
3085    // truncation) keeps CJK / emoji change descriptions from byte-slicing
3086    // through a multi-byte UTF-8 sequence and panicking — same class as
3087    // #1718.
3088    let change_style = Style::default().fg(theme.popup_text_fg);
3089    for change in changes
3090        .iter()
3091        .take((dialog_height as usize).saturating_sub(7))
3092    {
3093        let max_chars = (inner.width as usize).saturating_sub(2);
3094        let truncated = format!("• {}", truncate_chars_with_ellipsis(change, max_chars));
3095        frame.render_widget(
3096            Paragraph::new(truncated).style(change_style),
3097            Rect::new(inner.x, y, inner.width, 1),
3098        );
3099        y += 1;
3100    }
3101
3102    // Skip to button row
3103    let button_y = dialog_area.y + dialog_area.height - 3;
3104
3105    // Draw separator
3106    let sep_line: String = "─".repeat(inner.width as usize);
3107    frame.render_widget(
3108        Paragraph::new(sep_line).style(Style::default().fg(theme.split_separator_fg)),
3109        Rect::new(inner.x, button_y - 1, inner.width, 1),
3110    );
3111
3112    // Render the three options
3113    let options = [
3114        t!("confirm.save_and_exit").to_string(),
3115        t!("confirm.discard").to_string(),
3116        t!("confirm.cancel").to_string(),
3117    ];
3118    let total_width: u16 = options.iter().map(|o| o.len() as u16 + 4).sum::<u16>() + 4; // +4 for gaps
3119    let mut x = inner.x + (inner.width.saturating_sub(total_width)) / 2;
3120
3121    for (idx, label) in options.iter().enumerate() {
3122        let is_selected = idx == state.confirm_dialog_selection;
3123        let is_hovered = state.confirm_dialog_hover == Some(idx);
3124        let button_width = label.len() as u16 + 4;
3125
3126        let style = if is_selected {
3127            Style::default()
3128                .fg(theme.menu_highlight_fg)
3129                .bg(theme.menu_highlight_bg)
3130                .add_modifier(ratatui::style::Modifier::BOLD)
3131        } else if is_hovered {
3132            Style::default()
3133                .fg(theme.menu_hover_fg)
3134                .bg(theme.menu_hover_bg)
3135        } else {
3136            Style::default().fg(theme.popup_text_fg)
3137        };
3138
3139        let text = if is_selected {
3140            format!(">[ {} ]", label)
3141        } else {
3142            format!(" [ {} ]", label)
3143        };
3144        frame.render_widget(
3145            Paragraph::new(text).style(style),
3146            Rect::new(x, button_y, button_width + 1, 1),
3147        );
3148
3149        x += button_width + 3;
3150    }
3151
3152    // Help text
3153    let help = "←/→/Tab: Select   Enter: Confirm   Esc: Cancel";
3154    let help_style = Style::default().fg(theme.line_number_fg);
3155    frame.render_widget(
3156        Paragraph::new(help).style(help_style),
3157        Rect::new(inner.x, button_y + 1, inner.width, 1),
3158    );
3159}
3160
3161/// Render the reset confirmation dialog
3162fn render_reset_dialog(frame: &mut Frame, parent_area: Rect, state: &SettingsState, theme: &Theme) {
3163    let changes = state.get_change_descriptions();
3164    let dialog_width = 50.min(parent_area.width.saturating_sub(4));
3165    // Base height: 2 borders + 2 prompt lines + 1 separator + 1 buttons + 1 help = 7
3166    // Plus one line per change
3167    let dialog_height = (7 + changes.len() as u16)
3168        .min(20)
3169        .min(parent_area.height.saturating_sub(4));
3170
3171    // Center the dialog
3172    let dialog_x = parent_area.x + (parent_area.width.saturating_sub(dialog_width)) / 2;
3173    let dialog_y = parent_area.y + (parent_area.height.saturating_sub(dialog_height)) / 2;
3174    let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
3175
3176    // Clear and draw border
3177    frame.render_widget(Clear, dialog_area);
3178
3179    let block = Block::default()
3180        .title(" Reset All Changes ")
3181        .borders(Borders::ALL)
3182        .border_type(BorderType::Rounded)
3183        .border_style(Style::default().fg(theme.diagnostic_warning_fg))
3184        .style(Style::default().bg(theme.popup_bg));
3185    frame.render_widget(block, dialog_area);
3186
3187    // Inner area
3188    let inner = Rect::new(
3189        dialog_area.x + 2,
3190        dialog_area.y + 1,
3191        dialog_area.width.saturating_sub(4),
3192        dialog_area.height.saturating_sub(2),
3193    );
3194
3195    let mut y = inner.y;
3196
3197    // Prompt text
3198    let prompt_style = Style::default().fg(theme.popup_text_fg);
3199    frame.render_widget(
3200        Paragraph::new("Discard all pending changes?").style(prompt_style),
3201        Rect::new(inner.x, y, inner.width, 1),
3202    );
3203    y += 2;
3204
3205    // List changes. Character-based truncation here (rather than byte
3206    // truncation) keeps CJK / emoji change descriptions from byte-slicing
3207    // through a multi-byte UTF-8 sequence and panicking — same class as
3208    // #1718.
3209    let change_style = Style::default().fg(theme.popup_text_fg);
3210    for change in changes
3211        .iter()
3212        .take((dialog_height as usize).saturating_sub(7))
3213    {
3214        let max_chars = (inner.width as usize).saturating_sub(2);
3215        let truncated = format!("• {}", truncate_chars_with_ellipsis(change, max_chars));
3216        frame.render_widget(
3217            Paragraph::new(truncated).style(change_style),
3218            Rect::new(inner.x, y, inner.width, 1),
3219        );
3220        y += 1;
3221    }
3222
3223    // Skip to button row
3224    let button_y = dialog_area.y + dialog_area.height - 3;
3225
3226    // Draw separator
3227    let sep_line: String = "─".repeat(inner.width as usize);
3228    frame.render_widget(
3229        Paragraph::new(sep_line).style(Style::default().fg(theme.split_separator_fg)),
3230        Rect::new(inner.x, button_y - 1, inner.width, 1),
3231    );
3232
3233    // Render the two options: Reset, Cancel
3234    let options = ["Reset", "Cancel"];
3235    let total_width: u16 = options.iter().map(|o| o.len() as u16 + 4).sum::<u16>() + 4;
3236    let mut x = inner.x + (inner.width.saturating_sub(total_width)) / 2;
3237
3238    for (idx, label) in options.iter().enumerate() {
3239        let is_selected = idx == state.reset_dialog_selection;
3240        let is_hovered = state.reset_dialog_hover == Some(idx);
3241        let button_width = label.len() as u16 + 4;
3242
3243        let style = if is_selected {
3244            Style::default()
3245                .fg(theme.menu_highlight_fg)
3246                .bg(theme.menu_highlight_bg)
3247                .add_modifier(ratatui::style::Modifier::BOLD)
3248        } else if is_hovered {
3249            Style::default()
3250                .fg(theme.menu_hover_fg)
3251                .bg(theme.menu_hover_bg)
3252        } else {
3253            Style::default().fg(theme.popup_text_fg)
3254        };
3255
3256        let text = if is_selected {
3257            format!(">[ {} ]", label)
3258        } else {
3259            format!(" [ {} ]", label)
3260        };
3261        frame.render_widget(
3262            Paragraph::new(text).style(style),
3263            Rect::new(x, button_y, button_width + 1, 1),
3264        );
3265
3266        x += button_width + 3;
3267    }
3268
3269    // Help text
3270    let help = "←/→/Tab: Select   Enter: Confirm   Esc: Cancel";
3271    let help_style = Style::default().fg(theme.line_number_fg);
3272    frame.render_widget(
3273        Paragraph::new(help).style(help_style),
3274        Rect::new(inner.x, button_y + 1, inner.width, 1),
3275    );
3276}
3277
3278/// Render the "Discard changes?" prompt that appears when the user
3279/// presses Esc on a dirty entry dialog.
3280fn render_entry_discard_confirm(
3281    frame: &mut Frame,
3282    parent_area: Rect,
3283    state: &SettingsState,
3284    theme: &Theme,
3285) {
3286    let dialog_width = 50.min(parent_area.width.saturating_sub(4));
3287    let dialog_height = 7u16.min(parent_area.height.saturating_sub(4));
3288    let dialog_x = parent_area.x + (parent_area.width.saturating_sub(dialog_width)) / 2;
3289    let dialog_y = parent_area.y + (parent_area.height.saturating_sub(dialog_height)) / 2;
3290    let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
3291
3292    frame.render_widget(Clear, dialog_area);
3293
3294    let block = Block::default()
3295        .title(" Discard changes? ")
3296        .borders(Borders::ALL)
3297        .border_type(BorderType::Rounded)
3298        .border_style(Style::default().fg(theme.diagnostic_warning_fg))
3299        .style(Style::default().bg(theme.popup_bg));
3300    frame.render_widget(block, dialog_area);
3301
3302    let inner = Rect::new(
3303        dialog_area.x + 2,
3304        dialog_area.y + 1,
3305        dialog_area.width.saturating_sub(4),
3306        dialog_area.height.saturating_sub(2),
3307    );
3308
3309    let prompt_style = Style::default().fg(theme.popup_text_fg);
3310    frame.render_widget(
3311        Paragraph::new("You have uncommitted edits in this dialog.").style(prompt_style),
3312        Rect::new(inner.x, inner.y, inner.width, 1),
3313    );
3314
3315    // Buttons. 0 = Keep editing (default), 1 = Discard. Discard styled
3316    // in the danger fg to make the destructive choice unmistakable.
3317    let button_y = dialog_area.y + dialog_area.height - 3;
3318    let options = ["Keep editing", "Discard"];
3319    let total_width: u16 = options.iter().map(|o| o.len() as u16 + 4).sum::<u16>() + 4;
3320    let mut x = inner.x + (inner.width.saturating_sub(total_width)) / 2;
3321
3322    for (idx, label) in options.iter().enumerate() {
3323        let is_selected = idx == state.entry_discard_confirm_selection;
3324        let is_discard = idx == 1;
3325        let style = if is_selected && is_discard {
3326            Style::default()
3327                .fg(theme.diagnostic_error_fg)
3328                .bg(theme.popup_selection_bg)
3329                .add_modifier(Modifier::BOLD)
3330        } else if is_selected {
3331            Style::default()
3332                .fg(theme.popup_selection_fg)
3333                .bg(theme.popup_selection_bg)
3334                .add_modifier(Modifier::BOLD)
3335        } else if is_discard {
3336            Style::default()
3337                .fg(theme.diagnostic_error_fg)
3338                .add_modifier(Modifier::BOLD)
3339        } else {
3340            Style::default().fg(theme.popup_text_fg)
3341        };
3342        let text = if is_selected {
3343            format!(">[ {} ]", label)
3344        } else {
3345            format!(" [ {} ]", label)
3346        };
3347        let w = label.len() as u16 + 5;
3348        frame.render_widget(
3349            Paragraph::new(text).style(style),
3350            Rect::new(x, button_y, w, 1),
3351        );
3352        x += w + 2;
3353    }
3354
3355    let help = "Tab/←→: Select   Enter: Confirm   Esc: Keep editing";
3356    let help_style = Style::default().fg(theme.line_number_fg);
3357    frame.render_widget(
3358        Paragraph::new(help).style(help_style),
3359        Rect::new(inner.x, button_y + 1, inner.width, 1),
3360    );
3361}
3362
3363/// Render the "Delete <name>?" prompt that appears when the user
3364/// activates the Delete button on an entry dialog.
3365fn render_entry_delete_confirm(
3366    frame: &mut Frame,
3367    parent_area: Rect,
3368    state: &SettingsState,
3369    theme: &Theme,
3370) {
3371    let dialog_width = 60.min(parent_area.width.saturating_sub(4));
3372    let dialog_height = 7u16.min(parent_area.height.saturating_sub(4));
3373    let dialog_x = parent_area.x + (parent_area.width.saturating_sub(dialog_width)) / 2;
3374    let dialog_y = parent_area.y + (parent_area.height.saturating_sub(dialog_height)) / 2;
3375    let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
3376
3377    frame.render_widget(Clear, dialog_area);
3378
3379    let title = if state.entry_delete_target_name.is_empty() {
3380        " Delete entry? ".to_string()
3381    } else {
3382        format!(" Delete \"{}\"? ", state.entry_delete_target_name)
3383    };
3384
3385    let block = Block::default()
3386        .title(title)
3387        .borders(Borders::ALL)
3388        .border_type(BorderType::Rounded)
3389        .border_style(Style::default().fg(theme.diagnostic_error_fg))
3390        .style(Style::default().bg(theme.popup_bg));
3391    frame.render_widget(block, dialog_area);
3392
3393    let inner = Rect::new(
3394        dialog_area.x + 2,
3395        dialog_area.y + 1,
3396        dialog_area.width.saturating_sub(4),
3397        dialog_area.height.saturating_sub(2),
3398    );
3399
3400    let body = if state.entry_delete_target_name.is_empty() {
3401        "This will permanently remove the entry.".to_string()
3402    } else {
3403        format!(
3404            "This will permanently remove \"{}\".",
3405            state.entry_delete_target_name
3406        )
3407    };
3408    let prompt_style = Style::default().fg(theme.popup_text_fg);
3409    frame.render_widget(
3410        Paragraph::new(body).style(prompt_style),
3411        Rect::new(inner.x, inner.y, inner.width, 1),
3412    );
3413
3414    let button_y = dialog_area.y + dialog_area.height - 3;
3415    let options = ["Cancel", "Delete"];
3416    let total_width: u16 = options.iter().map(|o| o.len() as u16 + 5).sum::<u16>() + 2;
3417    let mut x = inner.x + (inner.width.saturating_sub(total_width)) / 2;
3418
3419    for (idx, label) in options.iter().enumerate() {
3420        let is_selected = idx == state.entry_delete_confirm_selection;
3421        let is_delete = idx == 1;
3422        let style = if is_selected && is_delete {
3423            Style::default()
3424                .fg(theme.diagnostic_error_fg)
3425                .bg(theme.popup_selection_bg)
3426                .add_modifier(Modifier::BOLD)
3427        } else if is_selected {
3428            Style::default()
3429                .fg(theme.popup_selection_fg)
3430                .bg(theme.popup_selection_bg)
3431                .add_modifier(Modifier::BOLD)
3432        } else if is_delete {
3433            Style::default()
3434                .fg(theme.diagnostic_error_fg)
3435                .add_modifier(Modifier::BOLD)
3436        } else {
3437            Style::default().fg(theme.popup_text_fg)
3438        };
3439        let text = if is_selected {
3440            format!(">[ {} ]", label)
3441        } else {
3442            format!(" [ {} ]", label)
3443        };
3444        let w = label.len() as u16 + 5;
3445        frame.render_widget(
3446            Paragraph::new(text).style(style),
3447            Rect::new(x, button_y, w, 1),
3448        );
3449        x += w + 2;
3450    }
3451
3452    let help = "Tab/←→: Select   Enter: Confirm   Esc: Cancel";
3453    let help_style = Style::default().fg(theme.line_number_fg);
3454    frame.render_widget(
3455        Paragraph::new(help).style(help_style),
3456        Rect::new(inner.x, button_y + 1, inner.width, 1),
3457    );
3458}
3459
3460/// Render a specific entry dialog from the stack by index.
3461fn render_entry_dialog_at(
3462    frame: &mut Frame,
3463    parent_area: Rect,
3464    state: &mut SettingsState,
3465    theme: &Theme,
3466    dialog_idx: usize,
3467) {
3468    let Some(dialog) = state.entry_dialog_stack.get_mut(dialog_idx) else {
3469        return;
3470    };
3471    render_entry_dialog_inner(frame, parent_area, dialog, theme);
3472}
3473
3474/// Render the entry detail dialog for editing Language/LSP/Keybinding entries
3475///
3476/// Now uses the same SettingItem/SettingControl infrastructure as the main settings UI,
3477/// eliminating duplication and ensuring consistent rendering.
3478fn render_entry_dialog_inner(
3479    frame: &mut Frame,
3480    parent_area: Rect,
3481    dialog: &mut super::entry_dialog::EntryDialogState,
3482    theme: &Theme,
3483) {
3484    // Calculate dialog size - use most of available space for editing
3485    let dialog_width = (parent_area.width * 85 / 100).clamp(50, 90);
3486    let dialog_height = (parent_area.height * 90 / 100).max(15);
3487    let dialog_x = parent_area.x + (parent_area.width.saturating_sub(dialog_width)) / 2;
3488    let dialog_y = parent_area.y + (parent_area.height.saturating_sub(dialog_height)) / 2;
3489
3490    let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
3491
3492    // Clear and draw border
3493    frame.render_widget(Clear, dialog_area);
3494
3495    // Dialog title bar reads `Edit Item` clean / `Edit Item • modified`
3496    // when the form has uncommitted edits — gives the user an
3497    // at-a-glance "is there anything to save?" answer without making
3498    // the Save button itself misleadingly disabled (UX review
3499    // suggestion: Save stays clickable; the title carries the cue).
3500    let title = if dialog.is_dirty() {
3501        format!(" {} • modified ", dialog.title)
3502    } else {
3503        format!(" {} ", dialog.title)
3504    };
3505
3506    let border_color = if dialog.is_dirty() {
3507        theme.diagnostic_warning_fg
3508    } else {
3509        theme.popup_border_fg
3510    };
3511
3512    let block = Block::default()
3513        .title(title)
3514        .borders(Borders::ALL)
3515        .border_type(BorderType::Rounded)
3516        .border_style(Style::default().fg(border_color))
3517        .style(Style::default().bg(theme.popup_bg));
3518    frame.render_widget(block, dialog_area);
3519
3520    // Inner area (reserve 2 lines for buttons and help at bottom)
3521    let inner = Rect::new(
3522        dialog_area.x + 2,
3523        dialog_area.y + 1,
3524        dialog_area.width.saturating_sub(4),
3525        dialog_area.height.saturating_sub(5), // 1 border + 2 button/help rows + 2 padding
3526    );
3527
3528    // Calculate optimal label column width based on actual item names
3529    let max_label_width = (inner.width / 2).max(20);
3530    let label_col_width = dialog
3531        .items
3532        .iter()
3533        .map(|item| item.name.len() as u16 + 2) // +2 for ": "
3534        .filter(|&w| w <= max_label_width)
3535        .max()
3536        .unwrap_or(20)
3537        .min(max_label_width);
3538
3539    // Calculate total content height and viewport
3540    let total_content_height = dialog.total_content_height();
3541    let viewport_height = inner.height as usize;
3542
3543    // Store viewport height for use in focus navigation
3544    dialog.viewport_height = viewport_height;
3545
3546    let scroll_offset = dialog.scroll_offset;
3547    let needs_scroll = total_content_height > viewport_height;
3548
3549    // Track current position in content (for scrolling)
3550    let mut content_y: usize = 0;
3551    let mut screen_y = inner.y;
3552
3553    // Track if we need to render a separator (between read-only and editable items)
3554    let first_editable = dialog.first_editable_index;
3555    let has_readonly_items = first_editable > 0;
3556    let has_editable_items = first_editable < dialog.items.len();
3557    let needs_separator = has_readonly_items && has_editable_items;
3558
3559    for (idx, item) in dialog.items.iter().enumerate() {
3560        // Render separator before first editable item
3561        if needs_separator && idx == first_editable {
3562            // Add separator row to content height calculation
3563            let separator_start = content_y;
3564            let separator_end = content_y + 1;
3565
3566            if separator_end > scroll_offset && screen_y < inner.y + inner.height {
3567                // Separator is visible
3568                let skip_sep = if separator_start < scroll_offset {
3569                    1
3570                } else {
3571                    0
3572                };
3573                if skip_sep == 0 {
3574                    let sep_style = Style::default().fg(theme.line_number_fg);
3575                    let separator_line = "─".repeat(inner.width.saturating_sub(2) as usize);
3576                    frame.render_widget(
3577                        Paragraph::new(separator_line).style(sep_style),
3578                        Rect::new(inner.x + 1, screen_y, inner.width.saturating_sub(2), 1),
3579                    );
3580                    screen_y += 1;
3581                }
3582            }
3583            content_y = separator_end;
3584        }
3585
3586        // Render section header if this is the first item in a section
3587        if item.is_section_start {
3588            if let Some(ref section_name) = item.section {
3589                let header_start = content_y;
3590                let header_end = content_y + 2; // 2 lines: label + separator
3591
3592                if header_end > scroll_offset && screen_y < inner.y + inner.height {
3593                    let skip_h = if header_start < scroll_offset {
3594                        (scroll_offset - header_start) as u16
3595                    } else {
3596                        0
3597                    };
3598                    if skip_h == 0 {
3599                        // Section label
3600                        let section_style = Style::default()
3601                            .fg(theme.line_number_fg)
3602                            .add_modifier(Modifier::BOLD);
3603                        frame.render_widget(
3604                            Paragraph::new(format!("── {} ──", section_name)).style(section_style),
3605                            Rect::new(inner.x + 1, screen_y, inner.width.saturating_sub(2), 1),
3606                        );
3607                        screen_y += 1;
3608                    }
3609                    if skip_h <= 1 && screen_y < inner.y + inner.height {
3610                        // Blank line after section header
3611                        screen_y += 1;
3612                    }
3613                }
3614                content_y = header_end;
3615            }
3616        }
3617
3618        let control_height = item.control.control_height() as usize;
3619
3620        // Check if this item is visible in the viewport
3621        let item_start = content_y;
3622        let item_end = content_y + control_height;
3623
3624        // Skip items completely above the viewport
3625        if item_end <= scroll_offset {
3626            content_y = item_end;
3627            continue;
3628        }
3629
3630        // Stop if we're past the viewport
3631        if screen_y >= inner.y + inner.height {
3632            break;
3633        }
3634
3635        // Calculate how many rows to skip at top of this item
3636        let skip_rows = if item_start < scroll_offset {
3637            (scroll_offset - item_start) as u16
3638        } else {
3639            0
3640        };
3641
3642        // Calculate visible height for this item
3643        let visible_height = control_height.saturating_sub(skip_rows as usize);
3644        let available_height = (inner.y + inner.height).saturating_sub(screen_y) as usize;
3645        let render_height = visible_height.min(available_height);
3646
3647        if render_height == 0 {
3648            content_y = item_end;
3649            continue;
3650        }
3651
3652        // Read-only items are not focusable - no focus/hover highlighting
3653        let is_readonly = item.read_only;
3654        let is_focused = !is_readonly && !dialog.focus_on_buttons && dialog.selected_item == idx;
3655        let is_hovered = !is_readonly && dialog.hover_item == Some(idx);
3656
3657        // Draw selection or hover highlight background (only for editable items)
3658        if is_focused || is_hovered {
3659            let bg_style = if is_focused {
3660                Style::default().bg(theme.settings_selected_bg)
3661            } else {
3662                Style::default().bg(theme.menu_hover_bg)
3663            };
3664
3665            if item.control.is_composite() {
3666                // For composite controls, only highlight the focused sub-row
3667                let sub_row = item.control.focused_sub_row();
3668                if sub_row >= skip_rows && (sub_row - skip_rows) < render_height as u16 {
3669                    let highlight_y = screen_y + sub_row - skip_rows;
3670                    let row_area = Rect::new(inner.x, highlight_y, inner.width, 1);
3671                    frame.render_widget(Paragraph::new("").style(bg_style), row_area);
3672                }
3673            } else {
3674                // For simple controls, highlight the entire area
3675                for row in 0..render_height as u16 {
3676                    let row_area = Rect::new(inner.x, screen_y + row, inner.width, 1);
3677                    frame.render_widget(Paragraph::new("").style(bg_style), row_area);
3678                }
3679            }
3680        }
3681
3682        // Indicator area takes 3 chars: [>][●][ ] -> focus, modified, separator
3683        // Examples: ">● ", ">  ", " ● ", "   "
3684        let focus_indicator_width: u16 = 3;
3685
3686        // Render focus indicator ">" — on sub-row for composites, first row for simple controls
3687        if is_focused && skip_rows == 0 {
3688            let indicator_style = Style::default()
3689                .fg(theme.settings_selected_fg)
3690                .add_modifier(Modifier::BOLD);
3691
3692            let indicator_y = if item.control.is_composite() {
3693                let sub_row = item.control.focused_sub_row();
3694                if sub_row < render_height as u16 {
3695                    screen_y + sub_row
3696                } else {
3697                    screen_y
3698                }
3699            } else {
3700                screen_y
3701            };
3702
3703            frame.render_widget(
3704                Paragraph::new(">").style(indicator_style),
3705                Rect::new(inner.x, indicator_y, 1, 1),
3706            );
3707        } else if is_focused && skip_rows > 0 {
3708            // If the item is partially scrolled, check if the focused sub-row is visible
3709            if item.control.is_composite() {
3710                let sub_row = item.control.focused_sub_row();
3711                if sub_row >= skip_rows && (sub_row - skip_rows) < render_height as u16 {
3712                    let indicator_style = Style::default()
3713                        .fg(theme.settings_selected_fg)
3714                        .add_modifier(Modifier::BOLD);
3715                    let indicator_y = screen_y + sub_row - skip_rows;
3716                    frame.render_widget(
3717                        Paragraph::new(">").style(indicator_style),
3718                        Rect::new(inner.x, indicator_y, 1, 1),
3719                    );
3720                }
3721            }
3722        }
3723
3724        // Render modified indicator "●" at position 1 for modified items
3725        if item.modified && skip_rows == 0 {
3726            let modified_style = Style::default().fg(theme.settings_selected_fg);
3727            frame.render_widget(
3728                Paragraph::new("●").style(modified_style),
3729                Rect::new(inner.x + 1, screen_y, 1, 1),
3730            );
3731        }
3732
3733        // Calculate control area (offset by focus indicator width)
3734        let control_area = Rect::new(
3735            inner.x + focus_indicator_width,
3736            screen_y,
3737            inner.width.saturating_sub(focus_indicator_width),
3738            render_height as u16,
3739        );
3740
3741        // Render using the same render_control function as main settings
3742        let _layout = render_control(
3743            frame,
3744            control_area,
3745            &item.control,
3746            &item.name,
3747            skip_rows,
3748            theme,
3749            Some(label_col_width.saturating_sub(focus_indicator_width)),
3750            item.read_only,
3751            item.is_null,
3752        );
3753
3754        screen_y += render_height as u16;
3755        content_y = item_end;
3756    }
3757
3758    // Render scrollbar if needed
3759    if needs_scroll {
3760        use crate::view::ui::scrollbar::{render_scrollbar, ScrollbarColors, ScrollbarState};
3761
3762        let scrollbar_x = dialog_area.x + dialog_area.width - 3;
3763        let scrollbar_area = Rect::new(scrollbar_x, inner.y, 1, inner.height);
3764        let scrollbar_state =
3765            ScrollbarState::new(total_content_height, viewport_height, scroll_offset);
3766        let scrollbar_colors = ScrollbarColors::from_theme(theme);
3767        render_scrollbar(frame, scrollbar_area, &scrollbar_state, &scrollbar_colors);
3768    }
3769
3770    // Render buttons at bottom.
3771    //
3772    // Order is [Save, Cancel, Delete] — Save and Cancel are the
3773    // non-destructive pair the user reaches first via Tab, with
3774    // Delete pushed to the far right (with extra spacing) so the
3775    // destructive action is impossible to hit by accidentally
3776    // tabbing one too many times. Delete keeps its red Danger
3777    // styling whether focused or not.
3778    let button_y = dialog_area.y + dialog_area.height - 2;
3779    let has_delete = !dialog.is_new && !dialog.no_delete;
3780    let buttons: Vec<&str> = if has_delete {
3781        vec!["[ Save ]", "[ Cancel ]", "[ Delete ]"]
3782    } else {
3783        vec!["[ Save ]", "[ Cancel ]"]
3784    };
3785    // Index of Delete (last entry, when present). Used for the
3786    // visual gap between safe buttons and Delete.
3787    let delete_idx = if has_delete {
3788        Some(buttons.len() - 1)
3789    } else {
3790        None
3791    };
3792    const BUTTON_GAP: u16 = 2;
3793    const DELETE_GAP: u16 = 6;
3794    let button_width: u16 = buttons
3795        .iter()
3796        .enumerate()
3797        .map(|(i, b)| {
3798            let gap = if Some(i) == delete_idx {
3799                DELETE_GAP
3800            } else if i == 0 {
3801                0
3802            } else {
3803                BUTTON_GAP
3804            };
3805            b.len() as u16 + gap
3806        })
3807        .sum();
3808    let button_x = dialog_area.x + (dialog_area.width.saturating_sub(button_width)) / 2;
3809
3810    let mut x = button_x;
3811    for (idx, label) in buttons.iter().enumerate() {
3812        let is_selected = dialog.focus_on_buttons && dialog.focused_button == idx;
3813        let is_hovered = dialog.hover_button == Some(idx);
3814        let is_delete = Some(idx) == delete_idx;
3815        // Apply the inter-button gap before this button (except the
3816        // first one). Delete gets a wider gap so it stands apart.
3817        if idx > 0 {
3818            let gap = if is_delete { DELETE_GAP } else { BUTTON_GAP };
3819            x += gap;
3820        }
3821        // Render ">" focus indicator before selected button
3822        if is_selected {
3823            let indicator_style = Style::default()
3824                .fg(theme.settings_selected_fg)
3825                .add_modifier(Modifier::BOLD);
3826            frame.render_widget(
3827                Paragraph::new(">").style(indicator_style),
3828                Rect::new(x.saturating_sub(2), button_y, 1, 1),
3829            );
3830        }
3831        let style = if is_selected && is_delete {
3832            // Selected Delete: keep the red fg as a "this is still a
3833            // destructive action" cue, but add a focused bg so the
3834            // user can still see Tab landed here. REVERSED matches the
3835            // visual convention for "this is the active button" and
3836            // also lets keyboard-focus tests detect the selected button
3837            // via cell style rather than fragile byte-vs-cell offsets.
3838            Style::default()
3839                .fg(theme.diagnostic_error_fg)
3840                .bg(theme.popup_selection_bg)
3841                .add_modifier(Modifier::BOLD | Modifier::REVERSED)
3842        } else if is_selected {
3843            Style::default()
3844                .fg(theme.popup_selection_fg)
3845                .bg(theme.popup_selection_bg)
3846                .add_modifier(Modifier::BOLD | Modifier::REVERSED)
3847        } else if is_hovered && is_delete {
3848            Style::default()
3849                .fg(theme.diagnostic_error_fg)
3850                .bg(theme.menu_hover_bg)
3851                .add_modifier(Modifier::BOLD)
3852        } else if is_hovered {
3853            Style::default()
3854                .fg(theme.menu_hover_fg)
3855                .bg(theme.menu_hover_bg)
3856        } else if is_delete {
3857            Style::default()
3858                .fg(theme.diagnostic_error_fg)
3859                .add_modifier(Modifier::BOLD)
3860        } else {
3861            Style::default().fg(theme.editor_fg)
3862        };
3863        frame.render_widget(
3864            Paragraph::new(*label).style(style),
3865            Rect::new(x, button_y, label.len() as u16, 1),
3866        );
3867        x += label.len() as u16;
3868    }
3869
3870    // Render a single line of helper text for the focused field, on
3871    // the row immediately above the button bar. Surface the schema's
3872    // description so the user can tell, say, what `Name` actually
3873    // means without having to leave the dialog. Skip when focused on
3874    // buttons (no field selected).
3875    let helper_y = button_y.saturating_sub(1);
3876    if !dialog.focus_on_buttons && helper_y > inner.y {
3877        // F4: when the user is sitting on a TextList's pending
3878        // `[+] Add new` row (slot focused via `focused_item.is_none()`),
3879        // override the field description with a caption that names the
3880        // current state. Before this, the [+] row silently absorbed
3881        // keystrokes with no UI feedback — typing felt like it might or
3882        // might not be saved. The caption tells the user exactly what
3883        // Enter / Esc will do here.
3884        let pending_list_caption = dialog.current_item().and_then(|it| {
3885            if let SettingControl::TextList(state) = &it.control {
3886                if state.focused_item.is_none() {
3887                    return Some(if !state.pending_active && state.new_item_text.is_empty() {
3888                        "Press Enter (or type) to add a new item; ↓/Tab to leave"
3889                    } else if state.new_item_text.is_empty() {
3890                        "Type the new item — Enter to add, Esc to cancel"
3891                    } else {
3892                        "Editing new item — Enter to add, Esc to cancel"
3893                    });
3894                }
3895            }
3896            None
3897        });
3898
3899        let text: Option<String> = pending_list_caption.map(String::from).or_else(|| {
3900            dialog
3901                .current_item()
3902                .and_then(|it| it.description.as_deref())
3903                .filter(|d| !d.is_empty())
3904                .map(String::from)
3905        });
3906
3907        if let Some(text) = text {
3908            let max_width = dialog_area.width.saturating_sub(4) as usize;
3909            let truncated: String = text.chars().take(max_width).collect();
3910            let helper_style = Style::default()
3911                .fg(theme.line_number_fg)
3912                .add_modifier(Modifier::ITALIC);
3913            frame.render_widget(
3914                Paragraph::new(truncated).style(helper_style),
3915                Rect::new(
3916                    dialog_area.x + 2,
3917                    helper_y,
3918                    dialog_area.width.saturating_sub(4),
3919                    1,
3920                ),
3921            );
3922        }
3923    }
3924
3925    // Check if current item has invalid JSON (for Text controls with validation)
3926    // and if we're actively editing a JSON control
3927    let is_editing_json = dialog.editing_text && dialog.is_editing_json();
3928    let (has_invalid_json, is_json_control) = dialog
3929        .current_item()
3930        .map(|item| match &item.control {
3931            SettingControl::Text(state) => (!state.is_valid(), false),
3932            SettingControl::Json(state) => (!state.is_valid(), is_editing_json),
3933            _ => (false, false),
3934        })
3935        .unwrap_or((false, false));
3936
3937    // Render help text or warning
3938    let help_area = Rect::new(
3939        dialog_area.x + 2,
3940        button_y + 1,
3941        dialog_area.width.saturating_sub(4),
3942        1,
3943    );
3944
3945    if has_invalid_json && !is_json_control {
3946        // Text control with JSON validation - must fix before leaving
3947        let warning = "⚠ Invalid JSON - fix before leaving field";
3948        let warning_style = Style::default().fg(theme.diagnostic_warning_fg);
3949        frame.render_widget(Paragraph::new(warning).style(warning_style), help_area);
3950    } else if has_invalid_json && is_json_control {
3951        // JSON control with invalid JSON
3952        let warning = "⚠ Invalid JSON";
3953        let warning_style = Style::default().fg(theme.diagnostic_warning_fg);
3954        frame.render_widget(Paragraph::new(warning).style(warning_style), help_area);
3955    } else if is_json_control {
3956        // Editing JSON control
3957        let help = "↑↓←→:Move  Enter:Newline  Tab/Esc:Exit";
3958        let help_style = Style::default().fg(theme.line_number_fg);
3959        frame.render_widget(Paragraph::new(help).style(help_style), help_area);
3960    } else {
3961        // The trailing `●:modified` legend is the only place in the
3962        // dialog that explains what the `●` row-indicator means. Without
3963        // it the user has to guess whether it's a focus dot, a bullet,
3964        // or noise.
3965        let help = "↑↓:Navigate  Tab:Fields/Buttons  Enter:Edit  Ctrl+S:Save  Ctrl+R:Reset  Esc:Cancel  ●:modified";
3966        let help_style = Style::default().fg(theme.line_number_fg);
3967        frame.render_widget(Paragraph::new(help).style(help_style), help_area);
3968    }
3969}
3970
3971/// Render the help overlay showing keyboard shortcuts
3972fn render_help_overlay(frame: &mut Frame, parent_area: Rect, theme: &Theme) {
3973    // Define the help content
3974    let help_items = [
3975        (
3976            "Navigation",
3977            vec![
3978                ("↑ / ↓", "Move up/down"),
3979                ("Tab", "Switch between categories and settings"),
3980                ("Enter", "Activate/toggle setting"),
3981            ],
3982        ),
3983        (
3984            "Search",
3985            vec![
3986                ("/", "Start search"),
3987                ("Esc", "Cancel search"),
3988                ("↑ / ↓", "Navigate results"),
3989                ("Enter", "Jump to result"),
3990            ],
3991        ),
3992        (
3993            "Actions",
3994            vec![
3995                ("Ctrl+S", "Save settings"),
3996                ("Esc", "Close settings"),
3997                ("?", "Toggle this help"),
3998            ],
3999        ),
4000    ];
4001
4002    // Calculate dialog size
4003    let dialog_width = 50.min(parent_area.width.saturating_sub(4));
4004    let dialog_height = 20.min(parent_area.height.saturating_sub(4));
4005
4006    // Center the dialog
4007    let dialog_x = parent_area.x + (parent_area.width.saturating_sub(dialog_width)) / 2;
4008    let dialog_y = parent_area.y + (parent_area.height.saturating_sub(dialog_height)) / 2;
4009    let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
4010
4011    // Clear and draw border
4012    frame.render_widget(Clear, dialog_area);
4013
4014    let block = Block::default()
4015        .title(" Keyboard Shortcuts ")
4016        .borders(Borders::ALL)
4017        .border_type(BorderType::Rounded)
4018        .border_style(Style::default().fg(theme.menu_highlight_fg))
4019        .style(Style::default().bg(theme.popup_bg));
4020    frame.render_widget(block, dialog_area);
4021
4022    // Inner area
4023    let inner = Rect::new(
4024        dialog_area.x + 2,
4025        dialog_area.y + 1,
4026        dialog_area.width.saturating_sub(4),
4027        dialog_area.height.saturating_sub(2),
4028    );
4029
4030    let mut y = inner.y;
4031
4032    for (section_name, bindings) in &help_items {
4033        if y >= inner.y + inner.height.saturating_sub(1) {
4034            break;
4035        }
4036
4037        // Section header
4038        let header_style = Style::default()
4039            .fg(theme.menu_active_fg)
4040            .add_modifier(Modifier::BOLD);
4041        frame.render_widget(
4042            Paragraph::new(*section_name).style(header_style),
4043            Rect::new(inner.x, y, inner.width, 1),
4044        );
4045        y += 1;
4046
4047        for (key, description) in bindings {
4048            if y >= inner.y + inner.height.saturating_sub(1) {
4049                break;
4050            }
4051
4052            let key_style = Style::default()
4053                .fg(theme.popup_text_fg)
4054                .bg(theme.split_separator_fg);
4055            let desc_style = Style::default().fg(theme.popup_text_fg);
4056
4057            let line = Line::from(vec![
4058                Span::styled("  ", Style::default()),
4059                Span::styled(format!(" {} ", key), key_style),
4060                Span::styled(format!("  {}", description), desc_style),
4061            ]);
4062            frame.render_widget(Paragraph::new(line), Rect::new(inner.x, y, inner.width, 1));
4063            y += 1;
4064        }
4065
4066        y += 1; // Blank line between sections
4067    }
4068
4069    // Footer hint
4070    let footer_y = dialog_area.y + dialog_area.height - 2;
4071    let footer = "Press ? or Esc or Enter to close";
4072    let footer_style = Style::default().fg(theme.line_number_fg);
4073    let centered_x = inner.x + (inner.width.saturating_sub(footer.len() as u16)) / 2;
4074    frame.render_widget(
4075        Paragraph::new(footer).style(footer_style),
4076        Rect::new(centered_x, footer_y, footer.len() as u16, 1),
4077    );
4078}
4079
4080#[cfg(test)]
4081mod tests {
4082    use super::*;
4083
4084    #[test]
4085    fn truncate_chars_with_ellipsis_ascii_fits() {
4086        assert_eq!(truncate_chars_with_ellipsis("hi", 10), "hi");
4087    }
4088
4089    #[test]
4090    fn truncate_chars_with_ellipsis_ascii_truncates() {
4091        assert_eq!(truncate_chars_with_ellipsis("hello world!", 8), "hello...");
4092    }
4093
4094    #[test]
4095    fn truncate_chars_with_ellipsis_multibyte_does_not_panic() {
4096        // Regression: byte-slicing this string at `max - 3` would land
4097        // inside the 3-byte UTF-8 sequence for `こ` and panic — same class
4098        // as #1718.
4099        let out = truncate_chars_with_ellipsis("こんにちは世界からのテスト", 8);
4100        assert!(out.ends_with("..."));
4101        // 5 kept chars + 3 ellipsis chars = 8 total chars.
4102        assert_eq!(out.chars().count(), 8);
4103    }
4104
4105    #[test]
4106    fn truncate_chars_with_ellipsis_emoji_does_not_panic() {
4107        let out = truncate_chars_with_ellipsis("📦📦📦📦📦📦📦📦", 5);
4108        assert!(out.ends_with("..."));
4109        assert_eq!(out.chars().count(), 5);
4110    }
4111
4112    // Basic compile test - actual rendering tests would need a test backend
4113    #[test]
4114    fn test_control_layout_info() {
4115        let toggle = ControlLayoutInfo::Toggle(Rect::new(0, 0, 10, 1));
4116        assert!(matches!(toggle, ControlLayoutInfo::Toggle(_)));
4117
4118        let number = ControlLayoutInfo::Number {
4119            decrement: Rect::new(0, 0, 3, 1),
4120            increment: Rect::new(4, 0, 3, 1),
4121            value: Rect::new(8, 0, 5, 1),
4122        };
4123        assert!(matches!(number, ControlLayoutInfo::Number { .. }));
4124    }
4125}