1use rust_i18n::t;
6
7use crate::primitives::display_width::str_width;
8
9use super::entry_dialog::EntryDialogState;
10use super::items::SettingControl;
11use super::layout::{SettingsHit, SettingsLayout};
12use super::search::{DeepMatch, SearchResult};
13use super::state::SettingsState;
14use crate::view::controls::{
15 render_dropdown_aligned, render_dual_list_partial, render_number_input_aligned,
16 render_text_input_aligned, render_toggle_aligned, DropdownColors, DualListColors, MapColors,
17 NumberInputColors, TextInputColors, TextListColors, ToggleColors,
18};
19use crate::view::theme::Theme;
20use crate::view::ui::scrollbar::{render_scrollbar, ScrollbarColors, ScrollbarState};
21use ratatui::layout::{Constraint, Layout, Rect};
22use ratatui::style::{Color, Modifier, Style};
23use ratatui::text::{Line, Span};
24use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};
25use ratatui::Frame;
26
27#[allow(clippy::too_many_arguments)]
31fn build_selection_spans(
32 display_text: &str,
33 display_len: usize,
34 line_idx: usize,
35 start_row: usize,
36 start_col: usize,
37 end_row: usize,
38 end_col: usize,
39 text_color: Color,
40 selection_bg: Color,
41) -> Vec<Span<'static>> {
42 let chars: Vec<char> = display_text.chars().collect();
43 let char_count = chars.len();
44
45 let (sel_start, sel_end) = if line_idx < start_row || line_idx > end_row {
47 (char_count, char_count)
49 } else if line_idx == start_row && line_idx == end_row {
50 let start = byte_to_char_idx(display_text, start_col).min(char_count);
52 let end = byte_to_char_idx(display_text, end_col).min(char_count);
53 (start, end)
54 } else if line_idx == start_row {
55 let start = byte_to_char_idx(display_text, start_col).min(char_count);
57 (start, char_count)
58 } else if line_idx == end_row {
59 let end = byte_to_char_idx(display_text, end_col).min(char_count);
61 (0, end)
62 } else {
63 (0, char_count)
65 };
66
67 let mut spans = Vec::new();
68 let normal_style = Style::default().fg(text_color);
69 let selected_style = Style::default().fg(text_color).bg(selection_bg);
70
71 if sel_start >= sel_end || sel_start >= char_count {
72 let padded = format!("{:width$}", display_text, width = display_len);
74 spans.push(Span::styled(padded, normal_style));
75 } else {
76 if sel_start > 0 {
78 let before: String = chars[..sel_start].iter().collect();
79 spans.push(Span::styled(before, normal_style));
80 }
81
82 let selected: String = chars[sel_start..sel_end].iter().collect();
84 spans.push(Span::styled(selected, selected_style));
85
86 if sel_end < char_count {
88 let after: String = chars[sel_end..].iter().collect();
89 spans.push(Span::styled(after, normal_style));
90 }
91
92 let current_len = char_count;
94 if current_len < display_len {
95 let padding = " ".repeat(display_len - current_len);
96 spans.push(Span::styled(padding, normal_style));
97 }
98 }
99
100 spans
101}
102
103fn byte_to_char_idx(s: &str, byte_offset: usize) -> usize {
105 s.char_indices()
106 .take_while(|(i, _)| *i < byte_offset)
107 .count()
108}
109
110fn truncate_chars_with_ellipsis(s: &str, max_chars: usize) -> String {
115 if s.chars().count() <= max_chars {
116 s.to_string()
117 } else {
118 let kept: String = s.chars().take(max_chars.saturating_sub(3)).collect();
119 format!("{}...", kept)
120 }
121}
122
123pub fn render_settings(
125 frame: &mut Frame,
126 area: Rect,
127 state: &mut SettingsState,
128 theme: &Theme,
129) -> SettingsLayout {
130 if area.width < 40 || area.height < 10 {
132 let msg = "[Terminal too small for settings]";
133 let x = area.x + area.width.saturating_sub(msg.len() as u16) / 2;
134 let y = area.y + area.height / 2;
135 if area.width > 0 && area.height > 0 {
136 frame.render_widget(
137 Paragraph::new(msg).style(Style::default().fg(theme.diagnostic_warning_fg)),
138 Rect::new(x, y, msg.len() as u16, 1),
139 );
140 }
141 return SettingsLayout::new(Rect::ZERO);
142 }
143
144 let modal_width = (area.width * 90 / 100).min(160);
146 let modal_height = area.height * 90 / 100;
147 let modal_x = area.x + (area.width.saturating_sub(modal_width)) / 2;
153 let modal_y = area.y + (area.height.saturating_sub(modal_height)) / 2;
154
155 let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
156
157 frame.render_widget(Clear, modal_area);
159
160 let title = if state.has_changes() {
161 format!(" Settings [{}] • (modified) ", state.target_layer_name())
162 } else {
163 format!(" Settings [{}] ", state.target_layer_name())
164 };
165
166 let block = Block::default()
167 .title(title.as_str())
168 .borders(Borders::ALL)
169 .border_type(BorderType::Rounded)
170 .border_style(Style::default().fg(theme.popup_border_fg))
171 .style(Style::default().bg(theme.popup_bg));
172 frame.render_widget(block, modal_area);
173
174 let inner_area = Rect::new(
176 modal_area.x + 1,
177 modal_area.y + 1,
178 modal_area.width.saturating_sub(2),
179 modal_area.height.saturating_sub(2),
180 );
181
182 let narrow_mode = inner_area.width < 60;
185
186 let search_area = Rect::new(inner_area.x, inner_area.y, inner_area.width, 1);
190 let search_header_height = 1u16;
191 let search_gap = 1u16;
192 if state.search_active {
193 render_search_header(frame, search_area, state, theme);
194 } else {
195 render_search_hint(frame, search_area, theme);
196 }
197
198 let footer_height = if narrow_mode { 7 } else { 2 };
200 let chrome_height = search_header_height + search_gap + footer_height;
201 let content_area = Rect::new(
202 inner_area.x,
203 inner_area.y + search_header_height + search_gap,
204 inner_area.width,
205 inner_area.height.saturating_sub(chrome_height),
206 );
207
208 let mut layout = SettingsLayout::new(modal_area);
210
211 if narrow_mode {
212 render_vertical_layout(frame, content_area, modal_area, state, theme, &mut layout);
214 } else {
215 render_horizontal_layout(frame, content_area, modal_area, state, theme, &mut layout);
217 }
218
219 let has_confirm = state.showing_confirm_dialog;
221 let has_reset = state.showing_reset_dialog;
222 let has_entry = state.showing_entry_dialog();
223 let has_help = state.showing_help;
224
225 if has_confirm {
227 if !has_entry && !has_help {
228 crate::view::dimming::apply_dimming(frame, modal_area);
229 }
230 render_confirm_dialog(frame, modal_area, state, theme);
231 }
232
233 if has_reset {
235 if !has_confirm && !has_entry && !has_help {
236 crate::view::dimming::apply_dimming(frame, modal_area);
237 }
238 render_reset_dialog(frame, modal_area, state, theme);
239 }
240
241 if has_entry {
243 let stack_depth = state.entry_dialog_stack.len();
244 for dialog_idx in 0..stack_depth {
245 if !has_help || dialog_idx < stack_depth - 1 {
246 crate::view::dimming::apply_dimming(frame, modal_area);
247 }
248 render_entry_dialog_at(frame, modal_area, state, theme, dialog_idx);
249 }
250 }
251
252 if state.showing_entry_discard_confirm {
256 crate::view::dimming::apply_dimming(frame, modal_area);
257 render_entry_discard_confirm(frame, modal_area, state, theme);
258 }
259
260 if state.showing_entry_delete_confirm {
264 crate::view::dimming::apply_dimming(frame, modal_area);
265 render_entry_delete_confirm(frame, modal_area, state, theme);
266 }
267
268 if has_help {
270 crate::view::dimming::apply_dimming(frame, modal_area);
271 render_help_overlay(frame, modal_area, theme);
272 }
273
274 layout
275}
276
277fn render_horizontal_layout(
279 frame: &mut Frame,
280 content_area: Rect,
281 modal_area: Rect,
282 state: &mut SettingsState,
283 theme: &Theme,
284 layout: &mut SettingsLayout,
285) {
286 let chunks = Layout::horizontal([
289 Constraint::Length(24),
290 Constraint::Length(1),
291 Constraint::Min(40),
292 ])
293 .split(content_area);
294
295 let categories_area = chunks[0];
296 let divider_area = chunks[1];
297 let settings_area = chunks[2];
298
299 render_categories(frame, categories_area, state, theme, layout);
301
302 let divider_style = Style::default().fg(theme.split_separator_fg);
304 for y in 0..divider_area.height {
305 frame.render_widget(
306 Paragraph::new("│").style(divider_style),
307 Rect::new(divider_area.x, divider_area.y + y, 1, 1),
308 );
309 }
310
311 let horizontal_padding = 1u16;
313 let settings_inner = Rect::new(
314 settings_area.x + horizontal_padding,
315 settings_area.y,
316 settings_area.width.saturating_sub(horizontal_padding * 2),
317 settings_area.height,
318 );
319
320 if state.search_active && !state.search_results.is_empty() {
321 render_search_results(frame, settings_inner, state, theme, layout);
322 } else {
323 render_settings_panel(frame, settings_inner, state, theme, layout);
324 }
325
326 render_footer(frame, modal_area, state, theme, layout, false);
328}
329
330fn render_vertical_layout(
332 frame: &mut Frame,
333 content_area: Rect,
334 modal_area: Rect,
335 state: &mut SettingsState,
336 theme: &Theme,
337 layout: &mut SettingsLayout,
338) {
339 let footer_height = 7;
341
342 let main_height = content_area.height.saturating_sub(footer_height);
344 let category_height = 3u16.min(main_height);
345 let settings_height = main_height.saturating_sub(category_height + 1); let categories_area = Rect::new(
349 content_area.x,
350 content_area.y,
351 content_area.width,
352 category_height,
353 );
354
355 let sep_y = content_area.y + category_height;
357
358 let settings_area = Rect::new(
360 content_area.x,
361 sep_y + 1,
362 content_area.width,
363 settings_height,
364 );
365
366 render_categories_horizontal(frame, categories_area, state, theme, layout);
368
369 if sep_y < content_area.y + content_area.height {
371 let sep_line: String = "─".repeat(content_area.width as usize);
372 frame.render_widget(
373 Paragraph::new(sep_line).style(Style::default().fg(theme.split_separator_fg)),
374 Rect::new(content_area.x, sep_y, content_area.width, 1),
375 );
376 }
377
378 if state.search_active && !state.search_results.is_empty() {
380 render_search_results(frame, settings_area, state, theme, layout);
381 } else {
382 render_settings_panel(frame, settings_area, state, theme, layout);
383 }
384
385 render_footer(frame, modal_area, state, theme, layout, true);
387}
388
389fn render_categories_horizontal(
391 frame: &mut Frame,
392 area: Rect,
393 state: &SettingsState,
394 theme: &Theme,
395 layout: &mut SettingsLayout,
396) {
397 use super::state::FocusPanel;
398
399 if area.height == 0 || area.width == 0 {
400 return;
401 }
402
403 let is_focused = state.focus_panel() == FocusPanel::Categories;
404
405 let mut spans = Vec::new();
407 let mut total_width = 0u16;
408
409 for (i, page) in state.pages.iter().enumerate() {
410 let is_selected = i == state.selected_category;
411 let has_modified = page.items.iter().any(|item| item.modified);
412
413 let indicator = if has_modified { "● " } else { " " };
414 let name = &page.name;
415
416 let style = if is_selected && is_focused {
417 Style::default()
418 .fg(theme.menu_highlight_fg)
419 .bg(theme.menu_highlight_bg)
420 .add_modifier(Modifier::BOLD)
421 } else if is_selected {
422 Style::default()
423 .fg(theme.menu_highlight_fg)
424 .add_modifier(Modifier::BOLD)
425 } else {
426 Style::default().fg(theme.popup_text_fg)
427 };
428
429 let indicator_style = if has_modified {
430 Style::default().fg(theme.menu_highlight_fg)
431 } else {
432 style
433 };
434
435 if i > 0 {
437 spans.push(Span::styled(
438 " │ ",
439 Style::default().fg(theme.split_separator_fg),
440 ));
441 total_width += 3;
442 }
443
444 spans.push(Span::styled(indicator, indicator_style));
445 spans.push(Span::styled(name.as_str(), style));
446 total_width += (indicator.len() + name.len()) as u16;
447
448 let cat_x = area.x + total_width.saturating_sub((indicator.len() + name.len()) as u16);
450 let cat_width = (indicator.len() + name.len()) as u16;
451 layout
452 .categories
453 .push((i, Rect::new(cat_x, area.y, cat_width, 1)));
454 }
455
456 let line = Line::from(spans);
458 frame.render_widget(Paragraph::new(line), area);
459
460 if area.height >= 2 {
462 let hint = "←→: Switch category";
463 let hint_style = Style::default().fg(theme.line_number_fg);
464 frame.render_widget(
465 Paragraph::new(hint).style(hint_style),
466 Rect::new(area.x, area.y + 1, area.width, 1),
467 );
468 }
469}
470
471fn category_icon(name: &str) -> &'static str {
473 match name.to_lowercase().as_str() {
474 "general" => "\u{f013} ", "editor" => "\u{f044} ", "clipboard" => "\u{f328} ", "file browser" => "\u{f07b} ", "file explorer" => "\u{f07c} ", "packages" => "\u{f487} ", "plugins" => "\u{f1e6} ", "terminal" => "\u{f120} ", "warnings" => "\u{f071} ", "keybindings" => "\u{f11c} ", _ => "\u{f111} ", }
486}
487
488fn render_categories(
494 frame: &mut Frame,
495 area: Rect,
496 state: &mut SettingsState,
497 theme: &Theme,
498 layout: &mut SettingsLayout,
499) {
500 use super::state::{FocusPanel, TreeRow};
501
502 layout.categories_panel_area = Some(area);
503
504 let rows = state.visible_tree();
505 state.categories_scroll.set_viewport(area.height);
506 state
507 .categories_scroll
508 .update_content_height(&rows, area.width);
509
510 let focus_panel = state.focus_panel();
511 let selected_category = state.selected_category;
512 let tree_cursor = state.tree_cursor_section;
517
518 struct RowData {
521 chevron: &'static str,
522 is_expandable: bool,
523 is_selected: bool,
524 has_changes: bool,
525 indent_cols: u16,
526 is_category: bool,
527 cat_idx: Option<usize>,
528 section_idx: Option<usize>,
529 label: String,
530 icon: Option<&'static str>,
531 }
532 let row_data: Vec<RowData> = rows
533 .iter()
534 .map(|row| match *row {
535 TreeRow::Category {
536 idx,
537 expandable,
538 expanded,
539 } => {
540 let page = &state.pages[idx];
541 RowData {
542 chevron: if expandable {
543 if expanded {
544 "▼"
545 } else {
546 "▶"
547 }
548 } else {
549 " "
550 },
551 is_expandable: expandable,
552 is_selected: idx == selected_category && tree_cursor.is_none(),
555 has_changes: page.items.iter().any(|i| i.modified),
556 indent_cols: 0,
557 is_category: true,
558 cat_idx: Some(idx),
559 section_idx: None,
560 label: page.name.clone(),
561 icon: Some(category_icon(&page.name)),
562 }
563 }
564 TreeRow::Section {
565 cat_idx,
566 section_idx,
567 } => {
568 let section = &state.pages[cat_idx].sections[section_idx];
569 let is_current = cat_idx == selected_category && tree_cursor == Some(section_idx);
575 RowData {
576 chevron: " ",
577 is_expandable: false,
578 is_selected: is_current,
579 has_changes: false,
580 indent_cols: 4,
581 is_category: false,
582 cat_idx: Some(cat_idx),
583 section_idx: Some(section_idx),
584 label: section.name.clone(),
585 icon: None,
586 }
587 }
588 })
589 .collect();
590
591 let panel_layout = state.categories_scroll.render(
593 frame,
594 area,
595 &rows,
596 |frame, info, row| {
597 let idx = info.index;
599 let data = &row_data[idx];
600 let row_area = info.area;
601
602 let row_bg = if data.is_selected {
610 if focus_panel == FocusPanel::Categories {
611 Some(theme.menu_highlight_bg)
612 } else {
613 Some(theme.selection_bg)
614 }
615 } else {
616 None
617 };
618 if let Some(bg) = row_bg {
619 frame.render_widget(
620 Paragraph::new(" ".repeat(row_area.width as usize))
621 .style(Style::default().bg(bg)),
622 row_area,
623 );
624 }
625
626 let fg = if data.is_selected {
627 if focus_panel == FocusPanel::Categories {
628 theme.menu_highlight_fg
629 } else {
630 theme.menu_fg
631 }
632 } else {
633 theme.popup_text_fg
634 };
635 let bg = row_bg.unwrap_or(theme.popup_bg);
636 let style = Style::default().fg(fg).bg(bg);
637
638 let mut spans: Vec<Span> = Vec::with_capacity(8);
639 let selected_marker = if data.is_selected && focus_panel == FocusPanel::Categories {
643 ">"
644 } else {
645 " "
646 };
647 spans.push(Span::styled(selected_marker.to_string(), style));
648 if data.indent_cols > 0 {
649 spans.push(Span::styled(" ".repeat(data.indent_cols as usize), style));
650 }
651 spans.push(Span::styled(format!("{} ", data.chevron), style));
653 if data.has_changes {
654 spans.push(Span::styled(
655 "● ",
656 Style::default().fg(theme.menu_highlight_fg).bg(bg),
657 ));
658 } else {
659 spans.push(Span::styled(" ", style));
660 }
661 if let Some(icon) = data.icon {
662 spans.push(Span::styled(
663 icon.to_string(),
664 Style::default().fg(theme.popup_border_fg).bg(bg),
665 ));
666 } else {
667 spans.push(Span::styled(" ", style));
668 }
669 spans.push(Span::styled(data.label.clone(), style));
670
671 frame.render_widget(Paragraph::new(Line::from(spans)), row_area);
672
673 (
676 row_area,
677 data.is_category,
678 data.is_expandable,
679 data.cat_idx,
680 data.section_idx,
681 data.indent_cols,
682 *row,
683 )
684 },
685 theme,
686 );
687
688 for layout_info in panel_layout.item_layouts.iter() {
690 let (row_area, is_category, is_expandable, cat_idx, section_idx, indent_cols, _row) =
691 layout_info.layout;
692 if is_category {
693 if let Some(idx) = cat_idx {
694 layout.add_category(idx, row_area);
695 if is_expandable {
696 let chevron_x = row_area.x.saturating_add(1 + indent_cols);
699 let chevron_area = Rect::new(chevron_x, row_area.y, 1, 1);
700 layout.add_category_disclosure(idx, chevron_area);
701 }
702 }
703 } else if let (Some(c), Some(s)) = (cat_idx, section_idx) {
704 layout.add_section(c, s, row_area);
705 }
706 }
707 if let Some(scrollbar) = panel_layout.scrollbar_area {
708 layout.categories_scrollbar_area = Some(scrollbar);
709 }
710}
711
712struct RenderContext {
714 selected_item: usize,
715 settings_focused: bool,
716 hover_hit: Option<SettingsHit>,
717}
718
719fn render_settings_panel(
721 frame: &mut Frame,
722 area: Rect,
723 state: &mut SettingsState,
724 theme: &Theme,
725 layout: &mut SettingsLayout,
726) {
727 let page = match state.current_page() {
728 Some(p) => p,
729 None => return,
730 };
731
732 let mut y = area.y;
737 let header_start_y = y;
738
739 if page.nullable && state.current_category_has_values() {
741 let btn_text = format!("[{}]", t!("settings.btn_clear_category"));
742 let btn_len = btn_text.len() as u16;
743 let is_hovered = matches!(state.hover_hit, Some(SettingsHit::ClearCategoryButton));
744 let btn_style = if is_hovered {
745 Style::default()
746 .fg(theme.menu_hover_fg)
747 .bg(theme.menu_hover_bg)
748 } else {
749 Style::default().fg(theme.line_number_fg)
750 };
751 let btn_area = Rect::new(area.x, y, btn_len, 1);
752 frame.render_widget(Paragraph::new(btn_text).style(btn_style), btn_area);
753 layout.clear_category_button = Some(btn_area);
754 y += 1;
755 } else {
756 layout.clear_category_button = None;
757 }
758
759 y += 1; let header_height = (y - header_start_y) as usize;
762 let items_start_y = y;
763
764 let available_height = area.height.saturating_sub(header_height as u16);
766
767 state.layout_width = area.width;
772
773 let page = state.pages.get(state.selected_category).unwrap();
775 state.scroll_panel.set_viewport(available_height);
776 state
777 .scroll_panel
778 .update_content_height(&page.items, area.width);
779
780 use super::state::FocusPanel;
782 let render_ctx = RenderContext {
783 selected_item: state.selected_item,
784 settings_focused: state.focus_panel() == FocusPanel::Settings,
785 hover_hit: state.hover_hit,
786 };
787
788 let items_area = Rect::new(area.x, items_start_y, area.width, available_height.max(1));
790
791 let page = state.pages.get(state.selected_category).unwrap();
793
794 let max_label_width = page
796 .items
797 .iter()
798 .filter_map(|item| {
799 match &item.control {
801 SettingControl::Toggle(s) => Some(s.label.len() as u16),
802 SettingControl::Number(s) => Some(s.label.len() as u16),
803 SettingControl::Dropdown(s) => Some(s.label.len() as u16),
804 SettingControl::Text(s) => Some(s.label.len() as u16),
805 _ => None,
807 }
808 })
809 .max();
810
811 let panel_layout = state.scroll_panel.render(
813 frame,
814 items_area,
815 &page.items,
816 |frame, info, item| {
817 render_setting_item_pure(
818 frame,
819 info.area,
820 item,
821 info.index,
822 info.skip_top,
823 &render_ctx,
824 theme,
825 max_label_width,
826 )
827 },
828 theme,
829 );
830
831 let page = state.pages.get(state.selected_category).unwrap();
833 for item_info in panel_layout.item_layouts {
834 layout.add_item(
835 item_info.index,
836 page.items[item_info.index].path.clone(),
837 item_info.area,
838 item_info.layout.control,
839 item_info.layout.inherit_button,
840 );
841 }
842
843 layout.settings_panel_area = Some(panel_layout.content_area);
845
846 if let Some(sb_area) = panel_layout.scrollbar_area {
848 layout.scrollbar_area = Some(sb_area);
849 }
850}
851
852fn wrap_text(text: &str, width: usize) -> Vec<String> {
854 if width == 0 || text.is_empty() {
855 return vec![text.to_string()];
856 }
857
858 let mut lines = Vec::new();
859 let mut current_line = String::new();
860 let mut current_len = 0;
861
862 for word in text.split_whitespace() {
863 let word_len = word.chars().count();
864
865 if current_len == 0 {
866 current_line = word.to_string();
868 current_len = word_len;
869 } else if current_len + 1 + word_len <= width {
870 current_line.push(' ');
872 current_line.push_str(word);
873 current_len += 1 + word_len;
874 } else {
875 lines.push(current_line);
877 current_line = word.to_string();
878 current_len = word_len;
879 }
880 }
881
882 if !current_line.is_empty() {
883 lines.push(current_line);
884 }
885
886 if lines.is_empty() {
887 lines.push(String::new());
888 }
889
890 lines
891}
892
893#[allow(clippy::too_many_arguments)]
904fn render_setting_item_pure(
905 frame: &mut Frame,
906 area: Rect,
907 item: &super::items::SettingItem,
908 idx: usize,
909 skip_top: u16,
910 ctx: &RenderContext,
911 theme: &Theme,
912 label_width: Option<u16>,
913) -> SettingItemLayoutInfo {
914 let plan = item.layout_box(area.width, &item.style);
915 let style = item.style;
916 let viewport_end_logical = skip_top.saturating_add(area.height); let band_rect = |logical_y: u16, rows: u16| -> Option<Rect> {
922 if rows == 0 {
923 return None;
924 }
925 let band_end = logical_y.saturating_add(rows);
926 if band_end <= skip_top || logical_y >= viewport_end_logical {
927 return None;
928 }
929 let visible_top_logical = logical_y.max(skip_top);
930 let visible_bottom_logical = band_end.min(viewport_end_logical);
931 let physical_y = area.y + (visible_top_logical - skip_top);
932 let visible_h = visible_bottom_logical - visible_top_logical;
933 Some(Rect::new(area.x, physical_y, area.width, visible_h))
934 };
935
936 if let (Some(section_name), Some(_header_rect)) = (
942 item.section.as_deref().filter(|_| item.is_section_start),
943 band_rect(0, plan.section_header_rows),
944 ) {
945 let title_logical_y = plan.section_header_rows.saturating_sub(1);
946 if let Some(title_rect) = band_rect(title_logical_y, 1) {
947 let header_style = Style::default()
948 .fg(theme.editor_fg)
949 .add_modifier(Modifier::BOLD);
950 frame.render_widget(
951 Paragraph::new(section_name).style(header_style),
952 Rect::new(title_rect.x, title_rect.y, title_rect.width, 1),
953 );
954 }
955 }
956
957 let card_logical_top = plan.card_top_y();
962 let card_logical_bottom = plan.total_rows();
963 if let Some(card_rect) = band_rect(
964 card_logical_top,
965 card_logical_bottom.saturating_sub(card_logical_top),
966 ) {
967 let mut borders = Borders::NONE;
968 if style.card_border_cols > 0 {
969 borders |= Borders::LEFT | Borders::RIGHT;
970 }
971 if style.card_border_rows > 0 {
972 if card_logical_top >= skip_top {
974 borders |= Borders::TOP;
975 }
976 let bottom_logical = card_logical_bottom.saturating_sub(1);
978 if bottom_logical >= skip_top && bottom_logical < viewport_end_logical {
979 borders |= Borders::BOTTOM;
980 }
981 }
982 if !borders.is_empty() {
983 let block = Block::default()
987 .borders(borders)
988 .border_type(BorderType::Rounded)
989 .border_style(Style::default().fg(theme.split_separator_fg));
990 frame.render_widget(block, card_rect);
991 }
992 }
993
994 let is_selected = ctx.settings_focused && idx == ctx.selected_item;
996 let is_item_hovered = matches!(
997 ctx.hover_hit,
998 Some(SettingsHit::Item(i))
999 | Some(SettingsHit::ControlToggle(i))
1000 | Some(SettingsHit::ControlDecrement(i))
1001 | Some(SettingsHit::ControlIncrement(i))
1002 | Some(SettingsHit::ControlDropdown(i))
1003 | Some(SettingsHit::ControlText(i))
1004 | Some(SettingsHit::ControlTextListRow(i, _))
1005 | Some(SettingsHit::ControlMapRow(i, _))
1006 | Some(SettingsHit::ControlInherit(i))
1007 if i == idx
1008 );
1009 let is_focused_or_hovered = is_selected || is_item_hovered;
1010
1011 let content_logical_top = plan.control_y();
1014 let content_logical_bottom = plan.bottom_border_y();
1015 let mut control_layout = ControlLayoutInfo::default();
1016 let mut inherit_button_area: Option<Rect> = None;
1017 if let Some(content_rect) = band_rect(
1018 content_logical_top,
1019 content_logical_bottom.saturating_sub(content_logical_top),
1020 ) {
1021 let inner_x = content_rect.x.saturating_add(style.card_border_cols);
1023 let inner_width = content_rect
1024 .width
1025 .saturating_sub(2 * style.card_border_cols);
1026 let inner_area = Rect::new(inner_x, content_rect.y, inner_width, content_rect.height);
1027
1028 let label_visible = skip_top <= content_logical_top;
1036 if is_focused_or_hovered && inner_width > 0 && label_visible {
1037 let bg_style = if is_selected {
1038 Style::default().bg(theme.settings_selected_bg)
1039 } else {
1040 Style::default().bg(theme.menu_hover_bg)
1041 };
1042 let row_area = Rect::new(inner_area.x, inner_area.y, inner_area.width, 1);
1043 frame.render_widget(Paragraph::new("").style(bg_style), row_area);
1044 }
1045
1046 let content_skip_top = skip_top.saturating_sub(content_logical_top);
1050
1051 let label_row_visible = content_skip_top == 0 && inner_area.height > 0;
1055 if is_selected && label_row_visible {
1056 frame.render_widget(
1057 Paragraph::new(">").style(
1058 Style::default()
1059 .fg(theme.settings_selected_fg)
1060 .add_modifier(Modifier::BOLD),
1061 ),
1062 Rect::new(inner_area.x, inner_area.y, 1, 1),
1063 );
1064 }
1065 if item.modified && label_row_visible && inner_area.width >= 2 {
1066 frame.render_widget(
1067 Paragraph::new("●").style(Style::default().fg(theme.settings_selected_fg)),
1068 Rect::new(inner_area.x + 1, inner_area.y, 1, 1),
1069 );
1070 }
1071
1072 let control_logical_rows = plan.control_rows;
1074 if let Some(control_rect) = band_rect(content_logical_top, control_logical_rows).map(|r| {
1075 let x =
1076 r.x.saturating_add(style.card_border_cols + style.focus_indicator_cols);
1077 let w = r
1078 .width
1079 .saturating_sub(2 * style.card_border_cols + style.focus_indicator_cols);
1080 Rect::new(x, r.y, w, r.height)
1081 }) {
1082 control_layout = render_control(
1083 frame,
1084 control_rect,
1085 &item.control,
1086 &item.name,
1087 content_skip_top,
1088 theme,
1089 label_width
1090 .map(|w| w.saturating_sub(style.card_border_cols + style.focus_indicator_cols)),
1091 item.read_only,
1092 item.is_null,
1093 );
1094
1095 if item.nullable && content_skip_top == 0 && control_rect.width > 0 {
1098 if item.is_null {
1099 let badge_text = t!("settings.inherited_badge").to_string();
1100 let badge_len = badge_text.len() as u16 + 1;
1101 let badge_x = control_rect
1102 .x
1103 .saturating_add(control_rect.width)
1104 .saturating_sub(badge_len);
1105 if badge_x > control_rect.x {
1106 frame.render_widget(
1107 Paragraph::new(badge_text).style(
1108 Style::default()
1109 .fg(theme.line_number_fg)
1110 .add_modifier(Modifier::ITALIC),
1111 ),
1112 Rect::new(badge_x, control_rect.y, badge_len, 1),
1113 );
1114 }
1115 } else {
1116 let btn_text = format!("[{}]", t!("settings.btn_inherit"));
1117 let btn_len = btn_text.len() as u16 + 1;
1118 let btn_x = control_rect
1119 .x
1120 .saturating_add(control_rect.width)
1121 .saturating_sub(btn_len);
1122 if btn_x > control_rect.x {
1123 let btn_area = Rect::new(btn_x, control_rect.y, btn_len, 1);
1124 let is_hovered = matches!(
1125 ctx.hover_hit,
1126 Some(SettingsHit::ControlInherit(i)) if i == idx
1127 );
1128 let btn_style = if is_hovered {
1129 Style::default()
1130 .fg(theme.menu_hover_fg)
1131 .bg(theme.menu_hover_bg)
1132 } else {
1133 Style::default().fg(theme.line_number_fg)
1134 };
1135 frame.render_widget(Paragraph::new(btn_text).style(btn_style), btn_area);
1136 inherit_button_area = Some(btn_area);
1137 }
1138 }
1139 }
1140 }
1141
1142 let desc_logical_rows = plan.description_rows;
1146 let layer_label = match item.layer_source {
1147 crate::config_io::ConfigLayer::System => None,
1148 crate::config_io::ConfigLayer::User => Some("user"),
1149 crate::config_io::ConfigLayer::Project => Some("project"),
1150 crate::config_io::ConfigLayer::Session => Some("session"),
1151 };
1152
1153 if desc_logical_rows > 0 {
1154 if let Some(desc_rect) = band_rect(plan.description_y(), desc_logical_rows).map(|r| {
1155 let x =
1156 r.x.saturating_add(style.card_border_cols + style.focus_indicator_cols);
1157 let w = r
1158 .width
1159 .saturating_sub(2 * style.card_border_cols + style.focus_indicator_cols);
1160 Rect::new(x, r.y, w, r.height)
1161 }) {
1162 let desc_skip = skip_top.saturating_sub(plan.description_y());
1163 let max_text_width = desc_rect
1164 .width
1165 .saturating_sub(style.description_right_padding_cols)
1166 as usize;
1167 let mut lines = match item.description.as_deref() {
1168 Some(d) if !d.is_empty() => wrap_text(d, max_text_width),
1169 _ => Vec::new(),
1170 };
1171 if let Some(layer) = layer_label {
1172 if let Some(last) = lines.last_mut() {
1173 last.push_str(&format!(" ({})", layer));
1174 } else {
1175 lines.push(format!("({})", layer));
1176 }
1177 }
1178 let desc_style = Style::default().fg(theme.line_number_fg);
1179 let take = desc_rect.height as usize;
1180 for (i, line) in lines.iter().skip(desc_skip as usize).take(take).enumerate() {
1181 frame.render_widget(
1182 Paragraph::new(line.as_str()).style(desc_style),
1183 Rect::new(desc_rect.x, desc_rect.y + i as u16, desc_rect.width, 1),
1184 );
1185 }
1186 }
1187 } else if let Some(layer) = layer_label {
1188 if let Some(layer_rect) = band_rect(plan.description_y(), 1).map(|r| {
1191 let x =
1192 r.x.saturating_add(style.card_border_cols + style.focus_indicator_cols);
1193 let w = r
1194 .width
1195 .saturating_sub(2 * style.card_border_cols + style.focus_indicator_cols);
1196 Rect::new(x, r.y, w, r.height)
1197 }) {
1198 frame.render_widget(
1199 Paragraph::new(format!("({})", layer))
1200 .style(Style::default().fg(theme.line_number_fg)),
1201 layer_rect,
1202 );
1203 }
1204 }
1205 }
1206
1207 SettingItemLayoutInfo {
1208 control: control_layout,
1209 inherit_button: inherit_button_area,
1210 }
1211}
1212
1213#[allow(clippy::too_many_arguments)]
1221fn render_control(
1222 frame: &mut Frame,
1223 area: Rect,
1224 control: &SettingControl,
1225 name: &str,
1226 skip_rows: u16,
1227 theme: &Theme,
1228 label_width: Option<u16>,
1229 read_only: bool,
1230 is_null: bool,
1231) -> ControlLayoutInfo {
1232 match control {
1233 SettingControl::Toggle(state) => {
1235 if skip_rows > 0 {
1236 return ControlLayoutInfo::Toggle(Rect::default());
1237 }
1238 let colors = ToggleColors::from_theme(theme);
1239 let toggle_layout = render_toggle_aligned(frame, area, state, &colors, label_width);
1240 ControlLayoutInfo::Toggle(toggle_layout.full_area)
1241 }
1242
1243 SettingControl::Number(state) => {
1244 if skip_rows > 0 {
1245 return ControlLayoutInfo::Number {
1246 decrement: Rect::default(),
1247 increment: Rect::default(),
1248 value: Rect::default(),
1249 };
1250 }
1251 let colors = NumberInputColors::from_theme(theme);
1252 let num_layout = render_number_input_aligned(frame, area, state, &colors, label_width);
1253 ControlLayoutInfo::Number {
1254 decrement: num_layout.decrement_area,
1255 increment: num_layout.increment_area,
1256 value: num_layout.value_area,
1257 }
1258 }
1259
1260 SettingControl::Dropdown(state) => {
1261 if skip_rows > 0 {
1262 return ControlLayoutInfo::Dropdown {
1263 button_area: Rect::default(),
1264 option_areas: Vec::new(),
1265 scroll_offset: 0,
1266 };
1267 }
1268 let colors = DropdownColors::from_theme(theme);
1269 let drop_layout = render_dropdown_aligned(frame, area, state, &colors, label_width);
1270 ControlLayoutInfo::Dropdown {
1271 button_area: drop_layout.button_area,
1272 option_areas: drop_layout.option_areas,
1273 scroll_offset: drop_layout.scroll_offset,
1274 }
1275 }
1276
1277 SettingControl::Text(state) => {
1278 if skip_rows > 0 {
1279 return ControlLayoutInfo::Text(Rect::default());
1280 }
1281 if read_only {
1282 let label_w = label_width.unwrap_or(20);
1284 let label_style = Style::default().fg(theme.editor_fg);
1285 let value_style = Style::default().fg(theme.line_number_fg);
1286 let label = format!("{}: ", state.label);
1287 let value = &state.value;
1288
1289 let label_area = Rect::new(area.x, area.y, label_w, 1);
1290 let value_area = Rect::new(
1291 area.x + label_w,
1292 area.y,
1293 area.width.saturating_sub(label_w),
1294 1,
1295 );
1296
1297 frame.render_widget(Paragraph::new(label.clone()).style(label_style), label_area);
1298 frame.render_widget(
1299 Paragraph::new(value.as_str()).style(value_style),
1300 value_area,
1301 );
1302 ControlLayoutInfo::Text(Rect::default())
1303 } else if is_null {
1304 let colors = TextInputColors::from_theme_disabled(theme);
1306 let text_layout =
1307 render_text_input_aligned(frame, area, state, &colors, 30, label_width);
1308 ControlLayoutInfo::Text(text_layout.input_area)
1309 } else {
1310 let colors = TextInputColors::from_theme(theme);
1311 let text_layout =
1312 render_text_input_aligned(frame, area, state, &colors, 30, label_width);
1313 ControlLayoutInfo::Text(text_layout.input_area)
1314 }
1315 }
1316
1317 SettingControl::TextList(state) => {
1319 let colors = TextListColors::from_theme(theme);
1320 let list_layout = render_text_list_partial(frame, area, state, &colors, 30, skip_rows);
1321 ControlLayoutInfo::TextList {
1322 rows: list_layout
1323 .rows
1324 .iter()
1325 .map(|r| (r.index, r.text_area))
1326 .collect(),
1327 }
1328 }
1329
1330 SettingControl::DualList(state) => {
1331 let colors = DualListColors::from_theme(theme);
1332 let dual_layout = render_dual_list_partial(frame, area, state, &colors, skip_rows);
1333 ControlLayoutInfo::DualList(dual_layout)
1334 }
1335
1336 SettingControl::Map(state) => {
1337 let colors = MapColors::from_theme(theme);
1338 let map_layout = render_map_partial(frame, area, state, &colors, 20, skip_rows);
1339 ControlLayoutInfo::Map {
1340 entry_rows: map_layout
1341 .entry_areas
1342 .iter()
1343 .map(|e| (e.index, e.row_area))
1344 .collect(),
1345 add_row_area: map_layout.add_row_area,
1346 }
1347 }
1348
1349 SettingControl::ObjectArray(state) => {
1350 let colors = crate::view::controls::KeybindingListColors {
1351 label_fg: theme.editor_fg,
1352 key_fg: theme.help_key_fg,
1353 action_fg: theme.syntax_function,
1354 row_bg: theme.popup_bg,
1358 focused_bg: theme.settings_selected_bg,
1360 focused_fg: theme.settings_selected_fg,
1361 add_fg: theme.syntax_string,
1362 };
1363 let kb_layout = render_keybinding_list_partial(frame, area, state, &colors, skip_rows);
1364 ControlLayoutInfo::ObjectArray {
1365 entry_rows: kb_layout
1366 .entry_rects
1367 .iter()
1368 .map(|&(idx, rect)| (idx, rect))
1369 .collect(),
1370 }
1371 }
1372
1373 SettingControl::Json(state) => {
1374 render_json_control(frame, area, state, name, skip_rows, theme)
1375 }
1376
1377 SettingControl::Complex { type_name } => {
1378 if skip_rows > 0 {
1379 return ControlLayoutInfo::Complex;
1380 }
1381 let label_style = Style::default().fg(theme.editor_fg);
1383 let value_style = Style::default().fg(theme.line_number_fg);
1384
1385 let label = Span::styled(format!("{}: ", name), label_style);
1386 let value = Span::styled(
1387 format!("<{} - edit in config.toml>", type_name),
1388 value_style,
1389 );
1390
1391 frame.render_widget(Paragraph::new(Line::from(vec![label, value])), area);
1392 ControlLayoutInfo::Complex
1393 }
1394 }
1395}
1396
1397fn render_json_control(
1399 frame: &mut Frame,
1400 area: Rect,
1401 state: &super::items::JsonEditState,
1402 name: &str,
1403 skip_rows: u16,
1404 theme: &Theme,
1405) -> ControlLayoutInfo {
1406 use crate::view::controls::FocusState;
1407
1408 let empty_layout = ControlLayoutInfo::Json {
1409 edit_area: Rect::default(),
1410 };
1411
1412 if area.height == 0 || area.width < 10 {
1413 return empty_layout;
1414 }
1415
1416 let is_focused = state.focus == FocusState::Focused;
1417 let is_valid = state.is_valid();
1418
1419 let label_color = if is_focused {
1420 theme.menu_highlight_fg
1421 } else {
1422 theme.editor_fg
1423 };
1424
1425 let text_color = theme.editor_fg;
1426 let border_color = if !is_valid {
1427 theme.diagnostic_error_fg
1428 } else if is_focused {
1429 theme.menu_highlight_fg
1430 } else {
1431 theme.split_separator_fg
1432 };
1433
1434 let mut y = area.y;
1435 let mut content_row = 0u16;
1436
1437 if content_row >= skip_rows {
1439 let label_line = Line::from(vec![Span::styled(
1440 format!("{}:", name),
1441 Style::default().fg(label_color),
1442 )]);
1443 frame.render_widget(
1444 Paragraph::new(label_line),
1445 Rect::new(area.x, y, area.width, 1),
1446 );
1447 y += 1;
1448 }
1449 content_row += 1;
1450
1451 let indent = 2u16;
1452 let edit_width = area.width.saturating_sub(indent + 1);
1453 let edit_x = area.x + indent;
1454 let edit_start_y = y;
1455
1456 if state.is_unset() && content_row >= skip_rows && y < area.y + area.height {
1463 let hint = "(not set — press Enter to add)";
1464 let hint_line = Line::from(vec![
1465 Span::raw(" ".repeat(indent as usize)),
1466 Span::styled(
1467 hint,
1468 Style::default()
1469 .fg(theme.line_number_fg)
1470 .add_modifier(Modifier::ITALIC),
1471 ),
1472 ]);
1473 frame.render_widget(
1474 Paragraph::new(hint_line),
1475 Rect::new(area.x, y, area.width, 1),
1476 );
1477 return ControlLayoutInfo::Json {
1478 edit_area: Rect::new(edit_x, edit_start_y, edit_width, 1),
1479 };
1480 }
1481
1482 let lines = state.lines();
1484 let total_lines = lines.len();
1485 for line_idx in 0..total_lines {
1486 let actual_line_idx = line_idx;
1487
1488 if content_row < skip_rows {
1489 content_row += 1;
1490 continue;
1491 }
1492
1493 if y >= area.y + area.height {
1494 break;
1495 }
1496
1497 let line_content = lines.get(actual_line_idx).map(|s| s.as_str()).unwrap_or("");
1498
1499 let display_len = edit_width.saturating_sub(2) as usize;
1501 let display_text: String = line_content.chars().take(display_len).collect();
1502
1503 let selection = state.selection_range();
1505 let (cursor_row, cursor_col) = state.cursor_pos();
1506
1507 let content_spans = if is_focused {
1509 if let Some(((start_row, start_col), (end_row, end_col))) = selection {
1510 build_selection_spans(
1511 &display_text,
1512 display_len,
1513 actual_line_idx,
1514 start_row,
1515 start_col,
1516 end_row,
1517 end_col,
1518 text_color,
1519 theme.selection_bg,
1520 )
1521 } else {
1522 vec![Span::styled(
1523 format!("{:width$}", display_text, width = display_len),
1524 Style::default().fg(text_color),
1525 )]
1526 }
1527 } else {
1528 vec![Span::styled(
1529 format!("{:width$}", display_text, width = display_len),
1530 Style::default().fg(text_color),
1531 )]
1532 };
1533
1534 let mut spans = vec![
1536 Span::raw(" ".repeat(indent as usize)),
1537 Span::styled("│", Style::default().fg(border_color)),
1538 ];
1539 spans.extend(content_spans);
1540 spans.push(Span::styled("│", Style::default().fg(border_color)));
1541 let line = Line::from(spans);
1542
1543 frame.render_widget(Paragraph::new(line), Rect::new(area.x, y, area.width, 1));
1544
1545 if is_focused && actual_line_idx == cursor_row {
1547 let cursor_x = edit_x + 1 + cursor_col.min(display_len) as u16;
1548 if cursor_x < area.x + area.width - 1 {
1549 let cursor_char = line_content.chars().nth(cursor_col).unwrap_or(' ');
1550 let cursor_span = Span::styled(
1551 cursor_char.to_string(),
1552 Style::default()
1553 .fg(theme.cursor)
1554 .add_modifier(Modifier::REVERSED),
1555 );
1556 frame.render_widget(
1557 Paragraph::new(Line::from(vec![cursor_span])),
1558 Rect::new(cursor_x, y, 1, 1),
1559 );
1560 }
1561 }
1562
1563 y += 1;
1564 content_row += 1;
1565 }
1566
1567 if !is_valid && y < area.y + area.height {
1569 let warning = Span::styled(
1570 " ⚠ Invalid JSON",
1571 Style::default().fg(theme.diagnostic_warning_fg),
1572 );
1573 frame.render_widget(
1574 Paragraph::new(Line::from(vec![warning])),
1575 Rect::new(area.x, y, area.width, 1),
1576 );
1577 }
1578
1579 let edit_height = y.saturating_sub(edit_start_y);
1580 ControlLayoutInfo::Json {
1581 edit_area: Rect::new(edit_x, edit_start_y, edit_width, edit_height),
1582 }
1583}
1584
1585fn render_text_list_partial(
1587 frame: &mut Frame,
1588 area: Rect,
1589 state: &crate::view::controls::TextListState,
1590 colors: &TextListColors,
1591 field_width: u16,
1592 skip_rows: u16,
1593) -> crate::view::controls::TextListLayout {
1594 use crate::view::controls::text_list::{TextListLayout, TextListRowLayout};
1595 use crate::view::controls::FocusState;
1596
1597 let empty_layout = TextListLayout {
1598 rows: Vec::new(),
1599 full_area: area,
1600 };
1601
1602 if area.height == 0 || area.width < 10 {
1603 return empty_layout;
1604 }
1605
1606 let label_color = match state.focus {
1608 FocusState::Focused => colors.focused_fg,
1609 FocusState::Hovered => colors.focused_fg,
1610 FocusState::Disabled => colors.disabled,
1611 FocusState::Normal => colors.label,
1612 };
1613
1614 let mut rows = Vec::new();
1615 let mut y = area.y;
1616 let mut content_row = 0u16; if skip_rows == 0 {
1620 let label_line = Line::from(vec![
1621 Span::styled(&state.label, Style::default().fg(label_color)),
1622 Span::raw(":"),
1623 ]);
1624 frame.render_widget(
1625 Paragraph::new(label_line),
1626 Rect::new(area.x, y, area.width, 1),
1627 );
1628 y += 1;
1629 }
1630 content_row += 1;
1631
1632 let indent = 2u16;
1633 let actual_field_width = field_width.min(area.width.saturating_sub(indent + 5));
1634
1635 for (idx, item) in state.items.iter().enumerate() {
1637 if y >= area.y + area.height {
1638 break;
1639 }
1640
1641 if content_row < skip_rows {
1643 content_row += 1;
1644 continue;
1645 }
1646
1647 let is_focused = state.focused_item == Some(idx) && state.focus == FocusState::Focused;
1648 let (border_color, text_color) = if is_focused {
1649 (colors.focused, colors.text)
1650 } else if state.focus == FocusState::Disabled {
1651 (colors.disabled, colors.disabled)
1652 } else {
1653 (colors.border, colors.text)
1654 };
1655
1656 let inner_width = actual_field_width.saturating_sub(2) as usize;
1657 let visible: String = item.chars().take(inner_width).collect();
1658 let padded = format!("{:width$}", visible, width = inner_width);
1659
1660 let mut spans = vec![
1661 Span::raw(" ".repeat(indent as usize)),
1662 Span::styled("[", Style::default().fg(border_color)),
1663 Span::styled(padded, Style::default().fg(text_color)),
1664 Span::styled("]", Style::default().fg(border_color)),
1665 Span::raw(" "),
1666 Span::styled("[x]", Style::default().fg(colors.remove_button)),
1667 ];
1668 if is_focused {
1672 spans.push(Span::styled(
1673 " Del:remove Enter:edit",
1674 Style::default()
1675 .fg(colors.disabled)
1676 .add_modifier(ratatui::style::Modifier::ITALIC),
1677 ));
1678 }
1679 let line = Line::from(spans);
1680
1681 let row_area = Rect::new(area.x, y, area.width, 1);
1682 frame.render_widget(Paragraph::new(line), row_area);
1683
1684 let text_area = Rect::new(area.x + indent, y, actual_field_width, 1);
1685 let button_area = Rect::new(area.x + indent + actual_field_width + 1, y, 3, 1);
1686 rows.push(TextListRowLayout {
1687 text_area,
1688 button_area,
1689 index: Some(idx),
1690 });
1691
1692 y += 1;
1693 content_row += 1;
1694 }
1695
1696 if y < area.y + area.height && content_row >= skip_rows {
1698 let is_add_focused = state.focused_item.is_none() && state.focus == FocusState::Focused;
1700 let show_input_box =
1704 is_add_focused && (state.pending_active || !state.new_item_text.is_empty());
1705
1706 if show_input_box {
1707 let inner_width = actual_field_width.saturating_sub(2) as usize;
1712 let (visible_text, text_style) = if state.new_item_text.is_empty() {
1713 let placeholder = "type new item";
1714 let truncated: String = placeholder.chars().take(inner_width).collect();
1715 (
1716 truncated,
1717 Style::default()
1718 .fg(colors.disabled)
1719 .add_modifier(ratatui::style::Modifier::ITALIC),
1720 )
1721 } else {
1722 let visible: String = state.new_item_text.chars().take(inner_width).collect();
1723 (visible, Style::default().fg(colors.text))
1724 };
1725 let padded = format!("{:width$}", visible_text, width = inner_width);
1726
1727 let hint = " Enter:add Esc:cancel";
1731 let line = Line::from(vec![
1732 Span::raw(" ".repeat(indent as usize)),
1733 Span::styled(
1734 "[",
1735 Style::default()
1736 .fg(colors.focused)
1737 .add_modifier(ratatui::style::Modifier::BOLD),
1738 ),
1739 Span::styled(padded, text_style),
1740 Span::styled(
1741 "]",
1742 Style::default()
1743 .fg(colors.focused)
1744 .add_modifier(ratatui::style::Modifier::BOLD),
1745 ),
1746 Span::raw(" "),
1747 Span::styled("[+]", Style::default().fg(colors.add_button)),
1748 Span::styled(
1749 hint,
1750 Style::default()
1751 .fg(colors.disabled)
1752 .add_modifier(ratatui::style::Modifier::ITALIC),
1753 ),
1754 ]);
1755 let row_area = Rect::new(area.x, y, area.width, 1);
1756 frame.render_widget(Paragraph::new(line), row_area);
1757
1758 if !state.new_item_text.is_empty() && state.cursor <= inner_width {
1762 let cursor_x = area.x + indent + 1 + state.cursor as u16;
1763 let cursor_char = state.new_item_text.chars().nth(state.cursor).unwrap_or(' ');
1764 let cursor_area = Rect::new(cursor_x, y, 1, 1);
1765 let cursor_span = Span::styled(
1766 cursor_char.to_string(),
1767 Style::default()
1768 .fg(colors.focused)
1769 .add_modifier(ratatui::style::Modifier::REVERSED),
1770 );
1771 frame.render_widget(Paragraph::new(Line::from(vec![cursor_span])), cursor_area);
1772 }
1773
1774 rows.push(TextListRowLayout {
1775 text_area: Rect::new(area.x + indent, y, actual_field_width, 1),
1776 button_area: Rect::new(area.x + indent + actual_field_width + 1, y, 3, 1),
1777 index: None,
1778 });
1779 } else {
1780 let label_fg = if is_add_focused {
1787 colors.focused_fg
1788 } else {
1789 colors.add_button
1790 };
1791 let mut spans = vec![
1792 Span::raw(" ".repeat(indent as usize)),
1793 Span::styled("[+] Add new", Style::default().fg(label_fg)),
1794 ];
1795 if is_add_focused {
1796 spans.push(Span::styled(
1797 " press Enter (or type) to add a new item",
1798 Style::default()
1799 .fg(colors.disabled)
1800 .add_modifier(ratatui::style::Modifier::ITALIC),
1801 ));
1802 }
1803 let add_line = Line::from(spans);
1804 let row_area = Rect::new(area.x, y, area.width, 1);
1805 frame.render_widget(Paragraph::new(add_line), row_area);
1806
1807 rows.push(TextListRowLayout {
1808 text_area: Rect::new(area.x + indent, y, 11, 1), button_area: Rect::new(area.x + indent, y, 11, 1),
1810 index: None,
1811 });
1812 }
1813 }
1814
1815 TextListLayout {
1816 rows,
1817 full_area: area,
1818 }
1819}
1820
1821fn render_map_partial(
1823 frame: &mut Frame,
1824 area: Rect,
1825 state: &crate::view::controls::MapState,
1826 colors: &MapColors,
1827 key_width: u16,
1828 skip_rows: u16,
1829) -> crate::view::controls::MapLayout {
1830 use crate::view::controls::map_input::{MapEntryLayout, MapLayout};
1831 use crate::view::controls::FocusState;
1832
1833 let empty_layout = MapLayout {
1834 entry_areas: Vec::new(),
1835 add_row_area: None,
1836 full_area: area,
1837 };
1838
1839 if area.height == 0 || area.width < 15 {
1840 return empty_layout;
1841 }
1842
1843 let label_color = match state.focus {
1845 FocusState::Focused => colors.focused_fg,
1846 FocusState::Hovered => colors.focused_fg,
1847 FocusState::Disabled => colors.disabled,
1848 FocusState::Normal => colors.label,
1849 };
1850
1851 let mut entry_areas = Vec::new();
1852 let mut y = area.y;
1853 let mut content_row = 0u16;
1854
1855 if skip_rows == 0 {
1857 let label_line = Line::from(vec![
1858 Span::styled(&state.label, Style::default().fg(label_color)),
1859 Span::raw(":"),
1860 ]);
1861 frame.render_widget(
1862 Paragraph::new(label_line),
1863 Rect::new(area.x, y, area.width, 1),
1864 );
1865 y += 1;
1866 }
1867 content_row += 1;
1868
1869 let indent = 2u16;
1870
1871 if state.display_field.is_some() && y < area.y + area.height {
1873 if content_row >= skip_rows {
1874 let value_header = state
1876 .display_field
1877 .as_ref()
1878 .map(|f| {
1879 let name = f.trim_start_matches('/');
1880 let mut chars = name.chars();
1882 match chars.next() {
1883 None => String::new(),
1884 Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
1885 }
1886 })
1887 .unwrap_or_else(|| "Value".to_string());
1888
1889 let header_style = Style::default()
1890 .fg(colors.label)
1891 .add_modifier(Modifier::DIM);
1892 let header_line = Line::from(vec![
1893 Span::styled(" ".repeat(indent as usize), header_style),
1894 Span::styled(
1895 format!("{:width$}", "Name", width = key_width as usize),
1896 header_style,
1897 ),
1898 Span::raw(" "),
1899 Span::styled(value_header, header_style),
1900 ]);
1901 frame.render_widget(
1902 Paragraph::new(header_line),
1903 Rect::new(area.x, y, area.width, 1),
1904 );
1905 y += 1;
1906 }
1907 content_row += 1;
1908 }
1909
1910 for (idx, (key, value)) in state.entries.iter().enumerate() {
1912 if y >= area.y + area.height {
1913 break;
1914 }
1915
1916 if content_row < skip_rows {
1917 content_row += 1;
1918 continue;
1919 }
1920
1921 let is_focused = state.focused_entry == Some(idx) && state.focus == FocusState::Focused;
1922
1923 let row_area = Rect::new(area.x, y, area.width, 1);
1924
1925 if is_focused {
1927 let highlight_style = Style::default().bg(colors.focused);
1928 let bg_line = Line::from(Span::styled(
1929 " ".repeat(area.width as usize),
1930 highlight_style,
1931 ));
1932 frame.render_widget(Paragraph::new(bg_line), row_area);
1933 }
1934
1935 let (key_color, value_color) = if is_focused {
1936 (colors.focused_fg, colors.focused_fg)
1938 } else if state.focus == FocusState::Disabled {
1939 (colors.disabled, colors.disabled)
1940 } else {
1941 (colors.key, colors.value_preview)
1942 };
1943
1944 let base_style = if is_focused {
1945 Style::default().bg(colors.focused)
1946 } else {
1947 Style::default()
1948 };
1949
1950 let value_preview = state.get_display_value(value);
1954 let value_preview = truncate_chars_with_ellipsis(&value_preview, 20);
1955
1956 let display_key: String = key.chars().take(key_width as usize).collect();
1957 let mut spans = vec![
1958 Span::styled(" ".repeat(indent as usize), base_style),
1959 Span::styled(
1960 format!("{:width$}", display_key, width = key_width as usize),
1961 base_style.fg(key_color),
1962 ),
1963 Span::raw(" "),
1964 Span::styled(value_preview, base_style.fg(value_color)),
1965 ];
1966
1967 if is_focused {
1969 spans.push(Span::styled(
1970 " [Enter to edit]",
1971 base_style.fg(colors.focused_fg).add_modifier(Modifier::DIM),
1972 ));
1973 }
1974
1975 frame.render_widget(Paragraph::new(Line::from(spans)), row_area);
1976
1977 entry_areas.push(MapEntryLayout {
1978 index: idx,
1979 row_area,
1980 expand_area: Rect::default(), key_area: Rect::new(area.x + indent, y, key_width, 1),
1982 remove_area: Rect::new(area.x + indent + key_width + 1, y, 3, 1),
1983 });
1984
1985 y += 1;
1986 content_row += 1;
1987 }
1988
1989 let add_row_area = if !state.no_add && y < area.y + area.height && content_row >= skip_rows {
1991 let row_area = Rect::new(area.x, y, area.width, 1);
1992 let is_focused = state.focused_entry.is_none() && state.focus == FocusState::Focused;
1993
1994 if is_focused {
1996 let highlight_style = Style::default().bg(colors.focused);
1997 let bg_line = Line::from(Span::styled(
1998 " ".repeat(area.width as usize),
1999 highlight_style,
2000 ));
2001 frame.render_widget(Paragraph::new(bg_line), row_area);
2002 }
2003
2004 let base_style = if is_focused {
2005 Style::default().bg(colors.focused)
2006 } else {
2007 Style::default()
2008 };
2009
2010 let mut spans = vec![
2011 Span::styled(" ".repeat(indent as usize), base_style),
2012 Span::styled("[+] Add new", base_style.fg(colors.add_button)),
2013 ];
2014
2015 if is_focused {
2016 spans.push(Span::styled(
2017 " [Enter to add]",
2018 base_style.fg(colors.focused_fg).add_modifier(Modifier::DIM),
2019 ));
2020 }
2021
2022 frame.render_widget(Paragraph::new(Line::from(spans)), row_area);
2023 Some(row_area)
2024 } else {
2025 None
2026 };
2027
2028 MapLayout {
2029 entry_areas,
2030 add_row_area,
2031 full_area: area,
2032 }
2033}
2034
2035fn render_keybinding_list_partial(
2037 frame: &mut Frame,
2038 area: Rect,
2039 state: &crate::view::controls::KeybindingListState,
2040 colors: &crate::view::controls::KeybindingListColors,
2041 skip_rows: u16,
2042) -> crate::view::controls::KeybindingListLayout {
2043 use crate::view::controls::keybinding_list::format_key_combo;
2044 use crate::view::controls::FocusState;
2045 use ratatui::text::{Line, Span};
2046 use ratatui::widgets::Paragraph;
2047
2048 let empty_layout = crate::view::controls::KeybindingListLayout {
2049 entry_rects: Vec::new(),
2050 add_rect: None,
2051 };
2052
2053 if area.height == 0 {
2054 return empty_layout;
2055 }
2056
2057 let indent = 2u16;
2058 let is_focused = state.focus == FocusState::Focused;
2059 let mut entry_rects = Vec::new();
2060 let mut content_row = 0u16;
2061 let mut y = area.y;
2062
2063 if content_row >= skip_rows {
2065 let label_line = Line::from(vec![Span::styled(
2066 format!("{}:", state.label),
2067 Style::default().fg(colors.label_fg),
2068 )]);
2069 frame.render_widget(
2070 Paragraph::new(label_line),
2071 Rect::new(area.x, y, area.width, 1),
2072 );
2073 y += 1;
2074 }
2075 content_row += 1;
2076
2077 for (idx, binding) in state.bindings.iter().enumerate() {
2079 if y >= area.y + area.height {
2080 break;
2081 }
2082
2083 if content_row >= skip_rows {
2084 let entry_area = Rect::new(area.x + indent, y, area.width.saturating_sub(indent), 1);
2085 entry_rects.push((idx, entry_area));
2086
2087 let is_entry_focused = is_focused && state.focused_index == Some(idx);
2088 let bg = if is_entry_focused {
2089 colors.focused_bg
2090 } else {
2091 colors.row_bg
2092 };
2093
2094 let key_combo = format_key_combo(binding);
2095 let field_name = state
2097 .display_field
2098 .as_ref()
2099 .and_then(|p| p.strip_prefix('/'))
2100 .unwrap_or("action");
2101 let action = binding
2102 .get(field_name)
2103 .and_then(|a| a.as_str())
2104 .unwrap_or("(no action)");
2105
2106 let indicator = if is_entry_focused { "> " } else { " " };
2107 let (indicator_fg, key_fg, arrow_fg, action_fg) = if is_entry_focused {
2109 (
2110 colors.focused_fg,
2111 colors.focused_fg,
2112 colors.focused_fg,
2113 colors.focused_fg,
2114 )
2115 } else {
2116 (
2117 colors.label_fg,
2118 colors.key_fg,
2119 colors.label_fg,
2120 colors.action_fg,
2121 )
2122 };
2123 let line = if key_combo.trim().is_empty() {
2128 Line::from(vec![
2129 Span::styled(indicator, Style::default().fg(indicator_fg).bg(bg)),
2130 Span::styled(action, Style::default().fg(action_fg).bg(bg)),
2131 ])
2132 } else {
2133 Line::from(vec![
2134 Span::styled(indicator, Style::default().fg(indicator_fg).bg(bg)),
2135 Span::styled(
2136 format!("{:<20}", key_combo),
2137 Style::default().fg(key_fg).bg(bg),
2138 ),
2139 Span::styled(" → ", Style::default().fg(arrow_fg).bg(bg)),
2140 Span::styled(action, Style::default().fg(action_fg).bg(bg)),
2141 ])
2142 };
2143 frame.render_widget(Paragraph::new(line), entry_area);
2144
2145 y += 1;
2146 }
2147 content_row += 1;
2148 }
2149
2150 let add_rect = if y < area.y + area.height && content_row >= skip_rows {
2152 let is_add_focused = is_focused && state.focused_index.is_none();
2153 let bg = if is_add_focused {
2154 colors.focused_bg
2155 } else {
2156 colors.row_bg
2157 };
2158
2159 let indicator = if is_add_focused { "> " } else { " " };
2160 let (indicator_fg, add_fg) = if is_add_focused {
2162 (colors.focused_fg, colors.focused_fg)
2163 } else {
2164 (colors.label_fg, colors.add_fg)
2165 };
2166 let line = Line::from(vec![
2167 Span::styled(indicator, Style::default().fg(indicator_fg).bg(bg)),
2168 Span::styled("[+] Add new", Style::default().fg(add_fg).bg(bg)),
2169 ]);
2170 let add_area = Rect::new(area.x + indent, y, area.width.saturating_sub(indent), 1);
2171 frame.render_widget(Paragraph::new(line), add_area);
2172 Some(add_area)
2173 } else {
2174 None
2175 };
2176
2177 crate::view::controls::KeybindingListLayout {
2178 entry_rects,
2179 add_rect,
2180 }
2181}
2182
2183#[derive(Debug, Clone, Default)]
2185pub struct SettingItemLayoutInfo {
2186 pub control: ControlLayoutInfo,
2187 pub inherit_button: Option<Rect>,
2188}
2189
2190#[derive(Debug, Clone, Default)]
2192pub enum ControlLayoutInfo {
2193 Toggle(Rect),
2194 Number {
2195 decrement: Rect,
2196 increment: Rect,
2197 value: Rect,
2198 },
2199 Dropdown {
2200 button_area: Rect,
2201 option_areas: Vec<Rect>,
2202 scroll_offset: usize,
2203 },
2204 Text(Rect),
2205 TextList {
2206 rows: Vec<(Option<usize>, Rect)>,
2208 },
2209 DualList(crate::view::controls::DualListLayout),
2210 Map {
2211 entry_rows: Vec<(usize, Rect)>,
2213 add_row_area: Option<Rect>,
2214 },
2215 ObjectArray {
2216 entry_rows: Vec<(usize, Rect)>,
2218 },
2219 Json {
2220 edit_area: Rect,
2221 },
2222 #[default]
2223 Complex,
2224}
2225
2226#[allow(clippy::too_many_arguments)]
2228fn render_button(
2229 frame: &mut Frame,
2230 area: Rect,
2231 text: &str,
2232 focused_text: &str,
2233 is_focused: bool,
2234 is_hovered: bool,
2235 theme: &Theme,
2236 dimmed: bool,
2237) {
2238 if is_focused {
2239 let style = Style::default()
2240 .fg(theme.menu_highlight_fg)
2241 .bg(theme.menu_highlight_bg)
2242 .add_modifier(Modifier::BOLD);
2243 frame.render_widget(Paragraph::new(focused_text).style(style), area);
2244 } else if is_hovered {
2245 let style = Style::default()
2246 .fg(theme.menu_hover_fg)
2247 .bg(theme.menu_hover_bg);
2248 frame.render_widget(Paragraph::new(text).style(style), area);
2249 } else {
2250 let fg = if dimmed {
2251 theme.line_number_fg
2252 } else {
2253 theme.popup_text_fg
2254 };
2255 frame.render_widget(Paragraph::new(text).style(Style::default().fg(fg)), area);
2256 }
2257}
2258
2259fn render_footer(
2262 frame: &mut Frame,
2263 modal_area: Rect,
2264 state: &SettingsState,
2265 theme: &Theme,
2266 layout: &mut SettingsLayout,
2267 vertical: bool,
2268) {
2269 use super::layout::SettingsHit;
2270 use super::state::FocusPanel;
2271
2272 if modal_area.height < 4 || modal_area.width < 10 {
2274 return;
2275 }
2276
2277 if vertical {
2278 render_footer_vertical(frame, modal_area, state, theme, layout);
2279 return;
2280 }
2281
2282 let footer_y = modal_area.y + modal_area.height.saturating_sub(2);
2283 let footer_width = modal_area.width.saturating_sub(2);
2284 let footer_area = Rect::new(modal_area.x + 1, footer_y, footer_width, 1);
2285
2286 if footer_y > modal_area.y {
2288 let sep_y = footer_y.saturating_sub(1);
2289 let sep_area = Rect::new(modal_area.x + 1, sep_y, footer_width, 1);
2290 let sep_line: String = "─".repeat(sep_area.width as usize);
2291 frame.render_widget(
2292 Paragraph::new(sep_line).style(Style::default().fg(theme.split_separator_fg)),
2293 sep_area,
2294 );
2295 }
2296
2297 let footer_focused = state.focus_panel() == FocusPanel::Footer;
2299
2300 let layer_hovered = matches!(state.hover_hit, Some(SettingsHit::LayerButton));
2303 let reset_hovered = matches!(state.hover_hit, Some(SettingsHit::ResetButton));
2304 let save_hovered = matches!(state.hover_hit, Some(SettingsHit::SaveButton));
2305 let cancel_hovered = matches!(state.hover_hit, Some(SettingsHit::CancelButton));
2306 let edit_hovered = matches!(state.hover_hit, Some(SettingsHit::EditButton));
2307
2308 let layer_focused = footer_focused && state.footer_button_index == 0;
2309 let reset_focused = footer_focused && state.footer_button_index == 1;
2310 let save_focused = footer_focused && state.footer_button_index == 2;
2311 let cancel_focused = footer_focused && state.footer_button_index == 3;
2312 let edit_focused = footer_focused && state.footer_button_index == 4;
2313
2314 let current_is_nullable_set = state
2317 .current_item()
2318 .map(|item| item.nullable && !item.is_null)
2319 .unwrap_or(false);
2320 let save_label = t!("settings.btn_save").to_string();
2321 let cancel_label = t!("settings.btn_cancel").to_string();
2322 let reset_label = if current_is_nullable_set {
2323 t!("settings.btn_inherit").to_string()
2324 } else {
2325 t!("settings.btn_reset").to_string()
2326 };
2327 let edit_label = t!("settings.btn_edit").to_string();
2328
2329 let layer_text = format!("[ {} ]", state.target_layer_name());
2331 let layer_text_focused = format!(">[ {} ]", state.target_layer_name());
2332 let save_text = format!("[ {} ]", save_label);
2333 let save_text_focused = format!(">[ {} ]", save_label);
2334 let cancel_text = format!("[ {} ]", cancel_label);
2335 let cancel_text_focused = format!(">[ {} ]", cancel_label);
2336 let reset_text = format!("[ {} ]", reset_label);
2337 let reset_text_focused = format!(">[ {} ]", reset_label);
2338 let edit_text = format!("[ {} ]", edit_label);
2339 let edit_text_focused = format!(">[ {} ]", edit_label);
2340
2341 let cancel_width = str_width(if cancel_focused {
2343 &cancel_text_focused
2344 } else {
2345 &cancel_text
2346 }) as u16;
2347 let save_width = str_width(if save_focused {
2348 &save_text_focused
2349 } else {
2350 &save_text
2351 }) as u16;
2352 let reset_width = str_width(if reset_focused {
2353 &reset_text_focused
2354 } else {
2355 &reset_text
2356 }) as u16;
2357 let layer_width = str_width(if layer_focused {
2358 &layer_text_focused
2359 } else {
2360 &layer_text
2361 }) as u16;
2362 let edit_width = str_width(if edit_focused {
2363 &edit_text_focused
2364 } else {
2365 &edit_text
2366 }) as u16;
2367 let gap: u16 = 2;
2368
2369 let min_buttons_width = save_width + gap + cancel_width;
2372 let all_buttons_width =
2374 edit_width + gap + layer_width + gap + reset_width + gap + save_width + gap + cancel_width;
2375
2376 let available = footer_area.width;
2378 let show_edit = available >= all_buttons_width;
2379 let show_layer = available >= (layer_width + gap + reset_width + gap + min_buttons_width);
2380 let show_reset = available >= (reset_width + gap + min_buttons_width);
2381
2382 let cancel_x = footer_area
2384 .x
2385 .saturating_add(footer_area.width.saturating_sub(cancel_width));
2386 let save_x = cancel_x.saturating_sub(save_width + gap);
2387 let reset_x = if show_reset {
2388 save_x.saturating_sub(reset_width + gap)
2389 } else {
2390 0
2391 };
2392 let layer_x = if show_layer {
2393 reset_x.saturating_sub(layer_width + gap)
2394 } else {
2395 0
2396 };
2397 let edit_x = footer_area.x; if show_layer {
2402 let layer_area = Rect::new(layer_x, footer_y, layer_width, 1);
2403 render_button(
2404 frame,
2405 layer_area,
2406 &layer_text,
2407 &layer_text_focused,
2408 layer_focused,
2409 layer_hovered,
2410 theme,
2411 false,
2412 );
2413 layout.layer_button = Some(layer_area);
2414 }
2415
2416 if show_reset {
2418 let reset_area = Rect::new(reset_x, footer_y, reset_width, 1);
2419 render_button(
2420 frame,
2421 reset_area,
2422 &reset_text,
2423 &reset_text_focused,
2424 reset_focused,
2425 reset_hovered,
2426 theme,
2427 false,
2428 );
2429 layout.reset_button = Some(reset_area);
2430 }
2431
2432 let save_area = Rect::new(save_x, footer_y, save_width, 1);
2434 render_button(
2435 frame,
2436 save_area,
2437 &save_text,
2438 &save_text_focused,
2439 save_focused,
2440 save_hovered,
2441 theme,
2442 false,
2443 );
2444 layout.save_button = Some(save_area);
2445
2446 let cancel_area = Rect::new(cancel_x, footer_y, cancel_width, 1);
2448 render_button(
2449 frame,
2450 cancel_area,
2451 &cancel_text,
2452 &cancel_text_focused,
2453 cancel_focused,
2454 cancel_hovered,
2455 theme,
2456 false,
2457 );
2458 layout.cancel_button = Some(cancel_area);
2459
2460 if show_edit {
2462 let edit_area = Rect::new(edit_x, footer_y, edit_width, 1);
2463 render_button(
2464 frame,
2465 edit_area,
2466 &edit_text,
2467 &edit_text_focused,
2468 edit_focused,
2469 edit_hovered,
2470 theme,
2471 true, );
2473 layout.edit_button = Some(edit_area);
2474 }
2475
2476 let help_start_x = if show_edit {
2479 edit_x + edit_width + 2
2480 } else {
2481 footer_area.x
2482 };
2483 let help_end_x = if show_layer {
2484 layer_x
2485 } else if show_reset {
2486 reset_x
2487 } else {
2488 save_x
2489 };
2490 let help_width = help_end_x.saturating_sub(help_start_x + 1);
2491
2492 let help = if state.search_active {
2494 t!("settings.help_search").to_string()
2495 } else if footer_focused {
2496 t!("settings.help_footer").to_string()
2497 } else {
2498 t!("settings.help_default").to_string()
2499 };
2500 let help_line = build_keyhint_line(&help, theme);
2503 frame.render_widget(
2504 Paragraph::new(help_line),
2505 Rect::new(help_start_x, footer_y, help_width, 1),
2506 );
2507}
2508
2509fn build_keyhint_line<'a>(text: &str, theme: &Theme) -> Line<'a> {
2511 let key_style = Style::default()
2512 .fg(theme.popup_text_fg)
2513 .bg(theme.split_separator_fg);
2514 let desc_style = Style::default().fg(theme.line_number_fg);
2515 let sep_style = Style::default().fg(theme.line_number_fg);
2516
2517 let mut spans: Vec<Span<'a>> = Vec::new();
2518
2519 for (i, segment) in text.split(" ").enumerate() {
2521 let segment = segment.trim();
2522 if segment.is_empty() {
2523 continue;
2524 }
2525 if i > 0 {
2526 spans.push(Span::styled(" ", sep_style));
2527 }
2528 if let Some(colon_pos) = segment.find(':') {
2530 let key = &segment[..colon_pos];
2531 let action = &segment[colon_pos + 1..];
2532 spans.push(Span::styled(format!(" {} ", key), key_style));
2533 spans.push(Span::styled(action.to_string(), desc_style));
2534 } else {
2535 spans.push(Span::styled(segment.to_string(), desc_style));
2537 }
2538 }
2539
2540 Line::from(spans)
2541}
2542
2543fn render_footer_vertical(
2545 frame: &mut Frame,
2546 modal_area: Rect,
2547 state: &SettingsState,
2548 theme: &Theme,
2549 layout: &mut SettingsLayout,
2550) {
2551 use super::layout::SettingsHit;
2552 use super::state::FocusPanel;
2553
2554 let footer_height = 7u16;
2556 let footer_y = modal_area
2557 .y
2558 .saturating_add(modal_area.height.saturating_sub(footer_height));
2559 let footer_width = modal_area.width.saturating_sub(2);
2560
2561 let sep_y = footer_y;
2563 if sep_y > modal_area.y {
2564 let sep_line: String = "─".repeat(footer_width as usize);
2565 frame.render_widget(
2566 Paragraph::new(sep_line).style(Style::default().fg(theme.split_separator_fg)),
2567 Rect::new(modal_area.x + 1, sep_y, footer_width, 1),
2568 );
2569 }
2570
2571 let footer_focused = state.focus_panel() == FocusPanel::Footer;
2573
2574 let layer_hovered = matches!(state.hover_hit, Some(SettingsHit::LayerButton));
2576 let reset_hovered = matches!(state.hover_hit, Some(SettingsHit::ResetButton));
2577 let save_hovered = matches!(state.hover_hit, Some(SettingsHit::SaveButton));
2578 let cancel_hovered = matches!(state.hover_hit, Some(SettingsHit::CancelButton));
2579 let edit_hovered = matches!(state.hover_hit, Some(SettingsHit::EditButton));
2580
2581 let layer_focused = footer_focused && state.footer_button_index == 0;
2582 let reset_focused = footer_focused && state.footer_button_index == 1;
2583 let save_focused = footer_focused && state.footer_button_index == 2;
2584 let cancel_focused = footer_focused && state.footer_button_index == 3;
2585 let edit_focused = footer_focused && state.footer_button_index == 4;
2586
2587 let current_is_nullable_set = state
2590 .current_item()
2591 .map(|item| item.nullable && !item.is_null)
2592 .unwrap_or(false);
2593 let save_label = t!("settings.btn_save").to_string();
2594 let cancel_label = t!("settings.btn_cancel").to_string();
2595 let reset_label = if current_is_nullable_set {
2596 t!("settings.btn_inherit").to_string()
2597 } else {
2598 t!("settings.btn_reset").to_string()
2599 };
2600 let edit_label = t!("settings.btn_edit").to_string();
2601
2602 let layer_text = format!("[ {} ]", state.target_layer_name());
2604 let layer_text_focused = format!(">[ {} ]", state.target_layer_name());
2605 let save_text = format!("[ {} ]", save_label);
2606 let save_text_focused = format!(">[ {} ]", save_label);
2607 let cancel_text = format!("[ {} ]", cancel_label);
2608 let cancel_text_focused = format!(">[ {} ]", cancel_label);
2609 let reset_text = format!("[ {} ]", reset_label);
2610 let reset_text_focused = format!(">[ {} ]", reset_label);
2611 let edit_text = format!("[ {} ]", edit_label);
2612 let edit_text_focused = format!(">[ {} ]", edit_label);
2613
2614 let button_x = modal_area.x + 2;
2616 let mut y = sep_y + 1;
2617
2618 let layer_width = str_width(if layer_focused {
2620 &layer_text_focused
2621 } else {
2622 &layer_text
2623 }) as u16;
2624 let layer_area = Rect::new(button_x, y, layer_width.min(footer_width), 1);
2625 render_button(
2626 frame,
2627 layer_area,
2628 &layer_text,
2629 &layer_text_focused,
2630 layer_focused,
2631 layer_hovered,
2632 theme,
2633 false,
2634 );
2635 layout.layer_button = Some(layer_area);
2636 y += 1;
2637
2638 let save_width = str_width(if save_focused {
2640 &save_text_focused
2641 } else {
2642 &save_text
2643 }) as u16;
2644 let save_area = Rect::new(button_x, y, save_width.min(footer_width), 1);
2645 render_button(
2646 frame,
2647 save_area,
2648 &save_text,
2649 &save_text_focused,
2650 save_focused,
2651 save_hovered,
2652 theme,
2653 false,
2654 );
2655 layout.save_button = Some(save_area);
2656 y += 1;
2657
2658 let reset_width = str_width(if reset_focused {
2660 &reset_text_focused
2661 } else {
2662 &reset_text
2663 }) as u16;
2664 let reset_area = Rect::new(button_x, y, reset_width.min(footer_width), 1);
2665 render_button(
2666 frame,
2667 reset_area,
2668 &reset_text,
2669 &reset_text_focused,
2670 reset_focused,
2671 reset_hovered,
2672 theme,
2673 false,
2674 );
2675 layout.reset_button = Some(reset_area);
2676 y += 1;
2677
2678 let cancel_width = str_width(if cancel_focused {
2680 &cancel_text_focused
2681 } else {
2682 &cancel_text
2683 }) as u16;
2684 let cancel_area = Rect::new(button_x, y, cancel_width.min(footer_width), 1);
2685 render_button(
2686 frame,
2687 cancel_area,
2688 &cancel_text,
2689 &cancel_text_focused,
2690 cancel_focused,
2691 cancel_hovered,
2692 theme,
2693 false,
2694 );
2695 layout.cancel_button = Some(cancel_area);
2696 y += 1;
2697
2698 let edit_width = str_width(if edit_focused {
2700 &edit_text_focused
2701 } else {
2702 &edit_text
2703 }) as u16;
2704 let edit_area = Rect::new(button_x, y, edit_width.min(footer_width), 1);
2705 render_button(
2706 frame,
2707 edit_area,
2708 &edit_text,
2709 &edit_text_focused,
2710 edit_focused,
2711 edit_hovered,
2712 theme,
2713 true, );
2715 layout.edit_button = Some(edit_area);
2716}
2717
2718fn render_search_header(frame: &mut Frame, area: Rect, state: &SettingsState, theme: &Theme) {
2720 let search_style = Style::default().fg(theme.settings_selected_fg);
2721 let cursor_style = Style::default()
2722 .fg(theme.settings_selected_fg)
2723 .add_modifier(Modifier::REVERSED);
2724
2725 let result_count = state.search_results.len();
2727 let count_text = if state.search_query.is_empty() {
2728 String::new()
2729 } else if result_count == 0 {
2730 " (no results)".to_string()
2731 } else if result_count == 1 {
2732 " (1 result)".to_string()
2733 } else if state.search_max_visible >= result_count {
2734 format!(" ({} results)", result_count)
2736 } else {
2737 let first = state.search_scroll_offset + 1;
2739 let last = (state.search_scroll_offset + state.search_max_visible).min(result_count);
2740 format!(" ({}-{} of {})", first, last, result_count)
2741 };
2742
2743 let has_more_above = state.search_scroll_offset > 0;
2745 let has_more_below = state.search_scroll_offset + state.search_max_visible < result_count;
2746 let scroll_indicator = match (has_more_above, has_more_below) {
2747 (true, true) => " ↑↓",
2748 (true, false) => " ↑",
2749 (false, true) => " ↓",
2750 (false, false) => "",
2751 };
2752
2753 let count_style = Style::default().fg(theme.line_number_fg);
2754 let indicator_style = Style::default()
2755 .fg(theme.menu_active_fg)
2756 .add_modifier(Modifier::BOLD);
2757
2758 let spans = vec![
2759 Span::styled("> ", search_style),
2760 Span::styled(&state.search_query, search_style),
2761 Span::styled(" ", cursor_style), Span::styled(count_text, count_style),
2763 Span::styled(scroll_indicator, indicator_style),
2764 ];
2765 let line = Line::from(spans);
2766 frame.render_widget(Paragraph::new(line), area);
2767}
2768
2769fn render_search_hint(frame: &mut Frame, area: Rect, theme: &Theme) {
2771 let hint_style = Style::default().fg(theme.line_number_fg);
2772 let key_style = Style::default()
2773 .fg(theme.popup_text_fg)
2774 .bg(theme.split_separator_fg);
2775
2776 let spans = vec![
2777 Span::styled("Press ", hint_style),
2778 Span::styled(" / ", key_style),
2779 Span::styled(" to search settings...", hint_style),
2780 ];
2781 let line = Line::from(spans);
2782 frame.render_widget(Paragraph::new(line), area);
2783}
2784
2785fn render_search_results(
2787 frame: &mut Frame,
2788 area: Rect,
2789 state: &mut SettingsState,
2790 theme: &Theme,
2791 layout: &mut SettingsLayout,
2792) {
2793 let max_visible = (area.height.saturating_sub(3) / 3) as usize;
2795 state.search_max_visible = max_visible.max(1);
2796
2797 if state.search_scroll_offset >= state.search_results.len() {
2799 state.search_scroll_offset = state.search_results.len().saturating_sub(1);
2800 }
2801
2802 let needs_scrollbar = state.search_results.len() > state.search_max_visible;
2804 let scrollbar_width = if needs_scrollbar { 1 } else { 0 };
2805
2806 let content_area = Rect::new(
2808 area.x,
2809 area.y,
2810 area.width.saturating_sub(scrollbar_width),
2811 area.height,
2812 );
2813
2814 let mut y = content_area.y;
2815
2816 for (idx, result) in state
2817 .search_results
2818 .iter()
2819 .enumerate()
2820 .skip(state.search_scroll_offset)
2821 {
2822 if y >= content_area.y + content_area.height.saturating_sub(3) {
2823 break;
2824 }
2825
2826 let is_selected = idx == state.selected_search_result;
2827 let is_hovered = matches!(state.hover_hit, Some(SettingsHit::SearchResult(i)) if i == idx);
2828 let item_area = Rect::new(content_area.x, y, content_area.width, 3);
2829
2830 render_search_result_item(
2831 frame,
2832 item_area,
2833 result,
2834 is_selected,
2835 is_hovered,
2836 theme,
2837 layout,
2838 );
2839 y += 3;
2840 }
2841
2842 layout.search_results_area = Some(content_area);
2844
2845 if needs_scrollbar {
2847 let scrollbar_area = Rect::new(
2848 area.x + area.width - 1,
2849 area.y,
2850 1,
2851 area.height.saturating_sub(3), );
2853
2854 let scrollbar_state = ScrollbarState::new(
2855 state.search_results.len(),
2856 state.search_max_visible,
2857 state.search_scroll_offset,
2858 );
2859
2860 let colors = ScrollbarColors::from_theme(theme);
2861 render_scrollbar(frame, scrollbar_area, &scrollbar_state, &colors);
2862
2863 layout.search_scrollbar_area = Some(scrollbar_area);
2865 } else {
2866 layout.search_scrollbar_area = None;
2867 }
2868}
2869
2870fn render_search_result_item(
2872 frame: &mut Frame,
2873 area: Rect,
2874 result: &SearchResult,
2875 is_selected: bool,
2876 is_hovered: bool,
2877 theme: &Theme,
2878 layout: &mut SettingsLayout,
2879) {
2880 if is_selected {
2882 let bg_style = Style::default().bg(theme.settings_selected_bg);
2884 for row in 0..area.height.min(3) {
2885 let row_area = Rect::new(area.x, area.y + row, area.width, 1);
2886 frame.render_widget(Paragraph::new("").style(bg_style), row_area);
2887 }
2888 } else if is_hovered {
2889 let bg_style = Style::default().bg(theme.menu_hover_bg);
2891 for row in 0..area.height.min(3) {
2892 let row_area = Rect::new(area.x, area.y + row, area.width, 1);
2893 frame.render_widget(Paragraph::new("").style(bg_style), row_area);
2894 }
2895 }
2896
2897 let (display_name, display_desc) = match &result.deep_match {
2899 Some(DeepMatch::MapKey { key, .. }) => (key.clone(), Some(result.item.name.clone())),
2900 Some(DeepMatch::MapValue {
2901 matched_text, key, ..
2902 }) => (
2903 matched_text.clone(),
2904 Some(format!("{} > {}", result.item.name, key)),
2905 ),
2906 Some(DeepMatch::TextListItem { text, .. }) => {
2907 (text.clone(), Some(result.item.name.clone()))
2908 }
2909 None => (result.item.name.clone(), result.item.description.clone()),
2910 };
2911
2912 let name_style = if is_selected {
2914 Style::default().fg(theme.settings_selected_fg)
2915 } else if is_hovered {
2916 Style::default().fg(theme.menu_hover_fg)
2917 } else {
2918 Style::default().fg(theme.popup_text_fg)
2919 };
2920
2921 let indicator = if is_selected { "▸ " } else { " " };
2923 let indicator_style = if is_selected {
2924 Style::default()
2925 .fg(theme.settings_selected_fg)
2926 .add_modifier(Modifier::BOLD)
2927 } else {
2928 name_style
2929 };
2930 let mut name_line = build_highlighted_text(
2931 &display_name,
2932 &result.name_matches,
2933 name_style,
2934 Style::default()
2935 .fg(theme.diagnostic_warning_fg)
2936 .add_modifier(Modifier::BOLD),
2937 );
2938 name_line
2939 .spans
2940 .insert(0, Span::styled(indicator, indicator_style));
2941 frame.render_widget(
2942 Paragraph::new(name_line),
2943 Rect::new(area.x, area.y, area.width, 1),
2944 );
2945
2946 let breadcrumb_style = Style::default()
2948 .fg(theme.line_number_fg)
2949 .add_modifier(Modifier::ITALIC);
2950 let breadcrumb = format!(" {} > {}", result.breadcrumb, result.item.path);
2951 let breadcrumb_line = Line::from(Span::styled(breadcrumb, breadcrumb_style));
2952 frame.render_widget(
2953 Paragraph::new(breadcrumb_line),
2954 Rect::new(area.x, area.y + 1, area.width, 1),
2955 );
2956
2957 if let Some(ref desc) = display_desc {
2962 let desc_style = Style::default().fg(theme.line_number_fg);
2963 let max_chars = (area.width as usize).saturating_sub(2);
2964 let truncated_desc = format!(" {}", truncate_chars_with_ellipsis(desc, max_chars));
2965 frame.render_widget(
2966 Paragraph::new(truncated_desc).style(desc_style),
2967 Rect::new(area.x, area.y + 2, area.width, 1),
2968 );
2969 }
2970
2971 layout.add_search_result(result.page_index, result.item_index, area);
2973}
2974
2975fn build_highlighted_text(
2977 text: &str,
2978 matches: &[usize],
2979 normal_style: Style,
2980 highlight_style: Style,
2981) -> Line<'static> {
2982 if matches.is_empty() {
2983 return Line::from(Span::styled(text.to_string(), normal_style));
2984 }
2985
2986 let chars: Vec<char> = text.chars().collect();
2987 let mut spans = Vec::new();
2988 let mut current = String::new();
2989 let mut in_highlight = false;
2990
2991 for (idx, ch) in chars.iter().enumerate() {
2992 let should_highlight = matches.contains(&idx);
2993
2994 if should_highlight != in_highlight {
2995 if !current.is_empty() {
2996 let style = if in_highlight {
2997 highlight_style
2998 } else {
2999 normal_style
3000 };
3001 spans.push(Span::styled(current, style));
3002 current = String::new();
3003 }
3004 in_highlight = should_highlight;
3005 }
3006
3007 current.push(*ch);
3008 }
3009
3010 if !current.is_empty() {
3012 let style = if in_highlight {
3013 highlight_style
3014 } else {
3015 normal_style
3016 };
3017 spans.push(Span::styled(current, style));
3018 }
3019
3020 Line::from(spans)
3021}
3022
3023fn render_confirm_dialog(
3025 frame: &mut Frame,
3026 parent_area: Rect,
3027 state: &SettingsState,
3028 theme: &Theme,
3029) {
3030 let changes = state.get_change_descriptions();
3032 let dialog_width = 50.min(parent_area.width.saturating_sub(4));
3033 let dialog_height = (7 + changes.len() as u16)
3036 .min(20)
3037 .min(parent_area.height.saturating_sub(4));
3038
3039 let dialog_x = parent_area.x + (parent_area.width.saturating_sub(dialog_width)) / 2;
3041 let dialog_y = parent_area.y + (parent_area.height.saturating_sub(dialog_height)) / 2;
3042 let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
3043
3044 frame.render_widget(Clear, dialog_area);
3046
3047 let title = format!(" {} ", t!("confirm.unsaved_changes_title"));
3048 let block = Block::default()
3049 .title(title)
3050 .borders(Borders::ALL)
3051 .border_type(BorderType::Rounded)
3052 .border_style(Style::default().fg(theme.diagnostic_warning_fg))
3053 .style(Style::default().bg(theme.popup_bg));
3054 frame.render_widget(block, dialog_area);
3055
3056 let inner = Rect::new(
3058 dialog_area.x + 2,
3059 dialog_area.y + 1,
3060 dialog_area.width.saturating_sub(4),
3061 dialog_area.height.saturating_sub(2),
3062 );
3063
3064 let mut y = inner.y;
3065
3066 let prompt = t!("confirm.unsaved_changes_prompt").to_string();
3068 let prompt_style = Style::default().fg(theme.popup_text_fg);
3069 frame.render_widget(
3070 Paragraph::new(prompt).style(prompt_style),
3071 Rect::new(inner.x, y, inner.width, 1),
3072 );
3073 y += 2;
3074
3075 let change_style = Style::default().fg(theme.popup_text_fg);
3080 for change in changes
3081 .iter()
3082 .take((dialog_height as usize).saturating_sub(7))
3083 {
3084 let max_chars = (inner.width as usize).saturating_sub(2);
3085 let truncated = format!("• {}", truncate_chars_with_ellipsis(change, max_chars));
3086 frame.render_widget(
3087 Paragraph::new(truncated).style(change_style),
3088 Rect::new(inner.x, y, inner.width, 1),
3089 );
3090 y += 1;
3091 }
3092
3093 let button_y = dialog_area.y + dialog_area.height - 3;
3095
3096 let sep_line: String = "─".repeat(inner.width as usize);
3098 frame.render_widget(
3099 Paragraph::new(sep_line).style(Style::default().fg(theme.split_separator_fg)),
3100 Rect::new(inner.x, button_y - 1, inner.width, 1),
3101 );
3102
3103 let options = [
3105 t!("confirm.save_and_exit").to_string(),
3106 t!("confirm.discard").to_string(),
3107 t!("confirm.cancel").to_string(),
3108 ];
3109 let total_width: u16 = options.iter().map(|o| o.len() as u16 + 4).sum::<u16>() + 4; let mut x = inner.x + (inner.width.saturating_sub(total_width)) / 2;
3111
3112 for (idx, label) in options.iter().enumerate() {
3113 let is_selected = idx == state.confirm_dialog_selection;
3114 let is_hovered = state.confirm_dialog_hover == Some(idx);
3115 let button_width = label.len() as u16 + 4;
3116
3117 let style = if is_selected {
3118 Style::default()
3119 .fg(theme.menu_highlight_fg)
3120 .bg(theme.menu_highlight_bg)
3121 .add_modifier(ratatui::style::Modifier::BOLD)
3122 } else if is_hovered {
3123 Style::default()
3124 .fg(theme.menu_hover_fg)
3125 .bg(theme.menu_hover_bg)
3126 } else {
3127 Style::default().fg(theme.popup_text_fg)
3128 };
3129
3130 let text = if is_selected {
3131 format!(">[ {} ]", label)
3132 } else {
3133 format!(" [ {} ]", label)
3134 };
3135 frame.render_widget(
3136 Paragraph::new(text).style(style),
3137 Rect::new(x, button_y, button_width + 1, 1),
3138 );
3139
3140 x += button_width + 3;
3141 }
3142
3143 let help = "←/→/Tab: Select Enter: Confirm Esc: Cancel";
3145 let help_style = Style::default().fg(theme.line_number_fg);
3146 frame.render_widget(
3147 Paragraph::new(help).style(help_style),
3148 Rect::new(inner.x, button_y + 1, inner.width, 1),
3149 );
3150}
3151
3152fn render_reset_dialog(frame: &mut Frame, parent_area: Rect, state: &SettingsState, theme: &Theme) {
3154 let changes = state.get_change_descriptions();
3155 let dialog_width = 50.min(parent_area.width.saturating_sub(4));
3156 let dialog_height = (7 + changes.len() as u16)
3159 .min(20)
3160 .min(parent_area.height.saturating_sub(4));
3161
3162 let dialog_x = parent_area.x + (parent_area.width.saturating_sub(dialog_width)) / 2;
3164 let dialog_y = parent_area.y + (parent_area.height.saturating_sub(dialog_height)) / 2;
3165 let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
3166
3167 frame.render_widget(Clear, dialog_area);
3169
3170 let block = Block::default()
3171 .title(" Reset All Changes ")
3172 .borders(Borders::ALL)
3173 .border_type(BorderType::Rounded)
3174 .border_style(Style::default().fg(theme.diagnostic_warning_fg))
3175 .style(Style::default().bg(theme.popup_bg));
3176 frame.render_widget(block, dialog_area);
3177
3178 let inner = Rect::new(
3180 dialog_area.x + 2,
3181 dialog_area.y + 1,
3182 dialog_area.width.saturating_sub(4),
3183 dialog_area.height.saturating_sub(2),
3184 );
3185
3186 let mut y = inner.y;
3187
3188 let prompt_style = Style::default().fg(theme.popup_text_fg);
3190 frame.render_widget(
3191 Paragraph::new("Discard all pending changes?").style(prompt_style),
3192 Rect::new(inner.x, y, inner.width, 1),
3193 );
3194 y += 2;
3195
3196 let change_style = Style::default().fg(theme.popup_text_fg);
3201 for change in changes
3202 .iter()
3203 .take((dialog_height as usize).saturating_sub(7))
3204 {
3205 let max_chars = (inner.width as usize).saturating_sub(2);
3206 let truncated = format!("• {}", truncate_chars_with_ellipsis(change, max_chars));
3207 frame.render_widget(
3208 Paragraph::new(truncated).style(change_style),
3209 Rect::new(inner.x, y, inner.width, 1),
3210 );
3211 y += 1;
3212 }
3213
3214 let button_y = dialog_area.y + dialog_area.height - 3;
3216
3217 let sep_line: String = "─".repeat(inner.width as usize);
3219 frame.render_widget(
3220 Paragraph::new(sep_line).style(Style::default().fg(theme.split_separator_fg)),
3221 Rect::new(inner.x, button_y - 1, inner.width, 1),
3222 );
3223
3224 let options = ["Reset", "Cancel"];
3226 let total_width: u16 = options.iter().map(|o| o.len() as u16 + 4).sum::<u16>() + 4;
3227 let mut x = inner.x + (inner.width.saturating_sub(total_width)) / 2;
3228
3229 for (idx, label) in options.iter().enumerate() {
3230 let is_selected = idx == state.reset_dialog_selection;
3231 let is_hovered = state.reset_dialog_hover == Some(idx);
3232 let button_width = label.len() as u16 + 4;
3233
3234 let style = if is_selected {
3235 Style::default()
3236 .fg(theme.menu_highlight_fg)
3237 .bg(theme.menu_highlight_bg)
3238 .add_modifier(ratatui::style::Modifier::BOLD)
3239 } else if is_hovered {
3240 Style::default()
3241 .fg(theme.menu_hover_fg)
3242 .bg(theme.menu_hover_bg)
3243 } else {
3244 Style::default().fg(theme.popup_text_fg)
3245 };
3246
3247 let text = if is_selected {
3248 format!(">[ {} ]", label)
3249 } else {
3250 format!(" [ {} ]", label)
3251 };
3252 frame.render_widget(
3253 Paragraph::new(text).style(style),
3254 Rect::new(x, button_y, button_width + 1, 1),
3255 );
3256
3257 x += button_width + 3;
3258 }
3259
3260 let help = "←/→/Tab: Select Enter: Confirm Esc: Cancel";
3262 let help_style = Style::default().fg(theme.line_number_fg);
3263 frame.render_widget(
3264 Paragraph::new(help).style(help_style),
3265 Rect::new(inner.x, button_y + 1, inner.width, 1),
3266 );
3267}
3268
3269fn render_entry_discard_confirm(
3272 frame: &mut Frame,
3273 parent_area: Rect,
3274 state: &SettingsState,
3275 theme: &Theme,
3276) {
3277 let dialog_width = 50.min(parent_area.width.saturating_sub(4));
3278 let dialog_height = 7u16.min(parent_area.height.saturating_sub(4));
3279 let dialog_x = parent_area.x + (parent_area.width.saturating_sub(dialog_width)) / 2;
3280 let dialog_y = parent_area.y + (parent_area.height.saturating_sub(dialog_height)) / 2;
3281 let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
3282
3283 frame.render_widget(Clear, dialog_area);
3284
3285 let block = Block::default()
3286 .title(" Discard changes? ")
3287 .borders(Borders::ALL)
3288 .border_type(BorderType::Rounded)
3289 .border_style(Style::default().fg(theme.diagnostic_warning_fg))
3290 .style(Style::default().bg(theme.popup_bg));
3291 frame.render_widget(block, dialog_area);
3292
3293 let inner = Rect::new(
3294 dialog_area.x + 2,
3295 dialog_area.y + 1,
3296 dialog_area.width.saturating_sub(4),
3297 dialog_area.height.saturating_sub(2),
3298 );
3299
3300 let prompt_style = Style::default().fg(theme.popup_text_fg);
3301 frame.render_widget(
3302 Paragraph::new("You have uncommitted edits in this dialog.").style(prompt_style),
3303 Rect::new(inner.x, inner.y, inner.width, 1),
3304 );
3305
3306 let button_y = dialog_area.y + dialog_area.height - 3;
3309 let options = ["Keep editing", "Discard"];
3310 let total_width: u16 = options.iter().map(|o| o.len() as u16 + 4).sum::<u16>() + 4;
3311 let mut x = inner.x + (inner.width.saturating_sub(total_width)) / 2;
3312
3313 for (idx, label) in options.iter().enumerate() {
3314 let is_selected = idx == state.entry_discard_confirm_selection;
3315 let is_discard = idx == 1;
3316 let style = if is_selected && is_discard {
3317 Style::default()
3318 .fg(theme.diagnostic_error_fg)
3319 .bg(theme.popup_selection_bg)
3320 .add_modifier(Modifier::BOLD)
3321 } else if is_selected {
3322 Style::default()
3323 .fg(theme.popup_selection_fg)
3324 .bg(theme.popup_selection_bg)
3325 .add_modifier(Modifier::BOLD)
3326 } else if is_discard {
3327 Style::default()
3328 .fg(theme.diagnostic_error_fg)
3329 .add_modifier(Modifier::BOLD)
3330 } else {
3331 Style::default().fg(theme.popup_text_fg)
3332 };
3333 let text = if is_selected {
3334 format!(">[ {} ]", label)
3335 } else {
3336 format!(" [ {} ]", label)
3337 };
3338 let w = label.len() as u16 + 5;
3339 frame.render_widget(
3340 Paragraph::new(text).style(style),
3341 Rect::new(x, button_y, w, 1),
3342 );
3343 x += w + 2;
3344 }
3345
3346 let help = "Tab/←→: Select Enter: Confirm Esc: Keep editing";
3347 let help_style = Style::default().fg(theme.line_number_fg);
3348 frame.render_widget(
3349 Paragraph::new(help).style(help_style),
3350 Rect::new(inner.x, button_y + 1, inner.width, 1),
3351 );
3352}
3353
3354fn entry_delete_button_label(dialog: &EntryDialogState) -> String {
3362 const MAX_KEY_IN_LABEL: usize = 24;
3363 if dialog.is_array_item {
3364 "[ Delete item ]".to_string()
3365 } else if dialog.entry_key.is_empty() {
3366 "[ Delete entry ]".to_string()
3367 } else {
3368 let key = if dialog.entry_key.chars().count() > MAX_KEY_IN_LABEL {
3369 let truncated: String = dialog
3370 .entry_key
3371 .chars()
3372 .take(MAX_KEY_IN_LABEL - 1)
3373 .collect();
3374 format!("{}…", truncated)
3375 } else {
3376 dialog.entry_key.clone()
3377 };
3378 format!("[ Delete \"{}\" ]", key)
3379 }
3380}
3381
3382fn render_entry_delete_confirm(
3385 frame: &mut Frame,
3386 parent_area: Rect,
3387 state: &SettingsState,
3388 theme: &Theme,
3389) {
3390 let dialog_width = 60.min(parent_area.width.saturating_sub(4));
3391 let dialog_height = 7u16.min(parent_area.height.saturating_sub(4));
3392 let dialog_x = parent_area.x + (parent_area.width.saturating_sub(dialog_width)) / 2;
3393 let dialog_y = parent_area.y + (parent_area.height.saturating_sub(dialog_height)) / 2;
3394 let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
3395
3396 frame.render_widget(Clear, dialog_area);
3397
3398 let title = if !state.entry_delete_target_name.is_empty() {
3399 format!(" Delete \"{}\"? ", state.entry_delete_target_name)
3400 } else if state.entry_delete_target_is_array_item {
3401 " Delete item? ".to_string()
3402 } else {
3403 " Delete entry? ".to_string()
3404 };
3405
3406 let block = Block::default()
3407 .title(title)
3408 .borders(Borders::ALL)
3409 .border_type(BorderType::Rounded)
3410 .border_style(Style::default().fg(theme.diagnostic_error_fg))
3411 .style(Style::default().bg(theme.popup_bg));
3412 frame.render_widget(block, dialog_area);
3413
3414 let inner = Rect::new(
3415 dialog_area.x + 2,
3416 dialog_area.y + 1,
3417 dialog_area.width.saturating_sub(4),
3418 dialog_area.height.saturating_sub(2),
3419 );
3420
3421 let body = if !state.entry_delete_target_name.is_empty() {
3422 format!(
3423 "This will permanently remove \"{}\".",
3424 state.entry_delete_target_name
3425 )
3426 } else if state.entry_delete_target_is_array_item {
3427 "This will permanently remove this item.".to_string()
3428 } else {
3429 "This will permanently remove the entry.".to_string()
3430 };
3431 let prompt_style = Style::default().fg(theme.popup_text_fg);
3432 frame.render_widget(
3433 Paragraph::new(body).style(prompt_style),
3434 Rect::new(inner.x, inner.y, inner.width, 1),
3435 );
3436
3437 let button_y = dialog_area.y + dialog_area.height - 3;
3438 let options = ["Cancel", "Delete"];
3439 let total_width: u16 = options.iter().map(|o| o.len() as u16 + 5).sum::<u16>() + 2;
3440 let mut x = inner.x + (inner.width.saturating_sub(total_width)) / 2;
3441
3442 for (idx, label) in options.iter().enumerate() {
3443 let is_selected = idx == state.entry_delete_confirm_selection;
3444 let is_delete = idx == 1;
3445 let style = if is_selected && is_delete {
3446 Style::default()
3447 .fg(theme.diagnostic_error_fg)
3448 .bg(theme.popup_selection_bg)
3449 .add_modifier(Modifier::BOLD)
3450 } else if is_selected {
3451 Style::default()
3452 .fg(theme.popup_selection_fg)
3453 .bg(theme.popup_selection_bg)
3454 .add_modifier(Modifier::BOLD)
3455 } else if is_delete {
3456 Style::default()
3457 .fg(theme.diagnostic_error_fg)
3458 .add_modifier(Modifier::BOLD)
3459 } else {
3460 Style::default().fg(theme.popup_text_fg)
3461 };
3462 let text = if is_selected {
3463 format!(">[ {} ]", label)
3464 } else {
3465 format!(" [ {} ]", label)
3466 };
3467 let w = label.len() as u16 + 5;
3468 frame.render_widget(
3469 Paragraph::new(text).style(style),
3470 Rect::new(x, button_y, w, 1),
3471 );
3472 x += w + 2;
3473 }
3474
3475 let help = "Tab/←→: Select Enter: Confirm Esc: Cancel";
3476 let help_style = Style::default().fg(theme.line_number_fg);
3477 frame.render_widget(
3478 Paragraph::new(help).style(help_style),
3479 Rect::new(inner.x, button_y + 1, inner.width, 1),
3480 );
3481}
3482
3483fn render_entry_dialog_at(
3485 frame: &mut Frame,
3486 parent_area: Rect,
3487 state: &mut SettingsState,
3488 theme: &Theme,
3489 dialog_idx: usize,
3490) {
3491 let Some(dialog) = state.entry_dialog_stack.get_mut(dialog_idx) else {
3492 return;
3493 };
3494 render_entry_dialog_inner(frame, parent_area, dialog, theme);
3495}
3496
3497#[allow(clippy::too_many_arguments)]
3499fn render_entry_items(
3500 frame: &mut Frame,
3501 dialog_area: Rect,
3502 inner: Rect,
3503 dialog: &super::entry_dialog::EntryDialogState,
3504 theme: &Theme,
3505 label_col_width: u16,
3506 scroll_offset: usize,
3507 total_content_height: usize,
3508 viewport_height: usize,
3509) {
3510 let needs_scroll = total_content_height > viewport_height;
3511 let mut content_y: usize = 0;
3512 let mut screen_y = inner.y;
3513
3514 let first_editable = dialog.first_editable_index;
3515 let needs_separator = first_editable > 0 && first_editable < dialog.items.len();
3516
3517 for (idx, item) in dialog.items.iter().enumerate() {
3518 if needs_separator && idx == first_editable {
3520 let separator_end = content_y + 1;
3521 if separator_end > scroll_offset
3522 && screen_y < inner.y + inner.height
3523 && content_y >= scroll_offset
3524 {
3525 let sep_style = Style::default().fg(theme.line_number_fg);
3526 let separator_line = "─".repeat(inner.width.saturating_sub(2) as usize);
3527 frame.render_widget(
3528 Paragraph::new(separator_line).style(sep_style),
3529 Rect::new(inner.x + 1, screen_y, inner.width.saturating_sub(2), 1),
3530 );
3531 screen_y += 1;
3532 }
3533 content_y = separator_end;
3534 }
3535
3536 if item.is_section_start {
3538 if let Some(ref section_name) = item.section {
3539 let header_start = content_y;
3540 let header_end = content_y + 2;
3541 if header_end > scroll_offset && screen_y < inner.y + inner.height {
3542 let skip_h = header_start.saturating_sub(scroll_offset) as u16;
3543 if skip_h == 0 {
3544 let section_style = Style::default()
3545 .fg(theme.line_number_fg)
3546 .add_modifier(Modifier::BOLD);
3547 frame.render_widget(
3548 Paragraph::new(format!("── {} ──", section_name)).style(section_style),
3549 Rect::new(inner.x + 1, screen_y, inner.width.saturating_sub(2), 1),
3550 );
3551 screen_y += 1;
3552 }
3553 if skip_h <= 1 && screen_y < inner.y + inner.height {
3554 screen_y += 1; }
3556 }
3557 content_y = header_end;
3558 }
3559 }
3560
3561 let control_height = item.control.control_height() as usize;
3562 let item_start = content_y;
3563 let item_end = content_y + control_height;
3564
3565 if item_end <= scroll_offset {
3566 content_y = item_end;
3567 continue;
3568 }
3569 if screen_y >= inner.y + inner.height {
3570 break;
3571 }
3572
3573 let skip_rows = if item_start < scroll_offset {
3574 (scroll_offset - item_start) as u16
3575 } else {
3576 0
3577 };
3578 let visible_height = control_height.saturating_sub(skip_rows as usize);
3579 let available_height = (inner.y + inner.height).saturating_sub(screen_y) as usize;
3580 let render_height = visible_height.min(available_height);
3581
3582 if render_height == 0 {
3583 content_y = item_end;
3584 continue;
3585 }
3586
3587 let is_readonly = item.read_only;
3588 let is_focused = !is_readonly && !dialog.focus_on_buttons && dialog.selected_item == idx;
3589 let is_hovered = !is_readonly && dialog.hover_item == Some(idx);
3590
3591 if is_focused || is_hovered {
3592 let bg_style = if is_focused {
3593 Style::default().bg(theme.settings_selected_bg)
3594 } else {
3595 Style::default().bg(theme.menu_hover_bg)
3596 };
3597 if item.control.is_composite() {
3598 let sub_row = item.control.focused_sub_row();
3599 if sub_row >= skip_rows && (sub_row - skip_rows) < render_height as u16 {
3600 let highlight_y = screen_y + sub_row - skip_rows;
3601 frame.render_widget(
3602 Paragraph::new("").style(bg_style),
3603 Rect::new(inner.x, highlight_y, inner.width, 1),
3604 );
3605 }
3606 } else {
3607 for row in 0..render_height as u16 {
3608 frame.render_widget(
3609 Paragraph::new("").style(bg_style),
3610 Rect::new(inner.x, screen_y + row, inner.width, 1),
3611 );
3612 }
3613 }
3614 }
3615
3616 let focus_indicator_width: u16 = 3;
3618 if is_focused {
3619 let indicator_y = if item.control.is_composite() {
3620 let sub_row = item.control.focused_sub_row();
3621 let visible_sub = sub_row.saturating_sub(skip_rows);
3622 if visible_sub < render_height as u16 {
3623 screen_y + visible_sub
3624 } else {
3625 screen_y
3626 }
3627 } else {
3628 screen_y
3629 };
3630 if indicator_y >= screen_y && indicator_y < screen_y + render_height as u16 {
3631 let indicator_style = Style::default()
3632 .fg(theme.settings_selected_fg)
3633 .add_modifier(Modifier::BOLD);
3634 frame.render_widget(
3635 Paragraph::new(">").style(indicator_style),
3636 Rect::new(inner.x, indicator_y, 1, 1),
3637 );
3638 }
3639 }
3640 if item.modified && skip_rows == 0 {
3641 let modified_style = Style::default().fg(theme.settings_selected_fg);
3642 frame.render_widget(
3643 Paragraph::new("●").style(modified_style),
3644 Rect::new(inner.x + 1, screen_y, 1, 1),
3645 );
3646 }
3647
3648 let control_area = Rect::new(
3649 inner.x + focus_indicator_width,
3650 screen_y,
3651 inner.width.saturating_sub(focus_indicator_width),
3652 render_height as u16,
3653 );
3654 let _layout = render_control(
3655 frame,
3656 control_area,
3657 &item.control,
3658 &item.name,
3659 skip_rows,
3660 theme,
3661 Some(label_col_width.saturating_sub(focus_indicator_width)),
3662 item.read_only,
3663 item.is_null,
3664 );
3665
3666 screen_y += render_height as u16;
3667 content_y = item_end;
3668 }
3669
3670 if needs_scroll {
3671 let scrollbar_x = dialog_area.x + dialog_area.width - 3;
3672 let scrollbar_area = Rect::new(scrollbar_x, inner.y, 1, inner.height);
3673 let scrollbar_state =
3674 ScrollbarState::new(total_content_height, viewport_height, scroll_offset);
3675 let scrollbar_colors = ScrollbarColors::from_theme(theme);
3676 render_scrollbar(frame, scrollbar_area, &scrollbar_state, &scrollbar_colors);
3677 }
3678}
3679
3680fn render_entry_buttons(
3687 frame: &mut Frame,
3688 dialog_area: Rect,
3689 dialog: &super::entry_dialog::EntryDialogState,
3690 theme: &Theme,
3691) {
3692 let button_y = dialog_area.y + dialog_area.height - 2;
3693 let has_delete = !dialog.is_new && !dialog.no_delete;
3694 let delete_label = entry_delete_button_label(dialog);
3695 let buttons: Vec<String> = if has_delete {
3696 vec![
3697 "[ Save ]".to_string(),
3698 "[ Cancel ]".to_string(),
3699 delete_label,
3700 ]
3701 } else {
3702 vec!["[ Save ]".to_string(), "[ Cancel ]".to_string()]
3703 };
3704 let delete_idx = if has_delete {
3705 Some(buttons.len() - 1)
3706 } else {
3707 None
3708 };
3709
3710 const BUTTON_GAP: u16 = 2;
3711 const DELETE_GAP: u16 = 6;
3712 let button_width: u16 = buttons
3713 .iter()
3714 .enumerate()
3715 .map(|(i, b)| {
3716 let gap = if Some(i) == delete_idx {
3717 DELETE_GAP
3718 } else if i == 0 {
3719 0
3720 } else {
3721 BUTTON_GAP
3722 };
3723 b.len() as u16 + gap
3724 })
3725 .sum();
3726 let button_x = dialog_area.x + (dialog_area.width.saturating_sub(button_width)) / 2;
3727
3728 let mut x = button_x;
3729 for (idx, label) in buttons.iter().enumerate() {
3730 let is_selected = dialog.focus_on_buttons && dialog.focused_button == idx;
3731 let is_hovered = dialog.hover_button == Some(idx);
3732 let is_delete = Some(idx) == delete_idx;
3733
3734 if idx > 0 {
3735 x += if is_delete { DELETE_GAP } else { BUTTON_GAP };
3736 }
3737 if is_selected {
3738 let indicator_style = Style::default()
3739 .fg(theme.settings_selected_fg)
3740 .add_modifier(Modifier::BOLD);
3741 frame.render_widget(
3742 Paragraph::new(">").style(indicator_style),
3743 Rect::new(x.saturating_sub(2), button_y, 1, 1),
3744 );
3745 }
3746
3747 let style = if is_selected && is_delete {
3750 Style::default()
3751 .fg(theme.diagnostic_error_fg)
3752 .bg(theme.popup_selection_bg)
3753 .add_modifier(Modifier::BOLD | Modifier::REVERSED)
3754 } else if is_selected {
3755 Style::default()
3756 .fg(theme.popup_selection_fg)
3757 .bg(theme.popup_selection_bg)
3758 .add_modifier(Modifier::BOLD | Modifier::REVERSED)
3759 } else if is_hovered && is_delete {
3760 Style::default()
3761 .fg(theme.diagnostic_error_fg)
3762 .bg(theme.menu_hover_bg)
3763 .add_modifier(Modifier::BOLD)
3764 } else if is_hovered {
3765 Style::default()
3766 .fg(theme.menu_hover_fg)
3767 .bg(theme.menu_hover_bg)
3768 } else if is_delete {
3769 Style::default()
3770 .fg(theme.diagnostic_error_fg)
3771 .add_modifier(Modifier::BOLD)
3772 } else {
3773 Style::default().fg(theme.editor_fg)
3774 };
3775
3776 frame.render_widget(
3777 Paragraph::new(label.as_str()).style(style),
3778 Rect::new(x, button_y, label.len() as u16, 1),
3779 );
3780 x += label.len() as u16;
3781 }
3782}
3783
3784fn render_entry_footer(
3787 frame: &mut Frame,
3788 dialog_area: Rect,
3789 inner: Rect,
3790 dialog: &super::entry_dialog::EntryDialogState,
3791 theme: &Theme,
3792) {
3793 let button_y = dialog_area.y + dialog_area.height - 2;
3794 let helper_y = button_y.saturating_sub(1);
3795
3796 if !dialog.focus_on_buttons && helper_y > inner.y {
3798 let pending_list_caption = dialog.current_item().and_then(|it| {
3802 if let SettingControl::TextList(state) = &it.control {
3803 if state.focused_item.is_none() {
3804 return Some(if !state.pending_active && state.new_item_text.is_empty() {
3805 "Press Enter (or type) to add a new item; ↓/Tab to leave"
3806 } else if state.new_item_text.is_empty() {
3807 "Type the new item — Enter to add, Esc to cancel"
3808 } else {
3809 "Editing new item — Enter to add, Esc to cancel"
3810 });
3811 }
3812 }
3813 None
3814 });
3815
3816 let text: Option<String> = pending_list_caption.map(String::from).or_else(|| {
3817 dialog
3818 .current_item()
3819 .and_then(|it| it.description.as_deref())
3820 .filter(|d| !d.is_empty())
3821 .map(String::from)
3822 });
3823
3824 if let Some(text) = text {
3825 let max_width = dialog_area.width.saturating_sub(4) as usize;
3826 let truncated: String = text.chars().take(max_width).collect();
3827 let helper_style = Style::default()
3828 .fg(theme.line_number_fg)
3829 .add_modifier(Modifier::ITALIC);
3830 frame.render_widget(
3831 Paragraph::new(truncated).style(helper_style),
3832 Rect::new(
3833 dialog_area.x + 2,
3834 helper_y,
3835 dialog_area.width.saturating_sub(4),
3836 1,
3837 ),
3838 );
3839 }
3840 }
3841
3842 let is_editing_json = dialog.editing_text && dialog.is_editing_json();
3844 let (has_invalid_json, is_json_control) = dialog
3845 .current_item()
3846 .map(|item| match &item.control {
3847 SettingControl::Text(state) => (!state.is_valid(), false),
3848 SettingControl::Json(state) => (!state.is_valid(), is_editing_json),
3849 _ => (false, false),
3850 })
3851 .unwrap_or((false, false));
3852
3853 let help_area = Rect::new(
3854 dialog_area.x + 2,
3855 button_y + 1,
3856 dialog_area.width.saturating_sub(4),
3857 1,
3858 );
3859
3860 let (text, style) = if has_invalid_json && !is_json_control {
3861 (
3862 "⚠ Invalid JSON - fix before leaving field",
3863 Style::default().fg(theme.diagnostic_warning_fg),
3864 )
3865 } else if has_invalid_json {
3866 (
3867 "⚠ Invalid JSON",
3868 Style::default().fg(theme.diagnostic_warning_fg),
3869 )
3870 } else if is_json_control {
3871 (
3872 "↑↓←→:Move Enter:Newline Tab/Esc:Exit",
3873 Style::default().fg(theme.line_number_fg),
3874 )
3875 } else if dialog.editing_text {
3876 (
3877 "Enter/Tab:Commit field Esc:Cancel",
3878 Style::default().fg(theme.line_number_fg),
3879 )
3880 } else {
3881 (
3883 "↑↓:Navigate Tab:Fields/Buttons Enter:Edit Ctrl+S:Save Ctrl+R:Reset Esc:Cancel ●:modified",
3884 Style::default().fg(theme.line_number_fg),
3885 )
3886 };
3887 frame.render_widget(Paragraph::new(text).style(style), help_area);
3888}
3889
3890fn render_entry_dialog_inner(
3892 frame: &mut Frame,
3893 parent_area: Rect,
3894 dialog: &mut super::entry_dialog::EntryDialogState,
3895 theme: &Theme,
3896) {
3897 let dialog_width = (parent_area.width * 85 / 100).clamp(50, 90);
3898 let dialog_height = (parent_area.height * 90 / 100).max(15);
3899 let dialog_x = parent_area.x + (parent_area.width.saturating_sub(dialog_width)) / 2;
3900 let dialog_y = parent_area.y + (parent_area.height.saturating_sub(dialog_height)) / 2;
3901 let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
3902
3903 frame.render_widget(Clear, dialog_area);
3904
3905 let title = if dialog.is_dirty() {
3907 format!(" {} • modified ", dialog.title)
3908 } else {
3909 format!(" {} ", dialog.title)
3910 };
3911 let border_color = if dialog.is_dirty() {
3912 theme.diagnostic_warning_fg
3913 } else {
3914 theme.popup_border_fg
3915 };
3916 let block = Block::default()
3917 .title(title)
3918 .borders(Borders::ALL)
3919 .border_type(BorderType::Rounded)
3920 .border_style(Style::default().fg(border_color))
3921 .style(Style::default().bg(theme.popup_bg));
3922 frame.render_widget(block, dialog_area);
3923
3924 let inner = Rect::new(
3926 dialog_area.x + 2,
3927 dialog_area.y + 1,
3928 dialog_area.width.saturating_sub(4),
3929 dialog_area.height.saturating_sub(5),
3930 );
3931
3932 let max_label_width = (inner.width / 2).max(20);
3933 let label_col_width = dialog
3934 .items
3935 .iter()
3936 .map(|item| item.name.len() as u16 + 2)
3937 .filter(|&w| w <= max_label_width)
3938 .max()
3939 .unwrap_or(20)
3940 .min(max_label_width);
3941
3942 let total_content_height = dialog.total_content_height();
3943 let viewport_height = inner.height as usize;
3944 dialog.viewport_height = viewport_height;
3945 let scroll_offset = dialog.scroll_offset;
3946
3947 render_entry_items(
3948 frame,
3949 dialog_area,
3950 inner,
3951 dialog,
3952 theme,
3953 label_col_width,
3954 scroll_offset,
3955 total_content_height,
3956 viewport_height,
3957 );
3958 render_entry_buttons(frame, dialog_area, dialog, theme);
3959 render_entry_footer(frame, dialog_area, inner, dialog, theme);
3960}
3961
3962fn render_help_overlay(frame: &mut Frame, parent_area: Rect, theme: &Theme) {
3964 let help_items = [
3966 (
3967 "Navigation",
3968 vec![
3969 ("↑ / ↓", "Move up/down"),
3970 ("Tab", "Switch between categories and settings"),
3971 ("Enter", "Activate/toggle setting"),
3972 ],
3973 ),
3974 (
3975 "Search",
3976 vec![
3977 ("/", "Start search"),
3978 ("Esc", "Cancel search"),
3979 ("↑ / ↓", "Navigate results"),
3980 ("Enter", "Jump to result"),
3981 ],
3982 ),
3983 (
3984 "Actions",
3985 vec![
3986 ("Ctrl+S", "Save settings"),
3987 ("Esc", "Close settings"),
3988 ("?", "Toggle this help"),
3989 ],
3990 ),
3991 ];
3992
3993 let dialog_width = 50.min(parent_area.width.saturating_sub(4));
3995 let dialog_height = 20.min(parent_area.height.saturating_sub(4));
3996
3997 let dialog_x = parent_area.x + (parent_area.width.saturating_sub(dialog_width)) / 2;
3999 let dialog_y = parent_area.y + (parent_area.height.saturating_sub(dialog_height)) / 2;
4000 let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
4001
4002 frame.render_widget(Clear, dialog_area);
4004
4005 let block = Block::default()
4006 .title(" Keyboard Shortcuts ")
4007 .borders(Borders::ALL)
4008 .border_type(BorderType::Rounded)
4009 .border_style(Style::default().fg(theme.menu_highlight_fg))
4010 .style(Style::default().bg(theme.popup_bg));
4011 frame.render_widget(block, dialog_area);
4012
4013 let inner = Rect::new(
4015 dialog_area.x + 2,
4016 dialog_area.y + 1,
4017 dialog_area.width.saturating_sub(4),
4018 dialog_area.height.saturating_sub(2),
4019 );
4020
4021 let mut y = inner.y;
4022
4023 for (section_name, bindings) in &help_items {
4024 if y >= inner.y + inner.height.saturating_sub(1) {
4025 break;
4026 }
4027
4028 let header_style = Style::default()
4030 .fg(theme.menu_active_fg)
4031 .add_modifier(Modifier::BOLD);
4032 frame.render_widget(
4033 Paragraph::new(*section_name).style(header_style),
4034 Rect::new(inner.x, y, inner.width, 1),
4035 );
4036 y += 1;
4037
4038 for (key, description) in bindings {
4039 if y >= inner.y + inner.height.saturating_sub(1) {
4040 break;
4041 }
4042
4043 let key_style = Style::default()
4044 .fg(theme.popup_text_fg)
4045 .bg(theme.split_separator_fg);
4046 let desc_style = Style::default().fg(theme.popup_text_fg);
4047
4048 let line = Line::from(vec![
4049 Span::styled(" ", Style::default()),
4050 Span::styled(format!(" {} ", key), key_style),
4051 Span::styled(format!(" {}", description), desc_style),
4052 ]);
4053 frame.render_widget(Paragraph::new(line), Rect::new(inner.x, y, inner.width, 1));
4054 y += 1;
4055 }
4056
4057 y += 1; }
4059
4060 let footer_y = dialog_area.y + dialog_area.height - 2;
4062 let footer = "Press ? or Esc or Enter to close";
4063 let footer_style = Style::default().fg(theme.line_number_fg);
4064 let centered_x = inner.x + (inner.width.saturating_sub(footer.len() as u16)) / 2;
4065 frame.render_widget(
4066 Paragraph::new(footer).style(footer_style),
4067 Rect::new(centered_x, footer_y, footer.len() as u16, 1),
4068 );
4069}
4070
4071#[cfg(test)]
4072mod tests {
4073 use super::*;
4074
4075 #[test]
4076 fn truncate_chars_with_ellipsis_ascii_fits() {
4077 assert_eq!(truncate_chars_with_ellipsis("hi", 10), "hi");
4078 }
4079
4080 #[test]
4081 fn truncate_chars_with_ellipsis_ascii_truncates() {
4082 assert_eq!(truncate_chars_with_ellipsis("hello world!", 8), "hello...");
4083 }
4084
4085 #[test]
4086 fn truncate_chars_with_ellipsis_multibyte_does_not_panic() {
4087 let out = truncate_chars_with_ellipsis("こんにちは世界からのテスト", 8);
4091 assert!(out.ends_with("..."));
4092 assert_eq!(out.chars().count(), 8);
4094 }
4095
4096 #[test]
4097 fn truncate_chars_with_ellipsis_emoji_does_not_panic() {
4098 let out = truncate_chars_with_ellipsis("📦📦📦📦📦📦📦📦", 5);
4099 assert!(out.ends_with("..."));
4100 assert_eq!(out.chars().count(), 5);
4101 }
4102
4103 #[test]
4105 fn test_control_layout_info() {
4106 let toggle = ControlLayoutInfo::Toggle(Rect::new(0, 0, 10, 1));
4107 assert!(matches!(toggle, ControlLayoutInfo::Toggle(_)));
4108
4109 let number = ControlLayoutInfo::Number {
4110 decrement: Rect::new(0, 0, 3, 1),
4111 increment: Rect::new(4, 0, 3, 1),
4112 value: Rect::new(8, 0, 5, 1),
4113 };
4114 assert!(matches!(number, ControlLayoutInfo::Number { .. }));
4115 }
4116}