1use rust_i18n::t;
6
7use crate::primitives::display_width::str_width;
8
9use super::items::SettingControl;
10use super::layout::{SettingsHit, SettingsLayout};
11use super::search::{DeepMatch, SearchResult};
12use super::state::SettingsState;
13use crate::view::controls::{
14 render_dropdown_aligned, render_dual_list_partial, render_number_input_aligned,
15 render_text_input_aligned, render_toggle_aligned, DropdownColors, DualListColors, MapColors,
16 NumberInputColors, TextInputColors, TextListColors, ToggleColors,
17};
18use crate::view::theme::Theme;
19use crate::view::ui::scrollbar::{render_scrollbar, ScrollbarColors, ScrollbarState};
20use ratatui::layout::{Constraint, Layout, Rect};
21use ratatui::style::{Color, Modifier, Style};
22use ratatui::text::{Line, Span};
23use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};
24use ratatui::Frame;
25
26#[allow(clippy::too_many_arguments)]
30fn build_selection_spans(
31 display_text: &str,
32 display_len: usize,
33 line_idx: usize,
34 start_row: usize,
35 start_col: usize,
36 end_row: usize,
37 end_col: usize,
38 text_color: Color,
39 selection_bg: Color,
40) -> Vec<Span<'static>> {
41 let chars: Vec<char> = display_text.chars().collect();
42 let char_count = chars.len();
43
44 let (sel_start, sel_end) = if line_idx < start_row || line_idx > end_row {
46 (char_count, char_count)
48 } else if line_idx == start_row && line_idx == end_row {
49 let start = byte_to_char_idx(display_text, start_col).min(char_count);
51 let end = byte_to_char_idx(display_text, end_col).min(char_count);
52 (start, end)
53 } else if line_idx == start_row {
54 let start = byte_to_char_idx(display_text, start_col).min(char_count);
56 (start, char_count)
57 } else if line_idx == end_row {
58 let end = byte_to_char_idx(display_text, end_col).min(char_count);
60 (0, end)
61 } else {
62 (0, char_count)
64 };
65
66 let mut spans = Vec::new();
67 let normal_style = Style::default().fg(text_color);
68 let selected_style = Style::default().fg(text_color).bg(selection_bg);
69
70 if sel_start >= sel_end || sel_start >= char_count {
71 let padded = format!("{:width$}", display_text, width = display_len);
73 spans.push(Span::styled(padded, normal_style));
74 } else {
75 if sel_start > 0 {
77 let before: String = chars[..sel_start].iter().collect();
78 spans.push(Span::styled(before, normal_style));
79 }
80
81 let selected: String = chars[sel_start..sel_end].iter().collect();
83 spans.push(Span::styled(selected, selected_style));
84
85 if sel_end < char_count {
87 let after: String = chars[sel_end..].iter().collect();
88 spans.push(Span::styled(after, normal_style));
89 }
90
91 let current_len = char_count;
93 if current_len < display_len {
94 let padding = " ".repeat(display_len - current_len);
95 spans.push(Span::styled(padding, normal_style));
96 }
97 }
98
99 spans
100}
101
102fn byte_to_char_idx(s: &str, byte_offset: usize) -> usize {
104 s.char_indices()
105 .take_while(|(i, _)| *i < byte_offset)
106 .count()
107}
108
109fn truncate_chars_with_ellipsis(s: &str, max_chars: usize) -> String {
114 if s.chars().count() <= max_chars {
115 s.to_string()
116 } else {
117 let kept: String = s.chars().take(max_chars.saturating_sub(3)).collect();
118 format!("{}...", kept)
119 }
120}
121
122pub fn render_settings(
124 frame: &mut Frame,
125 area: Rect,
126 state: &mut SettingsState,
127 theme: &Theme,
128) -> SettingsLayout {
129 if area.width < 40 || area.height < 10 {
131 let msg = "[Terminal too small for settings]";
132 let x = area.x + area.width.saturating_sub(msg.len() as u16) / 2;
133 let y = area.y + area.height / 2;
134 if area.width > 0 && area.height > 0 {
135 frame.render_widget(
136 Paragraph::new(msg).style(Style::default().fg(theme.diagnostic_warning_fg)),
137 Rect::new(x, y, msg.len() as u16, 1),
138 );
139 }
140 return SettingsLayout::new(Rect::ZERO);
141 }
142
143 let modal_width = (area.width * 90 / 100).min(160);
145 let modal_height = area.height * 90 / 100;
146 let modal_x = (area.width.saturating_sub(modal_width)) / 2;
147 let modal_y = (area.height.saturating_sub(modal_height)) / 2;
148
149 let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
150
151 frame.render_widget(Clear, modal_area);
153
154 let title = if state.has_changes() {
155 format!(" Settings [{}] • (modified) ", state.target_layer_name())
156 } else {
157 format!(" Settings [{}] ", state.target_layer_name())
158 };
159
160 let block = Block::default()
161 .title(title.as_str())
162 .borders(Borders::ALL)
163 .border_type(BorderType::Rounded)
164 .border_style(Style::default().fg(theme.popup_border_fg))
165 .style(Style::default().bg(theme.popup_bg));
166 frame.render_widget(block, modal_area);
167
168 let inner_area = Rect::new(
170 modal_area.x + 1,
171 modal_area.y + 1,
172 modal_area.width.saturating_sub(2),
173 modal_area.height.saturating_sub(2),
174 );
175
176 let narrow_mode = inner_area.width < 60;
179
180 let search_area = Rect::new(inner_area.x, inner_area.y, inner_area.width, 1);
184 let search_header_height = 1u16;
185 let search_gap = 1u16;
186 if state.search_active {
187 render_search_header(frame, search_area, state, theme);
188 } else {
189 render_search_hint(frame, search_area, theme);
190 }
191
192 let footer_height = if narrow_mode { 7 } else { 2 };
194 let chrome_height = search_header_height + search_gap + footer_height;
195 let content_area = Rect::new(
196 inner_area.x,
197 inner_area.y + search_header_height + search_gap,
198 inner_area.width,
199 inner_area.height.saturating_sub(chrome_height),
200 );
201
202 let mut layout = SettingsLayout::new(modal_area);
204
205 if narrow_mode {
206 render_vertical_layout(frame, content_area, modal_area, state, theme, &mut layout);
208 } else {
209 render_horizontal_layout(frame, content_area, modal_area, state, theme, &mut layout);
211 }
212
213 let has_confirm = state.showing_confirm_dialog;
215 let has_reset = state.showing_reset_dialog;
216 let has_entry = state.showing_entry_dialog();
217 let has_help = state.showing_help;
218
219 if has_confirm {
221 if !has_entry && !has_help {
222 crate::view::dimming::apply_dimming(frame, modal_area);
223 }
224 render_confirm_dialog(frame, modal_area, state, theme);
225 }
226
227 if has_reset {
229 if !has_confirm && !has_entry && !has_help {
230 crate::view::dimming::apply_dimming(frame, modal_area);
231 }
232 render_reset_dialog(frame, modal_area, state, theme);
233 }
234
235 if has_entry {
237 let stack_depth = state.entry_dialog_stack.len();
238 for dialog_idx in 0..stack_depth {
239 if !has_help || dialog_idx < stack_depth - 1 {
240 crate::view::dimming::apply_dimming(frame, modal_area);
241 }
242 render_entry_dialog_at(frame, modal_area, state, theme, dialog_idx);
243 }
244 }
245
246 if has_help {
248 crate::view::dimming::apply_dimming(frame, modal_area);
249 render_help_overlay(frame, modal_area, theme);
250 }
251
252 layout
253}
254
255fn render_horizontal_layout(
257 frame: &mut Frame,
258 content_area: Rect,
259 modal_area: Rect,
260 state: &mut SettingsState,
261 theme: &Theme,
262 layout: &mut SettingsLayout,
263) {
264 let chunks = Layout::horizontal([
267 Constraint::Length(24),
268 Constraint::Length(1),
269 Constraint::Min(40),
270 ])
271 .split(content_area);
272
273 let categories_area = chunks[0];
274 let divider_area = chunks[1];
275 let settings_area = chunks[2];
276
277 render_categories(frame, categories_area, state, theme, layout);
279
280 let divider_style = Style::default().fg(theme.split_separator_fg);
282 for y in 0..divider_area.height {
283 frame.render_widget(
284 Paragraph::new("│").style(divider_style),
285 Rect::new(divider_area.x, divider_area.y + y, 1, 1),
286 );
287 }
288
289 let horizontal_padding = 1u16;
291 let settings_inner = Rect::new(
292 settings_area.x + horizontal_padding,
293 settings_area.y,
294 settings_area.width.saturating_sub(horizontal_padding * 2),
295 settings_area.height,
296 );
297
298 if state.search_active && !state.search_results.is_empty() {
299 render_search_results(frame, settings_inner, state, theme, layout);
300 } else {
301 render_settings_panel(frame, settings_inner, state, theme, layout);
302 }
303
304 render_footer(frame, modal_area, state, theme, layout, false);
306}
307
308fn render_vertical_layout(
310 frame: &mut Frame,
311 content_area: Rect,
312 modal_area: Rect,
313 state: &mut SettingsState,
314 theme: &Theme,
315 layout: &mut SettingsLayout,
316) {
317 let footer_height = 7;
319
320 let main_height = content_area.height.saturating_sub(footer_height);
322 let category_height = 3u16.min(main_height);
323 let settings_height = main_height.saturating_sub(category_height + 1); let categories_area = Rect::new(
327 content_area.x,
328 content_area.y,
329 content_area.width,
330 category_height,
331 );
332
333 let sep_y = content_area.y + category_height;
335
336 let settings_area = Rect::new(
338 content_area.x,
339 sep_y + 1,
340 content_area.width,
341 settings_height,
342 );
343
344 render_categories_horizontal(frame, categories_area, state, theme, layout);
346
347 if sep_y < content_area.y + content_area.height {
349 let sep_line: String = "─".repeat(content_area.width as usize);
350 frame.render_widget(
351 Paragraph::new(sep_line).style(Style::default().fg(theme.split_separator_fg)),
352 Rect::new(content_area.x, sep_y, content_area.width, 1),
353 );
354 }
355
356 if state.search_active && !state.search_results.is_empty() {
358 render_search_results(frame, settings_area, state, theme, layout);
359 } else {
360 render_settings_panel(frame, settings_area, state, theme, layout);
361 }
362
363 render_footer(frame, modal_area, state, theme, layout, true);
365}
366
367fn render_categories_horizontal(
369 frame: &mut Frame,
370 area: Rect,
371 state: &SettingsState,
372 theme: &Theme,
373 layout: &mut SettingsLayout,
374) {
375 use super::state::FocusPanel;
376
377 if area.height == 0 || area.width == 0 {
378 return;
379 }
380
381 let is_focused = state.focus_panel() == FocusPanel::Categories;
382
383 let mut spans = Vec::new();
385 let mut total_width = 0u16;
386
387 for (i, page) in state.pages.iter().enumerate() {
388 let is_selected = i == state.selected_category;
389 let has_modified = page.items.iter().any(|item| item.modified);
390
391 let indicator = if has_modified { "● " } else { " " };
392 let name = &page.name;
393
394 let style = if is_selected && is_focused {
395 Style::default()
396 .fg(theme.menu_highlight_fg)
397 .bg(theme.menu_highlight_bg)
398 .add_modifier(Modifier::BOLD)
399 } else if is_selected {
400 Style::default()
401 .fg(theme.menu_highlight_fg)
402 .add_modifier(Modifier::BOLD)
403 } else {
404 Style::default().fg(theme.popup_text_fg)
405 };
406
407 let indicator_style = if has_modified {
408 Style::default().fg(theme.menu_highlight_fg)
409 } else {
410 style
411 };
412
413 if i > 0 {
415 spans.push(Span::styled(
416 " │ ",
417 Style::default().fg(theme.split_separator_fg),
418 ));
419 total_width += 3;
420 }
421
422 spans.push(Span::styled(indicator, indicator_style));
423 spans.push(Span::styled(name.as_str(), style));
424 total_width += (indicator.len() + name.len()) as u16;
425
426 let cat_x = area.x + total_width.saturating_sub((indicator.len() + name.len()) as u16);
428 let cat_width = (indicator.len() + name.len()) as u16;
429 layout
430 .categories
431 .push((i, Rect::new(cat_x, area.y, cat_width, 1)));
432 }
433
434 let line = Line::from(spans);
436 frame.render_widget(Paragraph::new(line), area);
437
438 if area.height >= 2 {
440 let hint = "←→: Switch category";
441 let hint_style = Style::default().fg(theme.line_number_fg);
442 frame.render_widget(
443 Paragraph::new(hint).style(hint_style),
444 Rect::new(area.x, area.y + 1, area.width, 1),
445 );
446 }
447}
448
449fn category_icon(name: &str) -> &'static str {
451 match name.to_lowercase().as_str() {
452 "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} ", }
464}
465
466fn render_categories(
472 frame: &mut Frame,
473 area: Rect,
474 state: &mut SettingsState,
475 theme: &Theme,
476 layout: &mut SettingsLayout,
477) {
478 use super::state::{FocusPanel, TreeRow};
479
480 layout.categories_panel_area = Some(area);
481
482 let rows = state.visible_tree();
483 state.categories_scroll.set_viewport(area.height);
484 state
485 .categories_scroll
486 .update_content_height(&rows, area.width);
487
488 let focus_panel = state.focus_panel();
489 let selected_category = state.selected_category;
490 let tree_cursor = state.tree_cursor_section;
495
496 struct RowData {
499 chevron: &'static str,
500 is_expandable: bool,
501 is_selected: bool,
502 has_changes: bool,
503 indent_cols: u16,
504 is_category: bool,
505 cat_idx: Option<usize>,
506 section_idx: Option<usize>,
507 label: String,
508 icon: Option<&'static str>,
509 }
510 let row_data: Vec<RowData> = rows
511 .iter()
512 .map(|row| match *row {
513 TreeRow::Category {
514 idx,
515 expandable,
516 expanded,
517 } => {
518 let page = &state.pages[idx];
519 RowData {
520 chevron: if expandable {
521 if expanded {
522 "▼"
523 } else {
524 "▶"
525 }
526 } else {
527 " "
528 },
529 is_expandable: expandable,
530 is_selected: idx == selected_category && tree_cursor.is_none(),
533 has_changes: page.items.iter().any(|i| i.modified),
534 indent_cols: 0,
535 is_category: true,
536 cat_idx: Some(idx),
537 section_idx: None,
538 label: page.name.clone(),
539 icon: Some(category_icon(&page.name)),
540 }
541 }
542 TreeRow::Section {
543 cat_idx,
544 section_idx,
545 } => {
546 let section = &state.pages[cat_idx].sections[section_idx];
547 let is_current = cat_idx == selected_category && tree_cursor == Some(section_idx);
553 RowData {
554 chevron: " ",
555 is_expandable: false,
556 is_selected: is_current,
557 has_changes: false,
558 indent_cols: 4,
559 is_category: false,
560 cat_idx: Some(cat_idx),
561 section_idx: Some(section_idx),
562 label: section.name.clone(),
563 icon: None,
564 }
565 }
566 })
567 .collect();
568
569 let panel_layout = state.categories_scroll.render(
571 frame,
572 area,
573 &rows,
574 |frame, info, row| {
575 let idx = info.index;
577 let data = &row_data[idx];
578 let row_area = info.area;
579
580 let row_bg = if data.is_selected {
588 if focus_panel == FocusPanel::Categories {
589 Some(theme.menu_highlight_bg)
590 } else {
591 Some(theme.selection_bg)
592 }
593 } else {
594 None
595 };
596 if let Some(bg) = row_bg {
597 frame.render_widget(
598 Paragraph::new(" ".repeat(row_area.width as usize))
599 .style(Style::default().bg(bg)),
600 row_area,
601 );
602 }
603
604 let fg = if data.is_selected {
605 if focus_panel == FocusPanel::Categories {
606 theme.menu_highlight_fg
607 } else {
608 theme.menu_fg
609 }
610 } else {
611 theme.popup_text_fg
612 };
613 let bg = row_bg.unwrap_or(theme.popup_bg);
614 let style = Style::default().fg(fg).bg(bg);
615
616 let mut spans: Vec<Span> = Vec::with_capacity(8);
617 let selected_marker = if data.is_selected && focus_panel == FocusPanel::Categories {
621 ">"
622 } else {
623 " "
624 };
625 spans.push(Span::styled(selected_marker.to_string(), style));
626 if data.indent_cols > 0 {
627 spans.push(Span::styled(" ".repeat(data.indent_cols as usize), style));
628 }
629 spans.push(Span::styled(format!("{} ", data.chevron), style));
631 if data.has_changes {
632 spans.push(Span::styled(
633 "● ",
634 Style::default().fg(theme.menu_highlight_fg).bg(bg),
635 ));
636 } else {
637 spans.push(Span::styled(" ", style));
638 }
639 if let Some(icon) = data.icon {
640 spans.push(Span::styled(
641 icon.to_string(),
642 Style::default().fg(theme.popup_border_fg).bg(bg),
643 ));
644 } else {
645 spans.push(Span::styled(" ", style));
646 }
647 spans.push(Span::styled(data.label.clone(), style));
648
649 frame.render_widget(Paragraph::new(Line::from(spans)), row_area);
650
651 (
654 row_area,
655 data.is_category,
656 data.is_expandable,
657 data.cat_idx,
658 data.section_idx,
659 data.indent_cols,
660 *row,
661 )
662 },
663 theme,
664 );
665
666 for layout_info in panel_layout.item_layouts.iter() {
668 let (row_area, is_category, is_expandable, cat_idx, section_idx, indent_cols, _row) =
669 layout_info.layout;
670 if is_category {
671 if let Some(idx) = cat_idx {
672 layout.add_category(idx, row_area);
673 if is_expandable {
674 let chevron_x = row_area.x.saturating_add(1 + indent_cols);
677 let chevron_area = Rect::new(chevron_x, row_area.y, 1, 1);
678 layout.add_category_disclosure(idx, chevron_area);
679 }
680 }
681 } else if let (Some(c), Some(s)) = (cat_idx, section_idx) {
682 layout.add_section(c, s, row_area);
683 }
684 }
685 if let Some(scrollbar) = panel_layout.scrollbar_area {
686 layout.categories_scrollbar_area = Some(scrollbar);
687 }
688}
689
690struct RenderContext {
692 selected_item: usize,
693 settings_focused: bool,
694 hover_hit: Option<SettingsHit>,
695}
696
697fn render_settings_panel(
699 frame: &mut Frame,
700 area: Rect,
701 state: &mut SettingsState,
702 theme: &Theme,
703 layout: &mut SettingsLayout,
704) {
705 let page = match state.current_page() {
706 Some(p) => p,
707 None => return,
708 };
709
710 let mut y = area.y;
715 let header_start_y = y;
716
717 if page.nullable && state.current_category_has_values() {
719 let btn_text = format!("[{}]", t!("settings.btn_clear_category"));
720 let btn_len = btn_text.len() as u16;
721 let is_hovered = matches!(state.hover_hit, Some(SettingsHit::ClearCategoryButton));
722 let btn_style = if is_hovered {
723 Style::default()
724 .fg(theme.menu_hover_fg)
725 .bg(theme.menu_hover_bg)
726 } else {
727 Style::default().fg(theme.line_number_fg)
728 };
729 let btn_area = Rect::new(area.x, y, btn_len, 1);
730 frame.render_widget(Paragraph::new(btn_text).style(btn_style), btn_area);
731 layout.clear_category_button = Some(btn_area);
732 y += 1;
733 } else {
734 layout.clear_category_button = None;
735 }
736
737 y += 1; let header_height = (y - header_start_y) as usize;
740 let items_start_y = y;
741
742 let available_height = area.height.saturating_sub(header_height as u16);
744
745 state.layout_width = area.width;
750
751 let page = state.pages.get(state.selected_category).unwrap();
753 state.scroll_panel.set_viewport(available_height);
754 state
755 .scroll_panel
756 .update_content_height(&page.items, area.width);
757
758 use super::state::FocusPanel;
760 let render_ctx = RenderContext {
761 selected_item: state.selected_item,
762 settings_focused: state.focus_panel() == FocusPanel::Settings,
763 hover_hit: state.hover_hit,
764 };
765
766 let items_area = Rect::new(area.x, items_start_y, area.width, available_height.max(1));
768
769 let page = state.pages.get(state.selected_category).unwrap();
771
772 let max_label_width = page
774 .items
775 .iter()
776 .filter_map(|item| {
777 match &item.control {
779 SettingControl::Toggle(s) => Some(s.label.len() as u16),
780 SettingControl::Number(s) => Some(s.label.len() as u16),
781 SettingControl::Dropdown(s) => Some(s.label.len() as u16),
782 SettingControl::Text(s) => Some(s.label.len() as u16),
783 _ => None,
785 }
786 })
787 .max();
788
789 let panel_layout = state.scroll_panel.render(
791 frame,
792 items_area,
793 &page.items,
794 |frame, info, item| {
795 render_setting_item_pure(
796 frame,
797 info.area,
798 item,
799 info.index,
800 info.skip_top,
801 &render_ctx,
802 theme,
803 max_label_width,
804 )
805 },
806 theme,
807 );
808
809 let page = state.pages.get(state.selected_category).unwrap();
811 for item_info in panel_layout.item_layouts {
812 layout.add_item(
813 item_info.index,
814 page.items[item_info.index].path.clone(),
815 item_info.area,
816 item_info.layout.control,
817 item_info.layout.inherit_button,
818 );
819 }
820
821 layout.settings_panel_area = Some(panel_layout.content_area);
823
824 if let Some(sb_area) = panel_layout.scrollbar_area {
826 layout.scrollbar_area = Some(sb_area);
827 }
828}
829
830fn wrap_text(text: &str, width: usize) -> Vec<String> {
832 if width == 0 || text.is_empty() {
833 return vec![text.to_string()];
834 }
835
836 let mut lines = Vec::new();
837 let mut current_line = String::new();
838 let mut current_len = 0;
839
840 for word in text.split_whitespace() {
841 let word_len = word.chars().count();
842
843 if current_len == 0 {
844 current_line = word.to_string();
846 current_len = word_len;
847 } else if current_len + 1 + word_len <= width {
848 current_line.push(' ');
850 current_line.push_str(word);
851 current_len += 1 + word_len;
852 } else {
853 lines.push(current_line);
855 current_line = word.to_string();
856 current_len = word_len;
857 }
858 }
859
860 if !current_line.is_empty() {
861 lines.push(current_line);
862 }
863
864 if lines.is_empty() {
865 lines.push(String::new());
866 }
867
868 lines
869}
870
871#[allow(clippy::too_many_arguments)]
882fn render_setting_item_pure(
883 frame: &mut Frame,
884 area: Rect,
885 item: &super::items::SettingItem,
886 idx: usize,
887 skip_top: u16,
888 ctx: &RenderContext,
889 theme: &Theme,
890 label_width: Option<u16>,
891) -> SettingItemLayoutInfo {
892 let plan = item.layout_box(area.width, &item.style);
893 let style = item.style;
894 let viewport_end_logical = skip_top.saturating_add(area.height); let band_rect = |logical_y: u16, rows: u16| -> Option<Rect> {
900 if rows == 0 {
901 return None;
902 }
903 let band_end = logical_y.saturating_add(rows);
904 if band_end <= skip_top || logical_y >= viewport_end_logical {
905 return None;
906 }
907 let visible_top_logical = logical_y.max(skip_top);
908 let visible_bottom_logical = band_end.min(viewport_end_logical);
909 let physical_y = area.y + (visible_top_logical - skip_top);
910 let visible_h = visible_bottom_logical - visible_top_logical;
911 Some(Rect::new(area.x, physical_y, area.width, visible_h))
912 };
913
914 if let (Some(section_name), Some(_header_rect)) = (
920 item.section.as_deref().filter(|_| item.is_section_start),
921 band_rect(0, plan.section_header_rows),
922 ) {
923 let title_logical_y = plan.section_header_rows.saturating_sub(1);
924 if let Some(title_rect) = band_rect(title_logical_y, 1) {
925 let header_style = Style::default()
926 .fg(theme.editor_fg)
927 .add_modifier(Modifier::BOLD);
928 frame.render_widget(
929 Paragraph::new(section_name).style(header_style),
930 Rect::new(title_rect.x, title_rect.y, title_rect.width, 1),
931 );
932 }
933 }
934
935 let card_logical_top = plan.card_top_y();
940 let card_logical_bottom = plan.total_rows();
941 if let Some(card_rect) = band_rect(
942 card_logical_top,
943 card_logical_bottom.saturating_sub(card_logical_top),
944 ) {
945 let mut borders = Borders::NONE;
946 if style.card_border_cols > 0 {
947 borders |= Borders::LEFT | Borders::RIGHT;
948 }
949 if style.card_border_rows > 0 {
950 if card_logical_top >= skip_top {
952 borders |= Borders::TOP;
953 }
954 let bottom_logical = card_logical_bottom.saturating_sub(1);
956 if bottom_logical >= skip_top && bottom_logical < viewport_end_logical {
957 borders |= Borders::BOTTOM;
958 }
959 }
960 if !borders.is_empty() {
961 let block = Block::default()
965 .borders(borders)
966 .border_type(BorderType::Rounded)
967 .border_style(Style::default().fg(theme.split_separator_fg));
968 frame.render_widget(block, card_rect);
969 }
970 }
971
972 let is_selected = ctx.settings_focused && idx == ctx.selected_item;
974 let is_item_hovered = matches!(
975 ctx.hover_hit,
976 Some(SettingsHit::Item(i))
977 | Some(SettingsHit::ControlToggle(i))
978 | Some(SettingsHit::ControlDecrement(i))
979 | Some(SettingsHit::ControlIncrement(i))
980 | Some(SettingsHit::ControlDropdown(i))
981 | Some(SettingsHit::ControlText(i))
982 | Some(SettingsHit::ControlTextListRow(i, _))
983 | Some(SettingsHit::ControlMapRow(i, _))
984 | Some(SettingsHit::ControlInherit(i))
985 if i == idx
986 );
987 let is_focused_or_hovered = is_selected || is_item_hovered;
988
989 let content_logical_top = plan.control_y();
992 let content_logical_bottom = plan.bottom_border_y();
993 let mut control_layout = ControlLayoutInfo::default();
994 let mut inherit_button_area: Option<Rect> = None;
995 if let Some(content_rect) = band_rect(
996 content_logical_top,
997 content_logical_bottom.saturating_sub(content_logical_top),
998 ) {
999 let inner_x = content_rect.x.saturating_add(style.card_border_cols);
1001 let inner_width = content_rect
1002 .width
1003 .saturating_sub(2 * style.card_border_cols);
1004 let inner_area = Rect::new(inner_x, content_rect.y, inner_width, content_rect.height);
1005
1006 let label_visible = skip_top <= content_logical_top;
1014 if is_focused_or_hovered && inner_width > 0 && label_visible {
1015 let bg_style = if is_selected {
1016 Style::default().bg(theme.settings_selected_bg)
1017 } else {
1018 Style::default().bg(theme.menu_hover_bg)
1019 };
1020 let row_area = Rect::new(inner_area.x, inner_area.y, inner_area.width, 1);
1021 frame.render_widget(Paragraph::new("").style(bg_style), row_area);
1022 }
1023
1024 let content_skip_top = skip_top.saturating_sub(content_logical_top);
1028
1029 let label_row_visible = content_skip_top == 0 && inner_area.height > 0;
1033 if is_selected && label_row_visible {
1034 frame.render_widget(
1035 Paragraph::new(">").style(
1036 Style::default()
1037 .fg(theme.settings_selected_fg)
1038 .add_modifier(Modifier::BOLD),
1039 ),
1040 Rect::new(inner_area.x, inner_area.y, 1, 1),
1041 );
1042 }
1043 if item.modified && label_row_visible && inner_area.width >= 2 {
1044 frame.render_widget(
1045 Paragraph::new("●").style(Style::default().fg(theme.settings_selected_fg)),
1046 Rect::new(inner_area.x + 1, inner_area.y, 1, 1),
1047 );
1048 }
1049
1050 let control_logical_rows = plan.control_rows;
1052 if let Some(control_rect) = band_rect(content_logical_top, control_logical_rows).map(|r| {
1053 let x =
1054 r.x.saturating_add(style.card_border_cols + style.focus_indicator_cols);
1055 let w = r
1056 .width
1057 .saturating_sub(2 * style.card_border_cols + style.focus_indicator_cols);
1058 Rect::new(x, r.y, w, r.height)
1059 }) {
1060 control_layout = render_control(
1061 frame,
1062 control_rect,
1063 &item.control,
1064 &item.name,
1065 content_skip_top,
1066 theme,
1067 label_width
1068 .map(|w| w.saturating_sub(style.card_border_cols + style.focus_indicator_cols)),
1069 item.read_only,
1070 item.is_null,
1071 );
1072
1073 if item.nullable && content_skip_top == 0 && control_rect.width > 0 {
1076 if item.is_null {
1077 let badge_text = t!("settings.inherited_badge").to_string();
1078 let badge_len = badge_text.len() as u16 + 1;
1079 let badge_x = control_rect
1080 .x
1081 .saturating_add(control_rect.width)
1082 .saturating_sub(badge_len);
1083 if badge_x > control_rect.x {
1084 frame.render_widget(
1085 Paragraph::new(badge_text).style(
1086 Style::default()
1087 .fg(theme.line_number_fg)
1088 .add_modifier(Modifier::ITALIC),
1089 ),
1090 Rect::new(badge_x, control_rect.y, badge_len, 1),
1091 );
1092 }
1093 } else {
1094 let btn_text = format!("[{}]", t!("settings.btn_inherit"));
1095 let btn_len = btn_text.len() as u16 + 1;
1096 let btn_x = control_rect
1097 .x
1098 .saturating_add(control_rect.width)
1099 .saturating_sub(btn_len);
1100 if btn_x > control_rect.x {
1101 let btn_area = Rect::new(btn_x, control_rect.y, btn_len, 1);
1102 let is_hovered = matches!(
1103 ctx.hover_hit,
1104 Some(SettingsHit::ControlInherit(i)) if i == idx
1105 );
1106 let btn_style = if is_hovered {
1107 Style::default()
1108 .fg(theme.menu_hover_fg)
1109 .bg(theme.menu_hover_bg)
1110 } else {
1111 Style::default().fg(theme.line_number_fg)
1112 };
1113 frame.render_widget(Paragraph::new(btn_text).style(btn_style), btn_area);
1114 inherit_button_area = Some(btn_area);
1115 }
1116 }
1117 }
1118 }
1119
1120 let desc_logical_rows = plan.description_rows;
1124 let layer_label = match item.layer_source {
1125 crate::config_io::ConfigLayer::System => None,
1126 crate::config_io::ConfigLayer::User => Some("user"),
1127 crate::config_io::ConfigLayer::Project => Some("project"),
1128 crate::config_io::ConfigLayer::Session => Some("session"),
1129 };
1130
1131 if desc_logical_rows > 0 {
1132 if let Some(desc_rect) = band_rect(plan.description_y(), desc_logical_rows).map(|r| {
1133 let x =
1134 r.x.saturating_add(style.card_border_cols + style.focus_indicator_cols);
1135 let w = r
1136 .width
1137 .saturating_sub(2 * style.card_border_cols + style.focus_indicator_cols);
1138 Rect::new(x, r.y, w, r.height)
1139 }) {
1140 let desc_skip = skip_top.saturating_sub(plan.description_y());
1141 let max_text_width = desc_rect
1142 .width
1143 .saturating_sub(style.description_right_padding_cols)
1144 as usize;
1145 let mut lines = match item.description.as_deref() {
1146 Some(d) if !d.is_empty() => wrap_text(d, max_text_width),
1147 _ => Vec::new(),
1148 };
1149 if let Some(layer) = layer_label {
1150 if let Some(last) = lines.last_mut() {
1151 last.push_str(&format!(" ({})", layer));
1152 } else {
1153 lines.push(format!("({})", layer));
1154 }
1155 }
1156 let desc_style = Style::default().fg(theme.line_number_fg);
1157 let take = desc_rect.height as usize;
1158 for (i, line) in lines.iter().skip(desc_skip as usize).take(take).enumerate() {
1159 frame.render_widget(
1160 Paragraph::new(line.as_str()).style(desc_style),
1161 Rect::new(desc_rect.x, desc_rect.y + i as u16, desc_rect.width, 1),
1162 );
1163 }
1164 }
1165 } else if let Some(layer) = layer_label {
1166 if let Some(layer_rect) = band_rect(plan.description_y(), 1).map(|r| {
1169 let x =
1170 r.x.saturating_add(style.card_border_cols + style.focus_indicator_cols);
1171 let w = r
1172 .width
1173 .saturating_sub(2 * style.card_border_cols + style.focus_indicator_cols);
1174 Rect::new(x, r.y, w, r.height)
1175 }) {
1176 frame.render_widget(
1177 Paragraph::new(format!("({})", layer))
1178 .style(Style::default().fg(theme.line_number_fg)),
1179 layer_rect,
1180 );
1181 }
1182 }
1183 }
1184
1185 SettingItemLayoutInfo {
1186 control: control_layout,
1187 inherit_button: inherit_button_area,
1188 }
1189}
1190
1191#[allow(clippy::too_many_arguments)]
1199fn render_control(
1200 frame: &mut Frame,
1201 area: Rect,
1202 control: &SettingControl,
1203 name: &str,
1204 skip_rows: u16,
1205 theme: &Theme,
1206 label_width: Option<u16>,
1207 read_only: bool,
1208 is_null: bool,
1209) -> ControlLayoutInfo {
1210 match control {
1211 SettingControl::Toggle(state) => {
1213 if skip_rows > 0 {
1214 return ControlLayoutInfo::Toggle(Rect::default());
1215 }
1216 let colors = ToggleColors::from_theme(theme);
1217 let toggle_layout = render_toggle_aligned(frame, area, state, &colors, label_width);
1218 ControlLayoutInfo::Toggle(toggle_layout.full_area)
1219 }
1220
1221 SettingControl::Number(state) => {
1222 if skip_rows > 0 {
1223 return ControlLayoutInfo::Number {
1224 decrement: Rect::default(),
1225 increment: Rect::default(),
1226 value: Rect::default(),
1227 };
1228 }
1229 let colors = NumberInputColors::from_theme(theme);
1230 let num_layout = render_number_input_aligned(frame, area, state, &colors, label_width);
1231 ControlLayoutInfo::Number {
1232 decrement: num_layout.decrement_area,
1233 increment: num_layout.increment_area,
1234 value: num_layout.value_area,
1235 }
1236 }
1237
1238 SettingControl::Dropdown(state) => {
1239 if skip_rows > 0 {
1240 return ControlLayoutInfo::Dropdown {
1241 button_area: Rect::default(),
1242 option_areas: Vec::new(),
1243 scroll_offset: 0,
1244 };
1245 }
1246 let colors = DropdownColors::from_theme(theme);
1247 let drop_layout = render_dropdown_aligned(frame, area, state, &colors, label_width);
1248 ControlLayoutInfo::Dropdown {
1249 button_area: drop_layout.button_area,
1250 option_areas: drop_layout.option_areas,
1251 scroll_offset: drop_layout.scroll_offset,
1252 }
1253 }
1254
1255 SettingControl::Text(state) => {
1256 if skip_rows > 0 {
1257 return ControlLayoutInfo::Text(Rect::default());
1258 }
1259 if read_only {
1260 let label_w = label_width.unwrap_or(20);
1262 let label_style = Style::default().fg(theme.editor_fg);
1263 let value_style = Style::default().fg(theme.line_number_fg);
1264 let label = format!("{}: ", state.label);
1265 let value = &state.value;
1266
1267 let label_area = Rect::new(area.x, area.y, label_w, 1);
1268 let value_area = Rect::new(
1269 area.x + label_w,
1270 area.y,
1271 area.width.saturating_sub(label_w),
1272 1,
1273 );
1274
1275 frame.render_widget(Paragraph::new(label.clone()).style(label_style), label_area);
1276 frame.render_widget(
1277 Paragraph::new(value.as_str()).style(value_style),
1278 value_area,
1279 );
1280 ControlLayoutInfo::Text(Rect::default())
1281 } else if is_null {
1282 let colors = TextInputColors::from_theme_disabled(theme);
1284 let text_layout =
1285 render_text_input_aligned(frame, area, state, &colors, 30, label_width);
1286 ControlLayoutInfo::Text(text_layout.input_area)
1287 } else {
1288 let colors = TextInputColors::from_theme(theme);
1289 let text_layout =
1290 render_text_input_aligned(frame, area, state, &colors, 30, label_width);
1291 ControlLayoutInfo::Text(text_layout.input_area)
1292 }
1293 }
1294
1295 SettingControl::TextList(state) => {
1297 let colors = TextListColors::from_theme(theme);
1298 let list_layout = render_text_list_partial(frame, area, state, &colors, 30, skip_rows);
1299 ControlLayoutInfo::TextList {
1300 rows: list_layout
1301 .rows
1302 .iter()
1303 .map(|r| (r.index, r.text_area))
1304 .collect(),
1305 }
1306 }
1307
1308 SettingControl::DualList(state) => {
1309 let colors = DualListColors::from_theme(theme);
1310 let dual_layout = render_dual_list_partial(frame, area, state, &colors, skip_rows);
1311 ControlLayoutInfo::DualList(dual_layout)
1312 }
1313
1314 SettingControl::Map(state) => {
1315 let colors = MapColors::from_theme(theme);
1316 let map_layout = render_map_partial(frame, area, state, &colors, 20, skip_rows);
1317 ControlLayoutInfo::Map {
1318 entry_rows: map_layout
1319 .entry_areas
1320 .iter()
1321 .map(|e| (e.index, e.row_area))
1322 .collect(),
1323 add_row_area: map_layout.add_row_area,
1324 }
1325 }
1326
1327 SettingControl::ObjectArray(state) => {
1328 let colors = crate::view::controls::KeybindingListColors {
1329 label_fg: theme.editor_fg,
1330 key_fg: theme.help_key_fg,
1331 action_fg: theme.syntax_function,
1332 focused_bg: theme.settings_selected_bg,
1334 focused_fg: theme.settings_selected_fg,
1335 delete_fg: theme.diagnostic_error_fg,
1336 add_fg: theme.syntax_string,
1337 };
1338 let kb_layout = render_keybinding_list_partial(frame, area, state, &colors, skip_rows);
1339 ControlLayoutInfo::ObjectArray {
1340 entry_rows: kb_layout
1341 .entry_rects
1342 .iter()
1343 .map(|&(idx, rect)| (idx, rect))
1344 .collect(),
1345 }
1346 }
1347
1348 SettingControl::Json(state) => {
1349 render_json_control(frame, area, state, name, skip_rows, theme)
1350 }
1351
1352 SettingControl::Complex { type_name } => {
1353 if skip_rows > 0 {
1354 return ControlLayoutInfo::Complex;
1355 }
1356 let label_style = Style::default().fg(theme.editor_fg);
1358 let value_style = Style::default().fg(theme.line_number_fg);
1359
1360 let label = Span::styled(format!("{}: ", name), label_style);
1361 let value = Span::styled(
1362 format!("<{} - edit in config.toml>", type_name),
1363 value_style,
1364 );
1365
1366 frame.render_widget(Paragraph::new(Line::from(vec![label, value])), area);
1367 ControlLayoutInfo::Complex
1368 }
1369 }
1370}
1371
1372fn render_json_control(
1374 frame: &mut Frame,
1375 area: Rect,
1376 state: &super::items::JsonEditState,
1377 name: &str,
1378 skip_rows: u16,
1379 theme: &Theme,
1380) -> ControlLayoutInfo {
1381 use crate::view::controls::FocusState;
1382
1383 let empty_layout = ControlLayoutInfo::Json {
1384 edit_area: Rect::default(),
1385 };
1386
1387 if area.height == 0 || area.width < 10 {
1388 return empty_layout;
1389 }
1390
1391 let is_focused = state.focus == FocusState::Focused;
1392 let is_valid = state.is_valid();
1393
1394 let label_color = if is_focused {
1395 theme.menu_highlight_fg
1396 } else {
1397 theme.editor_fg
1398 };
1399
1400 let text_color = theme.editor_fg;
1401 let border_color = if !is_valid {
1402 theme.diagnostic_error_fg
1403 } else if is_focused {
1404 theme.menu_highlight_fg
1405 } else {
1406 theme.split_separator_fg
1407 };
1408
1409 let mut y = area.y;
1410 let mut content_row = 0u16;
1411
1412 if content_row >= skip_rows {
1414 let label_line = Line::from(vec![Span::styled(
1415 format!("{}:", name),
1416 Style::default().fg(label_color),
1417 )]);
1418 frame.render_widget(
1419 Paragraph::new(label_line),
1420 Rect::new(area.x, y, area.width, 1),
1421 );
1422 y += 1;
1423 }
1424 content_row += 1;
1425
1426 let indent = 2u16;
1427 let edit_width = area.width.saturating_sub(indent + 1);
1428 let edit_x = area.x + indent;
1429 let edit_start_y = y;
1430
1431 let lines = state.lines();
1433 let total_lines = lines.len();
1434 for line_idx in 0..total_lines {
1435 let actual_line_idx = line_idx;
1436
1437 if content_row < skip_rows {
1438 content_row += 1;
1439 continue;
1440 }
1441
1442 if y >= area.y + area.height {
1443 break;
1444 }
1445
1446 let line_content = lines.get(actual_line_idx).map(|s| s.as_str()).unwrap_or("");
1447
1448 let display_len = edit_width.saturating_sub(2) as usize;
1450 let display_text: String = line_content.chars().take(display_len).collect();
1451
1452 let selection = state.selection_range();
1454 let (cursor_row, cursor_col) = state.cursor_pos();
1455
1456 let content_spans = if is_focused {
1458 if let Some(((start_row, start_col), (end_row, end_col))) = selection {
1459 build_selection_spans(
1460 &display_text,
1461 display_len,
1462 actual_line_idx,
1463 start_row,
1464 start_col,
1465 end_row,
1466 end_col,
1467 text_color,
1468 theme.selection_bg,
1469 )
1470 } else {
1471 vec![Span::styled(
1472 format!("{:width$}", display_text, width = display_len),
1473 Style::default().fg(text_color),
1474 )]
1475 }
1476 } else {
1477 vec![Span::styled(
1478 format!("{:width$}", display_text, width = display_len),
1479 Style::default().fg(text_color),
1480 )]
1481 };
1482
1483 let mut spans = vec![
1485 Span::raw(" ".repeat(indent as usize)),
1486 Span::styled("│", Style::default().fg(border_color)),
1487 ];
1488 spans.extend(content_spans);
1489 spans.push(Span::styled("│", Style::default().fg(border_color)));
1490 let line = Line::from(spans);
1491
1492 frame.render_widget(Paragraph::new(line), Rect::new(area.x, y, area.width, 1));
1493
1494 if is_focused && actual_line_idx == cursor_row {
1496 let cursor_x = edit_x + 1 + cursor_col.min(display_len) as u16;
1497 if cursor_x < area.x + area.width - 1 {
1498 let cursor_char = line_content.chars().nth(cursor_col).unwrap_or(' ');
1499 let cursor_span = Span::styled(
1500 cursor_char.to_string(),
1501 Style::default()
1502 .fg(theme.cursor)
1503 .add_modifier(Modifier::REVERSED),
1504 );
1505 frame.render_widget(
1506 Paragraph::new(Line::from(vec![cursor_span])),
1507 Rect::new(cursor_x, y, 1, 1),
1508 );
1509 }
1510 }
1511
1512 y += 1;
1513 content_row += 1;
1514 }
1515
1516 if !is_valid && y < area.y + area.height {
1518 let warning = Span::styled(
1519 " ⚠ Invalid JSON",
1520 Style::default().fg(theme.diagnostic_warning_fg),
1521 );
1522 frame.render_widget(
1523 Paragraph::new(Line::from(vec![warning])),
1524 Rect::new(area.x, y, area.width, 1),
1525 );
1526 }
1527
1528 let edit_height = y.saturating_sub(edit_start_y);
1529 ControlLayoutInfo::Json {
1530 edit_area: Rect::new(edit_x, edit_start_y, edit_width, edit_height),
1531 }
1532}
1533
1534fn render_text_list_partial(
1536 frame: &mut Frame,
1537 area: Rect,
1538 state: &crate::view::controls::TextListState,
1539 colors: &TextListColors,
1540 field_width: u16,
1541 skip_rows: u16,
1542) -> crate::view::controls::TextListLayout {
1543 use crate::view::controls::text_list::{TextListLayout, TextListRowLayout};
1544 use crate::view::controls::FocusState;
1545
1546 let empty_layout = TextListLayout {
1547 rows: Vec::new(),
1548 full_area: area,
1549 };
1550
1551 if area.height == 0 || area.width < 10 {
1552 return empty_layout;
1553 }
1554
1555 let label_color = match state.focus {
1557 FocusState::Focused => colors.focused_fg,
1558 FocusState::Hovered => colors.focused_fg,
1559 FocusState::Disabled => colors.disabled,
1560 FocusState::Normal => colors.label,
1561 };
1562
1563 let mut rows = Vec::new();
1564 let mut y = area.y;
1565 let mut content_row = 0u16; if skip_rows == 0 {
1569 let label_line = Line::from(vec![
1570 Span::styled(&state.label, Style::default().fg(label_color)),
1571 Span::raw(":"),
1572 ]);
1573 frame.render_widget(
1574 Paragraph::new(label_line),
1575 Rect::new(area.x, y, area.width, 1),
1576 );
1577 y += 1;
1578 }
1579 content_row += 1;
1580
1581 let indent = 2u16;
1582 let actual_field_width = field_width.min(area.width.saturating_sub(indent + 5));
1583
1584 for (idx, item) in state.items.iter().enumerate() {
1586 if y >= area.y + area.height {
1587 break;
1588 }
1589
1590 if content_row < skip_rows {
1592 content_row += 1;
1593 continue;
1594 }
1595
1596 let is_focused = state.focused_item == Some(idx) && state.focus == FocusState::Focused;
1597 let (border_color, text_color) = if is_focused {
1598 (colors.focused, colors.text)
1599 } else if state.focus == FocusState::Disabled {
1600 (colors.disabled, colors.disabled)
1601 } else {
1602 (colors.border, colors.text)
1603 };
1604
1605 let inner_width = actual_field_width.saturating_sub(2) as usize;
1606 let visible: String = item.chars().take(inner_width).collect();
1607 let padded = format!("{:width$}", visible, width = inner_width);
1608
1609 let line = Line::from(vec![
1610 Span::raw(" ".repeat(indent as usize)),
1611 Span::styled("[", Style::default().fg(border_color)),
1612 Span::styled(padded, Style::default().fg(text_color)),
1613 Span::styled("]", Style::default().fg(border_color)),
1614 Span::raw(" "),
1615 Span::styled("[x]", Style::default().fg(colors.remove_button)),
1616 ]);
1617
1618 let row_area = Rect::new(area.x, y, area.width, 1);
1619 frame.render_widget(Paragraph::new(line), row_area);
1620
1621 let text_area = Rect::new(area.x + indent, y, actual_field_width, 1);
1622 let button_area = Rect::new(area.x + indent + actual_field_width + 1, y, 3, 1);
1623 rows.push(TextListRowLayout {
1624 text_area,
1625 button_area,
1626 index: Some(idx),
1627 });
1628
1629 y += 1;
1630 content_row += 1;
1631 }
1632
1633 if y < area.y + area.height && content_row >= skip_rows {
1635 let is_add_focused = state.focused_item.is_none() && state.focus == FocusState::Focused;
1637
1638 if is_add_focused {
1639 let inner_width = actual_field_width.saturating_sub(2) as usize;
1641 let visible: String = state.new_item_text.chars().take(inner_width).collect();
1642 let padded = format!("{:width$}", visible, width = inner_width);
1643
1644 let line = Line::from(vec![
1645 Span::raw(" ".repeat(indent as usize)),
1646 Span::styled("[", Style::default().fg(colors.focused)),
1647 Span::styled(padded, Style::default().fg(colors.text)),
1648 Span::styled("]", Style::default().fg(colors.focused)),
1649 Span::raw(" "),
1650 Span::styled("[+]", Style::default().fg(colors.add_button)),
1651 ]);
1652 let row_area = Rect::new(area.x, y, area.width, 1);
1653 frame.render_widget(Paragraph::new(line), row_area);
1654
1655 if state.cursor <= inner_width {
1657 let cursor_x = area.x + indent + 1 + state.cursor as u16;
1658 let cursor_char = state.new_item_text.chars().nth(state.cursor).unwrap_or(' ');
1659 let cursor_area = Rect::new(cursor_x, y, 1, 1);
1660 let cursor_span = Span::styled(
1661 cursor_char.to_string(),
1662 Style::default()
1663 .fg(colors.focused)
1664 .add_modifier(ratatui::style::Modifier::REVERSED),
1665 );
1666 frame.render_widget(Paragraph::new(Line::from(vec![cursor_span])), cursor_area);
1667 }
1668
1669 rows.push(TextListRowLayout {
1670 text_area: Rect::new(area.x + indent, y, actual_field_width, 1),
1671 button_area: Rect::new(area.x + indent + actual_field_width + 1, y, 3, 1),
1672 index: None,
1673 });
1674 } else {
1675 let add_line = Line::from(vec![
1677 Span::raw(" ".repeat(indent as usize)),
1678 Span::styled("[+] Add new", Style::default().fg(colors.add_button)),
1679 ]);
1680 let row_area = Rect::new(area.x, y, area.width, 1);
1681 frame.render_widget(Paragraph::new(add_line), row_area);
1682
1683 rows.push(TextListRowLayout {
1684 text_area: Rect::new(area.x + indent, y, 11, 1), button_area: Rect::new(area.x + indent, y, 11, 1),
1686 index: None,
1687 });
1688 }
1689 }
1690
1691 TextListLayout {
1692 rows,
1693 full_area: area,
1694 }
1695}
1696
1697fn render_map_partial(
1699 frame: &mut Frame,
1700 area: Rect,
1701 state: &crate::view::controls::MapState,
1702 colors: &MapColors,
1703 key_width: u16,
1704 skip_rows: u16,
1705) -> crate::view::controls::MapLayout {
1706 use crate::view::controls::map_input::{MapEntryLayout, MapLayout};
1707 use crate::view::controls::FocusState;
1708
1709 let empty_layout = MapLayout {
1710 entry_areas: Vec::new(),
1711 add_row_area: None,
1712 full_area: area,
1713 };
1714
1715 if area.height == 0 || area.width < 15 {
1716 return empty_layout;
1717 }
1718
1719 let label_color = match state.focus {
1721 FocusState::Focused => colors.focused_fg,
1722 FocusState::Hovered => colors.focused_fg,
1723 FocusState::Disabled => colors.disabled,
1724 FocusState::Normal => colors.label,
1725 };
1726
1727 let mut entry_areas = Vec::new();
1728 let mut y = area.y;
1729 let mut content_row = 0u16;
1730
1731 if skip_rows == 0 {
1733 let label_line = Line::from(vec![
1734 Span::styled(&state.label, Style::default().fg(label_color)),
1735 Span::raw(":"),
1736 ]);
1737 frame.render_widget(
1738 Paragraph::new(label_line),
1739 Rect::new(area.x, y, area.width, 1),
1740 );
1741 y += 1;
1742 }
1743 content_row += 1;
1744
1745 let indent = 2u16;
1746
1747 if state.display_field.is_some() && y < area.y + area.height {
1749 if content_row >= skip_rows {
1750 let value_header = state
1752 .display_field
1753 .as_ref()
1754 .map(|f| {
1755 let name = f.trim_start_matches('/');
1756 let mut chars = name.chars();
1758 match chars.next() {
1759 None => String::new(),
1760 Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
1761 }
1762 })
1763 .unwrap_or_else(|| "Value".to_string());
1764
1765 let header_style = Style::default()
1766 .fg(colors.label)
1767 .add_modifier(Modifier::DIM);
1768 let header_line = Line::from(vec![
1769 Span::styled(" ".repeat(indent as usize), header_style),
1770 Span::styled(
1771 format!("{:width$}", "Name", width = key_width as usize),
1772 header_style,
1773 ),
1774 Span::raw(" "),
1775 Span::styled(value_header, header_style),
1776 ]);
1777 frame.render_widget(
1778 Paragraph::new(header_line),
1779 Rect::new(area.x, y, area.width, 1),
1780 );
1781 y += 1;
1782 }
1783 content_row += 1;
1784 }
1785
1786 for (idx, (key, value)) in state.entries.iter().enumerate() {
1788 if y >= area.y + area.height {
1789 break;
1790 }
1791
1792 if content_row < skip_rows {
1793 content_row += 1;
1794 continue;
1795 }
1796
1797 let is_focused = state.focused_entry == Some(idx) && state.focus == FocusState::Focused;
1798
1799 let row_area = Rect::new(area.x, y, area.width, 1);
1800
1801 if is_focused {
1803 let highlight_style = Style::default().bg(colors.focused);
1804 let bg_line = Line::from(Span::styled(
1805 " ".repeat(area.width as usize),
1806 highlight_style,
1807 ));
1808 frame.render_widget(Paragraph::new(bg_line), row_area);
1809 }
1810
1811 let (key_color, value_color) = if is_focused {
1812 (colors.focused_fg, colors.focused_fg)
1814 } else if state.focus == FocusState::Disabled {
1815 (colors.disabled, colors.disabled)
1816 } else {
1817 (colors.key, colors.value_preview)
1818 };
1819
1820 let base_style = if is_focused {
1821 Style::default().bg(colors.focused)
1822 } else {
1823 Style::default()
1824 };
1825
1826 let value_preview = state.get_display_value(value);
1830 let value_preview = truncate_chars_with_ellipsis(&value_preview, 20);
1831
1832 let display_key: String = key.chars().take(key_width as usize).collect();
1833 let mut spans = vec![
1834 Span::styled(" ".repeat(indent as usize), base_style),
1835 Span::styled(
1836 format!("{:width$}", display_key, width = key_width as usize),
1837 base_style.fg(key_color),
1838 ),
1839 Span::raw(" "),
1840 Span::styled(value_preview, base_style.fg(value_color)),
1841 ];
1842
1843 if is_focused {
1845 spans.push(Span::styled(
1846 " [Enter to edit]",
1847 base_style.fg(colors.focused_fg).add_modifier(Modifier::DIM),
1848 ));
1849 }
1850
1851 frame.render_widget(Paragraph::new(Line::from(spans)), row_area);
1852
1853 entry_areas.push(MapEntryLayout {
1854 index: idx,
1855 row_area,
1856 expand_area: Rect::default(), key_area: Rect::new(area.x + indent, y, key_width, 1),
1858 remove_area: Rect::new(area.x + indent + key_width + 1, y, 3, 1),
1859 });
1860
1861 y += 1;
1862 content_row += 1;
1863 }
1864
1865 let add_row_area = if !state.no_add && y < area.y + area.height && content_row >= skip_rows {
1867 let row_area = Rect::new(area.x, y, area.width, 1);
1868 let is_focused = state.focused_entry.is_none() && state.focus == FocusState::Focused;
1869
1870 if is_focused {
1872 let highlight_style = Style::default().bg(colors.focused);
1873 let bg_line = Line::from(Span::styled(
1874 " ".repeat(area.width as usize),
1875 highlight_style,
1876 ));
1877 frame.render_widget(Paragraph::new(bg_line), row_area);
1878 }
1879
1880 let base_style = if is_focused {
1881 Style::default().bg(colors.focused)
1882 } else {
1883 Style::default()
1884 };
1885
1886 let mut spans = vec![
1887 Span::styled(" ".repeat(indent as usize), base_style),
1888 Span::styled("[+] Add new", base_style.fg(colors.add_button)),
1889 ];
1890
1891 if is_focused {
1892 spans.push(Span::styled(
1893 " [Enter to add]",
1894 base_style.fg(colors.focused_fg).add_modifier(Modifier::DIM),
1895 ));
1896 }
1897
1898 frame.render_widget(Paragraph::new(Line::from(spans)), row_area);
1899 Some(row_area)
1900 } else {
1901 None
1902 };
1903
1904 MapLayout {
1905 entry_areas,
1906 add_row_area,
1907 full_area: area,
1908 }
1909}
1910
1911fn render_keybinding_list_partial(
1913 frame: &mut Frame,
1914 area: Rect,
1915 state: &crate::view::controls::KeybindingListState,
1916 colors: &crate::view::controls::KeybindingListColors,
1917 skip_rows: u16,
1918) -> crate::view::controls::KeybindingListLayout {
1919 use crate::view::controls::keybinding_list::format_key_combo;
1920 use crate::view::controls::FocusState;
1921 use ratatui::text::{Line, Span};
1922 use ratatui::widgets::Paragraph;
1923
1924 let empty_layout = crate::view::controls::KeybindingListLayout {
1925 entry_rects: Vec::new(),
1926 delete_rects: Vec::new(),
1927 add_rect: None,
1928 };
1929
1930 if area.height == 0 {
1931 return empty_layout;
1932 }
1933
1934 let indent = 2u16;
1935 let is_focused = state.focus == FocusState::Focused;
1936 let mut entry_rects = Vec::new();
1937 let mut delete_rects = Vec::new();
1938 let mut content_row = 0u16;
1939 let mut y = area.y;
1940
1941 if content_row >= skip_rows {
1943 let label_line = Line::from(vec![Span::styled(
1944 format!("{}:", state.label),
1945 Style::default().fg(colors.label_fg),
1946 )]);
1947 frame.render_widget(
1948 Paragraph::new(label_line),
1949 Rect::new(area.x, y, area.width, 1),
1950 );
1951 y += 1;
1952 }
1953 content_row += 1;
1954
1955 for (idx, binding) in state.bindings.iter().enumerate() {
1957 if y >= area.y + area.height {
1958 break;
1959 }
1960
1961 if content_row >= skip_rows {
1962 let entry_area = Rect::new(area.x + indent, y, area.width.saturating_sub(indent), 1);
1963 entry_rects.push((idx, entry_area));
1964
1965 let is_entry_focused = is_focused && state.focused_index == Some(idx);
1966 let bg = if is_entry_focused {
1967 colors.focused_bg
1968 } else {
1969 Color::Reset
1970 };
1971
1972 let key_combo = format_key_combo(binding);
1973 let field_name = state
1975 .display_field
1976 .as_ref()
1977 .and_then(|p| p.strip_prefix('/'))
1978 .unwrap_or("action");
1979 let action = binding
1980 .get(field_name)
1981 .and_then(|a| a.as_str())
1982 .unwrap_or("(no action)");
1983
1984 let indicator = if is_entry_focused { "> " } else { " " };
1985 let (indicator_fg, key_fg, arrow_fg, action_fg, delete_fg) = if is_entry_focused {
1987 (
1988 colors.focused_fg,
1989 colors.focused_fg,
1990 colors.focused_fg,
1991 colors.focused_fg,
1992 colors.focused_fg,
1993 )
1994 } else {
1995 (
1996 colors.label_fg,
1997 colors.key_fg,
1998 colors.label_fg,
1999 colors.action_fg,
2000 colors.delete_fg,
2001 )
2002 };
2003 let line = Line::from(vec![
2004 Span::styled(indicator, Style::default().fg(indicator_fg).bg(bg)),
2005 Span::styled(
2006 format!("{:<20}", key_combo),
2007 Style::default().fg(key_fg).bg(bg),
2008 ),
2009 Span::styled(" → ", Style::default().fg(arrow_fg).bg(bg)),
2010 Span::styled(action, Style::default().fg(action_fg).bg(bg)),
2011 Span::styled(" [x]", Style::default().fg(delete_fg).bg(bg)),
2012 ]);
2013 frame.render_widget(Paragraph::new(line), entry_area);
2014
2015 let delete_x = entry_area.x + entry_area.width.saturating_sub(4);
2017 delete_rects.push(Rect::new(delete_x, y, 3, 1));
2018
2019 y += 1;
2020 }
2021 content_row += 1;
2022 }
2023
2024 let add_rect = if y < area.y + area.height && content_row >= skip_rows {
2026 let is_add_focused = is_focused && state.focused_index.is_none();
2027 let bg = if is_add_focused {
2028 colors.focused_bg
2029 } else {
2030 Color::Reset
2031 };
2032
2033 let indicator = if is_add_focused { "> " } else { " " };
2034 let (indicator_fg, add_fg) = if is_add_focused {
2036 (colors.focused_fg, colors.focused_fg)
2037 } else {
2038 (colors.label_fg, colors.add_fg)
2039 };
2040 let line = Line::from(vec![
2041 Span::styled(indicator, Style::default().fg(indicator_fg).bg(bg)),
2042 Span::styled("[+] Add new", Style::default().fg(add_fg).bg(bg)),
2043 ]);
2044 let add_area = Rect::new(area.x + indent, y, area.width.saturating_sub(indent), 1);
2045 frame.render_widget(Paragraph::new(line), add_area);
2046 Some(add_area)
2047 } else {
2048 None
2049 };
2050
2051 crate::view::controls::KeybindingListLayout {
2052 entry_rects,
2053 delete_rects,
2054 add_rect,
2055 }
2056}
2057
2058#[derive(Debug, Clone, Default)]
2060pub struct SettingItemLayoutInfo {
2061 pub control: ControlLayoutInfo,
2062 pub inherit_button: Option<Rect>,
2063}
2064
2065#[derive(Debug, Clone, Default)]
2067pub enum ControlLayoutInfo {
2068 Toggle(Rect),
2069 Number {
2070 decrement: Rect,
2071 increment: Rect,
2072 value: Rect,
2073 },
2074 Dropdown {
2075 button_area: Rect,
2076 option_areas: Vec<Rect>,
2077 scroll_offset: usize,
2078 },
2079 Text(Rect),
2080 TextList {
2081 rows: Vec<(Option<usize>, Rect)>,
2083 },
2084 DualList(crate::view::controls::DualListLayout),
2085 Map {
2086 entry_rows: Vec<(usize, Rect)>,
2088 add_row_area: Option<Rect>,
2089 },
2090 ObjectArray {
2091 entry_rows: Vec<(usize, Rect)>,
2093 },
2094 Json {
2095 edit_area: Rect,
2096 },
2097 #[default]
2098 Complex,
2099}
2100
2101#[allow(clippy::too_many_arguments)]
2103fn render_button(
2104 frame: &mut Frame,
2105 area: Rect,
2106 text: &str,
2107 focused_text: &str,
2108 is_focused: bool,
2109 is_hovered: bool,
2110 theme: &Theme,
2111 dimmed: bool,
2112) {
2113 if is_focused {
2114 let style = Style::default()
2115 .fg(theme.menu_highlight_fg)
2116 .bg(theme.menu_highlight_bg)
2117 .add_modifier(Modifier::BOLD);
2118 frame.render_widget(Paragraph::new(focused_text).style(style), area);
2119 } else if is_hovered {
2120 let style = Style::default()
2121 .fg(theme.menu_hover_fg)
2122 .bg(theme.menu_hover_bg);
2123 frame.render_widget(Paragraph::new(text).style(style), area);
2124 } else {
2125 let fg = if dimmed {
2126 theme.line_number_fg
2127 } else {
2128 theme.popup_text_fg
2129 };
2130 frame.render_widget(Paragraph::new(text).style(Style::default().fg(fg)), area);
2131 }
2132}
2133
2134fn render_footer(
2137 frame: &mut Frame,
2138 modal_area: Rect,
2139 state: &SettingsState,
2140 theme: &Theme,
2141 layout: &mut SettingsLayout,
2142 vertical: bool,
2143) {
2144 use super::layout::SettingsHit;
2145 use super::state::FocusPanel;
2146
2147 if modal_area.height < 4 || modal_area.width < 10 {
2149 return;
2150 }
2151
2152 if vertical {
2153 render_footer_vertical(frame, modal_area, state, theme, layout);
2154 return;
2155 }
2156
2157 let footer_y = modal_area.y + modal_area.height.saturating_sub(2);
2158 let footer_width = modal_area.width.saturating_sub(2);
2159 let footer_area = Rect::new(modal_area.x + 1, footer_y, footer_width, 1);
2160
2161 if footer_y > modal_area.y {
2163 let sep_y = footer_y.saturating_sub(1);
2164 let sep_area = Rect::new(modal_area.x + 1, sep_y, footer_width, 1);
2165 let sep_line: String = "─".repeat(sep_area.width as usize);
2166 frame.render_widget(
2167 Paragraph::new(sep_line).style(Style::default().fg(theme.split_separator_fg)),
2168 sep_area,
2169 );
2170 }
2171
2172 let footer_focused = state.focus_panel() == FocusPanel::Footer;
2174
2175 let layer_hovered = matches!(state.hover_hit, Some(SettingsHit::LayerButton));
2178 let reset_hovered = matches!(state.hover_hit, Some(SettingsHit::ResetButton));
2179 let save_hovered = matches!(state.hover_hit, Some(SettingsHit::SaveButton));
2180 let cancel_hovered = matches!(state.hover_hit, Some(SettingsHit::CancelButton));
2181 let edit_hovered = matches!(state.hover_hit, Some(SettingsHit::EditButton));
2182
2183 let layer_focused = footer_focused && state.footer_button_index == 0;
2184 let reset_focused = footer_focused && state.footer_button_index == 1;
2185 let save_focused = footer_focused && state.footer_button_index == 2;
2186 let cancel_focused = footer_focused && state.footer_button_index == 3;
2187 let edit_focused = footer_focused && state.footer_button_index == 4;
2188
2189 let current_is_nullable_set = state
2192 .current_item()
2193 .map(|item| item.nullable && !item.is_null)
2194 .unwrap_or(false);
2195 let save_label = t!("settings.btn_save").to_string();
2196 let cancel_label = t!("settings.btn_cancel").to_string();
2197 let reset_label = if current_is_nullable_set {
2198 t!("settings.btn_inherit").to_string()
2199 } else {
2200 t!("settings.btn_reset").to_string()
2201 };
2202 let edit_label = t!("settings.btn_edit").to_string();
2203
2204 let layer_text = format!("[ {} ]", state.target_layer_name());
2206 let layer_text_focused = format!(">[ {} ]", state.target_layer_name());
2207 let save_text = format!("[ {} ]", save_label);
2208 let save_text_focused = format!(">[ {} ]", save_label);
2209 let cancel_text = format!("[ {} ]", cancel_label);
2210 let cancel_text_focused = format!(">[ {} ]", cancel_label);
2211 let reset_text = format!("[ {} ]", reset_label);
2212 let reset_text_focused = format!(">[ {} ]", reset_label);
2213 let edit_text = format!("[ {} ]", edit_label);
2214 let edit_text_focused = format!(">[ {} ]", edit_label);
2215
2216 let cancel_width = str_width(if cancel_focused {
2218 &cancel_text_focused
2219 } else {
2220 &cancel_text
2221 }) as u16;
2222 let save_width = str_width(if save_focused {
2223 &save_text_focused
2224 } else {
2225 &save_text
2226 }) as u16;
2227 let reset_width = str_width(if reset_focused {
2228 &reset_text_focused
2229 } else {
2230 &reset_text
2231 }) as u16;
2232 let layer_width = str_width(if layer_focused {
2233 &layer_text_focused
2234 } else {
2235 &layer_text
2236 }) as u16;
2237 let edit_width = str_width(if edit_focused {
2238 &edit_text_focused
2239 } else {
2240 &edit_text
2241 }) as u16;
2242 let gap: u16 = 2;
2243
2244 let min_buttons_width = save_width + gap + cancel_width;
2247 let all_buttons_width =
2249 edit_width + gap + layer_width + gap + reset_width + gap + save_width + gap + cancel_width;
2250
2251 let available = footer_area.width;
2253 let show_edit = available >= all_buttons_width;
2254 let show_layer = available >= (layer_width + gap + reset_width + gap + min_buttons_width);
2255 let show_reset = available >= (reset_width + gap + min_buttons_width);
2256
2257 let cancel_x = footer_area
2259 .x
2260 .saturating_add(footer_area.width.saturating_sub(cancel_width));
2261 let save_x = cancel_x.saturating_sub(save_width + gap);
2262 let reset_x = if show_reset {
2263 save_x.saturating_sub(reset_width + gap)
2264 } else {
2265 0
2266 };
2267 let layer_x = if show_layer {
2268 reset_x.saturating_sub(layer_width + gap)
2269 } else {
2270 0
2271 };
2272 let edit_x = footer_area.x; if show_layer {
2277 let layer_area = Rect::new(layer_x, footer_y, layer_width, 1);
2278 render_button(
2279 frame,
2280 layer_area,
2281 &layer_text,
2282 &layer_text_focused,
2283 layer_focused,
2284 layer_hovered,
2285 theme,
2286 false,
2287 );
2288 layout.layer_button = Some(layer_area);
2289 }
2290
2291 if show_reset {
2293 let reset_area = Rect::new(reset_x, footer_y, reset_width, 1);
2294 render_button(
2295 frame,
2296 reset_area,
2297 &reset_text,
2298 &reset_text_focused,
2299 reset_focused,
2300 reset_hovered,
2301 theme,
2302 false,
2303 );
2304 layout.reset_button = Some(reset_area);
2305 }
2306
2307 let save_area = Rect::new(save_x, footer_y, save_width, 1);
2309 render_button(
2310 frame,
2311 save_area,
2312 &save_text,
2313 &save_text_focused,
2314 save_focused,
2315 save_hovered,
2316 theme,
2317 false,
2318 );
2319 layout.save_button = Some(save_area);
2320
2321 let cancel_area = Rect::new(cancel_x, footer_y, cancel_width, 1);
2323 render_button(
2324 frame,
2325 cancel_area,
2326 &cancel_text,
2327 &cancel_text_focused,
2328 cancel_focused,
2329 cancel_hovered,
2330 theme,
2331 false,
2332 );
2333 layout.cancel_button = Some(cancel_area);
2334
2335 if show_edit {
2337 let edit_area = Rect::new(edit_x, footer_y, edit_width, 1);
2338 render_button(
2339 frame,
2340 edit_area,
2341 &edit_text,
2342 &edit_text_focused,
2343 edit_focused,
2344 edit_hovered,
2345 theme,
2346 true, );
2348 layout.edit_button = Some(edit_area);
2349 }
2350
2351 let help_start_x = if show_edit {
2354 edit_x + edit_width + 2
2355 } else {
2356 footer_area.x
2357 };
2358 let help_end_x = if show_layer {
2359 layer_x
2360 } else if show_reset {
2361 reset_x
2362 } else {
2363 save_x
2364 };
2365 let help_width = help_end_x.saturating_sub(help_start_x + 1);
2366
2367 let help = if state.search_active {
2369 t!("settings.help_search").to_string()
2370 } else if footer_focused {
2371 t!("settings.help_footer").to_string()
2372 } else {
2373 t!("settings.help_default").to_string()
2374 };
2375 let help_line = build_keyhint_line(&help, theme);
2378 frame.render_widget(
2379 Paragraph::new(help_line),
2380 Rect::new(help_start_x, footer_y, help_width, 1),
2381 );
2382}
2383
2384fn build_keyhint_line<'a>(text: &str, theme: &Theme) -> Line<'a> {
2386 let key_style = Style::default()
2387 .fg(theme.popup_text_fg)
2388 .bg(theme.split_separator_fg);
2389 let desc_style = Style::default().fg(theme.line_number_fg);
2390 let sep_style = Style::default().fg(theme.line_number_fg);
2391
2392 let mut spans: Vec<Span<'a>> = Vec::new();
2393
2394 for (i, segment) in text.split(" ").enumerate() {
2396 let segment = segment.trim();
2397 if segment.is_empty() {
2398 continue;
2399 }
2400 if i > 0 {
2401 spans.push(Span::styled(" ", sep_style));
2402 }
2403 if let Some(colon_pos) = segment.find(':') {
2405 let key = &segment[..colon_pos];
2406 let action = &segment[colon_pos + 1..];
2407 spans.push(Span::styled(format!(" {} ", key), key_style));
2408 spans.push(Span::styled(action.to_string(), desc_style));
2409 } else {
2410 spans.push(Span::styled(segment.to_string(), desc_style));
2412 }
2413 }
2414
2415 Line::from(spans)
2416}
2417
2418fn render_footer_vertical(
2420 frame: &mut Frame,
2421 modal_area: Rect,
2422 state: &SettingsState,
2423 theme: &Theme,
2424 layout: &mut SettingsLayout,
2425) {
2426 use super::layout::SettingsHit;
2427 use super::state::FocusPanel;
2428
2429 let footer_height = 7u16;
2431 let footer_y = modal_area
2432 .y
2433 .saturating_add(modal_area.height.saturating_sub(footer_height));
2434 let footer_width = modal_area.width.saturating_sub(2);
2435
2436 let sep_y = footer_y;
2438 if sep_y > modal_area.y {
2439 let sep_line: String = "─".repeat(footer_width as usize);
2440 frame.render_widget(
2441 Paragraph::new(sep_line).style(Style::default().fg(theme.split_separator_fg)),
2442 Rect::new(modal_area.x + 1, sep_y, footer_width, 1),
2443 );
2444 }
2445
2446 let footer_focused = state.focus_panel() == FocusPanel::Footer;
2448
2449 let layer_hovered = matches!(state.hover_hit, Some(SettingsHit::LayerButton));
2451 let reset_hovered = matches!(state.hover_hit, Some(SettingsHit::ResetButton));
2452 let save_hovered = matches!(state.hover_hit, Some(SettingsHit::SaveButton));
2453 let cancel_hovered = matches!(state.hover_hit, Some(SettingsHit::CancelButton));
2454 let edit_hovered = matches!(state.hover_hit, Some(SettingsHit::EditButton));
2455
2456 let layer_focused = footer_focused && state.footer_button_index == 0;
2457 let reset_focused = footer_focused && state.footer_button_index == 1;
2458 let save_focused = footer_focused && state.footer_button_index == 2;
2459 let cancel_focused = footer_focused && state.footer_button_index == 3;
2460 let edit_focused = footer_focused && state.footer_button_index == 4;
2461
2462 let current_is_nullable_set = state
2465 .current_item()
2466 .map(|item| item.nullable && !item.is_null)
2467 .unwrap_or(false);
2468 let save_label = t!("settings.btn_save").to_string();
2469 let cancel_label = t!("settings.btn_cancel").to_string();
2470 let reset_label = if current_is_nullable_set {
2471 t!("settings.btn_inherit").to_string()
2472 } else {
2473 t!("settings.btn_reset").to_string()
2474 };
2475 let edit_label = t!("settings.btn_edit").to_string();
2476
2477 let layer_text = format!("[ {} ]", state.target_layer_name());
2479 let layer_text_focused = format!(">[ {} ]", state.target_layer_name());
2480 let save_text = format!("[ {} ]", save_label);
2481 let save_text_focused = format!(">[ {} ]", save_label);
2482 let cancel_text = format!("[ {} ]", cancel_label);
2483 let cancel_text_focused = format!(">[ {} ]", cancel_label);
2484 let reset_text = format!("[ {} ]", reset_label);
2485 let reset_text_focused = format!(">[ {} ]", reset_label);
2486 let edit_text = format!("[ {} ]", edit_label);
2487 let edit_text_focused = format!(">[ {} ]", edit_label);
2488
2489 let button_x = modal_area.x + 2;
2491 let mut y = sep_y + 1;
2492
2493 let layer_width = str_width(if layer_focused {
2495 &layer_text_focused
2496 } else {
2497 &layer_text
2498 }) as u16;
2499 let layer_area = Rect::new(button_x, y, layer_width.min(footer_width), 1);
2500 render_button(
2501 frame,
2502 layer_area,
2503 &layer_text,
2504 &layer_text_focused,
2505 layer_focused,
2506 layer_hovered,
2507 theme,
2508 false,
2509 );
2510 layout.layer_button = Some(layer_area);
2511 y += 1;
2512
2513 let save_width = str_width(if save_focused {
2515 &save_text_focused
2516 } else {
2517 &save_text
2518 }) as u16;
2519 let save_area = Rect::new(button_x, y, save_width.min(footer_width), 1);
2520 render_button(
2521 frame,
2522 save_area,
2523 &save_text,
2524 &save_text_focused,
2525 save_focused,
2526 save_hovered,
2527 theme,
2528 false,
2529 );
2530 layout.save_button = Some(save_area);
2531 y += 1;
2532
2533 let reset_width = str_width(if reset_focused {
2535 &reset_text_focused
2536 } else {
2537 &reset_text
2538 }) as u16;
2539 let reset_area = Rect::new(button_x, y, reset_width.min(footer_width), 1);
2540 render_button(
2541 frame,
2542 reset_area,
2543 &reset_text,
2544 &reset_text_focused,
2545 reset_focused,
2546 reset_hovered,
2547 theme,
2548 false,
2549 );
2550 layout.reset_button = Some(reset_area);
2551 y += 1;
2552
2553 let cancel_width = str_width(if cancel_focused {
2555 &cancel_text_focused
2556 } else {
2557 &cancel_text
2558 }) as u16;
2559 let cancel_area = Rect::new(button_x, y, cancel_width.min(footer_width), 1);
2560 render_button(
2561 frame,
2562 cancel_area,
2563 &cancel_text,
2564 &cancel_text_focused,
2565 cancel_focused,
2566 cancel_hovered,
2567 theme,
2568 false,
2569 );
2570 layout.cancel_button = Some(cancel_area);
2571 y += 1;
2572
2573 let edit_width = str_width(if edit_focused {
2575 &edit_text_focused
2576 } else {
2577 &edit_text
2578 }) as u16;
2579 let edit_area = Rect::new(button_x, y, edit_width.min(footer_width), 1);
2580 render_button(
2581 frame,
2582 edit_area,
2583 &edit_text,
2584 &edit_text_focused,
2585 edit_focused,
2586 edit_hovered,
2587 theme,
2588 true, );
2590 layout.edit_button = Some(edit_area);
2591}
2592
2593fn render_search_header(frame: &mut Frame, area: Rect, state: &SettingsState, theme: &Theme) {
2595 let search_style = Style::default().fg(theme.settings_selected_fg);
2596 let cursor_style = Style::default()
2597 .fg(theme.settings_selected_fg)
2598 .add_modifier(Modifier::REVERSED);
2599
2600 let result_count = state.search_results.len();
2602 let count_text = if state.search_query.is_empty() {
2603 String::new()
2604 } else if result_count == 0 {
2605 " (no results)".to_string()
2606 } else if result_count == 1 {
2607 " (1 result)".to_string()
2608 } else if state.search_max_visible >= result_count {
2609 format!(" ({} results)", result_count)
2611 } else {
2612 let first = state.search_scroll_offset + 1;
2614 let last = (state.search_scroll_offset + state.search_max_visible).min(result_count);
2615 format!(" ({}-{} of {})", first, last, result_count)
2616 };
2617
2618 let has_more_above = state.search_scroll_offset > 0;
2620 let has_more_below = state.search_scroll_offset + state.search_max_visible < result_count;
2621 let scroll_indicator = match (has_more_above, has_more_below) {
2622 (true, true) => " ↑↓",
2623 (true, false) => " ↑",
2624 (false, true) => " ↓",
2625 (false, false) => "",
2626 };
2627
2628 let count_style = Style::default().fg(theme.line_number_fg);
2629 let indicator_style = Style::default()
2630 .fg(theme.menu_active_fg)
2631 .add_modifier(Modifier::BOLD);
2632
2633 let spans = vec![
2634 Span::styled("> ", search_style),
2635 Span::styled(&state.search_query, search_style),
2636 Span::styled(" ", cursor_style), Span::styled(count_text, count_style),
2638 Span::styled(scroll_indicator, indicator_style),
2639 ];
2640 let line = Line::from(spans);
2641 frame.render_widget(Paragraph::new(line), area);
2642}
2643
2644fn render_search_hint(frame: &mut Frame, area: Rect, theme: &Theme) {
2646 let hint_style = Style::default().fg(theme.line_number_fg);
2647 let key_style = Style::default()
2648 .fg(theme.popup_text_fg)
2649 .bg(theme.split_separator_fg);
2650
2651 let spans = vec![
2652 Span::styled("Press ", hint_style),
2653 Span::styled(" / ", key_style),
2654 Span::styled(" to search settings...", hint_style),
2655 ];
2656 let line = Line::from(spans);
2657 frame.render_widget(Paragraph::new(line), area);
2658}
2659
2660fn render_search_results(
2662 frame: &mut Frame,
2663 area: Rect,
2664 state: &mut SettingsState,
2665 theme: &Theme,
2666 layout: &mut SettingsLayout,
2667) {
2668 let max_visible = (area.height.saturating_sub(3) / 3) as usize;
2670 state.search_max_visible = max_visible.max(1);
2671
2672 if state.search_scroll_offset >= state.search_results.len() {
2674 state.search_scroll_offset = state.search_results.len().saturating_sub(1);
2675 }
2676
2677 let needs_scrollbar = state.search_results.len() > state.search_max_visible;
2679 let scrollbar_width = if needs_scrollbar { 1 } else { 0 };
2680
2681 let content_area = Rect::new(
2683 area.x,
2684 area.y,
2685 area.width.saturating_sub(scrollbar_width),
2686 area.height,
2687 );
2688
2689 let mut y = content_area.y;
2690
2691 for (idx, result) in state
2692 .search_results
2693 .iter()
2694 .enumerate()
2695 .skip(state.search_scroll_offset)
2696 {
2697 if y >= content_area.y + content_area.height.saturating_sub(3) {
2698 break;
2699 }
2700
2701 let is_selected = idx == state.selected_search_result;
2702 let is_hovered = matches!(state.hover_hit, Some(SettingsHit::SearchResult(i)) if i == idx);
2703 let item_area = Rect::new(content_area.x, y, content_area.width, 3);
2704
2705 render_search_result_item(
2706 frame,
2707 item_area,
2708 result,
2709 is_selected,
2710 is_hovered,
2711 theme,
2712 layout,
2713 );
2714 y += 3;
2715 }
2716
2717 layout.search_results_area = Some(content_area);
2719
2720 if needs_scrollbar {
2722 let scrollbar_area = Rect::new(
2723 area.x + area.width - 1,
2724 area.y,
2725 1,
2726 area.height.saturating_sub(3), );
2728
2729 let scrollbar_state = ScrollbarState::new(
2730 state.search_results.len(),
2731 state.search_max_visible,
2732 state.search_scroll_offset,
2733 );
2734
2735 let colors = ScrollbarColors::from_theme(theme);
2736 render_scrollbar(frame, scrollbar_area, &scrollbar_state, &colors);
2737
2738 layout.search_scrollbar_area = Some(scrollbar_area);
2740 } else {
2741 layout.search_scrollbar_area = None;
2742 }
2743}
2744
2745fn render_search_result_item(
2747 frame: &mut Frame,
2748 area: Rect,
2749 result: &SearchResult,
2750 is_selected: bool,
2751 is_hovered: bool,
2752 theme: &Theme,
2753 layout: &mut SettingsLayout,
2754) {
2755 if is_selected {
2757 let bg_style = Style::default().bg(theme.settings_selected_bg);
2759 for row in 0..area.height.min(3) {
2760 let row_area = Rect::new(area.x, area.y + row, area.width, 1);
2761 frame.render_widget(Paragraph::new("").style(bg_style), row_area);
2762 }
2763 } else if is_hovered {
2764 let bg_style = Style::default().bg(theme.menu_hover_bg);
2766 for row in 0..area.height.min(3) {
2767 let row_area = Rect::new(area.x, area.y + row, area.width, 1);
2768 frame.render_widget(Paragraph::new("").style(bg_style), row_area);
2769 }
2770 }
2771
2772 let (display_name, display_desc) = match &result.deep_match {
2774 Some(DeepMatch::MapKey { key, .. }) => (key.clone(), Some(result.item.name.clone())),
2775 Some(DeepMatch::MapValue {
2776 matched_text, key, ..
2777 }) => (
2778 matched_text.clone(),
2779 Some(format!("{} > {}", result.item.name, key)),
2780 ),
2781 Some(DeepMatch::TextListItem { text, .. }) => {
2782 (text.clone(), Some(result.item.name.clone()))
2783 }
2784 None => (result.item.name.clone(), result.item.description.clone()),
2785 };
2786
2787 let name_style = if is_selected {
2789 Style::default().fg(theme.settings_selected_fg)
2790 } else if is_hovered {
2791 Style::default().fg(theme.menu_hover_fg)
2792 } else {
2793 Style::default().fg(theme.popup_text_fg)
2794 };
2795
2796 let indicator = if is_selected { "▸ " } else { " " };
2798 let indicator_style = if is_selected {
2799 Style::default()
2800 .fg(theme.settings_selected_fg)
2801 .add_modifier(Modifier::BOLD)
2802 } else {
2803 name_style
2804 };
2805 let mut name_line = build_highlighted_text(
2806 &display_name,
2807 &result.name_matches,
2808 name_style,
2809 Style::default()
2810 .fg(theme.diagnostic_warning_fg)
2811 .add_modifier(Modifier::BOLD),
2812 );
2813 name_line
2814 .spans
2815 .insert(0, Span::styled(indicator, indicator_style));
2816 frame.render_widget(
2817 Paragraph::new(name_line),
2818 Rect::new(area.x, area.y, area.width, 1),
2819 );
2820
2821 let breadcrumb_style = Style::default()
2823 .fg(theme.line_number_fg)
2824 .add_modifier(Modifier::ITALIC);
2825 let breadcrumb = format!(" {} > {}", result.breadcrumb, result.item.path);
2826 let breadcrumb_line = Line::from(Span::styled(breadcrumb, breadcrumb_style));
2827 frame.render_widget(
2828 Paragraph::new(breadcrumb_line),
2829 Rect::new(area.x, area.y + 1, area.width, 1),
2830 );
2831
2832 if let Some(ref desc) = display_desc {
2837 let desc_style = Style::default().fg(theme.line_number_fg);
2838 let max_chars = (area.width as usize).saturating_sub(2);
2839 let truncated_desc = format!(" {}", truncate_chars_with_ellipsis(desc, max_chars));
2840 frame.render_widget(
2841 Paragraph::new(truncated_desc).style(desc_style),
2842 Rect::new(area.x, area.y + 2, area.width, 1),
2843 );
2844 }
2845
2846 layout.add_search_result(result.page_index, result.item_index, area);
2848}
2849
2850fn build_highlighted_text(
2852 text: &str,
2853 matches: &[usize],
2854 normal_style: Style,
2855 highlight_style: Style,
2856) -> Line<'static> {
2857 if matches.is_empty() {
2858 return Line::from(Span::styled(text.to_string(), normal_style));
2859 }
2860
2861 let chars: Vec<char> = text.chars().collect();
2862 let mut spans = Vec::new();
2863 let mut current = String::new();
2864 let mut in_highlight = false;
2865
2866 for (idx, ch) in chars.iter().enumerate() {
2867 let should_highlight = matches.contains(&idx);
2868
2869 if should_highlight != in_highlight {
2870 if !current.is_empty() {
2871 let style = if in_highlight {
2872 highlight_style
2873 } else {
2874 normal_style
2875 };
2876 spans.push(Span::styled(current, style));
2877 current = String::new();
2878 }
2879 in_highlight = should_highlight;
2880 }
2881
2882 current.push(*ch);
2883 }
2884
2885 if !current.is_empty() {
2887 let style = if in_highlight {
2888 highlight_style
2889 } else {
2890 normal_style
2891 };
2892 spans.push(Span::styled(current, style));
2893 }
2894
2895 Line::from(spans)
2896}
2897
2898fn render_confirm_dialog(
2900 frame: &mut Frame,
2901 parent_area: Rect,
2902 state: &SettingsState,
2903 theme: &Theme,
2904) {
2905 let changes = state.get_change_descriptions();
2907 let dialog_width = 50.min(parent_area.width.saturating_sub(4));
2908 let dialog_height = (7 + changes.len() as u16)
2911 .min(20)
2912 .min(parent_area.height.saturating_sub(4));
2913
2914 let dialog_x = parent_area.x + (parent_area.width.saturating_sub(dialog_width)) / 2;
2916 let dialog_y = parent_area.y + (parent_area.height.saturating_sub(dialog_height)) / 2;
2917 let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
2918
2919 frame.render_widget(Clear, dialog_area);
2921
2922 let title = format!(" {} ", t!("confirm.unsaved_changes_title"));
2923 let block = Block::default()
2924 .title(title)
2925 .borders(Borders::ALL)
2926 .border_type(BorderType::Rounded)
2927 .border_style(Style::default().fg(theme.diagnostic_warning_fg))
2928 .style(Style::default().bg(theme.popup_bg));
2929 frame.render_widget(block, dialog_area);
2930
2931 let inner = Rect::new(
2933 dialog_area.x + 2,
2934 dialog_area.y + 1,
2935 dialog_area.width.saturating_sub(4),
2936 dialog_area.height.saturating_sub(2),
2937 );
2938
2939 let mut y = inner.y;
2940
2941 let prompt = t!("confirm.unsaved_changes_prompt").to_string();
2943 let prompt_style = Style::default().fg(theme.popup_text_fg);
2944 frame.render_widget(
2945 Paragraph::new(prompt).style(prompt_style),
2946 Rect::new(inner.x, y, inner.width, 1),
2947 );
2948 y += 2;
2949
2950 let change_style = Style::default().fg(theme.popup_text_fg);
2955 for change in changes
2956 .iter()
2957 .take((dialog_height as usize).saturating_sub(7))
2958 {
2959 let max_chars = (inner.width as usize).saturating_sub(2);
2960 let truncated = format!("• {}", truncate_chars_with_ellipsis(change, max_chars));
2961 frame.render_widget(
2962 Paragraph::new(truncated).style(change_style),
2963 Rect::new(inner.x, y, inner.width, 1),
2964 );
2965 y += 1;
2966 }
2967
2968 let button_y = dialog_area.y + dialog_area.height - 3;
2970
2971 let sep_line: String = "─".repeat(inner.width as usize);
2973 frame.render_widget(
2974 Paragraph::new(sep_line).style(Style::default().fg(theme.split_separator_fg)),
2975 Rect::new(inner.x, button_y - 1, inner.width, 1),
2976 );
2977
2978 let options = [
2980 t!("confirm.save_and_exit").to_string(),
2981 t!("confirm.discard").to_string(),
2982 t!("confirm.cancel").to_string(),
2983 ];
2984 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;
2986
2987 for (idx, label) in options.iter().enumerate() {
2988 let is_selected = idx == state.confirm_dialog_selection;
2989 let is_hovered = state.confirm_dialog_hover == Some(idx);
2990 let button_width = label.len() as u16 + 4;
2991
2992 let style = if is_selected {
2993 Style::default()
2994 .fg(theme.menu_highlight_fg)
2995 .bg(theme.menu_highlight_bg)
2996 .add_modifier(ratatui::style::Modifier::BOLD)
2997 } else if is_hovered {
2998 Style::default()
2999 .fg(theme.menu_hover_fg)
3000 .bg(theme.menu_hover_bg)
3001 } else {
3002 Style::default().fg(theme.popup_text_fg)
3003 };
3004
3005 let text = if is_selected {
3006 format!(">[ {} ]", label)
3007 } else {
3008 format!(" [ {} ]", label)
3009 };
3010 frame.render_widget(
3011 Paragraph::new(text).style(style),
3012 Rect::new(x, button_y, button_width + 1, 1),
3013 );
3014
3015 x += button_width + 3;
3016 }
3017
3018 let help = "←/→/Tab: Select Enter: Confirm Esc: Cancel";
3020 let help_style = Style::default().fg(theme.line_number_fg);
3021 frame.render_widget(
3022 Paragraph::new(help).style(help_style),
3023 Rect::new(inner.x, button_y + 1, inner.width, 1),
3024 );
3025}
3026
3027fn render_reset_dialog(frame: &mut Frame, parent_area: Rect, state: &SettingsState, theme: &Theme) {
3029 let changes = state.get_change_descriptions();
3030 let dialog_width = 50.min(parent_area.width.saturating_sub(4));
3031 let dialog_height = (7 + changes.len() as u16)
3034 .min(20)
3035 .min(parent_area.height.saturating_sub(4));
3036
3037 let dialog_x = parent_area.x + (parent_area.width.saturating_sub(dialog_width)) / 2;
3039 let dialog_y = parent_area.y + (parent_area.height.saturating_sub(dialog_height)) / 2;
3040 let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
3041
3042 frame.render_widget(Clear, dialog_area);
3044
3045 let block = Block::default()
3046 .title(" Reset All Changes ")
3047 .borders(Borders::ALL)
3048 .border_type(BorderType::Rounded)
3049 .border_style(Style::default().fg(theme.diagnostic_warning_fg))
3050 .style(Style::default().bg(theme.popup_bg));
3051 frame.render_widget(block, dialog_area);
3052
3053 let inner = Rect::new(
3055 dialog_area.x + 2,
3056 dialog_area.y + 1,
3057 dialog_area.width.saturating_sub(4),
3058 dialog_area.height.saturating_sub(2),
3059 );
3060
3061 let mut y = inner.y;
3062
3063 let prompt_style = Style::default().fg(theme.popup_text_fg);
3065 frame.render_widget(
3066 Paragraph::new("Discard all pending changes?").style(prompt_style),
3067 Rect::new(inner.x, y, inner.width, 1),
3068 );
3069 y += 2;
3070
3071 let change_style = Style::default().fg(theme.popup_text_fg);
3076 for change in changes
3077 .iter()
3078 .take((dialog_height as usize).saturating_sub(7))
3079 {
3080 let max_chars = (inner.width as usize).saturating_sub(2);
3081 let truncated = format!("• {}", truncate_chars_with_ellipsis(change, max_chars));
3082 frame.render_widget(
3083 Paragraph::new(truncated).style(change_style),
3084 Rect::new(inner.x, y, inner.width, 1),
3085 );
3086 y += 1;
3087 }
3088
3089 let button_y = dialog_area.y + dialog_area.height - 3;
3091
3092 let sep_line: String = "─".repeat(inner.width as usize);
3094 frame.render_widget(
3095 Paragraph::new(sep_line).style(Style::default().fg(theme.split_separator_fg)),
3096 Rect::new(inner.x, button_y - 1, inner.width, 1),
3097 );
3098
3099 let options = ["Reset", "Cancel"];
3101 let total_width: u16 = options.iter().map(|o| o.len() as u16 + 4).sum::<u16>() + 4;
3102 let mut x = inner.x + (inner.width.saturating_sub(total_width)) / 2;
3103
3104 for (idx, label) in options.iter().enumerate() {
3105 let is_selected = idx == state.reset_dialog_selection;
3106 let is_hovered = state.reset_dialog_hover == Some(idx);
3107 let button_width = label.len() as u16 + 4;
3108
3109 let style = if is_selected {
3110 Style::default()
3111 .fg(theme.menu_highlight_fg)
3112 .bg(theme.menu_highlight_bg)
3113 .add_modifier(ratatui::style::Modifier::BOLD)
3114 } else if is_hovered {
3115 Style::default()
3116 .fg(theme.menu_hover_fg)
3117 .bg(theme.menu_hover_bg)
3118 } else {
3119 Style::default().fg(theme.popup_text_fg)
3120 };
3121
3122 let text = if is_selected {
3123 format!(">[ {} ]", label)
3124 } else {
3125 format!(" [ {} ]", label)
3126 };
3127 frame.render_widget(
3128 Paragraph::new(text).style(style),
3129 Rect::new(x, button_y, button_width + 1, 1),
3130 );
3131
3132 x += button_width + 3;
3133 }
3134
3135 let help = "←/→/Tab: Select Enter: Confirm Esc: Cancel";
3137 let help_style = Style::default().fg(theme.line_number_fg);
3138 frame.render_widget(
3139 Paragraph::new(help).style(help_style),
3140 Rect::new(inner.x, button_y + 1, inner.width, 1),
3141 );
3142}
3143
3144fn render_entry_dialog_at(
3146 frame: &mut Frame,
3147 parent_area: Rect,
3148 state: &mut SettingsState,
3149 theme: &Theme,
3150 dialog_idx: usize,
3151) {
3152 let Some(dialog) = state.entry_dialog_stack.get_mut(dialog_idx) else {
3153 return;
3154 };
3155 render_entry_dialog_inner(frame, parent_area, dialog, theme);
3156}
3157
3158fn render_entry_dialog_inner(
3163 frame: &mut Frame,
3164 parent_area: Rect,
3165 dialog: &mut super::entry_dialog::EntryDialogState,
3166 theme: &Theme,
3167) {
3168 let dialog_width = (parent_area.width * 85 / 100).clamp(50, 90);
3170 let dialog_height = (parent_area.height * 90 / 100).max(15);
3171 let dialog_x = parent_area.x + (parent_area.width.saturating_sub(dialog_width)) / 2;
3172 let dialog_y = parent_area.y + (parent_area.height.saturating_sub(dialog_height)) / 2;
3173
3174 let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
3175
3176 frame.render_widget(Clear, dialog_area);
3178
3179 let title = format!(" {} ", dialog.title);
3180
3181 let block = Block::default()
3182 .title(title)
3183 .borders(Borders::ALL)
3184 .border_type(BorderType::Rounded)
3185 .border_style(Style::default().fg(theme.popup_border_fg))
3186 .style(Style::default().bg(theme.popup_bg));
3187 frame.render_widget(block, dialog_area);
3188
3189 let inner = Rect::new(
3191 dialog_area.x + 2,
3192 dialog_area.y + 1,
3193 dialog_area.width.saturating_sub(4),
3194 dialog_area.height.saturating_sub(5), );
3196
3197 let max_label_width = (inner.width / 2).max(20);
3199 let label_col_width = dialog
3200 .items
3201 .iter()
3202 .map(|item| item.name.len() as u16 + 2) .filter(|&w| w <= max_label_width)
3204 .max()
3205 .unwrap_or(20)
3206 .min(max_label_width);
3207
3208 let total_content_height = dialog.total_content_height();
3210 let viewport_height = inner.height as usize;
3211
3212 dialog.viewport_height = viewport_height;
3214
3215 let scroll_offset = dialog.scroll_offset;
3216 let needs_scroll = total_content_height > viewport_height;
3217
3218 let mut content_y: usize = 0;
3220 let mut screen_y = inner.y;
3221
3222 let first_editable = dialog.first_editable_index;
3224 let has_readonly_items = first_editable > 0;
3225 let has_editable_items = first_editable < dialog.items.len();
3226 let needs_separator = has_readonly_items && has_editable_items;
3227
3228 for (idx, item) in dialog.items.iter().enumerate() {
3229 if needs_separator && idx == first_editable {
3231 let separator_start = content_y;
3233 let separator_end = content_y + 1;
3234
3235 if separator_end > scroll_offset && screen_y < inner.y + inner.height {
3236 let skip_sep = if separator_start < scroll_offset {
3238 1
3239 } else {
3240 0
3241 };
3242 if skip_sep == 0 {
3243 let sep_style = Style::default().fg(theme.line_number_fg);
3244 let separator_line = "─".repeat(inner.width.saturating_sub(2) as usize);
3245 frame.render_widget(
3246 Paragraph::new(separator_line).style(sep_style),
3247 Rect::new(inner.x + 1, screen_y, inner.width.saturating_sub(2), 1),
3248 );
3249 screen_y += 1;
3250 }
3251 }
3252 content_y = separator_end;
3253 }
3254
3255 if item.is_section_start {
3257 if let Some(ref section_name) = item.section {
3258 let header_start = content_y;
3259 let header_end = content_y + 2; if header_end > scroll_offset && screen_y < inner.y + inner.height {
3262 let skip_h = if header_start < scroll_offset {
3263 (scroll_offset - header_start) as u16
3264 } else {
3265 0
3266 };
3267 if skip_h == 0 {
3268 let section_style = Style::default()
3270 .fg(theme.line_number_fg)
3271 .add_modifier(Modifier::BOLD);
3272 frame.render_widget(
3273 Paragraph::new(format!("── {} ──", section_name)).style(section_style),
3274 Rect::new(inner.x + 1, screen_y, inner.width.saturating_sub(2), 1),
3275 );
3276 screen_y += 1;
3277 }
3278 if skip_h <= 1 && screen_y < inner.y + inner.height {
3279 screen_y += 1;
3281 }
3282 }
3283 content_y = header_end;
3284 }
3285 }
3286
3287 let control_height = item.control.control_height() as usize;
3288
3289 let item_start = content_y;
3291 let item_end = content_y + control_height;
3292
3293 if item_end <= scroll_offset {
3295 content_y = item_end;
3296 continue;
3297 }
3298
3299 if screen_y >= inner.y + inner.height {
3301 break;
3302 }
3303
3304 let skip_rows = if item_start < scroll_offset {
3306 (scroll_offset - item_start) as u16
3307 } else {
3308 0
3309 };
3310
3311 let visible_height = control_height.saturating_sub(skip_rows as usize);
3313 let available_height = (inner.y + inner.height).saturating_sub(screen_y) as usize;
3314 let render_height = visible_height.min(available_height);
3315
3316 if render_height == 0 {
3317 content_y = item_end;
3318 continue;
3319 }
3320
3321 let is_readonly = item.read_only;
3323 let is_focused = !is_readonly && !dialog.focus_on_buttons && dialog.selected_item == idx;
3324 let is_hovered = !is_readonly && dialog.hover_item == Some(idx);
3325
3326 if is_focused || is_hovered {
3328 let bg_style = if is_focused {
3329 Style::default().bg(theme.settings_selected_bg)
3330 } else {
3331 Style::default().bg(theme.menu_hover_bg)
3332 };
3333
3334 if item.control.is_composite() {
3335 let sub_row = item.control.focused_sub_row();
3337 if sub_row >= skip_rows && (sub_row - skip_rows) < render_height as u16 {
3338 let highlight_y = screen_y + sub_row - skip_rows;
3339 let row_area = Rect::new(inner.x, highlight_y, inner.width, 1);
3340 frame.render_widget(Paragraph::new("").style(bg_style), row_area);
3341 }
3342 } else {
3343 for row in 0..render_height as u16 {
3345 let row_area = Rect::new(inner.x, screen_y + row, inner.width, 1);
3346 frame.render_widget(Paragraph::new("").style(bg_style), row_area);
3347 }
3348 }
3349 }
3350
3351 let focus_indicator_width: u16 = 3;
3354
3355 if is_focused && skip_rows == 0 {
3357 let indicator_style = Style::default()
3358 .fg(theme.settings_selected_fg)
3359 .add_modifier(Modifier::BOLD);
3360
3361 let indicator_y = if item.control.is_composite() {
3362 let sub_row = item.control.focused_sub_row();
3363 if sub_row < render_height as u16 {
3364 screen_y + sub_row
3365 } else {
3366 screen_y
3367 }
3368 } else {
3369 screen_y
3370 };
3371
3372 frame.render_widget(
3373 Paragraph::new(">").style(indicator_style),
3374 Rect::new(inner.x, indicator_y, 1, 1),
3375 );
3376 } else if is_focused && skip_rows > 0 {
3377 if item.control.is_composite() {
3379 let sub_row = item.control.focused_sub_row();
3380 if sub_row >= skip_rows && (sub_row - skip_rows) < render_height as u16 {
3381 let indicator_style = Style::default()
3382 .fg(theme.settings_selected_fg)
3383 .add_modifier(Modifier::BOLD);
3384 let indicator_y = screen_y + sub_row - skip_rows;
3385 frame.render_widget(
3386 Paragraph::new(">").style(indicator_style),
3387 Rect::new(inner.x, indicator_y, 1, 1),
3388 );
3389 }
3390 }
3391 }
3392
3393 if item.modified && skip_rows == 0 {
3395 let modified_style = Style::default().fg(theme.settings_selected_fg);
3396 frame.render_widget(
3397 Paragraph::new("●").style(modified_style),
3398 Rect::new(inner.x + 1, screen_y, 1, 1),
3399 );
3400 }
3401
3402 let control_area = Rect::new(
3404 inner.x + focus_indicator_width,
3405 screen_y,
3406 inner.width.saturating_sub(focus_indicator_width),
3407 render_height as u16,
3408 );
3409
3410 let _layout = render_control(
3412 frame,
3413 control_area,
3414 &item.control,
3415 &item.name,
3416 skip_rows,
3417 theme,
3418 Some(label_col_width.saturating_sub(focus_indicator_width)),
3419 item.read_only,
3420 item.is_null,
3421 );
3422
3423 screen_y += render_height as u16;
3424 content_y = item_end;
3425 }
3426
3427 if needs_scroll {
3429 use crate::view::ui::scrollbar::{render_scrollbar, ScrollbarColors, ScrollbarState};
3430
3431 let scrollbar_x = dialog_area.x + dialog_area.width - 3;
3432 let scrollbar_area = Rect::new(scrollbar_x, inner.y, 1, inner.height);
3433 let scrollbar_state =
3434 ScrollbarState::new(total_content_height, viewport_height, scroll_offset);
3435 let scrollbar_colors = ScrollbarColors::from_theme(theme);
3436 render_scrollbar(frame, scrollbar_area, &scrollbar_state, &scrollbar_colors);
3437 }
3438
3439 let button_y = dialog_area.y + dialog_area.height - 2;
3441 let buttons: Vec<&str> = if dialog.is_new || dialog.no_delete {
3443 vec!["[ Save ]", "[ Cancel ]"]
3444 } else {
3445 vec!["[ Save ]", "[ Delete ]", "[ Cancel ]"]
3446 };
3447 let button_width: u16 = buttons.iter().map(|b: &&str| b.len() as u16 + 2).sum();
3448 let button_x = dialog_area.x + (dialog_area.width.saturating_sub(button_width)) / 2;
3449
3450 let mut x = button_x;
3451 for (idx, label) in buttons.iter().enumerate() {
3452 let is_selected = dialog.focus_on_buttons && dialog.focused_button == idx;
3453 let is_hovered = dialog.hover_button == Some(idx);
3454 let is_delete = !dialog.is_new && !dialog.no_delete && idx == 1;
3455 if is_selected {
3457 let indicator_style = Style::default()
3458 .fg(theme.settings_selected_fg)
3459 .add_modifier(Modifier::BOLD);
3460 frame.render_widget(
3461 Paragraph::new(">").style(indicator_style),
3462 Rect::new(x, button_y, 1, 1),
3463 );
3464 x += 2;
3465 }
3466 let style = if is_selected {
3467 Style::default()
3468 .fg(theme.menu_highlight_fg)
3469 .add_modifier(Modifier::BOLD | Modifier::REVERSED)
3470 } else if is_hovered {
3471 Style::default()
3472 .fg(theme.menu_hover_fg)
3473 .bg(theme.menu_hover_bg)
3474 } else if is_delete {
3475 Style::default().fg(theme.diagnostic_error_fg)
3476 } else {
3477 Style::default().fg(theme.editor_fg)
3478 };
3479 frame.render_widget(
3480 Paragraph::new(*label).style(style),
3481 Rect::new(x, button_y, label.len() as u16, 1),
3482 );
3483 x += label.len() as u16 + 2;
3484 }
3485
3486 let is_editing_json = dialog.editing_text && dialog.is_editing_json();
3489 let (has_invalid_json, is_json_control) = dialog
3490 .current_item()
3491 .map(|item| match &item.control {
3492 SettingControl::Text(state) => (!state.is_valid(), false),
3493 SettingControl::Json(state) => (!state.is_valid(), is_editing_json),
3494 _ => (false, false),
3495 })
3496 .unwrap_or((false, false));
3497
3498 let help_area = Rect::new(
3500 dialog_area.x + 2,
3501 button_y + 1,
3502 dialog_area.width.saturating_sub(4),
3503 1,
3504 );
3505
3506 if has_invalid_json && !is_json_control {
3507 let warning = "⚠ Invalid JSON - fix before leaving field";
3509 let warning_style = Style::default().fg(theme.diagnostic_warning_fg);
3510 frame.render_widget(Paragraph::new(warning).style(warning_style), help_area);
3511 } else if has_invalid_json && is_json_control {
3512 let warning = "⚠ Invalid JSON";
3514 let warning_style = Style::default().fg(theme.diagnostic_warning_fg);
3515 frame.render_widget(Paragraph::new(warning).style(warning_style), help_area);
3516 } else if is_json_control {
3517 let help = "↑↓←→:Move Enter:Newline Tab/Esc:Exit";
3519 let help_style = Style::default().fg(theme.line_number_fg);
3520 frame.render_widget(Paragraph::new(help).style(help_style), help_area);
3521 } else {
3522 let help = "↑↓:Navigate Tab:Fields/Buttons Enter:Edit Ctrl+S:Save Esc:Cancel";
3523 let help_style = Style::default().fg(theme.line_number_fg);
3524 frame.render_widget(Paragraph::new(help).style(help_style), help_area);
3525 }
3526}
3527
3528fn render_help_overlay(frame: &mut Frame, parent_area: Rect, theme: &Theme) {
3530 let help_items = [
3532 (
3533 "Navigation",
3534 vec![
3535 ("↑ / ↓", "Move up/down"),
3536 ("Tab", "Switch between categories and settings"),
3537 ("Enter", "Activate/toggle setting"),
3538 ],
3539 ),
3540 (
3541 "Search",
3542 vec![
3543 ("/", "Start search"),
3544 ("Esc", "Cancel search"),
3545 ("↑ / ↓", "Navigate results"),
3546 ("Enter", "Jump to result"),
3547 ],
3548 ),
3549 (
3550 "Actions",
3551 vec![
3552 ("Ctrl+S", "Save settings"),
3553 ("Esc", "Close settings"),
3554 ("?", "Toggle this help"),
3555 ],
3556 ),
3557 ];
3558
3559 let dialog_width = 50.min(parent_area.width.saturating_sub(4));
3561 let dialog_height = 20.min(parent_area.height.saturating_sub(4));
3562
3563 let dialog_x = parent_area.x + (parent_area.width.saturating_sub(dialog_width)) / 2;
3565 let dialog_y = parent_area.y + (parent_area.height.saturating_sub(dialog_height)) / 2;
3566 let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
3567
3568 frame.render_widget(Clear, dialog_area);
3570
3571 let block = Block::default()
3572 .title(" Keyboard Shortcuts ")
3573 .borders(Borders::ALL)
3574 .border_type(BorderType::Rounded)
3575 .border_style(Style::default().fg(theme.menu_highlight_fg))
3576 .style(Style::default().bg(theme.popup_bg));
3577 frame.render_widget(block, dialog_area);
3578
3579 let inner = Rect::new(
3581 dialog_area.x + 2,
3582 dialog_area.y + 1,
3583 dialog_area.width.saturating_sub(4),
3584 dialog_area.height.saturating_sub(2),
3585 );
3586
3587 let mut y = inner.y;
3588
3589 for (section_name, bindings) in &help_items {
3590 if y >= inner.y + inner.height.saturating_sub(1) {
3591 break;
3592 }
3593
3594 let header_style = Style::default()
3596 .fg(theme.menu_active_fg)
3597 .add_modifier(Modifier::BOLD);
3598 frame.render_widget(
3599 Paragraph::new(*section_name).style(header_style),
3600 Rect::new(inner.x, y, inner.width, 1),
3601 );
3602 y += 1;
3603
3604 for (key, description) in bindings {
3605 if y >= inner.y + inner.height.saturating_sub(1) {
3606 break;
3607 }
3608
3609 let key_style = Style::default()
3610 .fg(theme.popup_text_fg)
3611 .bg(theme.split_separator_fg);
3612 let desc_style = Style::default().fg(theme.popup_text_fg);
3613
3614 let line = Line::from(vec![
3615 Span::styled(" ", Style::default()),
3616 Span::styled(format!(" {} ", key), key_style),
3617 Span::styled(format!(" {}", description), desc_style),
3618 ]);
3619 frame.render_widget(Paragraph::new(line), Rect::new(inner.x, y, inner.width, 1));
3620 y += 1;
3621 }
3622
3623 y += 1; }
3625
3626 let footer_y = dialog_area.y + dialog_area.height - 2;
3628 let footer = "Press ? or Esc or Enter to close";
3629 let footer_style = Style::default().fg(theme.line_number_fg);
3630 let centered_x = inner.x + (inner.width.saturating_sub(footer.len() as u16)) / 2;
3631 frame.render_widget(
3632 Paragraph::new(footer).style(footer_style),
3633 Rect::new(centered_x, footer_y, footer.len() as u16, 1),
3634 );
3635}
3636
3637#[cfg(test)]
3638mod tests {
3639 use super::*;
3640
3641 #[test]
3642 fn truncate_chars_with_ellipsis_ascii_fits() {
3643 assert_eq!(truncate_chars_with_ellipsis("hi", 10), "hi");
3644 }
3645
3646 #[test]
3647 fn truncate_chars_with_ellipsis_ascii_truncates() {
3648 assert_eq!(truncate_chars_with_ellipsis("hello world!", 8), "hello...");
3649 }
3650
3651 #[test]
3652 fn truncate_chars_with_ellipsis_multibyte_does_not_panic() {
3653 let out = truncate_chars_with_ellipsis("こんにちは世界からのテスト", 8);
3657 assert!(out.ends_with("..."));
3658 assert_eq!(out.chars().count(), 8);
3660 }
3661
3662 #[test]
3663 fn truncate_chars_with_ellipsis_emoji_does_not_panic() {
3664 let out = truncate_chars_with_ellipsis("📦📦📦📦📦📦📦📦", 5);
3665 assert!(out.ends_with("..."));
3666 assert_eq!(out.chars().count(), 5);
3667 }
3668
3669 #[test]
3671 fn test_control_layout_info() {
3672 let toggle = ControlLayoutInfo::Toggle(Rect::new(0, 0, 10, 1));
3673 assert!(matches!(toggle, ControlLayoutInfo::Toggle(_)));
3674
3675 let number = ControlLayoutInfo::Number {
3676 decrement: Rect::new(0, 0, 3, 1),
3677 increment: Rect::new(4, 0, 3, 1),
3678 value: Rect::new(8, 0, 5, 1),
3679 };
3680 assert!(matches!(number, ControlLayoutInfo::Number { .. }));
3681 }
3682}