Skip to main content

fresh/view/settings/
render.rs

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