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