1use rust_i18n::t;
6
7use crate::primitives::display_width::str_width;
8
9use super::items::SettingControl;
10use super::layout::{SettingsHit, SettingsLayout};
11use super::search::{DeepMatch, SearchResult};
12use super::state::SettingsState;
13use crate::view::controls::{
14 render_dropdown_aligned, render_dual_list_partial, render_number_input_aligned,
15 render_text_input_aligned, render_toggle_aligned, DropdownColors, DualListColors, MapColors,
16 NumberInputColors, TextInputColors, TextListColors, ToggleColors,
17};
18use crate::view::theme::Theme;
19use crate::view::ui::scrollbar::{render_scrollbar, ScrollbarColors, ScrollbarState};
20use ratatui::layout::{Constraint, Layout, Rect};
21use ratatui::style::{Color, Modifier, Style};
22use ratatui::text::{Line, Span};
23use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};
24use ratatui::Frame;
25
26#[allow(clippy::too_many_arguments)]
30fn build_selection_spans(
31 display_text: &str,
32 display_len: usize,
33 line_idx: usize,
34 start_row: usize,
35 start_col: usize,
36 end_row: usize,
37 end_col: usize,
38 text_color: Color,
39 selection_bg: Color,
40) -> Vec<Span<'static>> {
41 let chars: Vec<char> = display_text.chars().collect();
42 let char_count = chars.len();
43
44 let (sel_start, sel_end) = if line_idx < start_row || line_idx > end_row {
46 (char_count, char_count)
48 } else if line_idx == start_row && line_idx == end_row {
49 let start = byte_to_char_idx(display_text, start_col).min(char_count);
51 let end = byte_to_char_idx(display_text, end_col).min(char_count);
52 (start, end)
53 } else if line_idx == start_row {
54 let start = byte_to_char_idx(display_text, start_col).min(char_count);
56 (start, char_count)
57 } else if line_idx == end_row {
58 let end = byte_to_char_idx(display_text, end_col).min(char_count);
60 (0, end)
61 } else {
62 (0, char_count)
64 };
65
66 let mut spans = Vec::new();
67 let normal_style = Style::default().fg(text_color);
68 let selected_style = Style::default().fg(text_color).bg(selection_bg);
69
70 if sel_start >= sel_end || sel_start >= char_count {
71 let padded = format!("{:width$}", display_text, width = display_len);
73 spans.push(Span::styled(padded, normal_style));
74 } else {
75 if sel_start > 0 {
77 let before: String = chars[..sel_start].iter().collect();
78 spans.push(Span::styled(before, normal_style));
79 }
80
81 let selected: String = chars[sel_start..sel_end].iter().collect();
83 spans.push(Span::styled(selected, selected_style));
84
85 if sel_end < char_count {
87 let after: String = chars[sel_end..].iter().collect();
88 spans.push(Span::styled(after, normal_style));
89 }
90
91 let current_len = char_count;
93 if current_len < display_len {
94 let padding = " ".repeat(display_len - current_len);
95 spans.push(Span::styled(padding, normal_style));
96 }
97 }
98
99 spans
100}
101
102fn byte_to_char_idx(s: &str, byte_offset: usize) -> usize {
104 s.char_indices()
105 .take_while(|(i, _)| *i < byte_offset)
106 .count()
107}
108
109fn truncate_chars_with_ellipsis(s: &str, max_chars: usize) -> String {
114 if s.chars().count() <= max_chars {
115 s.to_string()
116 } else {
117 let kept: String = s.chars().take(max_chars.saturating_sub(3)).collect();
118 format!("{}...", kept)
119 }
120}
121
122pub fn render_settings(
124 frame: &mut Frame,
125 area: Rect,
126 state: &mut SettingsState,
127 theme: &Theme,
128) -> SettingsLayout {
129 if area.width < 40 || area.height < 10 {
131 let msg = "[Terminal too small for settings]";
132 let x = area.x + area.width.saturating_sub(msg.len() as u16) / 2;
133 let y = area.y + area.height / 2;
134 if area.width > 0 && area.height > 0 {
135 frame.render_widget(
136 Paragraph::new(msg).style(Style::default().fg(theme.diagnostic_warning_fg)),
137 Rect::new(x, y, msg.len() as u16, 1),
138 );
139 }
140 return SettingsLayout::new(Rect::ZERO);
141 }
142
143 let modal_width = (area.width * 90 / 100).min(160);
145 let modal_height = area.height * 90 / 100;
146 let modal_x = (area.width.saturating_sub(modal_width)) / 2;
147 let modal_y = (area.height.saturating_sub(modal_height)) / 2;
148
149 let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
150
151 frame.render_widget(Clear, modal_area);
153
154 let title = if state.has_changes() {
155 format!(" Settings [{}] • (modified) ", state.target_layer_name())
156 } else {
157 format!(" Settings [{}] ", state.target_layer_name())
158 };
159
160 let block = Block::default()
161 .title(title.as_str())
162 .borders(Borders::ALL)
163 .border_type(BorderType::Rounded)
164 .border_style(Style::default().fg(theme.popup_border_fg))
165 .style(Style::default().bg(theme.popup_bg));
166 frame.render_widget(block, modal_area);
167
168 let inner_area = Rect::new(
170 modal_area.x + 1,
171 modal_area.y + 1,
172 modal_area.width.saturating_sub(2),
173 modal_area.height.saturating_sub(2),
174 );
175
176 let narrow_mode = inner_area.width < 60;
179
180 let search_area = Rect::new(inner_area.x, inner_area.y, inner_area.width, 1);
184 let search_header_height = 1u16;
185 let search_gap = 1u16;
186 if state.search_active {
187 render_search_header(frame, search_area, state, theme);
188 } else {
189 render_search_hint(frame, search_area, theme);
190 }
191
192 let footer_height = if narrow_mode { 7 } else { 2 };
194 let chrome_height = search_header_height + search_gap + footer_height;
195 let content_area = Rect::new(
196 inner_area.x,
197 inner_area.y + search_header_height + search_gap,
198 inner_area.width,
199 inner_area.height.saturating_sub(chrome_height),
200 );
201
202 let mut layout = SettingsLayout::new(modal_area);
204
205 if narrow_mode {
206 render_vertical_layout(frame, content_area, modal_area, state, theme, &mut layout);
208 } else {
209 render_horizontal_layout(frame, content_area, modal_area, state, theme, &mut layout);
211 }
212
213 let has_confirm = state.showing_confirm_dialog;
215 let has_reset = state.showing_reset_dialog;
216 let has_entry = state.showing_entry_dialog();
217 let has_help = state.showing_help;
218
219 if has_confirm {
221 if !has_entry && !has_help {
222 crate::view::dimming::apply_dimming(frame, modal_area);
223 }
224 render_confirm_dialog(frame, modal_area, state, theme);
225 }
226
227 if has_reset {
229 if !has_confirm && !has_entry && !has_help {
230 crate::view::dimming::apply_dimming(frame, modal_area);
231 }
232 render_reset_dialog(frame, modal_area, state, theme);
233 }
234
235 if has_entry {
237 let stack_depth = state.entry_dialog_stack.len();
238 for dialog_idx in 0..stack_depth {
239 if !has_help || dialog_idx < stack_depth - 1 {
240 crate::view::dimming::apply_dimming(frame, modal_area);
241 }
242 render_entry_dialog_at(frame, modal_area, state, theme, dialog_idx);
243 }
244 }
245
246 if state.showing_entry_discard_confirm {
250 crate::view::dimming::apply_dimming(frame, modal_area);
251 render_entry_discard_confirm(frame, modal_area, state, theme);
252 }
253
254 if state.showing_entry_delete_confirm {
258 crate::view::dimming::apply_dimming(frame, modal_area);
259 render_entry_delete_confirm(frame, modal_area, state, theme);
260 }
261
262 if has_help {
264 crate::view::dimming::apply_dimming(frame, modal_area);
265 render_help_overlay(frame, modal_area, theme);
266 }
267
268 layout
269}
270
271fn render_horizontal_layout(
273 frame: &mut Frame,
274 content_area: Rect,
275 modal_area: Rect,
276 state: &mut SettingsState,
277 theme: &Theme,
278 layout: &mut SettingsLayout,
279) {
280 let chunks = Layout::horizontal([
283 Constraint::Length(24),
284 Constraint::Length(1),
285 Constraint::Min(40),
286 ])
287 .split(content_area);
288
289 let categories_area = chunks[0];
290 let divider_area = chunks[1];
291 let settings_area = chunks[2];
292
293 render_categories(frame, categories_area, state, theme, layout);
295
296 let divider_style = Style::default().fg(theme.split_separator_fg);
298 for y in 0..divider_area.height {
299 frame.render_widget(
300 Paragraph::new("│").style(divider_style),
301 Rect::new(divider_area.x, divider_area.y + y, 1, 1),
302 );
303 }
304
305 let horizontal_padding = 1u16;
307 let settings_inner = Rect::new(
308 settings_area.x + horizontal_padding,
309 settings_area.y,
310 settings_area.width.saturating_sub(horizontal_padding * 2),
311 settings_area.height,
312 );
313
314 if state.search_active && !state.search_results.is_empty() {
315 render_search_results(frame, settings_inner, state, theme, layout);
316 } else {
317 render_settings_panel(frame, settings_inner, state, theme, layout);
318 }
319
320 render_footer(frame, modal_area, state, theme, layout, false);
322}
323
324fn render_vertical_layout(
326 frame: &mut Frame,
327 content_area: Rect,
328 modal_area: Rect,
329 state: &mut SettingsState,
330 theme: &Theme,
331 layout: &mut SettingsLayout,
332) {
333 let footer_height = 7;
335
336 let main_height = content_area.height.saturating_sub(footer_height);
338 let category_height = 3u16.min(main_height);
339 let settings_height = main_height.saturating_sub(category_height + 1); let categories_area = Rect::new(
343 content_area.x,
344 content_area.y,
345 content_area.width,
346 category_height,
347 );
348
349 let sep_y = content_area.y + category_height;
351
352 let settings_area = Rect::new(
354 content_area.x,
355 sep_y + 1,
356 content_area.width,
357 settings_height,
358 );
359
360 render_categories_horizontal(frame, categories_area, state, theme, layout);
362
363 if sep_y < content_area.y + content_area.height {
365 let sep_line: String = "─".repeat(content_area.width as usize);
366 frame.render_widget(
367 Paragraph::new(sep_line).style(Style::default().fg(theme.split_separator_fg)),
368 Rect::new(content_area.x, sep_y, content_area.width, 1),
369 );
370 }
371
372 if state.search_active && !state.search_results.is_empty() {
374 render_search_results(frame, settings_area, state, theme, layout);
375 } else {
376 render_settings_panel(frame, settings_area, state, theme, layout);
377 }
378
379 render_footer(frame, modal_area, state, theme, layout, true);
381}
382
383fn render_categories_horizontal(
385 frame: &mut Frame,
386 area: Rect,
387 state: &SettingsState,
388 theme: &Theme,
389 layout: &mut SettingsLayout,
390) {
391 use super::state::FocusPanel;
392
393 if area.height == 0 || area.width == 0 {
394 return;
395 }
396
397 let is_focused = state.focus_panel() == FocusPanel::Categories;
398
399 let mut spans = Vec::new();
401 let mut total_width = 0u16;
402
403 for (i, page) in state.pages.iter().enumerate() {
404 let is_selected = i == state.selected_category;
405 let has_modified = page.items.iter().any(|item| item.modified);
406
407 let indicator = if has_modified { "● " } else { " " };
408 let name = &page.name;
409
410 let style = if is_selected && is_focused {
411 Style::default()
412 .fg(theme.menu_highlight_fg)
413 .bg(theme.menu_highlight_bg)
414 .add_modifier(Modifier::BOLD)
415 } else if is_selected {
416 Style::default()
417 .fg(theme.menu_highlight_fg)
418 .add_modifier(Modifier::BOLD)
419 } else {
420 Style::default().fg(theme.popup_text_fg)
421 };
422
423 let indicator_style = if has_modified {
424 Style::default().fg(theme.menu_highlight_fg)
425 } else {
426 style
427 };
428
429 if i > 0 {
431 spans.push(Span::styled(
432 " │ ",
433 Style::default().fg(theme.split_separator_fg),
434 ));
435 total_width += 3;
436 }
437
438 spans.push(Span::styled(indicator, indicator_style));
439 spans.push(Span::styled(name.as_str(), style));
440 total_width += (indicator.len() + name.len()) as u16;
441
442 let cat_x = area.x + total_width.saturating_sub((indicator.len() + name.len()) as u16);
444 let cat_width = (indicator.len() + name.len()) as u16;
445 layout
446 .categories
447 .push((i, Rect::new(cat_x, area.y, cat_width, 1)));
448 }
449
450 let line = Line::from(spans);
452 frame.render_widget(Paragraph::new(line), area);
453
454 if area.height >= 2 {
456 let hint = "←→: Switch category";
457 let hint_style = Style::default().fg(theme.line_number_fg);
458 frame.render_widget(
459 Paragraph::new(hint).style(hint_style),
460 Rect::new(area.x, area.y + 1, area.width, 1),
461 );
462 }
463}
464
465fn category_icon(name: &str) -> &'static str {
467 match name.to_lowercase().as_str() {
468 "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} ", }
480}
481
482fn render_categories(
488 frame: &mut Frame,
489 area: Rect,
490 state: &mut SettingsState,
491 theme: &Theme,
492 layout: &mut SettingsLayout,
493) {
494 use super::state::{FocusPanel, TreeRow};
495
496 layout.categories_panel_area = Some(area);
497
498 let rows = state.visible_tree();
499 state.categories_scroll.set_viewport(area.height);
500 state
501 .categories_scroll
502 .update_content_height(&rows, area.width);
503
504 let focus_panel = state.focus_panel();
505 let selected_category = state.selected_category;
506 let tree_cursor = state.tree_cursor_section;
511
512 struct RowData {
515 chevron: &'static str,
516 is_expandable: bool,
517 is_selected: bool,
518 has_changes: bool,
519 indent_cols: u16,
520 is_category: bool,
521 cat_idx: Option<usize>,
522 section_idx: Option<usize>,
523 label: String,
524 icon: Option<&'static str>,
525 }
526 let row_data: Vec<RowData> = rows
527 .iter()
528 .map(|row| match *row {
529 TreeRow::Category {
530 idx,
531 expandable,
532 expanded,
533 } => {
534 let page = &state.pages[idx];
535 RowData {
536 chevron: if expandable {
537 if expanded {
538 "▼"
539 } else {
540 "▶"
541 }
542 } else {
543 " "
544 },
545 is_expandable: expandable,
546 is_selected: idx == selected_category && tree_cursor.is_none(),
549 has_changes: page.items.iter().any(|i| i.modified),
550 indent_cols: 0,
551 is_category: true,
552 cat_idx: Some(idx),
553 section_idx: None,
554 label: page.name.clone(),
555 icon: Some(category_icon(&page.name)),
556 }
557 }
558 TreeRow::Section {
559 cat_idx,
560 section_idx,
561 } => {
562 let section = &state.pages[cat_idx].sections[section_idx];
563 let is_current = cat_idx == selected_category && tree_cursor == Some(section_idx);
569 RowData {
570 chevron: " ",
571 is_expandable: false,
572 is_selected: is_current,
573 has_changes: false,
574 indent_cols: 4,
575 is_category: false,
576 cat_idx: Some(cat_idx),
577 section_idx: Some(section_idx),
578 label: section.name.clone(),
579 icon: None,
580 }
581 }
582 })
583 .collect();
584
585 let panel_layout = state.categories_scroll.render(
587 frame,
588 area,
589 &rows,
590 |frame, info, row| {
591 let idx = info.index;
593 let data = &row_data[idx];
594 let row_area = info.area;
595
596 let row_bg = if data.is_selected {
604 if focus_panel == FocusPanel::Categories {
605 Some(theme.menu_highlight_bg)
606 } else {
607 Some(theme.selection_bg)
608 }
609 } else {
610 None
611 };
612 if let Some(bg) = row_bg {
613 frame.render_widget(
614 Paragraph::new(" ".repeat(row_area.width as usize))
615 .style(Style::default().bg(bg)),
616 row_area,
617 );
618 }
619
620 let fg = if data.is_selected {
621 if focus_panel == FocusPanel::Categories {
622 theme.menu_highlight_fg
623 } else {
624 theme.menu_fg
625 }
626 } else {
627 theme.popup_text_fg
628 };
629 let bg = row_bg.unwrap_or(theme.popup_bg);
630 let style = Style::default().fg(fg).bg(bg);
631
632 let mut spans: Vec<Span> = Vec::with_capacity(8);
633 let selected_marker = if data.is_selected && focus_panel == FocusPanel::Categories {
637 ">"
638 } else {
639 " "
640 };
641 spans.push(Span::styled(selected_marker.to_string(), style));
642 if data.indent_cols > 0 {
643 spans.push(Span::styled(" ".repeat(data.indent_cols as usize), style));
644 }
645 spans.push(Span::styled(format!("{} ", data.chevron), style));
647 if data.has_changes {
648 spans.push(Span::styled(
649 "● ",
650 Style::default().fg(theme.menu_highlight_fg).bg(bg),
651 ));
652 } else {
653 spans.push(Span::styled(" ", style));
654 }
655 if let Some(icon) = data.icon {
656 spans.push(Span::styled(
657 icon.to_string(),
658 Style::default().fg(theme.popup_border_fg).bg(bg),
659 ));
660 } else {
661 spans.push(Span::styled(" ", style));
662 }
663 spans.push(Span::styled(data.label.clone(), style));
664
665 frame.render_widget(Paragraph::new(Line::from(spans)), row_area);
666
667 (
670 row_area,
671 data.is_category,
672 data.is_expandable,
673 data.cat_idx,
674 data.section_idx,
675 data.indent_cols,
676 *row,
677 )
678 },
679 theme,
680 );
681
682 for layout_info in panel_layout.item_layouts.iter() {
684 let (row_area, is_category, is_expandable, cat_idx, section_idx, indent_cols, _row) =
685 layout_info.layout;
686 if is_category {
687 if let Some(idx) = cat_idx {
688 layout.add_category(idx, row_area);
689 if is_expandable {
690 let chevron_x = row_area.x.saturating_add(1 + indent_cols);
693 let chevron_area = Rect::new(chevron_x, row_area.y, 1, 1);
694 layout.add_category_disclosure(idx, chevron_area);
695 }
696 }
697 } else if let (Some(c), Some(s)) = (cat_idx, section_idx) {
698 layout.add_section(c, s, row_area);
699 }
700 }
701 if let Some(scrollbar) = panel_layout.scrollbar_area {
702 layout.categories_scrollbar_area = Some(scrollbar);
703 }
704}
705
706struct RenderContext {
708 selected_item: usize,
709 settings_focused: bool,
710 hover_hit: Option<SettingsHit>,
711}
712
713fn render_settings_panel(
715 frame: &mut Frame,
716 area: Rect,
717 state: &mut SettingsState,
718 theme: &Theme,
719 layout: &mut SettingsLayout,
720) {
721 let page = match state.current_page() {
722 Some(p) => p,
723 None => return,
724 };
725
726 let mut y = area.y;
731 let header_start_y = y;
732
733 if page.nullable && state.current_category_has_values() {
735 let btn_text = format!("[{}]", t!("settings.btn_clear_category"));
736 let btn_len = btn_text.len() as u16;
737 let is_hovered = matches!(state.hover_hit, Some(SettingsHit::ClearCategoryButton));
738 let btn_style = if is_hovered {
739 Style::default()
740 .fg(theme.menu_hover_fg)
741 .bg(theme.menu_hover_bg)
742 } else {
743 Style::default().fg(theme.line_number_fg)
744 };
745 let btn_area = Rect::new(area.x, y, btn_len, 1);
746 frame.render_widget(Paragraph::new(btn_text).style(btn_style), btn_area);
747 layout.clear_category_button = Some(btn_area);
748 y += 1;
749 } else {
750 layout.clear_category_button = None;
751 }
752
753 y += 1; let header_height = (y - header_start_y) as usize;
756 let items_start_y = y;
757
758 let available_height = area.height.saturating_sub(header_height as u16);
760
761 state.layout_width = area.width;
766
767 let page = state.pages.get(state.selected_category).unwrap();
769 state.scroll_panel.set_viewport(available_height);
770 state
771 .scroll_panel
772 .update_content_height(&page.items, area.width);
773
774 use super::state::FocusPanel;
776 let render_ctx = RenderContext {
777 selected_item: state.selected_item,
778 settings_focused: state.focus_panel() == FocusPanel::Settings,
779 hover_hit: state.hover_hit,
780 };
781
782 let items_area = Rect::new(area.x, items_start_y, area.width, available_height.max(1));
784
785 let page = state.pages.get(state.selected_category).unwrap();
787
788 let max_label_width = page
790 .items
791 .iter()
792 .filter_map(|item| {
793 match &item.control {
795 SettingControl::Toggle(s) => Some(s.label.len() as u16),
796 SettingControl::Number(s) => Some(s.label.len() as u16),
797 SettingControl::Dropdown(s) => Some(s.label.len() as u16),
798 SettingControl::Text(s) => Some(s.label.len() as u16),
799 _ => None,
801 }
802 })
803 .max();
804
805 let panel_layout = state.scroll_panel.render(
807 frame,
808 items_area,
809 &page.items,
810 |frame, info, item| {
811 render_setting_item_pure(
812 frame,
813 info.area,
814 item,
815 info.index,
816 info.skip_top,
817 &render_ctx,
818 theme,
819 max_label_width,
820 )
821 },
822 theme,
823 );
824
825 let page = state.pages.get(state.selected_category).unwrap();
827 for item_info in panel_layout.item_layouts {
828 layout.add_item(
829 item_info.index,
830 page.items[item_info.index].path.clone(),
831 item_info.area,
832 item_info.layout.control,
833 item_info.layout.inherit_button,
834 );
835 }
836
837 layout.settings_panel_area = Some(panel_layout.content_area);
839
840 if let Some(sb_area) = panel_layout.scrollbar_area {
842 layout.scrollbar_area = Some(sb_area);
843 }
844}
845
846fn wrap_text(text: &str, width: usize) -> Vec<String> {
848 if width == 0 || text.is_empty() {
849 return vec![text.to_string()];
850 }
851
852 let mut lines = Vec::new();
853 let mut current_line = String::new();
854 let mut current_len = 0;
855
856 for word in text.split_whitespace() {
857 let word_len = word.chars().count();
858
859 if current_len == 0 {
860 current_line = word.to_string();
862 current_len = word_len;
863 } else if current_len + 1 + word_len <= width {
864 current_line.push(' ');
866 current_line.push_str(word);
867 current_len += 1 + word_len;
868 } else {
869 lines.push(current_line);
871 current_line = word.to_string();
872 current_len = word_len;
873 }
874 }
875
876 if !current_line.is_empty() {
877 lines.push(current_line);
878 }
879
880 if lines.is_empty() {
881 lines.push(String::new());
882 }
883
884 lines
885}
886
887#[allow(clippy::too_many_arguments)]
898fn render_setting_item_pure(
899 frame: &mut Frame,
900 area: Rect,
901 item: &super::items::SettingItem,
902 idx: usize,
903 skip_top: u16,
904 ctx: &RenderContext,
905 theme: &Theme,
906 label_width: Option<u16>,
907) -> SettingItemLayoutInfo {
908 let plan = item.layout_box(area.width, &item.style);
909 let style = item.style;
910 let viewport_end_logical = skip_top.saturating_add(area.height); let band_rect = |logical_y: u16, rows: u16| -> Option<Rect> {
916 if rows == 0 {
917 return None;
918 }
919 let band_end = logical_y.saturating_add(rows);
920 if band_end <= skip_top || logical_y >= viewport_end_logical {
921 return None;
922 }
923 let visible_top_logical = logical_y.max(skip_top);
924 let visible_bottom_logical = band_end.min(viewport_end_logical);
925 let physical_y = area.y + (visible_top_logical - skip_top);
926 let visible_h = visible_bottom_logical - visible_top_logical;
927 Some(Rect::new(area.x, physical_y, area.width, visible_h))
928 };
929
930 if let (Some(section_name), Some(_header_rect)) = (
936 item.section.as_deref().filter(|_| item.is_section_start),
937 band_rect(0, plan.section_header_rows),
938 ) {
939 let title_logical_y = plan.section_header_rows.saturating_sub(1);
940 if let Some(title_rect) = band_rect(title_logical_y, 1) {
941 let header_style = Style::default()
942 .fg(theme.editor_fg)
943 .add_modifier(Modifier::BOLD);
944 frame.render_widget(
945 Paragraph::new(section_name).style(header_style),
946 Rect::new(title_rect.x, title_rect.y, title_rect.width, 1),
947 );
948 }
949 }
950
951 let card_logical_top = plan.card_top_y();
956 let card_logical_bottom = plan.total_rows();
957 if let Some(card_rect) = band_rect(
958 card_logical_top,
959 card_logical_bottom.saturating_sub(card_logical_top),
960 ) {
961 let mut borders = Borders::NONE;
962 if style.card_border_cols > 0 {
963 borders |= Borders::LEFT | Borders::RIGHT;
964 }
965 if style.card_border_rows > 0 {
966 if card_logical_top >= skip_top {
968 borders |= Borders::TOP;
969 }
970 let bottom_logical = card_logical_bottom.saturating_sub(1);
972 if bottom_logical >= skip_top && bottom_logical < viewport_end_logical {
973 borders |= Borders::BOTTOM;
974 }
975 }
976 if !borders.is_empty() {
977 let block = Block::default()
981 .borders(borders)
982 .border_type(BorderType::Rounded)
983 .border_style(Style::default().fg(theme.split_separator_fg));
984 frame.render_widget(block, card_rect);
985 }
986 }
987
988 let is_selected = ctx.settings_focused && idx == ctx.selected_item;
990 let is_item_hovered = matches!(
991 ctx.hover_hit,
992 Some(SettingsHit::Item(i))
993 | Some(SettingsHit::ControlToggle(i))
994 | Some(SettingsHit::ControlDecrement(i))
995 | Some(SettingsHit::ControlIncrement(i))
996 | Some(SettingsHit::ControlDropdown(i))
997 | Some(SettingsHit::ControlText(i))
998 | Some(SettingsHit::ControlTextListRow(i, _))
999 | Some(SettingsHit::ControlMapRow(i, _))
1000 | Some(SettingsHit::ControlInherit(i))
1001 if i == idx
1002 );
1003 let is_focused_or_hovered = is_selected || is_item_hovered;
1004
1005 let content_logical_top = plan.control_y();
1008 let content_logical_bottom = plan.bottom_border_y();
1009 let mut control_layout = ControlLayoutInfo::default();
1010 let mut inherit_button_area: Option<Rect> = None;
1011 if let Some(content_rect) = band_rect(
1012 content_logical_top,
1013 content_logical_bottom.saturating_sub(content_logical_top),
1014 ) {
1015 let inner_x = content_rect.x.saturating_add(style.card_border_cols);
1017 let inner_width = content_rect
1018 .width
1019 .saturating_sub(2 * style.card_border_cols);
1020 let inner_area = Rect::new(inner_x, content_rect.y, inner_width, content_rect.height);
1021
1022 let label_visible = skip_top <= content_logical_top;
1030 if is_focused_or_hovered && inner_width > 0 && label_visible {
1031 let bg_style = if is_selected {
1032 Style::default().bg(theme.settings_selected_bg)
1033 } else {
1034 Style::default().bg(theme.menu_hover_bg)
1035 };
1036 let row_area = Rect::new(inner_area.x, inner_area.y, inner_area.width, 1);
1037 frame.render_widget(Paragraph::new("").style(bg_style), row_area);
1038 }
1039
1040 let content_skip_top = skip_top.saturating_sub(content_logical_top);
1044
1045 let label_row_visible = content_skip_top == 0 && inner_area.height > 0;
1049 if is_selected && label_row_visible {
1050 frame.render_widget(
1051 Paragraph::new(">").style(
1052 Style::default()
1053 .fg(theme.settings_selected_fg)
1054 .add_modifier(Modifier::BOLD),
1055 ),
1056 Rect::new(inner_area.x, inner_area.y, 1, 1),
1057 );
1058 }
1059 if item.modified && label_row_visible && inner_area.width >= 2 {
1060 frame.render_widget(
1061 Paragraph::new("●").style(Style::default().fg(theme.settings_selected_fg)),
1062 Rect::new(inner_area.x + 1, inner_area.y, 1, 1),
1063 );
1064 }
1065
1066 let control_logical_rows = plan.control_rows;
1068 if let Some(control_rect) = band_rect(content_logical_top, control_logical_rows).map(|r| {
1069 let x =
1070 r.x.saturating_add(style.card_border_cols + style.focus_indicator_cols);
1071 let w = r
1072 .width
1073 .saturating_sub(2 * style.card_border_cols + style.focus_indicator_cols);
1074 Rect::new(x, r.y, w, r.height)
1075 }) {
1076 control_layout = render_control(
1077 frame,
1078 control_rect,
1079 &item.control,
1080 &item.name,
1081 content_skip_top,
1082 theme,
1083 label_width
1084 .map(|w| w.saturating_sub(style.card_border_cols + style.focus_indicator_cols)),
1085 item.read_only,
1086 item.is_null,
1087 );
1088
1089 if item.nullable && content_skip_top == 0 && control_rect.width > 0 {
1092 if item.is_null {
1093 let badge_text = t!("settings.inherited_badge").to_string();
1094 let badge_len = badge_text.len() as u16 + 1;
1095 let badge_x = control_rect
1096 .x
1097 .saturating_add(control_rect.width)
1098 .saturating_sub(badge_len);
1099 if badge_x > control_rect.x {
1100 frame.render_widget(
1101 Paragraph::new(badge_text).style(
1102 Style::default()
1103 .fg(theme.line_number_fg)
1104 .add_modifier(Modifier::ITALIC),
1105 ),
1106 Rect::new(badge_x, control_rect.y, badge_len, 1),
1107 );
1108 }
1109 } else {
1110 let btn_text = format!("[{}]", t!("settings.btn_inherit"));
1111 let btn_len = btn_text.len() as u16 + 1;
1112 let btn_x = control_rect
1113 .x
1114 .saturating_add(control_rect.width)
1115 .saturating_sub(btn_len);
1116 if btn_x > control_rect.x {
1117 let btn_area = Rect::new(btn_x, control_rect.y, btn_len, 1);
1118 let is_hovered = matches!(
1119 ctx.hover_hit,
1120 Some(SettingsHit::ControlInherit(i)) if i == idx
1121 );
1122 let btn_style = if is_hovered {
1123 Style::default()
1124 .fg(theme.menu_hover_fg)
1125 .bg(theme.menu_hover_bg)
1126 } else {
1127 Style::default().fg(theme.line_number_fg)
1128 };
1129 frame.render_widget(Paragraph::new(btn_text).style(btn_style), btn_area);
1130 inherit_button_area = Some(btn_area);
1131 }
1132 }
1133 }
1134 }
1135
1136 let desc_logical_rows = plan.description_rows;
1140 let layer_label = match item.layer_source {
1141 crate::config_io::ConfigLayer::System => None,
1142 crate::config_io::ConfigLayer::User => Some("user"),
1143 crate::config_io::ConfigLayer::Project => Some("project"),
1144 crate::config_io::ConfigLayer::Session => Some("session"),
1145 };
1146
1147 if desc_logical_rows > 0 {
1148 if let Some(desc_rect) = band_rect(plan.description_y(), desc_logical_rows).map(|r| {
1149 let x =
1150 r.x.saturating_add(style.card_border_cols + style.focus_indicator_cols);
1151 let w = r
1152 .width
1153 .saturating_sub(2 * style.card_border_cols + style.focus_indicator_cols);
1154 Rect::new(x, r.y, w, r.height)
1155 }) {
1156 let desc_skip = skip_top.saturating_sub(plan.description_y());
1157 let max_text_width = desc_rect
1158 .width
1159 .saturating_sub(style.description_right_padding_cols)
1160 as usize;
1161 let mut lines = match item.description.as_deref() {
1162 Some(d) if !d.is_empty() => wrap_text(d, max_text_width),
1163 _ => Vec::new(),
1164 };
1165 if let Some(layer) = layer_label {
1166 if let Some(last) = lines.last_mut() {
1167 last.push_str(&format!(" ({})", layer));
1168 } else {
1169 lines.push(format!("({})", layer));
1170 }
1171 }
1172 let desc_style = Style::default().fg(theme.line_number_fg);
1173 let take = desc_rect.height as usize;
1174 for (i, line) in lines.iter().skip(desc_skip as usize).take(take).enumerate() {
1175 frame.render_widget(
1176 Paragraph::new(line.as_str()).style(desc_style),
1177 Rect::new(desc_rect.x, desc_rect.y + i as u16, desc_rect.width, 1),
1178 );
1179 }
1180 }
1181 } else if let Some(layer) = layer_label {
1182 if let Some(layer_rect) = band_rect(plan.description_y(), 1).map(|r| {
1185 let x =
1186 r.x.saturating_add(style.card_border_cols + style.focus_indicator_cols);
1187 let w = r
1188 .width
1189 .saturating_sub(2 * style.card_border_cols + style.focus_indicator_cols);
1190 Rect::new(x, r.y, w, r.height)
1191 }) {
1192 frame.render_widget(
1193 Paragraph::new(format!("({})", layer))
1194 .style(Style::default().fg(theme.line_number_fg)),
1195 layer_rect,
1196 );
1197 }
1198 }
1199 }
1200
1201 SettingItemLayoutInfo {
1202 control: control_layout,
1203 inherit_button: inherit_button_area,
1204 }
1205}
1206
1207#[allow(clippy::too_many_arguments)]
1215fn render_control(
1216 frame: &mut Frame,
1217 area: Rect,
1218 control: &SettingControl,
1219 name: &str,
1220 skip_rows: u16,
1221 theme: &Theme,
1222 label_width: Option<u16>,
1223 read_only: bool,
1224 is_null: bool,
1225) -> ControlLayoutInfo {
1226 match control {
1227 SettingControl::Toggle(state) => {
1229 if skip_rows > 0 {
1230 return ControlLayoutInfo::Toggle(Rect::default());
1231 }
1232 let colors = ToggleColors::from_theme(theme);
1233 let toggle_layout = render_toggle_aligned(frame, area, state, &colors, label_width);
1234 ControlLayoutInfo::Toggle(toggle_layout.full_area)
1235 }
1236
1237 SettingControl::Number(state) => {
1238 if skip_rows > 0 {
1239 return ControlLayoutInfo::Number {
1240 decrement: Rect::default(),
1241 increment: Rect::default(),
1242 value: Rect::default(),
1243 };
1244 }
1245 let colors = NumberInputColors::from_theme(theme);
1246 let num_layout = render_number_input_aligned(frame, area, state, &colors, label_width);
1247 ControlLayoutInfo::Number {
1248 decrement: num_layout.decrement_area,
1249 increment: num_layout.increment_area,
1250 value: num_layout.value_area,
1251 }
1252 }
1253
1254 SettingControl::Dropdown(state) => {
1255 if skip_rows > 0 {
1256 return ControlLayoutInfo::Dropdown {
1257 button_area: Rect::default(),
1258 option_areas: Vec::new(),
1259 scroll_offset: 0,
1260 };
1261 }
1262 let colors = DropdownColors::from_theme(theme);
1263 let drop_layout = render_dropdown_aligned(frame, area, state, &colors, label_width);
1264 ControlLayoutInfo::Dropdown {
1265 button_area: drop_layout.button_area,
1266 option_areas: drop_layout.option_areas,
1267 scroll_offset: drop_layout.scroll_offset,
1268 }
1269 }
1270
1271 SettingControl::Text(state) => {
1272 if skip_rows > 0 {
1273 return ControlLayoutInfo::Text(Rect::default());
1274 }
1275 if read_only {
1276 let label_w = label_width.unwrap_or(20);
1278 let label_style = Style::default().fg(theme.editor_fg);
1279 let value_style = Style::default().fg(theme.line_number_fg);
1280 let label = format!("{}: ", state.label);
1281 let value = &state.value;
1282
1283 let label_area = Rect::new(area.x, area.y, label_w, 1);
1284 let value_area = Rect::new(
1285 area.x + label_w,
1286 area.y,
1287 area.width.saturating_sub(label_w),
1288 1,
1289 );
1290
1291 frame.render_widget(Paragraph::new(label.clone()).style(label_style), label_area);
1292 frame.render_widget(
1293 Paragraph::new(value.as_str()).style(value_style),
1294 value_area,
1295 );
1296 ControlLayoutInfo::Text(Rect::default())
1297 } else if is_null {
1298 let colors = TextInputColors::from_theme_disabled(theme);
1300 let text_layout =
1301 render_text_input_aligned(frame, area, state, &colors, 30, label_width);
1302 ControlLayoutInfo::Text(text_layout.input_area)
1303 } else {
1304 let colors = TextInputColors::from_theme(theme);
1305 let text_layout =
1306 render_text_input_aligned(frame, area, state, &colors, 30, label_width);
1307 ControlLayoutInfo::Text(text_layout.input_area)
1308 }
1309 }
1310
1311 SettingControl::TextList(state) => {
1313 let colors = TextListColors::from_theme(theme);
1314 let list_layout = render_text_list_partial(frame, area, state, &colors, 30, skip_rows);
1315 ControlLayoutInfo::TextList {
1316 rows: list_layout
1317 .rows
1318 .iter()
1319 .map(|r| (r.index, r.text_area))
1320 .collect(),
1321 }
1322 }
1323
1324 SettingControl::DualList(state) => {
1325 let colors = DualListColors::from_theme(theme);
1326 let dual_layout = render_dual_list_partial(frame, area, state, &colors, skip_rows);
1327 ControlLayoutInfo::DualList(dual_layout)
1328 }
1329
1330 SettingControl::Map(state) => {
1331 let colors = MapColors::from_theme(theme);
1332 let map_layout = render_map_partial(frame, area, state, &colors, 20, skip_rows);
1333 ControlLayoutInfo::Map {
1334 entry_rows: map_layout
1335 .entry_areas
1336 .iter()
1337 .map(|e| (e.index, e.row_area))
1338 .collect(),
1339 add_row_area: map_layout.add_row_area,
1340 }
1341 }
1342
1343 SettingControl::ObjectArray(state) => {
1344 let colors = crate::view::controls::KeybindingListColors {
1345 label_fg: theme.editor_fg,
1346 key_fg: theme.help_key_fg,
1347 action_fg: theme.syntax_function,
1348 row_bg: theme.popup_bg,
1352 focused_bg: theme.settings_selected_bg,
1354 focused_fg: theme.settings_selected_fg,
1355 delete_fg: theme.diagnostic_error_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 delete_rects: Vec::new(),
2046 add_rect: None,
2047 };
2048
2049 if area.height == 0 {
2050 return empty_layout;
2051 }
2052
2053 let indent = 2u16;
2054 let is_focused = state.focus == FocusState::Focused;
2055 let mut entry_rects = Vec::new();
2056 let mut delete_rects = Vec::new();
2057 let mut content_row = 0u16;
2058 let mut y = area.y;
2059
2060 if content_row >= skip_rows {
2062 let label_line = Line::from(vec![Span::styled(
2063 format!("{}:", state.label),
2064 Style::default().fg(colors.label_fg),
2065 )]);
2066 frame.render_widget(
2067 Paragraph::new(label_line),
2068 Rect::new(area.x, y, area.width, 1),
2069 );
2070 y += 1;
2071 }
2072 content_row += 1;
2073
2074 for (idx, binding) in state.bindings.iter().enumerate() {
2076 if y >= area.y + area.height {
2077 break;
2078 }
2079
2080 if content_row >= skip_rows {
2081 let entry_area = Rect::new(area.x + indent, y, area.width.saturating_sub(indent), 1);
2082 entry_rects.push((idx, entry_area));
2083
2084 let is_entry_focused = is_focused && state.focused_index == Some(idx);
2085 let bg = if is_entry_focused {
2086 colors.focused_bg
2087 } else {
2088 colors.row_bg
2089 };
2090
2091 let key_combo = format_key_combo(binding);
2092 let field_name = state
2094 .display_field
2095 .as_ref()
2096 .and_then(|p| p.strip_prefix('/'))
2097 .unwrap_or("action");
2098 let action = binding
2099 .get(field_name)
2100 .and_then(|a| a.as_str())
2101 .unwrap_or("(no action)");
2102
2103 let indicator = if is_entry_focused { "> " } else { " " };
2104 let (indicator_fg, key_fg, arrow_fg, action_fg, delete_fg) = if is_entry_focused {
2106 (
2107 colors.focused_fg,
2108 colors.focused_fg,
2109 colors.focused_fg,
2110 colors.focused_fg,
2111 colors.focused_fg,
2112 )
2113 } else {
2114 (
2115 colors.label_fg,
2116 colors.key_fg,
2117 colors.label_fg,
2118 colors.action_fg,
2119 colors.delete_fg,
2120 )
2121 };
2122 let line = if key_combo.trim().is_empty() {
2130 Line::from(vec![
2131 Span::styled(indicator, Style::default().fg(indicator_fg).bg(bg)),
2132 Span::styled(action, Style::default().fg(action_fg).bg(bg)),
2133 Span::styled(" [x]", Style::default().fg(delete_fg).bg(bg)),
2134 ])
2135 } else {
2136 Line::from(vec![
2137 Span::styled(indicator, Style::default().fg(indicator_fg).bg(bg)),
2138 Span::styled(
2139 format!("{:<20}", key_combo),
2140 Style::default().fg(key_fg).bg(bg),
2141 ),
2142 Span::styled(" → ", Style::default().fg(arrow_fg).bg(bg)),
2143 Span::styled(action, Style::default().fg(action_fg).bg(bg)),
2144 Span::styled(" [x]", Style::default().fg(delete_fg).bg(bg)),
2145 ])
2146 };
2147 frame.render_widget(Paragraph::new(line), entry_area);
2148
2149 let delete_x = entry_area.x + entry_area.width.saturating_sub(4);
2151 delete_rects.push(Rect::new(delete_x, y, 3, 1));
2152
2153 y += 1;
2154 }
2155 content_row += 1;
2156 }
2157
2158 let add_rect = if y < area.y + area.height && content_row >= skip_rows {
2160 let is_add_focused = is_focused && state.focused_index.is_none();
2161 let bg = if is_add_focused {
2162 colors.focused_bg
2163 } else {
2164 colors.row_bg
2165 };
2166
2167 let indicator = if is_add_focused { "> " } else { " " };
2168 let (indicator_fg, add_fg) = if is_add_focused {
2170 (colors.focused_fg, colors.focused_fg)
2171 } else {
2172 (colors.label_fg, colors.add_fg)
2173 };
2174 let line = Line::from(vec![
2175 Span::styled(indicator, Style::default().fg(indicator_fg).bg(bg)),
2176 Span::styled("[+] Add new", Style::default().fg(add_fg).bg(bg)),
2177 ]);
2178 let add_area = Rect::new(area.x + indent, y, area.width.saturating_sub(indent), 1);
2179 frame.render_widget(Paragraph::new(line), add_area);
2180 Some(add_area)
2181 } else {
2182 None
2183 };
2184
2185 crate::view::controls::KeybindingListLayout {
2186 entry_rects,
2187 delete_rects,
2188 add_rect,
2189 }
2190}
2191
2192#[derive(Debug, Clone, Default)]
2194pub struct SettingItemLayoutInfo {
2195 pub control: ControlLayoutInfo,
2196 pub inherit_button: Option<Rect>,
2197}
2198
2199#[derive(Debug, Clone, Default)]
2201pub enum ControlLayoutInfo {
2202 Toggle(Rect),
2203 Number {
2204 decrement: Rect,
2205 increment: Rect,
2206 value: Rect,
2207 },
2208 Dropdown {
2209 button_area: Rect,
2210 option_areas: Vec<Rect>,
2211 scroll_offset: usize,
2212 },
2213 Text(Rect),
2214 TextList {
2215 rows: Vec<(Option<usize>, Rect)>,
2217 },
2218 DualList(crate::view::controls::DualListLayout),
2219 Map {
2220 entry_rows: Vec<(usize, Rect)>,
2222 add_row_area: Option<Rect>,
2223 },
2224 ObjectArray {
2225 entry_rows: Vec<(usize, Rect)>,
2227 },
2228 Json {
2229 edit_area: Rect,
2230 },
2231 #[default]
2232 Complex,
2233}
2234
2235#[allow(clippy::too_many_arguments)]
2237fn render_button(
2238 frame: &mut Frame,
2239 area: Rect,
2240 text: &str,
2241 focused_text: &str,
2242 is_focused: bool,
2243 is_hovered: bool,
2244 theme: &Theme,
2245 dimmed: bool,
2246) {
2247 if is_focused {
2248 let style = Style::default()
2249 .fg(theme.menu_highlight_fg)
2250 .bg(theme.menu_highlight_bg)
2251 .add_modifier(Modifier::BOLD);
2252 frame.render_widget(Paragraph::new(focused_text).style(style), area);
2253 } else if is_hovered {
2254 let style = Style::default()
2255 .fg(theme.menu_hover_fg)
2256 .bg(theme.menu_hover_bg);
2257 frame.render_widget(Paragraph::new(text).style(style), area);
2258 } else {
2259 let fg = if dimmed {
2260 theme.line_number_fg
2261 } else {
2262 theme.popup_text_fg
2263 };
2264 frame.render_widget(Paragraph::new(text).style(Style::default().fg(fg)), area);
2265 }
2266}
2267
2268fn render_footer(
2271 frame: &mut Frame,
2272 modal_area: Rect,
2273 state: &SettingsState,
2274 theme: &Theme,
2275 layout: &mut SettingsLayout,
2276 vertical: bool,
2277) {
2278 use super::layout::SettingsHit;
2279 use super::state::FocusPanel;
2280
2281 if modal_area.height < 4 || modal_area.width < 10 {
2283 return;
2284 }
2285
2286 if vertical {
2287 render_footer_vertical(frame, modal_area, state, theme, layout);
2288 return;
2289 }
2290
2291 let footer_y = modal_area.y + modal_area.height.saturating_sub(2);
2292 let footer_width = modal_area.width.saturating_sub(2);
2293 let footer_area = Rect::new(modal_area.x + 1, footer_y, footer_width, 1);
2294
2295 if footer_y > modal_area.y {
2297 let sep_y = footer_y.saturating_sub(1);
2298 let sep_area = Rect::new(modal_area.x + 1, sep_y, footer_width, 1);
2299 let sep_line: String = "─".repeat(sep_area.width as usize);
2300 frame.render_widget(
2301 Paragraph::new(sep_line).style(Style::default().fg(theme.split_separator_fg)),
2302 sep_area,
2303 );
2304 }
2305
2306 let footer_focused = state.focus_panel() == FocusPanel::Footer;
2308
2309 let layer_hovered = matches!(state.hover_hit, Some(SettingsHit::LayerButton));
2312 let reset_hovered = matches!(state.hover_hit, Some(SettingsHit::ResetButton));
2313 let save_hovered = matches!(state.hover_hit, Some(SettingsHit::SaveButton));
2314 let cancel_hovered = matches!(state.hover_hit, Some(SettingsHit::CancelButton));
2315 let edit_hovered = matches!(state.hover_hit, Some(SettingsHit::EditButton));
2316
2317 let layer_focused = footer_focused && state.footer_button_index == 0;
2318 let reset_focused = footer_focused && state.footer_button_index == 1;
2319 let save_focused = footer_focused && state.footer_button_index == 2;
2320 let cancel_focused = footer_focused && state.footer_button_index == 3;
2321 let edit_focused = footer_focused && state.footer_button_index == 4;
2322
2323 let current_is_nullable_set = state
2326 .current_item()
2327 .map(|item| item.nullable && !item.is_null)
2328 .unwrap_or(false);
2329 let save_label = t!("settings.btn_save").to_string();
2330 let cancel_label = t!("settings.btn_cancel").to_string();
2331 let reset_label = if current_is_nullable_set {
2332 t!("settings.btn_inherit").to_string()
2333 } else {
2334 t!("settings.btn_reset").to_string()
2335 };
2336 let edit_label = t!("settings.btn_edit").to_string();
2337
2338 let layer_text = format!("[ {} ]", state.target_layer_name());
2340 let layer_text_focused = format!(">[ {} ]", state.target_layer_name());
2341 let save_text = format!("[ {} ]", save_label);
2342 let save_text_focused = format!(">[ {} ]", save_label);
2343 let cancel_text = format!("[ {} ]", cancel_label);
2344 let cancel_text_focused = format!(">[ {} ]", cancel_label);
2345 let reset_text = format!("[ {} ]", reset_label);
2346 let reset_text_focused = format!(">[ {} ]", reset_label);
2347 let edit_text = format!("[ {} ]", edit_label);
2348 let edit_text_focused = format!(">[ {} ]", edit_label);
2349
2350 let cancel_width = str_width(if cancel_focused {
2352 &cancel_text_focused
2353 } else {
2354 &cancel_text
2355 }) as u16;
2356 let save_width = str_width(if save_focused {
2357 &save_text_focused
2358 } else {
2359 &save_text
2360 }) as u16;
2361 let reset_width = str_width(if reset_focused {
2362 &reset_text_focused
2363 } else {
2364 &reset_text
2365 }) as u16;
2366 let layer_width = str_width(if layer_focused {
2367 &layer_text_focused
2368 } else {
2369 &layer_text
2370 }) as u16;
2371 let edit_width = str_width(if edit_focused {
2372 &edit_text_focused
2373 } else {
2374 &edit_text
2375 }) as u16;
2376 let gap: u16 = 2;
2377
2378 let min_buttons_width = save_width + gap + cancel_width;
2381 let all_buttons_width =
2383 edit_width + gap + layer_width + gap + reset_width + gap + save_width + gap + cancel_width;
2384
2385 let available = footer_area.width;
2387 let show_edit = available >= all_buttons_width;
2388 let show_layer = available >= (layer_width + gap + reset_width + gap + min_buttons_width);
2389 let show_reset = available >= (reset_width + gap + min_buttons_width);
2390
2391 let cancel_x = footer_area
2393 .x
2394 .saturating_add(footer_area.width.saturating_sub(cancel_width));
2395 let save_x = cancel_x.saturating_sub(save_width + gap);
2396 let reset_x = if show_reset {
2397 save_x.saturating_sub(reset_width + gap)
2398 } else {
2399 0
2400 };
2401 let layer_x = if show_layer {
2402 reset_x.saturating_sub(layer_width + gap)
2403 } else {
2404 0
2405 };
2406 let edit_x = footer_area.x; if show_layer {
2411 let layer_area = Rect::new(layer_x, footer_y, layer_width, 1);
2412 render_button(
2413 frame,
2414 layer_area,
2415 &layer_text,
2416 &layer_text_focused,
2417 layer_focused,
2418 layer_hovered,
2419 theme,
2420 false,
2421 );
2422 layout.layer_button = Some(layer_area);
2423 }
2424
2425 if show_reset {
2427 let reset_area = Rect::new(reset_x, footer_y, reset_width, 1);
2428 render_button(
2429 frame,
2430 reset_area,
2431 &reset_text,
2432 &reset_text_focused,
2433 reset_focused,
2434 reset_hovered,
2435 theme,
2436 false,
2437 );
2438 layout.reset_button = Some(reset_area);
2439 }
2440
2441 let save_area = Rect::new(save_x, footer_y, save_width, 1);
2443 render_button(
2444 frame,
2445 save_area,
2446 &save_text,
2447 &save_text_focused,
2448 save_focused,
2449 save_hovered,
2450 theme,
2451 false,
2452 );
2453 layout.save_button = Some(save_area);
2454
2455 let cancel_area = Rect::new(cancel_x, footer_y, cancel_width, 1);
2457 render_button(
2458 frame,
2459 cancel_area,
2460 &cancel_text,
2461 &cancel_text_focused,
2462 cancel_focused,
2463 cancel_hovered,
2464 theme,
2465 false,
2466 );
2467 layout.cancel_button = Some(cancel_area);
2468
2469 if show_edit {
2471 let edit_area = Rect::new(edit_x, footer_y, edit_width, 1);
2472 render_button(
2473 frame,
2474 edit_area,
2475 &edit_text,
2476 &edit_text_focused,
2477 edit_focused,
2478 edit_hovered,
2479 theme,
2480 true, );
2482 layout.edit_button = Some(edit_area);
2483 }
2484
2485 let help_start_x = if show_edit {
2488 edit_x + edit_width + 2
2489 } else {
2490 footer_area.x
2491 };
2492 let help_end_x = if show_layer {
2493 layer_x
2494 } else if show_reset {
2495 reset_x
2496 } else {
2497 save_x
2498 };
2499 let help_width = help_end_x.saturating_sub(help_start_x + 1);
2500
2501 let help = if state.search_active {
2503 t!("settings.help_search").to_string()
2504 } else if footer_focused {
2505 t!("settings.help_footer").to_string()
2506 } else {
2507 t!("settings.help_default").to_string()
2508 };
2509 let help_line = build_keyhint_line(&help, theme);
2512 frame.render_widget(
2513 Paragraph::new(help_line),
2514 Rect::new(help_start_x, footer_y, help_width, 1),
2515 );
2516}
2517
2518fn build_keyhint_line<'a>(text: &str, theme: &Theme) -> Line<'a> {
2520 let key_style = Style::default()
2521 .fg(theme.popup_text_fg)
2522 .bg(theme.split_separator_fg);
2523 let desc_style = Style::default().fg(theme.line_number_fg);
2524 let sep_style = Style::default().fg(theme.line_number_fg);
2525
2526 let mut spans: Vec<Span<'a>> = Vec::new();
2527
2528 for (i, segment) in text.split(" ").enumerate() {
2530 let segment = segment.trim();
2531 if segment.is_empty() {
2532 continue;
2533 }
2534 if i > 0 {
2535 spans.push(Span::styled(" ", sep_style));
2536 }
2537 if let Some(colon_pos) = segment.find(':') {
2539 let key = &segment[..colon_pos];
2540 let action = &segment[colon_pos + 1..];
2541 spans.push(Span::styled(format!(" {} ", key), key_style));
2542 spans.push(Span::styled(action.to_string(), desc_style));
2543 } else {
2544 spans.push(Span::styled(segment.to_string(), desc_style));
2546 }
2547 }
2548
2549 Line::from(spans)
2550}
2551
2552fn render_footer_vertical(
2554 frame: &mut Frame,
2555 modal_area: Rect,
2556 state: &SettingsState,
2557 theme: &Theme,
2558 layout: &mut SettingsLayout,
2559) {
2560 use super::layout::SettingsHit;
2561 use super::state::FocusPanel;
2562
2563 let footer_height = 7u16;
2565 let footer_y = modal_area
2566 .y
2567 .saturating_add(modal_area.height.saturating_sub(footer_height));
2568 let footer_width = modal_area.width.saturating_sub(2);
2569
2570 let sep_y = footer_y;
2572 if sep_y > modal_area.y {
2573 let sep_line: String = "─".repeat(footer_width as usize);
2574 frame.render_widget(
2575 Paragraph::new(sep_line).style(Style::default().fg(theme.split_separator_fg)),
2576 Rect::new(modal_area.x + 1, sep_y, footer_width, 1),
2577 );
2578 }
2579
2580 let footer_focused = state.focus_panel() == FocusPanel::Footer;
2582
2583 let layer_hovered = matches!(state.hover_hit, Some(SettingsHit::LayerButton));
2585 let reset_hovered = matches!(state.hover_hit, Some(SettingsHit::ResetButton));
2586 let save_hovered = matches!(state.hover_hit, Some(SettingsHit::SaveButton));
2587 let cancel_hovered = matches!(state.hover_hit, Some(SettingsHit::CancelButton));
2588 let edit_hovered = matches!(state.hover_hit, Some(SettingsHit::EditButton));
2589
2590 let layer_focused = footer_focused && state.footer_button_index == 0;
2591 let reset_focused = footer_focused && state.footer_button_index == 1;
2592 let save_focused = footer_focused && state.footer_button_index == 2;
2593 let cancel_focused = footer_focused && state.footer_button_index == 3;
2594 let edit_focused = footer_focused && state.footer_button_index == 4;
2595
2596 let current_is_nullable_set = state
2599 .current_item()
2600 .map(|item| item.nullable && !item.is_null)
2601 .unwrap_or(false);
2602 let save_label = t!("settings.btn_save").to_string();
2603 let cancel_label = t!("settings.btn_cancel").to_string();
2604 let reset_label = if current_is_nullable_set {
2605 t!("settings.btn_inherit").to_string()
2606 } else {
2607 t!("settings.btn_reset").to_string()
2608 };
2609 let edit_label = t!("settings.btn_edit").to_string();
2610
2611 let layer_text = format!("[ {} ]", state.target_layer_name());
2613 let layer_text_focused = format!(">[ {} ]", state.target_layer_name());
2614 let save_text = format!("[ {} ]", save_label);
2615 let save_text_focused = format!(">[ {} ]", save_label);
2616 let cancel_text = format!("[ {} ]", cancel_label);
2617 let cancel_text_focused = format!(">[ {} ]", cancel_label);
2618 let reset_text = format!("[ {} ]", reset_label);
2619 let reset_text_focused = format!(">[ {} ]", reset_label);
2620 let edit_text = format!("[ {} ]", edit_label);
2621 let edit_text_focused = format!(">[ {} ]", edit_label);
2622
2623 let button_x = modal_area.x + 2;
2625 let mut y = sep_y + 1;
2626
2627 let layer_width = str_width(if layer_focused {
2629 &layer_text_focused
2630 } else {
2631 &layer_text
2632 }) as u16;
2633 let layer_area = Rect::new(button_x, y, layer_width.min(footer_width), 1);
2634 render_button(
2635 frame,
2636 layer_area,
2637 &layer_text,
2638 &layer_text_focused,
2639 layer_focused,
2640 layer_hovered,
2641 theme,
2642 false,
2643 );
2644 layout.layer_button = Some(layer_area);
2645 y += 1;
2646
2647 let save_width = str_width(if save_focused {
2649 &save_text_focused
2650 } else {
2651 &save_text
2652 }) as u16;
2653 let save_area = Rect::new(button_x, y, save_width.min(footer_width), 1);
2654 render_button(
2655 frame,
2656 save_area,
2657 &save_text,
2658 &save_text_focused,
2659 save_focused,
2660 save_hovered,
2661 theme,
2662 false,
2663 );
2664 layout.save_button = Some(save_area);
2665 y += 1;
2666
2667 let reset_width = str_width(if reset_focused {
2669 &reset_text_focused
2670 } else {
2671 &reset_text
2672 }) as u16;
2673 let reset_area = Rect::new(button_x, y, reset_width.min(footer_width), 1);
2674 render_button(
2675 frame,
2676 reset_area,
2677 &reset_text,
2678 &reset_text_focused,
2679 reset_focused,
2680 reset_hovered,
2681 theme,
2682 false,
2683 );
2684 layout.reset_button = Some(reset_area);
2685 y += 1;
2686
2687 let cancel_width = str_width(if cancel_focused {
2689 &cancel_text_focused
2690 } else {
2691 &cancel_text
2692 }) as u16;
2693 let cancel_area = Rect::new(button_x, y, cancel_width.min(footer_width), 1);
2694 render_button(
2695 frame,
2696 cancel_area,
2697 &cancel_text,
2698 &cancel_text_focused,
2699 cancel_focused,
2700 cancel_hovered,
2701 theme,
2702 false,
2703 );
2704 layout.cancel_button = Some(cancel_area);
2705 y += 1;
2706
2707 let edit_width = str_width(if edit_focused {
2709 &edit_text_focused
2710 } else {
2711 &edit_text
2712 }) as u16;
2713 let edit_area = Rect::new(button_x, y, edit_width.min(footer_width), 1);
2714 render_button(
2715 frame,
2716 edit_area,
2717 &edit_text,
2718 &edit_text_focused,
2719 edit_focused,
2720 edit_hovered,
2721 theme,
2722 true, );
2724 layout.edit_button = Some(edit_area);
2725}
2726
2727fn render_search_header(frame: &mut Frame, area: Rect, state: &SettingsState, theme: &Theme) {
2729 let search_style = Style::default().fg(theme.settings_selected_fg);
2730 let cursor_style = Style::default()
2731 .fg(theme.settings_selected_fg)
2732 .add_modifier(Modifier::REVERSED);
2733
2734 let result_count = state.search_results.len();
2736 let count_text = if state.search_query.is_empty() {
2737 String::new()
2738 } else if result_count == 0 {
2739 " (no results)".to_string()
2740 } else if result_count == 1 {
2741 " (1 result)".to_string()
2742 } else if state.search_max_visible >= result_count {
2743 format!(" ({} results)", result_count)
2745 } else {
2746 let first = state.search_scroll_offset + 1;
2748 let last = (state.search_scroll_offset + state.search_max_visible).min(result_count);
2749 format!(" ({}-{} of {})", first, last, result_count)
2750 };
2751
2752 let has_more_above = state.search_scroll_offset > 0;
2754 let has_more_below = state.search_scroll_offset + state.search_max_visible < result_count;
2755 let scroll_indicator = match (has_more_above, has_more_below) {
2756 (true, true) => " ↑↓",
2757 (true, false) => " ↑",
2758 (false, true) => " ↓",
2759 (false, false) => "",
2760 };
2761
2762 let count_style = Style::default().fg(theme.line_number_fg);
2763 let indicator_style = Style::default()
2764 .fg(theme.menu_active_fg)
2765 .add_modifier(Modifier::BOLD);
2766
2767 let spans = vec![
2768 Span::styled("> ", search_style),
2769 Span::styled(&state.search_query, search_style),
2770 Span::styled(" ", cursor_style), Span::styled(count_text, count_style),
2772 Span::styled(scroll_indicator, indicator_style),
2773 ];
2774 let line = Line::from(spans);
2775 frame.render_widget(Paragraph::new(line), area);
2776}
2777
2778fn render_search_hint(frame: &mut Frame, area: Rect, theme: &Theme) {
2780 let hint_style = Style::default().fg(theme.line_number_fg);
2781 let key_style = Style::default()
2782 .fg(theme.popup_text_fg)
2783 .bg(theme.split_separator_fg);
2784
2785 let spans = vec![
2786 Span::styled("Press ", hint_style),
2787 Span::styled(" / ", key_style),
2788 Span::styled(" to search settings...", hint_style),
2789 ];
2790 let line = Line::from(spans);
2791 frame.render_widget(Paragraph::new(line), area);
2792}
2793
2794fn render_search_results(
2796 frame: &mut Frame,
2797 area: Rect,
2798 state: &mut SettingsState,
2799 theme: &Theme,
2800 layout: &mut SettingsLayout,
2801) {
2802 let max_visible = (area.height.saturating_sub(3) / 3) as usize;
2804 state.search_max_visible = max_visible.max(1);
2805
2806 if state.search_scroll_offset >= state.search_results.len() {
2808 state.search_scroll_offset = state.search_results.len().saturating_sub(1);
2809 }
2810
2811 let needs_scrollbar = state.search_results.len() > state.search_max_visible;
2813 let scrollbar_width = if needs_scrollbar { 1 } else { 0 };
2814
2815 let content_area = Rect::new(
2817 area.x,
2818 area.y,
2819 area.width.saturating_sub(scrollbar_width),
2820 area.height,
2821 );
2822
2823 let mut y = content_area.y;
2824
2825 for (idx, result) in state
2826 .search_results
2827 .iter()
2828 .enumerate()
2829 .skip(state.search_scroll_offset)
2830 {
2831 if y >= content_area.y + content_area.height.saturating_sub(3) {
2832 break;
2833 }
2834
2835 let is_selected = idx == state.selected_search_result;
2836 let is_hovered = matches!(state.hover_hit, Some(SettingsHit::SearchResult(i)) if i == idx);
2837 let item_area = Rect::new(content_area.x, y, content_area.width, 3);
2838
2839 render_search_result_item(
2840 frame,
2841 item_area,
2842 result,
2843 is_selected,
2844 is_hovered,
2845 theme,
2846 layout,
2847 );
2848 y += 3;
2849 }
2850
2851 layout.search_results_area = Some(content_area);
2853
2854 if needs_scrollbar {
2856 let scrollbar_area = Rect::new(
2857 area.x + area.width - 1,
2858 area.y,
2859 1,
2860 area.height.saturating_sub(3), );
2862
2863 let scrollbar_state = ScrollbarState::new(
2864 state.search_results.len(),
2865 state.search_max_visible,
2866 state.search_scroll_offset,
2867 );
2868
2869 let colors = ScrollbarColors::from_theme(theme);
2870 render_scrollbar(frame, scrollbar_area, &scrollbar_state, &colors);
2871
2872 layout.search_scrollbar_area = Some(scrollbar_area);
2874 } else {
2875 layout.search_scrollbar_area = None;
2876 }
2877}
2878
2879fn render_search_result_item(
2881 frame: &mut Frame,
2882 area: Rect,
2883 result: &SearchResult,
2884 is_selected: bool,
2885 is_hovered: bool,
2886 theme: &Theme,
2887 layout: &mut SettingsLayout,
2888) {
2889 if is_selected {
2891 let bg_style = Style::default().bg(theme.settings_selected_bg);
2893 for row in 0..area.height.min(3) {
2894 let row_area = Rect::new(area.x, area.y + row, area.width, 1);
2895 frame.render_widget(Paragraph::new("").style(bg_style), row_area);
2896 }
2897 } else if is_hovered {
2898 let bg_style = Style::default().bg(theme.menu_hover_bg);
2900 for row in 0..area.height.min(3) {
2901 let row_area = Rect::new(area.x, area.y + row, area.width, 1);
2902 frame.render_widget(Paragraph::new("").style(bg_style), row_area);
2903 }
2904 }
2905
2906 let (display_name, display_desc) = match &result.deep_match {
2908 Some(DeepMatch::MapKey { key, .. }) => (key.clone(), Some(result.item.name.clone())),
2909 Some(DeepMatch::MapValue {
2910 matched_text, key, ..
2911 }) => (
2912 matched_text.clone(),
2913 Some(format!("{} > {}", result.item.name, key)),
2914 ),
2915 Some(DeepMatch::TextListItem { text, .. }) => {
2916 (text.clone(), Some(result.item.name.clone()))
2917 }
2918 None => (result.item.name.clone(), result.item.description.clone()),
2919 };
2920
2921 let name_style = if is_selected {
2923 Style::default().fg(theme.settings_selected_fg)
2924 } else if is_hovered {
2925 Style::default().fg(theme.menu_hover_fg)
2926 } else {
2927 Style::default().fg(theme.popup_text_fg)
2928 };
2929
2930 let indicator = if is_selected { "▸ " } else { " " };
2932 let indicator_style = if is_selected {
2933 Style::default()
2934 .fg(theme.settings_selected_fg)
2935 .add_modifier(Modifier::BOLD)
2936 } else {
2937 name_style
2938 };
2939 let mut name_line = build_highlighted_text(
2940 &display_name,
2941 &result.name_matches,
2942 name_style,
2943 Style::default()
2944 .fg(theme.diagnostic_warning_fg)
2945 .add_modifier(Modifier::BOLD),
2946 );
2947 name_line
2948 .spans
2949 .insert(0, Span::styled(indicator, indicator_style));
2950 frame.render_widget(
2951 Paragraph::new(name_line),
2952 Rect::new(area.x, area.y, area.width, 1),
2953 );
2954
2955 let breadcrumb_style = Style::default()
2957 .fg(theme.line_number_fg)
2958 .add_modifier(Modifier::ITALIC);
2959 let breadcrumb = format!(" {} > {}", result.breadcrumb, result.item.path);
2960 let breadcrumb_line = Line::from(Span::styled(breadcrumb, breadcrumb_style));
2961 frame.render_widget(
2962 Paragraph::new(breadcrumb_line),
2963 Rect::new(area.x, area.y + 1, area.width, 1),
2964 );
2965
2966 if let Some(ref desc) = display_desc {
2971 let desc_style = Style::default().fg(theme.line_number_fg);
2972 let max_chars = (area.width as usize).saturating_sub(2);
2973 let truncated_desc = format!(" {}", truncate_chars_with_ellipsis(desc, max_chars));
2974 frame.render_widget(
2975 Paragraph::new(truncated_desc).style(desc_style),
2976 Rect::new(area.x, area.y + 2, area.width, 1),
2977 );
2978 }
2979
2980 layout.add_search_result(result.page_index, result.item_index, area);
2982}
2983
2984fn build_highlighted_text(
2986 text: &str,
2987 matches: &[usize],
2988 normal_style: Style,
2989 highlight_style: Style,
2990) -> Line<'static> {
2991 if matches.is_empty() {
2992 return Line::from(Span::styled(text.to_string(), normal_style));
2993 }
2994
2995 let chars: Vec<char> = text.chars().collect();
2996 let mut spans = Vec::new();
2997 let mut current = String::new();
2998 let mut in_highlight = false;
2999
3000 for (idx, ch) in chars.iter().enumerate() {
3001 let should_highlight = matches.contains(&idx);
3002
3003 if should_highlight != in_highlight {
3004 if !current.is_empty() {
3005 let style = if in_highlight {
3006 highlight_style
3007 } else {
3008 normal_style
3009 };
3010 spans.push(Span::styled(current, style));
3011 current = String::new();
3012 }
3013 in_highlight = should_highlight;
3014 }
3015
3016 current.push(*ch);
3017 }
3018
3019 if !current.is_empty() {
3021 let style = if in_highlight {
3022 highlight_style
3023 } else {
3024 normal_style
3025 };
3026 spans.push(Span::styled(current, style));
3027 }
3028
3029 Line::from(spans)
3030}
3031
3032fn render_confirm_dialog(
3034 frame: &mut Frame,
3035 parent_area: Rect,
3036 state: &SettingsState,
3037 theme: &Theme,
3038) {
3039 let changes = state.get_change_descriptions();
3041 let dialog_width = 50.min(parent_area.width.saturating_sub(4));
3042 let dialog_height = (7 + changes.len() as u16)
3045 .min(20)
3046 .min(parent_area.height.saturating_sub(4));
3047
3048 let dialog_x = parent_area.x + (parent_area.width.saturating_sub(dialog_width)) / 2;
3050 let dialog_y = parent_area.y + (parent_area.height.saturating_sub(dialog_height)) / 2;
3051 let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
3052
3053 frame.render_widget(Clear, dialog_area);
3055
3056 let title = format!(" {} ", t!("confirm.unsaved_changes_title"));
3057 let block = Block::default()
3058 .title(title)
3059 .borders(Borders::ALL)
3060 .border_type(BorderType::Rounded)
3061 .border_style(Style::default().fg(theme.diagnostic_warning_fg))
3062 .style(Style::default().bg(theme.popup_bg));
3063 frame.render_widget(block, dialog_area);
3064
3065 let inner = Rect::new(
3067 dialog_area.x + 2,
3068 dialog_area.y + 1,
3069 dialog_area.width.saturating_sub(4),
3070 dialog_area.height.saturating_sub(2),
3071 );
3072
3073 let mut y = inner.y;
3074
3075 let prompt = t!("confirm.unsaved_changes_prompt").to_string();
3077 let prompt_style = Style::default().fg(theme.popup_text_fg);
3078 frame.render_widget(
3079 Paragraph::new(prompt).style(prompt_style),
3080 Rect::new(inner.x, y, inner.width, 1),
3081 );
3082 y += 2;
3083
3084 let change_style = Style::default().fg(theme.popup_text_fg);
3089 for change in changes
3090 .iter()
3091 .take((dialog_height as usize).saturating_sub(7))
3092 {
3093 let max_chars = (inner.width as usize).saturating_sub(2);
3094 let truncated = format!("• {}", truncate_chars_with_ellipsis(change, max_chars));
3095 frame.render_widget(
3096 Paragraph::new(truncated).style(change_style),
3097 Rect::new(inner.x, y, inner.width, 1),
3098 );
3099 y += 1;
3100 }
3101
3102 let button_y = dialog_area.y + dialog_area.height - 3;
3104
3105 let sep_line: String = "─".repeat(inner.width as usize);
3107 frame.render_widget(
3108 Paragraph::new(sep_line).style(Style::default().fg(theme.split_separator_fg)),
3109 Rect::new(inner.x, button_y - 1, inner.width, 1),
3110 );
3111
3112 let options = [
3114 t!("confirm.save_and_exit").to_string(),
3115 t!("confirm.discard").to_string(),
3116 t!("confirm.cancel").to_string(),
3117 ];
3118 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;
3120
3121 for (idx, label) in options.iter().enumerate() {
3122 let is_selected = idx == state.confirm_dialog_selection;
3123 let is_hovered = state.confirm_dialog_hover == Some(idx);
3124 let button_width = label.len() as u16 + 4;
3125
3126 let style = if is_selected {
3127 Style::default()
3128 .fg(theme.menu_highlight_fg)
3129 .bg(theme.menu_highlight_bg)
3130 .add_modifier(ratatui::style::Modifier::BOLD)
3131 } else if is_hovered {
3132 Style::default()
3133 .fg(theme.menu_hover_fg)
3134 .bg(theme.menu_hover_bg)
3135 } else {
3136 Style::default().fg(theme.popup_text_fg)
3137 };
3138
3139 let text = if is_selected {
3140 format!(">[ {} ]", label)
3141 } else {
3142 format!(" [ {} ]", label)
3143 };
3144 frame.render_widget(
3145 Paragraph::new(text).style(style),
3146 Rect::new(x, button_y, button_width + 1, 1),
3147 );
3148
3149 x += button_width + 3;
3150 }
3151
3152 let help = "←/→/Tab: Select Enter: Confirm Esc: Cancel";
3154 let help_style = Style::default().fg(theme.line_number_fg);
3155 frame.render_widget(
3156 Paragraph::new(help).style(help_style),
3157 Rect::new(inner.x, button_y + 1, inner.width, 1),
3158 );
3159}
3160
3161fn render_reset_dialog(frame: &mut Frame, parent_area: Rect, state: &SettingsState, theme: &Theme) {
3163 let changes = state.get_change_descriptions();
3164 let dialog_width = 50.min(parent_area.width.saturating_sub(4));
3165 let dialog_height = (7 + changes.len() as u16)
3168 .min(20)
3169 .min(parent_area.height.saturating_sub(4));
3170
3171 let dialog_x = parent_area.x + (parent_area.width.saturating_sub(dialog_width)) / 2;
3173 let dialog_y = parent_area.y + (parent_area.height.saturating_sub(dialog_height)) / 2;
3174 let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
3175
3176 frame.render_widget(Clear, dialog_area);
3178
3179 let block = Block::default()
3180 .title(" Reset All Changes ")
3181 .borders(Borders::ALL)
3182 .border_type(BorderType::Rounded)
3183 .border_style(Style::default().fg(theme.diagnostic_warning_fg))
3184 .style(Style::default().bg(theme.popup_bg));
3185 frame.render_widget(block, dialog_area);
3186
3187 let inner = Rect::new(
3189 dialog_area.x + 2,
3190 dialog_area.y + 1,
3191 dialog_area.width.saturating_sub(4),
3192 dialog_area.height.saturating_sub(2),
3193 );
3194
3195 let mut y = inner.y;
3196
3197 let prompt_style = Style::default().fg(theme.popup_text_fg);
3199 frame.render_widget(
3200 Paragraph::new("Discard all pending changes?").style(prompt_style),
3201 Rect::new(inner.x, y, inner.width, 1),
3202 );
3203 y += 2;
3204
3205 let change_style = Style::default().fg(theme.popup_text_fg);
3210 for change in changes
3211 .iter()
3212 .take((dialog_height as usize).saturating_sub(7))
3213 {
3214 let max_chars = (inner.width as usize).saturating_sub(2);
3215 let truncated = format!("• {}", truncate_chars_with_ellipsis(change, max_chars));
3216 frame.render_widget(
3217 Paragraph::new(truncated).style(change_style),
3218 Rect::new(inner.x, y, inner.width, 1),
3219 );
3220 y += 1;
3221 }
3222
3223 let button_y = dialog_area.y + dialog_area.height - 3;
3225
3226 let sep_line: String = "─".repeat(inner.width as usize);
3228 frame.render_widget(
3229 Paragraph::new(sep_line).style(Style::default().fg(theme.split_separator_fg)),
3230 Rect::new(inner.x, button_y - 1, inner.width, 1),
3231 );
3232
3233 let options = ["Reset", "Cancel"];
3235 let total_width: u16 = options.iter().map(|o| o.len() as u16 + 4).sum::<u16>() + 4;
3236 let mut x = inner.x + (inner.width.saturating_sub(total_width)) / 2;
3237
3238 for (idx, label) in options.iter().enumerate() {
3239 let is_selected = idx == state.reset_dialog_selection;
3240 let is_hovered = state.reset_dialog_hover == Some(idx);
3241 let button_width = label.len() as u16 + 4;
3242
3243 let style = if is_selected {
3244 Style::default()
3245 .fg(theme.menu_highlight_fg)
3246 .bg(theme.menu_highlight_bg)
3247 .add_modifier(ratatui::style::Modifier::BOLD)
3248 } else if is_hovered {
3249 Style::default()
3250 .fg(theme.menu_hover_fg)
3251 .bg(theme.menu_hover_bg)
3252 } else {
3253 Style::default().fg(theme.popup_text_fg)
3254 };
3255
3256 let text = if is_selected {
3257 format!(">[ {} ]", label)
3258 } else {
3259 format!(" [ {} ]", label)
3260 };
3261 frame.render_widget(
3262 Paragraph::new(text).style(style),
3263 Rect::new(x, button_y, button_width + 1, 1),
3264 );
3265
3266 x += button_width + 3;
3267 }
3268
3269 let help = "←/→/Tab: Select Enter: Confirm Esc: Cancel";
3271 let help_style = Style::default().fg(theme.line_number_fg);
3272 frame.render_widget(
3273 Paragraph::new(help).style(help_style),
3274 Rect::new(inner.x, button_y + 1, inner.width, 1),
3275 );
3276}
3277
3278fn render_entry_discard_confirm(
3281 frame: &mut Frame,
3282 parent_area: Rect,
3283 state: &SettingsState,
3284 theme: &Theme,
3285) {
3286 let dialog_width = 50.min(parent_area.width.saturating_sub(4));
3287 let dialog_height = 7u16.min(parent_area.height.saturating_sub(4));
3288 let dialog_x = parent_area.x + (parent_area.width.saturating_sub(dialog_width)) / 2;
3289 let dialog_y = parent_area.y + (parent_area.height.saturating_sub(dialog_height)) / 2;
3290 let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
3291
3292 frame.render_widget(Clear, dialog_area);
3293
3294 let block = Block::default()
3295 .title(" Discard changes? ")
3296 .borders(Borders::ALL)
3297 .border_type(BorderType::Rounded)
3298 .border_style(Style::default().fg(theme.diagnostic_warning_fg))
3299 .style(Style::default().bg(theme.popup_bg));
3300 frame.render_widget(block, dialog_area);
3301
3302 let inner = Rect::new(
3303 dialog_area.x + 2,
3304 dialog_area.y + 1,
3305 dialog_area.width.saturating_sub(4),
3306 dialog_area.height.saturating_sub(2),
3307 );
3308
3309 let prompt_style = Style::default().fg(theme.popup_text_fg);
3310 frame.render_widget(
3311 Paragraph::new("You have uncommitted edits in this dialog.").style(prompt_style),
3312 Rect::new(inner.x, inner.y, inner.width, 1),
3313 );
3314
3315 let button_y = dialog_area.y + dialog_area.height - 3;
3318 let options = ["Keep editing", "Discard"];
3319 let total_width: u16 = options.iter().map(|o| o.len() as u16 + 4).sum::<u16>() + 4;
3320 let mut x = inner.x + (inner.width.saturating_sub(total_width)) / 2;
3321
3322 for (idx, label) in options.iter().enumerate() {
3323 let is_selected = idx == state.entry_discard_confirm_selection;
3324 let is_discard = idx == 1;
3325 let style = if is_selected && is_discard {
3326 Style::default()
3327 .fg(theme.diagnostic_error_fg)
3328 .bg(theme.popup_selection_bg)
3329 .add_modifier(Modifier::BOLD)
3330 } else if is_selected {
3331 Style::default()
3332 .fg(theme.popup_selection_fg)
3333 .bg(theme.popup_selection_bg)
3334 .add_modifier(Modifier::BOLD)
3335 } else if is_discard {
3336 Style::default()
3337 .fg(theme.diagnostic_error_fg)
3338 .add_modifier(Modifier::BOLD)
3339 } else {
3340 Style::default().fg(theme.popup_text_fg)
3341 };
3342 let text = if is_selected {
3343 format!(">[ {} ]", label)
3344 } else {
3345 format!(" [ {} ]", label)
3346 };
3347 let w = label.len() as u16 + 5;
3348 frame.render_widget(
3349 Paragraph::new(text).style(style),
3350 Rect::new(x, button_y, w, 1),
3351 );
3352 x += w + 2;
3353 }
3354
3355 let help = "Tab/←→: Select Enter: Confirm Esc: Keep editing";
3356 let help_style = Style::default().fg(theme.line_number_fg);
3357 frame.render_widget(
3358 Paragraph::new(help).style(help_style),
3359 Rect::new(inner.x, button_y + 1, inner.width, 1),
3360 );
3361}
3362
3363fn render_entry_delete_confirm(
3366 frame: &mut Frame,
3367 parent_area: Rect,
3368 state: &SettingsState,
3369 theme: &Theme,
3370) {
3371 let dialog_width = 60.min(parent_area.width.saturating_sub(4));
3372 let dialog_height = 7u16.min(parent_area.height.saturating_sub(4));
3373 let dialog_x = parent_area.x + (parent_area.width.saturating_sub(dialog_width)) / 2;
3374 let dialog_y = parent_area.y + (parent_area.height.saturating_sub(dialog_height)) / 2;
3375 let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
3376
3377 frame.render_widget(Clear, dialog_area);
3378
3379 let title = if state.entry_delete_target_name.is_empty() {
3380 " Delete entry? ".to_string()
3381 } else {
3382 format!(" Delete \"{}\"? ", state.entry_delete_target_name)
3383 };
3384
3385 let block = Block::default()
3386 .title(title)
3387 .borders(Borders::ALL)
3388 .border_type(BorderType::Rounded)
3389 .border_style(Style::default().fg(theme.diagnostic_error_fg))
3390 .style(Style::default().bg(theme.popup_bg));
3391 frame.render_widget(block, dialog_area);
3392
3393 let inner = Rect::new(
3394 dialog_area.x + 2,
3395 dialog_area.y + 1,
3396 dialog_area.width.saturating_sub(4),
3397 dialog_area.height.saturating_sub(2),
3398 );
3399
3400 let body = if state.entry_delete_target_name.is_empty() {
3401 "This will permanently remove the entry.".to_string()
3402 } else {
3403 format!(
3404 "This will permanently remove \"{}\".",
3405 state.entry_delete_target_name
3406 )
3407 };
3408 let prompt_style = Style::default().fg(theme.popup_text_fg);
3409 frame.render_widget(
3410 Paragraph::new(body).style(prompt_style),
3411 Rect::new(inner.x, inner.y, inner.width, 1),
3412 );
3413
3414 let button_y = dialog_area.y + dialog_area.height - 3;
3415 let options = ["Cancel", "Delete"];
3416 let total_width: u16 = options.iter().map(|o| o.len() as u16 + 5).sum::<u16>() + 2;
3417 let mut x = inner.x + (inner.width.saturating_sub(total_width)) / 2;
3418
3419 for (idx, label) in options.iter().enumerate() {
3420 let is_selected = idx == state.entry_delete_confirm_selection;
3421 let is_delete = idx == 1;
3422 let style = if is_selected && is_delete {
3423 Style::default()
3424 .fg(theme.diagnostic_error_fg)
3425 .bg(theme.popup_selection_bg)
3426 .add_modifier(Modifier::BOLD)
3427 } else if is_selected {
3428 Style::default()
3429 .fg(theme.popup_selection_fg)
3430 .bg(theme.popup_selection_bg)
3431 .add_modifier(Modifier::BOLD)
3432 } else if is_delete {
3433 Style::default()
3434 .fg(theme.diagnostic_error_fg)
3435 .add_modifier(Modifier::BOLD)
3436 } else {
3437 Style::default().fg(theme.popup_text_fg)
3438 };
3439 let text = if is_selected {
3440 format!(">[ {} ]", label)
3441 } else {
3442 format!(" [ {} ]", label)
3443 };
3444 let w = label.len() as u16 + 5;
3445 frame.render_widget(
3446 Paragraph::new(text).style(style),
3447 Rect::new(x, button_y, w, 1),
3448 );
3449 x += w + 2;
3450 }
3451
3452 let help = "Tab/←→: Select Enter: Confirm Esc: Cancel";
3453 let help_style = Style::default().fg(theme.line_number_fg);
3454 frame.render_widget(
3455 Paragraph::new(help).style(help_style),
3456 Rect::new(inner.x, button_y + 1, inner.width, 1),
3457 );
3458}
3459
3460fn render_entry_dialog_at(
3462 frame: &mut Frame,
3463 parent_area: Rect,
3464 state: &mut SettingsState,
3465 theme: &Theme,
3466 dialog_idx: usize,
3467) {
3468 let Some(dialog) = state.entry_dialog_stack.get_mut(dialog_idx) else {
3469 return;
3470 };
3471 render_entry_dialog_inner(frame, parent_area, dialog, theme);
3472}
3473
3474fn render_entry_dialog_inner(
3479 frame: &mut Frame,
3480 parent_area: Rect,
3481 dialog: &mut super::entry_dialog::EntryDialogState,
3482 theme: &Theme,
3483) {
3484 let dialog_width = (parent_area.width * 85 / 100).clamp(50, 90);
3486 let dialog_height = (parent_area.height * 90 / 100).max(15);
3487 let dialog_x = parent_area.x + (parent_area.width.saturating_sub(dialog_width)) / 2;
3488 let dialog_y = parent_area.y + (parent_area.height.saturating_sub(dialog_height)) / 2;
3489
3490 let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
3491
3492 frame.render_widget(Clear, dialog_area);
3494
3495 let title = if dialog.is_dirty() {
3501 format!(" {} • modified ", dialog.title)
3502 } else {
3503 format!(" {} ", dialog.title)
3504 };
3505
3506 let border_color = if dialog.is_dirty() {
3507 theme.diagnostic_warning_fg
3508 } else {
3509 theme.popup_border_fg
3510 };
3511
3512 let block = Block::default()
3513 .title(title)
3514 .borders(Borders::ALL)
3515 .border_type(BorderType::Rounded)
3516 .border_style(Style::default().fg(border_color))
3517 .style(Style::default().bg(theme.popup_bg));
3518 frame.render_widget(block, dialog_area);
3519
3520 let inner = Rect::new(
3522 dialog_area.x + 2,
3523 dialog_area.y + 1,
3524 dialog_area.width.saturating_sub(4),
3525 dialog_area.height.saturating_sub(5), );
3527
3528 let max_label_width = (inner.width / 2).max(20);
3530 let label_col_width = dialog
3531 .items
3532 .iter()
3533 .map(|item| item.name.len() as u16 + 2) .filter(|&w| w <= max_label_width)
3535 .max()
3536 .unwrap_or(20)
3537 .min(max_label_width);
3538
3539 let total_content_height = dialog.total_content_height();
3541 let viewport_height = inner.height as usize;
3542
3543 dialog.viewport_height = viewport_height;
3545
3546 let scroll_offset = dialog.scroll_offset;
3547 let needs_scroll = total_content_height > viewport_height;
3548
3549 let mut content_y: usize = 0;
3551 let mut screen_y = inner.y;
3552
3553 let first_editable = dialog.first_editable_index;
3555 let has_readonly_items = first_editable > 0;
3556 let has_editable_items = first_editable < dialog.items.len();
3557 let needs_separator = has_readonly_items && has_editable_items;
3558
3559 for (idx, item) in dialog.items.iter().enumerate() {
3560 if needs_separator && idx == first_editable {
3562 let separator_start = content_y;
3564 let separator_end = content_y + 1;
3565
3566 if separator_end > scroll_offset && screen_y < inner.y + inner.height {
3567 let skip_sep = if separator_start < scroll_offset {
3569 1
3570 } else {
3571 0
3572 };
3573 if skip_sep == 0 {
3574 let sep_style = Style::default().fg(theme.line_number_fg);
3575 let separator_line = "─".repeat(inner.width.saturating_sub(2) as usize);
3576 frame.render_widget(
3577 Paragraph::new(separator_line).style(sep_style),
3578 Rect::new(inner.x + 1, screen_y, inner.width.saturating_sub(2), 1),
3579 );
3580 screen_y += 1;
3581 }
3582 }
3583 content_y = separator_end;
3584 }
3585
3586 if item.is_section_start {
3588 if let Some(ref section_name) = item.section {
3589 let header_start = content_y;
3590 let header_end = content_y + 2; if header_end > scroll_offset && screen_y < inner.y + inner.height {
3593 let skip_h = if header_start < scroll_offset {
3594 (scroll_offset - header_start) as u16
3595 } else {
3596 0
3597 };
3598 if skip_h == 0 {
3599 let section_style = Style::default()
3601 .fg(theme.line_number_fg)
3602 .add_modifier(Modifier::BOLD);
3603 frame.render_widget(
3604 Paragraph::new(format!("── {} ──", section_name)).style(section_style),
3605 Rect::new(inner.x + 1, screen_y, inner.width.saturating_sub(2), 1),
3606 );
3607 screen_y += 1;
3608 }
3609 if skip_h <= 1 && screen_y < inner.y + inner.height {
3610 screen_y += 1;
3612 }
3613 }
3614 content_y = header_end;
3615 }
3616 }
3617
3618 let control_height = item.control.control_height() as usize;
3619
3620 let item_start = content_y;
3622 let item_end = content_y + control_height;
3623
3624 if item_end <= scroll_offset {
3626 content_y = item_end;
3627 continue;
3628 }
3629
3630 if screen_y >= inner.y + inner.height {
3632 break;
3633 }
3634
3635 let skip_rows = if item_start < scroll_offset {
3637 (scroll_offset - item_start) as u16
3638 } else {
3639 0
3640 };
3641
3642 let visible_height = control_height.saturating_sub(skip_rows as usize);
3644 let available_height = (inner.y + inner.height).saturating_sub(screen_y) as usize;
3645 let render_height = visible_height.min(available_height);
3646
3647 if render_height == 0 {
3648 content_y = item_end;
3649 continue;
3650 }
3651
3652 let is_readonly = item.read_only;
3654 let is_focused = !is_readonly && !dialog.focus_on_buttons && dialog.selected_item == idx;
3655 let is_hovered = !is_readonly && dialog.hover_item == Some(idx);
3656
3657 if is_focused || is_hovered {
3659 let bg_style = if is_focused {
3660 Style::default().bg(theme.settings_selected_bg)
3661 } else {
3662 Style::default().bg(theme.menu_hover_bg)
3663 };
3664
3665 if item.control.is_composite() {
3666 let sub_row = item.control.focused_sub_row();
3668 if sub_row >= skip_rows && (sub_row - skip_rows) < render_height as u16 {
3669 let highlight_y = screen_y + sub_row - skip_rows;
3670 let row_area = Rect::new(inner.x, highlight_y, inner.width, 1);
3671 frame.render_widget(Paragraph::new("").style(bg_style), row_area);
3672 }
3673 } else {
3674 for row in 0..render_height as u16 {
3676 let row_area = Rect::new(inner.x, screen_y + row, inner.width, 1);
3677 frame.render_widget(Paragraph::new("").style(bg_style), row_area);
3678 }
3679 }
3680 }
3681
3682 let focus_indicator_width: u16 = 3;
3685
3686 if is_focused && skip_rows == 0 {
3688 let indicator_style = Style::default()
3689 .fg(theme.settings_selected_fg)
3690 .add_modifier(Modifier::BOLD);
3691
3692 let indicator_y = if item.control.is_composite() {
3693 let sub_row = item.control.focused_sub_row();
3694 if sub_row < render_height as u16 {
3695 screen_y + sub_row
3696 } else {
3697 screen_y
3698 }
3699 } else {
3700 screen_y
3701 };
3702
3703 frame.render_widget(
3704 Paragraph::new(">").style(indicator_style),
3705 Rect::new(inner.x, indicator_y, 1, 1),
3706 );
3707 } else if is_focused && skip_rows > 0 {
3708 if item.control.is_composite() {
3710 let sub_row = item.control.focused_sub_row();
3711 if sub_row >= skip_rows && (sub_row - skip_rows) < render_height as u16 {
3712 let indicator_style = Style::default()
3713 .fg(theme.settings_selected_fg)
3714 .add_modifier(Modifier::BOLD);
3715 let indicator_y = screen_y + sub_row - skip_rows;
3716 frame.render_widget(
3717 Paragraph::new(">").style(indicator_style),
3718 Rect::new(inner.x, indicator_y, 1, 1),
3719 );
3720 }
3721 }
3722 }
3723
3724 if item.modified && skip_rows == 0 {
3726 let modified_style = Style::default().fg(theme.settings_selected_fg);
3727 frame.render_widget(
3728 Paragraph::new("●").style(modified_style),
3729 Rect::new(inner.x + 1, screen_y, 1, 1),
3730 );
3731 }
3732
3733 let control_area = Rect::new(
3735 inner.x + focus_indicator_width,
3736 screen_y,
3737 inner.width.saturating_sub(focus_indicator_width),
3738 render_height as u16,
3739 );
3740
3741 let _layout = render_control(
3743 frame,
3744 control_area,
3745 &item.control,
3746 &item.name,
3747 skip_rows,
3748 theme,
3749 Some(label_col_width.saturating_sub(focus_indicator_width)),
3750 item.read_only,
3751 item.is_null,
3752 );
3753
3754 screen_y += render_height as u16;
3755 content_y = item_end;
3756 }
3757
3758 if needs_scroll {
3760 use crate::view::ui::scrollbar::{render_scrollbar, ScrollbarColors, ScrollbarState};
3761
3762 let scrollbar_x = dialog_area.x + dialog_area.width - 3;
3763 let scrollbar_area = Rect::new(scrollbar_x, inner.y, 1, inner.height);
3764 let scrollbar_state =
3765 ScrollbarState::new(total_content_height, viewport_height, scroll_offset);
3766 let scrollbar_colors = ScrollbarColors::from_theme(theme);
3767 render_scrollbar(frame, scrollbar_area, &scrollbar_state, &scrollbar_colors);
3768 }
3769
3770 let button_y = dialog_area.y + dialog_area.height - 2;
3779 let has_delete = !dialog.is_new && !dialog.no_delete;
3780 let buttons: Vec<&str> = if has_delete {
3781 vec!["[ Save ]", "[ Cancel ]", "[ Delete ]"]
3782 } else {
3783 vec!["[ Save ]", "[ Cancel ]"]
3784 };
3785 let delete_idx = if has_delete {
3788 Some(buttons.len() - 1)
3789 } else {
3790 None
3791 };
3792 const BUTTON_GAP: u16 = 2;
3793 const DELETE_GAP: u16 = 6;
3794 let button_width: u16 = buttons
3795 .iter()
3796 .enumerate()
3797 .map(|(i, b)| {
3798 let gap = if Some(i) == delete_idx {
3799 DELETE_GAP
3800 } else if i == 0 {
3801 0
3802 } else {
3803 BUTTON_GAP
3804 };
3805 b.len() as u16 + gap
3806 })
3807 .sum();
3808 let button_x = dialog_area.x + (dialog_area.width.saturating_sub(button_width)) / 2;
3809
3810 let mut x = button_x;
3811 for (idx, label) in buttons.iter().enumerate() {
3812 let is_selected = dialog.focus_on_buttons && dialog.focused_button == idx;
3813 let is_hovered = dialog.hover_button == Some(idx);
3814 let is_delete = Some(idx) == delete_idx;
3815 if idx > 0 {
3818 let gap = if is_delete { DELETE_GAP } else { BUTTON_GAP };
3819 x += gap;
3820 }
3821 if is_selected {
3823 let indicator_style = Style::default()
3824 .fg(theme.settings_selected_fg)
3825 .add_modifier(Modifier::BOLD);
3826 frame.render_widget(
3827 Paragraph::new(">").style(indicator_style),
3828 Rect::new(x.saturating_sub(2), button_y, 1, 1),
3829 );
3830 }
3831 let style = if is_selected && is_delete {
3832 Style::default()
3839 .fg(theme.diagnostic_error_fg)
3840 .bg(theme.popup_selection_bg)
3841 .add_modifier(Modifier::BOLD | Modifier::REVERSED)
3842 } else if is_selected {
3843 Style::default()
3844 .fg(theme.popup_selection_fg)
3845 .bg(theme.popup_selection_bg)
3846 .add_modifier(Modifier::BOLD | Modifier::REVERSED)
3847 } else if is_hovered && is_delete {
3848 Style::default()
3849 .fg(theme.diagnostic_error_fg)
3850 .bg(theme.menu_hover_bg)
3851 .add_modifier(Modifier::BOLD)
3852 } else if is_hovered {
3853 Style::default()
3854 .fg(theme.menu_hover_fg)
3855 .bg(theme.menu_hover_bg)
3856 } else if is_delete {
3857 Style::default()
3858 .fg(theme.diagnostic_error_fg)
3859 .add_modifier(Modifier::BOLD)
3860 } else {
3861 Style::default().fg(theme.editor_fg)
3862 };
3863 frame.render_widget(
3864 Paragraph::new(*label).style(style),
3865 Rect::new(x, button_y, label.len() as u16, 1),
3866 );
3867 x += label.len() as u16;
3868 }
3869
3870 let helper_y = button_y.saturating_sub(1);
3876 if !dialog.focus_on_buttons && helper_y > inner.y {
3877 let pending_list_caption = dialog.current_item().and_then(|it| {
3885 if let SettingControl::TextList(state) = &it.control {
3886 if state.focused_item.is_none() {
3887 return Some(if !state.pending_active && state.new_item_text.is_empty() {
3888 "Press Enter (or type) to add a new item; ↓/Tab to leave"
3889 } else if state.new_item_text.is_empty() {
3890 "Type the new item — Enter to add, Esc to cancel"
3891 } else {
3892 "Editing new item — Enter to add, Esc to cancel"
3893 });
3894 }
3895 }
3896 None
3897 });
3898
3899 let text: Option<String> = pending_list_caption.map(String::from).or_else(|| {
3900 dialog
3901 .current_item()
3902 .and_then(|it| it.description.as_deref())
3903 .filter(|d| !d.is_empty())
3904 .map(String::from)
3905 });
3906
3907 if let Some(text) = text {
3908 let max_width = dialog_area.width.saturating_sub(4) as usize;
3909 let truncated: String = text.chars().take(max_width).collect();
3910 let helper_style = Style::default()
3911 .fg(theme.line_number_fg)
3912 .add_modifier(Modifier::ITALIC);
3913 frame.render_widget(
3914 Paragraph::new(truncated).style(helper_style),
3915 Rect::new(
3916 dialog_area.x + 2,
3917 helper_y,
3918 dialog_area.width.saturating_sub(4),
3919 1,
3920 ),
3921 );
3922 }
3923 }
3924
3925 let is_editing_json = dialog.editing_text && dialog.is_editing_json();
3928 let (has_invalid_json, is_json_control) = dialog
3929 .current_item()
3930 .map(|item| match &item.control {
3931 SettingControl::Text(state) => (!state.is_valid(), false),
3932 SettingControl::Json(state) => (!state.is_valid(), is_editing_json),
3933 _ => (false, false),
3934 })
3935 .unwrap_or((false, false));
3936
3937 let help_area = Rect::new(
3939 dialog_area.x + 2,
3940 button_y + 1,
3941 dialog_area.width.saturating_sub(4),
3942 1,
3943 );
3944
3945 if has_invalid_json && !is_json_control {
3946 let warning = "⚠ Invalid JSON - fix before leaving field";
3948 let warning_style = Style::default().fg(theme.diagnostic_warning_fg);
3949 frame.render_widget(Paragraph::new(warning).style(warning_style), help_area);
3950 } else if has_invalid_json && is_json_control {
3951 let warning = "⚠ Invalid JSON";
3953 let warning_style = Style::default().fg(theme.diagnostic_warning_fg);
3954 frame.render_widget(Paragraph::new(warning).style(warning_style), help_area);
3955 } else if is_json_control {
3956 let help = "↑↓←→:Move Enter:Newline Tab/Esc:Exit";
3958 let help_style = Style::default().fg(theme.line_number_fg);
3959 frame.render_widget(Paragraph::new(help).style(help_style), help_area);
3960 } else {
3961 let help = "↑↓:Navigate Tab:Fields/Buttons Enter:Edit Ctrl+S:Save Ctrl+R:Reset Esc:Cancel ●:modified";
3966 let help_style = Style::default().fg(theme.line_number_fg);
3967 frame.render_widget(Paragraph::new(help).style(help_style), help_area);
3968 }
3969}
3970
3971fn render_help_overlay(frame: &mut Frame, parent_area: Rect, theme: &Theme) {
3973 let help_items = [
3975 (
3976 "Navigation",
3977 vec![
3978 ("↑ / ↓", "Move up/down"),
3979 ("Tab", "Switch between categories and settings"),
3980 ("Enter", "Activate/toggle setting"),
3981 ],
3982 ),
3983 (
3984 "Search",
3985 vec![
3986 ("/", "Start search"),
3987 ("Esc", "Cancel search"),
3988 ("↑ / ↓", "Navigate results"),
3989 ("Enter", "Jump to result"),
3990 ],
3991 ),
3992 (
3993 "Actions",
3994 vec![
3995 ("Ctrl+S", "Save settings"),
3996 ("Esc", "Close settings"),
3997 ("?", "Toggle this help"),
3998 ],
3999 ),
4000 ];
4001
4002 let dialog_width = 50.min(parent_area.width.saturating_sub(4));
4004 let dialog_height = 20.min(parent_area.height.saturating_sub(4));
4005
4006 let dialog_x = parent_area.x + (parent_area.width.saturating_sub(dialog_width)) / 2;
4008 let dialog_y = parent_area.y + (parent_area.height.saturating_sub(dialog_height)) / 2;
4009 let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
4010
4011 frame.render_widget(Clear, dialog_area);
4013
4014 let block = Block::default()
4015 .title(" Keyboard Shortcuts ")
4016 .borders(Borders::ALL)
4017 .border_type(BorderType::Rounded)
4018 .border_style(Style::default().fg(theme.menu_highlight_fg))
4019 .style(Style::default().bg(theme.popup_bg));
4020 frame.render_widget(block, dialog_area);
4021
4022 let inner = Rect::new(
4024 dialog_area.x + 2,
4025 dialog_area.y + 1,
4026 dialog_area.width.saturating_sub(4),
4027 dialog_area.height.saturating_sub(2),
4028 );
4029
4030 let mut y = inner.y;
4031
4032 for (section_name, bindings) in &help_items {
4033 if y >= inner.y + inner.height.saturating_sub(1) {
4034 break;
4035 }
4036
4037 let header_style = Style::default()
4039 .fg(theme.menu_active_fg)
4040 .add_modifier(Modifier::BOLD);
4041 frame.render_widget(
4042 Paragraph::new(*section_name).style(header_style),
4043 Rect::new(inner.x, y, inner.width, 1),
4044 );
4045 y += 1;
4046
4047 for (key, description) in bindings {
4048 if y >= inner.y + inner.height.saturating_sub(1) {
4049 break;
4050 }
4051
4052 let key_style = Style::default()
4053 .fg(theme.popup_text_fg)
4054 .bg(theme.split_separator_fg);
4055 let desc_style = Style::default().fg(theme.popup_text_fg);
4056
4057 let line = Line::from(vec![
4058 Span::styled(" ", Style::default()),
4059 Span::styled(format!(" {} ", key), key_style),
4060 Span::styled(format!(" {}", description), desc_style),
4061 ]);
4062 frame.render_widget(Paragraph::new(line), Rect::new(inner.x, y, inner.width, 1));
4063 y += 1;
4064 }
4065
4066 y += 1; }
4068
4069 let footer_y = dialog_area.y + dialog_area.height - 2;
4071 let footer = "Press ? or Esc or Enter to close";
4072 let footer_style = Style::default().fg(theme.line_number_fg);
4073 let centered_x = inner.x + (inner.width.saturating_sub(footer.len() as u16)) / 2;
4074 frame.render_widget(
4075 Paragraph::new(footer).style(footer_style),
4076 Rect::new(centered_x, footer_y, footer.len() as u16, 1),
4077 );
4078}
4079
4080#[cfg(test)]
4081mod tests {
4082 use super::*;
4083
4084 #[test]
4085 fn truncate_chars_with_ellipsis_ascii_fits() {
4086 assert_eq!(truncate_chars_with_ellipsis("hi", 10), "hi");
4087 }
4088
4089 #[test]
4090 fn truncate_chars_with_ellipsis_ascii_truncates() {
4091 assert_eq!(truncate_chars_with_ellipsis("hello world!", 8), "hello...");
4092 }
4093
4094 #[test]
4095 fn truncate_chars_with_ellipsis_multibyte_does_not_panic() {
4096 let out = truncate_chars_with_ellipsis("こんにちは世界からのテスト", 8);
4100 assert!(out.ends_with("..."));
4101 assert_eq!(out.chars().count(), 8);
4103 }
4104
4105 #[test]
4106 fn truncate_chars_with_ellipsis_emoji_does_not_panic() {
4107 let out = truncate_chars_with_ellipsis("📦📦📦📦📦📦📦📦", 5);
4108 assert!(out.ends_with("..."));
4109 assert_eq!(out.chars().count(), 5);
4110 }
4111
4112 #[test]
4114 fn test_control_layout_info() {
4115 let toggle = ControlLayoutInfo::Toggle(Rect::new(0, 0, 10, 1));
4116 assert!(matches!(toggle, ControlLayoutInfo::Toggle(_)));
4117
4118 let number = ControlLayoutInfo::Number {
4119 decrement: Rect::new(0, 0, 3, 1),
4120 increment: Rect::new(4, 0, 3, 1),
4121 value: Rect::new(8, 0, 5, 1),
4122 };
4123 assert!(matches!(number, ControlLayoutInfo::Number { .. }));
4124 }
4125}