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