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