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