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