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