Skip to main content

fresh/view/settings/
render.rs

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