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