Skip to main content

fresh/view/settings/
render.rs

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