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