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