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