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::SearchResult;
12use super::state::SettingsState;
13use crate::view::controls::{
14 render_dropdown_aligned, render_number_input_aligned, render_text_input_aligned,
15 render_toggle_aligned, DropdownColors, MapColors, NumberInputColors, TextInputColors,
16 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, 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 let modal_width = (area.width * 80 / 100).min(100);
118 let modal_height = area.height * 90 / 100;
119 let modal_x = (area.width.saturating_sub(modal_width)) / 2;
120 let modal_y = (area.height.saturating_sub(modal_height)) / 2;
121
122 let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
123
124 frame.render_widget(Clear, modal_area);
126
127 let title = if state.has_changes() {
128 format!(" Settings [{}] • (modified) ", state.target_layer_name())
129 } else {
130 format!(" Settings [{}] ", state.target_layer_name())
131 };
132
133 let block = Block::default()
134 .title(title.as_str())
135 .borders(Borders::ALL)
136 .border_style(Style::default().fg(theme.popup_border_fg))
137 .style(Style::default().bg(theme.popup_bg));
138 frame.render_widget(block, modal_area);
139
140 let inner_area = Rect::new(
142 modal_area.x + 1,
143 modal_area.y + 1,
144 modal_area.width.saturating_sub(2),
145 modal_area.height.saturating_sub(2),
146 );
147
148 let narrow_mode = inner_area.width < 60;
151
152 let search_area = Rect::new(inner_area.x, inner_area.y, inner_area.width, 1);
154 let search_header_height = 1;
155 if state.search_active {
156 render_search_header(frame, search_area, state, theme);
157 } else {
158 render_search_hint(frame, search_area, theme);
159 }
160
161 let footer_height = if narrow_mode { 7 } else { 2 };
163 let content_area = Rect::new(
164 inner_area.x,
165 inner_area.y + search_header_height,
166 inner_area.width,
167 inner_area
168 .height
169 .saturating_sub(search_header_height + footer_height),
170 );
171
172 let mut layout = SettingsLayout::new(modal_area);
174
175 if narrow_mode {
176 render_vertical_layout(frame, content_area, modal_area, state, theme, &mut layout);
178 } else {
179 render_horizontal_layout(frame, content_area, modal_area, state, theme, &mut layout);
181 }
182
183 let has_confirm = state.showing_confirm_dialog;
185 let has_reset = state.showing_reset_dialog;
186 let has_entry = state.showing_entry_dialog();
187 let has_help = state.showing_help;
188
189 if has_confirm {
191 if !has_entry && !has_help {
192 crate::view::dimming::apply_dimming(frame, modal_area);
193 }
194 render_confirm_dialog(frame, modal_area, state, theme);
195 }
196
197 if has_reset {
199 if !has_confirm && !has_entry && !has_help {
200 crate::view::dimming::apply_dimming(frame, modal_area);
201 }
202 render_reset_dialog(frame, modal_area, state, theme);
203 }
204
205 if has_entry {
207 if !has_help {
208 crate::view::dimming::apply_dimming(frame, modal_area);
209 }
210 render_entry_dialog(frame, modal_area, state, theme);
211 }
212
213 if has_help {
215 crate::view::dimming::apply_dimming(frame, modal_area);
216 render_help_overlay(frame, modal_area, theme);
217 }
218
219 layout
220}
221
222fn render_horizontal_layout(
224 frame: &mut Frame,
225 content_area: Rect,
226 modal_area: Rect,
227 state: &mut SettingsState,
228 theme: &Theme,
229 layout: &mut SettingsLayout,
230) {
231 let chunks =
233 Layout::horizontal([Constraint::Length(20), Constraint::Min(40)]).split(content_area);
234
235 let categories_area = chunks[0];
236 let settings_area = chunks[1];
237
238 render_categories(frame, categories_area, state, theme, layout);
240
241 let separator_area = Rect::new(
243 categories_area.x + categories_area.width,
244 categories_area.y,
245 1,
246 categories_area.height,
247 );
248 render_separator_with_selection(
249 frame,
250 separator_area,
251 theme,
252 state.selected_category,
253 state.pages.len(),
254 );
255
256 let horizontal_padding = 2;
258 let settings_inner = Rect::new(
259 settings_area.x + horizontal_padding,
260 settings_area.y,
261 settings_area.width.saturating_sub(horizontal_padding),
262 settings_area.height,
263 );
264
265 if state.search_active && !state.search_results.is_empty() {
266 render_search_results(frame, settings_inner, state, theme, layout);
267 } else {
268 render_settings_panel(frame, settings_inner, state, theme, layout);
269 }
270
271 render_footer(frame, modal_area, state, theme, layout, false);
273}
274
275fn render_vertical_layout(
277 frame: &mut Frame,
278 content_area: Rect,
279 modal_area: Rect,
280 state: &mut SettingsState,
281 theme: &Theme,
282 layout: &mut SettingsLayout,
283) {
284 let footer_height = 7;
286
287 let main_height = content_area.height.saturating_sub(footer_height);
289 let category_height = 3u16.min(main_height);
290 let settings_height = main_height.saturating_sub(category_height + 1); let categories_area = Rect::new(
294 content_area.x,
295 content_area.y,
296 content_area.width,
297 category_height,
298 );
299
300 let sep_y = content_area.y + category_height;
302
303 let settings_area = Rect::new(
305 content_area.x,
306 sep_y + 1,
307 content_area.width,
308 settings_height,
309 );
310
311 render_categories_horizontal(frame, categories_area, state, theme, layout);
313
314 if sep_y < content_area.y + content_area.height {
316 let sep_line: String = "─".repeat(content_area.width as usize);
317 frame.render_widget(
318 Paragraph::new(sep_line).style(Style::default().fg(theme.split_separator_fg)),
319 Rect::new(content_area.x, sep_y, content_area.width, 1),
320 );
321 }
322
323 if state.search_active && !state.search_results.is_empty() {
325 render_search_results(frame, settings_area, state, theme, layout);
326 } else {
327 render_settings_panel(frame, settings_area, state, theme, layout);
328 }
329
330 render_footer(frame, modal_area, state, theme, layout, true);
332}
333
334fn render_categories_horizontal(
336 frame: &mut Frame,
337 area: Rect,
338 state: &SettingsState,
339 theme: &Theme,
340 layout: &mut SettingsLayout,
341) {
342 use super::state::FocusPanel;
343
344 if area.height == 0 || area.width == 0 {
345 return;
346 }
347
348 let is_focused = state.focus_panel() == FocusPanel::Categories;
349
350 let mut spans = Vec::new();
352 let mut total_width = 0u16;
353
354 for (i, page) in state.pages.iter().enumerate() {
355 let is_selected = i == state.selected_category;
356 let has_modified = page.items.iter().any(|item| item.modified);
357
358 let indicator = if has_modified { "● " } else { " " };
359 let name = &page.name;
360
361 let style = if is_selected && is_focused {
362 Style::default()
363 .fg(theme.menu_highlight_fg)
364 .bg(theme.menu_highlight_bg)
365 .add_modifier(Modifier::BOLD)
366 } else if is_selected {
367 Style::default()
368 .fg(theme.menu_highlight_fg)
369 .add_modifier(Modifier::BOLD)
370 } else {
371 Style::default().fg(theme.popup_text_fg)
372 };
373
374 let indicator_style = if has_modified {
375 Style::default().fg(theme.menu_highlight_fg)
376 } else {
377 style
378 };
379
380 if i > 0 {
382 spans.push(Span::styled(
383 " │ ",
384 Style::default().fg(theme.split_separator_fg),
385 ));
386 total_width += 3;
387 }
388
389 spans.push(Span::styled(indicator, indicator_style));
390 spans.push(Span::styled(name.as_str(), style));
391 total_width += (indicator.len() + name.len()) as u16;
392
393 let cat_x = area.x + total_width.saturating_sub((indicator.len() + name.len()) as u16);
395 let cat_width = (indicator.len() + name.len()) as u16;
396 layout
397 .categories
398 .push((i, Rect::new(cat_x, area.y, cat_width, 1)));
399 }
400
401 let line = Line::from(spans);
403 frame.render_widget(Paragraph::new(line), area);
404
405 if area.height >= 2 {
407 let hint = "←→: Switch category";
408 let hint_style = Style::default().fg(theme.line_number_fg);
409 frame.render_widget(
410 Paragraph::new(hint).style(hint_style),
411 Rect::new(area.x, area.y + 1, area.width, 1),
412 );
413 }
414}
415
416fn render_categories(
418 frame: &mut Frame,
419 area: Rect,
420 state: &SettingsState,
421 theme: &Theme,
422 layout: &mut SettingsLayout,
423) {
424 use super::layout::SettingsHit;
425 use super::state::FocusPanel;
426
427 for (idx, page) in state.pages.iter().enumerate() {
428 if idx as u16 >= area.height {
429 break;
430 }
431
432 let is_selected = idx == state.selected_category;
433 let is_hovered = matches!(state.hover_hit, Some(SettingsHit::Category(i)) if i == idx);
434 let row_area = Rect::new(area.x, area.y + idx as u16, area.width, 1);
435
436 layout.add_category(idx, row_area);
437
438 let style = if is_selected {
439 if state.focus_panel() == FocusPanel::Categories {
440 Style::default()
441 .fg(theme.menu_highlight_fg)
442 .bg(theme.menu_highlight_bg)
443 } else {
444 Style::default().fg(theme.menu_fg).bg(theme.selection_bg)
445 }
446 } else if is_hovered {
447 Style::default()
449 .fg(theme.menu_hover_fg)
450 .bg(theme.menu_hover_bg)
451 } else {
452 Style::default().fg(theme.popup_text_fg)
453 };
454
455 let has_changes = page.items.iter().any(|i| i.modified);
457 let modified_indicator = if has_changes { "●" } else { " " };
458
459 let selection_indicator = if is_selected && state.focus_panel() == FocusPanel::Categories {
461 ">"
462 } else {
463 " "
464 };
465
466 let text = format!(
467 "{}{} {}",
468 selection_indicator, modified_indicator, page.name
469 );
470 let line = Line::from(Span::styled(text, style));
471 frame.render_widget(Paragraph::new(line), row_area);
472 }
473}
474
475fn render_separator_with_selection(
477 frame: &mut Frame,
478 area: Rect,
479 theme: &Theme,
480 selected_category: usize,
481 category_count: usize,
482) {
483 let sep_style = Style::default().fg(theme.split_separator_fg);
484 let highlight_style = Style::default().fg(theme.menu_highlight_fg);
485
486 for y in 0..area.height {
487 let cell = Rect::new(area.x, area.y + y, 1, 1);
488 let row_idx = y as usize;
489
490 let (char, style) = if row_idx == selected_category && row_idx < category_count {
492 ("┤", highlight_style)
494 } else {
495 ("│", sep_style)
496 };
497
498 let sep = Paragraph::new(char).style(style);
499 frame.render_widget(sep, cell);
500 }
501}
502
503struct RenderContext {
505 selected_item: usize,
506 settings_focused: bool,
507 hover_hit: Option<SettingsHit>,
508}
509
510fn render_settings_panel(
512 frame: &mut Frame,
513 area: Rect,
514 state: &mut SettingsState,
515 theme: &Theme,
516 layout: &mut SettingsLayout,
517) {
518 let page = match state.current_page() {
519 Some(p) => p,
520 None => return,
521 };
522
523 let mut y = area.y;
525 let header_start_y = y;
526
527 let title_style = Style::default()
529 .fg(theme.menu_active_fg)
530 .add_modifier(Modifier::BOLD);
531 let title = Line::from(Span::styled(&page.name, title_style));
532 frame.render_widget(Paragraph::new(title), Rect::new(area.x, y, area.width, 1));
533 y += 1;
534
535 if let Some(ref desc) = page.description {
537 let desc_style = Style::default().fg(theme.line_number_fg);
538 let desc_line = Line::from(Span::styled(desc, desc_style));
539 frame.render_widget(
540 Paragraph::new(desc_line),
541 Rect::new(area.x, y, area.width, 1),
542 );
543 y += 1;
544 }
545
546 y += 1; let header_height = (y - header_start_y) as usize;
549 let items_start_y = y;
550
551 let available_height = area.height.saturating_sub(header_height as u16);
553
554 let focus_indicator_width: u16 = 3;
556 state.layout_width = area.width.saturating_sub(focus_indicator_width);
557 state.update_layout_widths();
558
559 let page = state.pages.get(state.selected_category).unwrap();
561 state.scroll_panel.set_viewport(available_height);
562 state.scroll_panel.update_content_height(&page.items);
563
564 use super::state::FocusPanel;
566 let render_ctx = RenderContext {
567 selected_item: state.selected_item,
568 settings_focused: state.focus_panel() == FocusPanel::Settings,
569 hover_hit: state.hover_hit,
570 };
571
572 let items_area = Rect::new(area.x, items_start_y, area.width, available_height.max(1));
574
575 let page = state.pages.get(state.selected_category).unwrap();
577
578 let max_label_width = page
580 .items
581 .iter()
582 .filter_map(|item| {
583 match &item.control {
585 SettingControl::Toggle(s) => Some(s.label.len() as u16),
586 SettingControl::Number(s) => Some(s.label.len() as u16),
587 SettingControl::Dropdown(s) => Some(s.label.len() as u16),
588 SettingControl::Text(s) => Some(s.label.len() as u16),
589 _ => None,
591 }
592 })
593 .max();
594
595 let panel_layout = state.scroll_panel.render(
597 frame,
598 items_area,
599 &page.items,
600 |frame, info, item| {
601 render_setting_item_pure(
602 frame,
603 info.area,
604 item,
605 info.index,
606 info.skip_top,
607 &render_ctx,
608 theme,
609 max_label_width,
610 )
611 },
612 theme,
613 );
614
615 let page = state.pages.get(state.selected_category).unwrap();
617 for item_info in panel_layout.item_layouts {
618 layout.add_item(
619 item_info.index,
620 page.items[item_info.index].path.clone(),
621 item_info.area,
622 item_info.layout,
623 );
624 }
625
626 layout.settings_panel_area = Some(panel_layout.content_area);
628
629 if let Some(sb_area) = panel_layout.scrollbar_area {
631 layout.scrollbar_area = Some(sb_area);
632 }
633}
634
635fn wrap_text(text: &str, width: usize) -> Vec<String> {
637 if width == 0 || text.is_empty() {
638 return vec![text.to_string()];
639 }
640
641 let mut lines = Vec::new();
642 let mut current_line = String::new();
643 let mut current_len = 0;
644
645 for word in text.split_whitespace() {
646 let word_len = word.chars().count();
647
648 if current_len == 0 {
649 current_line = word.to_string();
651 current_len = word_len;
652 } else if current_len + 1 + word_len <= width {
653 current_line.push(' ');
655 current_line.push_str(word);
656 current_len += 1 + word_len;
657 } else {
658 lines.push(current_line);
660 current_line = word.to_string();
661 current_len = word_len;
662 }
663 }
664
665 if !current_line.is_empty() {
666 lines.push(current_line);
667 }
668
669 if lines.is_empty() {
670 lines.push(String::new());
671 }
672
673 lines
674}
675
676#[allow(clippy::too_many_arguments)]
682fn render_setting_item_pure(
683 frame: &mut Frame,
684 area: Rect,
685 item: &super::items::SettingItem,
686 idx: usize,
687 skip_top: u16,
688 ctx: &RenderContext,
689 theme: &Theme,
690 label_width: Option<u16>,
691) -> ControlLayoutInfo {
692 use super::items::SECTION_HEADER_HEIGHT;
693
694 let (item_area, item_skip_top) = if item.is_section_start {
696 if let Some(ref section_name) = item.section {
697 let header_visible_start = skip_top.min(SECTION_HEADER_HEIGHT);
699 let header_visible_height = SECTION_HEADER_HEIGHT.saturating_sub(skip_top);
700
701 if header_visible_height > 0 && area.height > 0 {
703 let header_y = area.y;
704 let _header_area_height = header_visible_height.min(area.height);
705
706 if header_visible_start == 0 {
708 let header_style = Style::default()
709 .fg(theme.menu_active_fg)
710 .add_modifier(Modifier::BOLD);
711 let header_text = format!("── {} ", section_name);
713 let remaining = area.width.saturating_sub(header_text.len() as u16);
715 let full_header = format!("{}{}", header_text, "─".repeat(remaining as usize));
716 frame.render_widget(
717 Paragraph::new(full_header).style(header_style),
718 Rect::new(area.x, header_y, area.width, 1),
719 );
720 }
721
722 }
724
725 let item_y_offset = header_visible_height.min(area.height);
727 let item_area = Rect::new(
728 area.x,
729 area.y + item_y_offset,
730 area.width,
731 area.height.saturating_sub(item_y_offset),
732 );
733 let item_skip_top = skip_top.saturating_sub(SECTION_HEADER_HEIGHT);
735 (item_area, item_skip_top)
736 } else {
737 (area, skip_top)
738 }
739 } else {
740 (area, skip_top)
741 };
742
743 if item_area.height == 0 {
745 return ControlLayoutInfo::default();
746 }
747
748 let area = item_area;
750 let skip_top = item_skip_top;
751
752 let is_selected = ctx.settings_focused && idx == ctx.selected_item;
753
754 let is_item_hovered = match ctx.hover_hit {
756 Some(SettingsHit::Item(i)) => i == idx,
757 Some(SettingsHit::ControlToggle(i)) => i == idx,
758 Some(SettingsHit::ControlDecrement(i)) => i == idx,
759 Some(SettingsHit::ControlIncrement(i)) => i == idx,
760 Some(SettingsHit::ControlDropdown(i)) => i == idx,
761 Some(SettingsHit::ControlText(i)) => i == idx,
762 Some(SettingsHit::ControlTextListRow(i, _)) => i == idx,
763 Some(SettingsHit::ControlMapRow(i, _)) => i == idx,
764 _ => false,
765 };
766
767 let is_focused_or_hovered = is_selected || is_item_hovered;
768
769 let focus_indicator_width: u16 = 3;
772
773 let content_height = item.content_height();
775 let visible_content_height = content_height.saturating_sub(skip_top);
777
778 if is_focused_or_hovered {
780 let bg_style = if is_selected {
782 Style::default().bg(theme.settings_selected_bg)
783 } else {
784 Style::default().bg(theme.menu_hover_bg)
785 };
786 let is_multi_row_control = matches!(
789 item.control,
790 SettingControl::Map(_) | SettingControl::ObjectArray(_) | SettingControl::TextList(_)
791 );
792 let highlight_rows = if is_multi_row_control && skip_top == 0 {
793 1 } else {
795 visible_content_height.min(area.height)
796 };
797 for row in 0..highlight_rows {
798 let row_area = Rect::new(area.x, area.y + row, area.width, 1);
799 frame.render_widget(Paragraph::new("").style(bg_style), row_area);
800 }
801 }
802
803 if is_selected && skip_top == 0 {
805 let indicator_style = Style::default()
806 .fg(theme.settings_selected_fg)
807 .add_modifier(Modifier::BOLD);
808 frame.render_widget(
809 Paragraph::new(">").style(indicator_style),
810 Rect::new(area.x, area.y, 1, 1),
811 );
812 }
813
814 if item.modified && skip_top == 0 {
816 let modified_style = Style::default().fg(theme.settings_selected_fg);
817 frame.render_widget(
818 Paragraph::new("●").style(modified_style),
819 Rect::new(area.x + 1, area.y, 1, 1),
820 );
821 }
822
823 let control_height = item.control.control_height();
825 let visible_control_height = control_height.saturating_sub(skip_top);
826 let control_area = Rect::new(
827 area.x + focus_indicator_width,
828 area.y,
829 area.width.saturating_sub(focus_indicator_width),
830 visible_control_height.min(area.height),
831 );
832
833 let layout = render_control(
835 frame,
836 control_area,
837 &item.control,
838 &item.name,
839 skip_top,
840 theme,
841 label_width.map(|w| w.saturating_sub(focus_indicator_width)),
842 item.read_only,
843 );
844
845 let desc_start_row = control_height.saturating_sub(skip_top);
848
849 let layer_label = match item.layer_source {
852 crate::config_io::ConfigLayer::System => None, crate::config_io::ConfigLayer::User => Some("user"),
854 crate::config_io::ConfigLayer::Project => Some("project"),
855 crate::config_io::ConfigLayer::Session => Some("session"),
856 };
857
858 if let Some(ref description) = item.description {
859 if desc_start_row < area.height {
860 let desc_x = area.x + focus_indicator_width;
861 let desc_y = area.y + desc_start_row;
862 let desc_width = area.width.saturating_sub(focus_indicator_width);
863 let desc_style = Style::default().fg(theme.line_number_fg);
864 let max_width = desc_width.saturating_sub(2) as usize;
865
866 let wrapped_lines = wrap_text(description, max_width);
868 let available_rows = area.height.saturating_sub(desc_start_row) as usize;
869
870 let mut lines = wrapped_lines;
872 if let Some(layer) = layer_label {
873 if let Some(last) = lines.last_mut() {
874 last.push_str(&format!(" ({})", layer));
875 }
876 }
877
878 for (i, line) in lines.iter().take(available_rows).enumerate() {
879 frame.render_widget(
880 Paragraph::new(line.as_str()).style(desc_style),
881 Rect::new(desc_x, desc_y + i as u16, desc_width, 1),
882 );
883 }
884 }
885 } else if let Some(layer) = layer_label {
886 if desc_start_row < area.height {
888 let desc_x = area.x + focus_indicator_width;
889 let desc_y = area.y + desc_start_row;
890 let desc_width = area.width.saturating_sub(focus_indicator_width);
891 let layer_style = Style::default().fg(theme.line_number_fg);
892 frame.render_widget(
893 Paragraph::new(format!("({})", layer)).style(layer_style),
894 Rect::new(desc_x, desc_y, desc_width, 1),
895 );
896 }
897 }
898
899 layout
900}
901
902#[allow(clippy::too_many_arguments)]
910fn render_control(
911 frame: &mut Frame,
912 area: Rect,
913 control: &SettingControl,
914 name: &str,
915 skip_rows: u16,
916 theme: &Theme,
917 label_width: Option<u16>,
918 read_only: bool,
919) -> ControlLayoutInfo {
920 match control {
921 SettingControl::Toggle(state) => {
923 if skip_rows > 0 {
924 return ControlLayoutInfo::Toggle(Rect::default());
925 }
926 let colors = ToggleColors::from_theme(theme);
927 let toggle_layout = render_toggle_aligned(frame, area, state, &colors, label_width);
928 ControlLayoutInfo::Toggle(toggle_layout.full_area)
929 }
930
931 SettingControl::Number(state) => {
932 if skip_rows > 0 {
933 return ControlLayoutInfo::Number {
934 decrement: Rect::default(),
935 increment: Rect::default(),
936 value: Rect::default(),
937 };
938 }
939 let colors = NumberInputColors::from_theme(theme);
940 let num_layout = render_number_input_aligned(frame, area, state, &colors, label_width);
941 ControlLayoutInfo::Number {
942 decrement: num_layout.decrement_area,
943 increment: num_layout.increment_area,
944 value: num_layout.value_area,
945 }
946 }
947
948 SettingControl::Dropdown(state) => {
949 if skip_rows > 0 {
950 return ControlLayoutInfo::Dropdown {
951 button_area: Rect::default(),
952 option_areas: Vec::new(),
953 scroll_offset: 0,
954 };
955 }
956 let colors = DropdownColors::from_theme(theme);
957 let drop_layout = render_dropdown_aligned(frame, area, state, &colors, label_width);
958 ControlLayoutInfo::Dropdown {
959 button_area: drop_layout.button_area,
960 option_areas: drop_layout.option_areas,
961 scroll_offset: drop_layout.scroll_offset,
962 }
963 }
964
965 SettingControl::Text(state) => {
966 if skip_rows > 0 {
967 return ControlLayoutInfo::Text(Rect::default());
968 }
969 if read_only {
970 let label_w = label_width.unwrap_or(20);
972 let label_style = Style::default().fg(theme.editor_fg);
973 let value_style = Style::default().fg(theme.line_number_fg);
974 let label = format!("{}: ", state.label);
975 let value = &state.value;
976
977 let label_area = Rect::new(area.x, area.y, label_w, 1);
978 let value_area = Rect::new(
979 area.x + label_w,
980 area.y,
981 area.width.saturating_sub(label_w),
982 1,
983 );
984
985 frame.render_widget(Paragraph::new(label.clone()).style(label_style), label_area);
986 frame.render_widget(
987 Paragraph::new(value.as_str()).style(value_style),
988 value_area,
989 );
990 ControlLayoutInfo::Text(Rect::default()) } else {
992 let colors = TextInputColors::from_theme(theme);
993 let text_layout =
994 render_text_input_aligned(frame, area, state, &colors, 30, label_width);
995 ControlLayoutInfo::Text(text_layout.input_area)
996 }
997 }
998
999 SettingControl::TextList(state) => {
1001 let colors = TextListColors::from_theme(theme);
1002 let list_layout = render_text_list_partial(frame, area, state, &colors, 30, skip_rows);
1003 ControlLayoutInfo::TextList {
1004 rows: list_layout.rows.iter().map(|r| r.text_area).collect(),
1005 }
1006 }
1007
1008 SettingControl::Map(state) => {
1009 let colors = MapColors::from_theme(theme);
1010 let map_layout = render_map_partial(frame, area, state, &colors, 20, skip_rows);
1011 ControlLayoutInfo::Map {
1012 entry_rows: map_layout.entry_areas.iter().map(|e| e.row_area).collect(),
1013 add_row_area: map_layout.add_row_area,
1014 }
1015 }
1016
1017 SettingControl::ObjectArray(state) => {
1018 let colors = crate::view::controls::KeybindingListColors {
1019 label_fg: theme.editor_fg,
1020 key_fg: theme.help_key_fg,
1021 action_fg: theme.syntax_function,
1022 focused_bg: theme.settings_selected_bg,
1024 focused_fg: theme.settings_selected_fg,
1025 delete_fg: theme.diagnostic_error_fg,
1026 add_fg: theme.syntax_string,
1027 };
1028 let kb_layout = render_keybinding_list_partial(frame, area, state, &colors, skip_rows);
1029 ControlLayoutInfo::ObjectArray {
1030 entry_rows: kb_layout.entry_rects,
1031 }
1032 }
1033
1034 SettingControl::Json(state) => {
1035 render_json_control(frame, area, state, name, skip_rows, theme)
1036 }
1037
1038 SettingControl::Complex { type_name } => {
1039 if skip_rows > 0 {
1040 return ControlLayoutInfo::Complex;
1041 }
1042 let label_style = Style::default().fg(theme.editor_fg);
1044 let value_style = Style::default().fg(theme.line_number_fg);
1045
1046 let label = Span::styled(format!("{}: ", name), label_style);
1047 let value = Span::styled(
1048 format!("<{} - edit in config.toml>", type_name),
1049 value_style,
1050 );
1051
1052 frame.render_widget(Paragraph::new(Line::from(vec![label, value])), area);
1053 ControlLayoutInfo::Complex
1054 }
1055 }
1056}
1057
1058fn render_json_control(
1060 frame: &mut Frame,
1061 area: Rect,
1062 state: &super::items::JsonEditState,
1063 name: &str,
1064 skip_rows: u16,
1065 theme: &Theme,
1066) -> ControlLayoutInfo {
1067 use crate::view::controls::FocusState;
1068
1069 let empty_layout = ControlLayoutInfo::Json {
1070 edit_area: Rect::default(),
1071 };
1072
1073 if area.height == 0 || area.width < 10 {
1074 return empty_layout;
1075 }
1076
1077 let is_focused = state.focus == FocusState::Focused;
1078 let is_valid = state.is_valid();
1079
1080 let label_color = if is_focused {
1081 theme.menu_highlight_fg
1082 } else {
1083 theme.editor_fg
1084 };
1085
1086 let text_color = theme.editor_fg;
1087 let border_color = if !is_valid {
1088 theme.diagnostic_error_fg
1089 } else if is_focused {
1090 theme.menu_highlight_fg
1091 } else {
1092 theme.split_separator_fg
1093 };
1094
1095 let mut y = area.y;
1096 let mut content_row = 0u16;
1097
1098 if content_row >= skip_rows {
1100 let label_line = Line::from(vec![Span::styled(
1101 format!("{}:", name),
1102 Style::default().fg(label_color),
1103 )]);
1104 frame.render_widget(
1105 Paragraph::new(label_line),
1106 Rect::new(area.x, y, area.width, 1),
1107 );
1108 y += 1;
1109 }
1110 content_row += 1;
1111
1112 let indent = 2u16;
1113 let edit_width = area.width.saturating_sub(indent + 1);
1114 let edit_x = area.x + indent;
1115 let edit_start_y = y;
1116
1117 let lines = state.lines();
1119 let total_lines = lines.len();
1120 for line_idx in 0..total_lines {
1121 let actual_line_idx = line_idx;
1122
1123 if content_row < skip_rows {
1124 content_row += 1;
1125 continue;
1126 }
1127
1128 if y >= area.y + area.height {
1129 break;
1130 }
1131
1132 let line_content = lines.get(actual_line_idx).map(|s| s.as_str()).unwrap_or("");
1133
1134 let display_len = edit_width.saturating_sub(2) as usize;
1136 let display_text: String = line_content.chars().take(display_len).collect();
1137
1138 let selection = state.selection_range();
1140 let (cursor_row, cursor_col) = state.cursor_pos();
1141
1142 let content_spans = if is_focused {
1144 if let Some(((start_row, start_col), (end_row, end_col))) = selection {
1145 build_selection_spans(
1146 &display_text,
1147 display_len,
1148 actual_line_idx,
1149 start_row,
1150 start_col,
1151 end_row,
1152 end_col,
1153 text_color,
1154 theme.selection_bg,
1155 )
1156 } else {
1157 vec![Span::styled(
1158 format!("{:width$}", display_text, width = display_len),
1159 Style::default().fg(text_color),
1160 )]
1161 }
1162 } else {
1163 vec![Span::styled(
1164 format!("{:width$}", display_text, width = display_len),
1165 Style::default().fg(text_color),
1166 )]
1167 };
1168
1169 let mut spans = vec![
1171 Span::raw(" ".repeat(indent as usize)),
1172 Span::styled("│", Style::default().fg(border_color)),
1173 ];
1174 spans.extend(content_spans);
1175 spans.push(Span::styled("│", Style::default().fg(border_color)));
1176 let line = Line::from(spans);
1177
1178 frame.render_widget(Paragraph::new(line), Rect::new(area.x, y, area.width, 1));
1179
1180 if is_focused && actual_line_idx == cursor_row {
1182 let cursor_x = edit_x + 1 + cursor_col.min(display_len) as u16;
1183 if cursor_x < area.x + area.width - 1 {
1184 let cursor_char = line_content.chars().nth(cursor_col).unwrap_or(' ');
1185 let cursor_span = Span::styled(
1186 cursor_char.to_string(),
1187 Style::default()
1188 .fg(theme.cursor)
1189 .add_modifier(Modifier::REVERSED),
1190 );
1191 frame.render_widget(
1192 Paragraph::new(Line::from(vec![cursor_span])),
1193 Rect::new(cursor_x, y, 1, 1),
1194 );
1195 }
1196 }
1197
1198 y += 1;
1199 content_row += 1;
1200 }
1201
1202 if !is_valid && y < area.y + area.height {
1204 let warning = Span::styled(
1205 " ⚠ Invalid JSON",
1206 Style::default().fg(theme.diagnostic_warning_fg),
1207 );
1208 frame.render_widget(
1209 Paragraph::new(Line::from(vec![warning])),
1210 Rect::new(area.x, y, area.width, 1),
1211 );
1212 }
1213
1214 let edit_height = y.saturating_sub(edit_start_y);
1215 ControlLayoutInfo::Json {
1216 edit_area: Rect::new(edit_x, edit_start_y, edit_width, edit_height),
1217 }
1218}
1219
1220fn render_text_list_partial(
1222 frame: &mut Frame,
1223 area: Rect,
1224 state: &crate::view::controls::TextListState,
1225 colors: &TextListColors,
1226 field_width: u16,
1227 skip_rows: u16,
1228) -> crate::view::controls::TextListLayout {
1229 use crate::view::controls::text_list::{TextListLayout, TextListRowLayout};
1230 use crate::view::controls::FocusState;
1231
1232 let empty_layout = TextListLayout {
1233 rows: Vec::new(),
1234 full_area: area,
1235 };
1236
1237 if area.height == 0 || area.width < 10 {
1238 return empty_layout;
1239 }
1240
1241 let label_color = match state.focus {
1243 FocusState::Focused => colors.focused_fg,
1244 FocusState::Hovered => colors.focused_fg,
1245 FocusState::Disabled => colors.disabled,
1246 FocusState::Normal => colors.label,
1247 };
1248
1249 let mut rows = Vec::new();
1250 let mut y = area.y;
1251 let mut content_row = 0u16; if skip_rows == 0 {
1255 let label_line = Line::from(vec![
1256 Span::styled(&state.label, Style::default().fg(label_color)),
1257 Span::raw(":"),
1258 ]);
1259 frame.render_widget(
1260 Paragraph::new(label_line),
1261 Rect::new(area.x, y, area.width, 1),
1262 );
1263 y += 1;
1264 }
1265 content_row += 1;
1266
1267 let indent = 2u16;
1268 let actual_field_width = field_width.min(area.width.saturating_sub(indent + 5));
1269
1270 for (idx, item) in state.items.iter().enumerate() {
1272 if y >= area.y + area.height {
1273 break;
1274 }
1275
1276 if content_row < skip_rows {
1278 content_row += 1;
1279 continue;
1280 }
1281
1282 let is_focused = state.focused_item == Some(idx) && state.focus == FocusState::Focused;
1283 let (border_color, text_color) = if is_focused {
1284 (colors.focused, colors.text)
1285 } else if state.focus == FocusState::Disabled {
1286 (colors.disabled, colors.disabled)
1287 } else {
1288 (colors.border, colors.text)
1289 };
1290
1291 let inner_width = actual_field_width.saturating_sub(2) as usize;
1292 let visible: String = item.chars().take(inner_width).collect();
1293 let padded = format!("{:width$}", visible, width = inner_width);
1294
1295 let line = Line::from(vec![
1296 Span::raw(" ".repeat(indent as usize)),
1297 Span::styled("[", Style::default().fg(border_color)),
1298 Span::styled(padded, Style::default().fg(text_color)),
1299 Span::styled("]", Style::default().fg(border_color)),
1300 Span::raw(" "),
1301 Span::styled("[x]", Style::default().fg(colors.remove_button)),
1302 ]);
1303
1304 let row_area = Rect::new(area.x, y, area.width, 1);
1305 frame.render_widget(Paragraph::new(line), row_area);
1306
1307 let text_area = Rect::new(area.x + indent, y, actual_field_width, 1);
1308 let button_area = Rect::new(area.x + indent + actual_field_width + 1, y, 3, 1);
1309 rows.push(TextListRowLayout {
1310 text_area,
1311 button_area,
1312 index: Some(idx),
1313 });
1314
1315 y += 1;
1316 content_row += 1;
1317 }
1318
1319 if y < area.y + area.height && content_row >= skip_rows {
1321 let is_add_focused = state.focused_item.is_none() && state.focus == FocusState::Focused;
1323
1324 if is_add_focused {
1325 let inner_width = actual_field_width.saturating_sub(2) as usize;
1327 let visible: String = state.new_item_text.chars().take(inner_width).collect();
1328 let padded = format!("{:width$}", visible, width = inner_width);
1329
1330 let line = Line::from(vec![
1331 Span::raw(" ".repeat(indent as usize)),
1332 Span::styled("[", Style::default().fg(colors.focused)),
1333 Span::styled(padded, Style::default().fg(colors.text)),
1334 Span::styled("]", Style::default().fg(colors.focused)),
1335 Span::raw(" "),
1336 Span::styled("[+]", Style::default().fg(colors.add_button)),
1337 ]);
1338 let row_area = Rect::new(area.x, y, area.width, 1);
1339 frame.render_widget(Paragraph::new(line), row_area);
1340
1341 if state.cursor <= inner_width {
1343 let cursor_x = area.x + indent + 1 + state.cursor as u16;
1344 let cursor_char = state.new_item_text.chars().nth(state.cursor).unwrap_or(' ');
1345 let cursor_area = Rect::new(cursor_x, y, 1, 1);
1346 let cursor_span = Span::styled(
1347 cursor_char.to_string(),
1348 Style::default()
1349 .fg(colors.focused)
1350 .add_modifier(ratatui::style::Modifier::REVERSED),
1351 );
1352 frame.render_widget(Paragraph::new(Line::from(vec![cursor_span])), cursor_area);
1353 }
1354
1355 rows.push(TextListRowLayout {
1356 text_area: Rect::new(area.x + indent, y, actual_field_width, 1),
1357 button_area: Rect::new(area.x + indent + actual_field_width + 1, y, 3, 1),
1358 index: None,
1359 });
1360 } else {
1361 let add_line = Line::from(vec![
1363 Span::raw(" ".repeat(indent as usize)),
1364 Span::styled("[+] Add new", Style::default().fg(colors.add_button)),
1365 ]);
1366 let row_area = Rect::new(area.x, y, area.width, 1);
1367 frame.render_widget(Paragraph::new(add_line), row_area);
1368
1369 rows.push(TextListRowLayout {
1370 text_area: Rect::new(area.x + indent, y, 11, 1), button_area: Rect::new(area.x + indent, y, 11, 1),
1372 index: None,
1373 });
1374 }
1375 }
1376
1377 TextListLayout {
1378 rows,
1379 full_area: area,
1380 }
1381}
1382
1383fn render_map_partial(
1385 frame: &mut Frame,
1386 area: Rect,
1387 state: &crate::view::controls::MapState,
1388 colors: &MapColors,
1389 key_width: u16,
1390 skip_rows: u16,
1391) -> crate::view::controls::MapLayout {
1392 use crate::view::controls::map_input::{MapEntryLayout, MapLayout};
1393 use crate::view::controls::FocusState;
1394
1395 let empty_layout = MapLayout {
1396 entry_areas: Vec::new(),
1397 add_row_area: None,
1398 full_area: area,
1399 };
1400
1401 if area.height == 0 || area.width < 15 {
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 entry_areas = Vec::new();
1414 let mut y = area.y;
1415 let mut content_row = 0u16;
1416
1417 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
1433 if state.display_field.is_some() && y < area.y + area.height {
1435 if content_row >= skip_rows {
1436 let value_header = state
1438 .display_field
1439 .as_ref()
1440 .map(|f| {
1441 let name = f.trim_start_matches('/');
1442 let mut chars = name.chars();
1444 match chars.next() {
1445 None => String::new(),
1446 Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
1447 }
1448 })
1449 .unwrap_or_else(|| "Value".to_string());
1450
1451 let header_style = Style::default()
1452 .fg(colors.label)
1453 .add_modifier(Modifier::DIM);
1454 let header_line = Line::from(vec![
1455 Span::styled(" ".repeat(indent as usize), header_style),
1456 Span::styled(
1457 format!("{:width$}", "Name", width = key_width as usize),
1458 header_style,
1459 ),
1460 Span::raw(" "),
1461 Span::styled(value_header, header_style),
1462 ]);
1463 frame.render_widget(
1464 Paragraph::new(header_line),
1465 Rect::new(area.x, y, area.width, 1),
1466 );
1467 y += 1;
1468 }
1469 content_row += 1;
1470 }
1471
1472 for (idx, (key, value)) in state.entries.iter().enumerate() {
1474 if y >= area.y + area.height {
1475 break;
1476 }
1477
1478 if content_row < skip_rows {
1479 content_row += 1;
1480 continue;
1481 }
1482
1483 let is_focused = state.focused_entry == Some(idx) && state.focus == FocusState::Focused;
1484
1485 let row_area = Rect::new(area.x, y, area.width, 1);
1486
1487 if is_focused {
1489 let highlight_style = Style::default().bg(colors.focused);
1490 let bg_line = Line::from(Span::styled(
1491 " ".repeat(area.width as usize),
1492 highlight_style,
1493 ));
1494 frame.render_widget(Paragraph::new(bg_line), row_area);
1495 }
1496
1497 let (key_color, value_color) = if is_focused {
1498 (colors.focused_fg, colors.focused_fg)
1500 } else if state.focus == FocusState::Disabled {
1501 (colors.disabled, colors.disabled)
1502 } else {
1503 (colors.key, colors.value_preview)
1504 };
1505
1506 let base_style = if is_focused {
1507 Style::default().bg(colors.focused)
1508 } else {
1509 Style::default()
1510 };
1511
1512 let value_preview = state.get_display_value(value);
1514 let max_preview_len = 20;
1515 let value_preview = if value_preview.len() > max_preview_len {
1516 format!("{}...", &value_preview[..max_preview_len - 3])
1517 } else {
1518 value_preview
1519 };
1520
1521 let display_key: String = key.chars().take(key_width as usize).collect();
1522 let mut spans = vec![
1523 Span::styled(" ".repeat(indent as usize), base_style),
1524 Span::styled(
1525 format!("{:width$}", display_key, width = key_width as usize),
1526 base_style.fg(key_color),
1527 ),
1528 Span::raw(" "),
1529 Span::styled(value_preview, base_style.fg(value_color)),
1530 ];
1531
1532 if is_focused {
1534 spans.push(Span::styled(
1535 " [Enter to edit]",
1536 base_style.fg(colors.focused_fg).add_modifier(Modifier::DIM),
1537 ));
1538 }
1539
1540 frame.render_widget(Paragraph::new(Line::from(spans)), row_area);
1541
1542 entry_areas.push(MapEntryLayout {
1543 index: idx,
1544 row_area,
1545 expand_area: Rect::default(), key_area: Rect::new(area.x + indent, y, key_width, 1),
1547 remove_area: Rect::new(area.x + indent + key_width + 1, y, 3, 1),
1548 });
1549
1550 y += 1;
1551 content_row += 1;
1552 }
1553
1554 let add_row_area = if !state.no_add && y < area.y + area.height && content_row >= skip_rows {
1556 let row_area = Rect::new(area.x, y, area.width, 1);
1557 let is_focused = state.focused_entry.is_none() && state.focus == FocusState::Focused;
1558
1559 if is_focused {
1561 let highlight_style = Style::default().bg(colors.focused);
1562 let bg_line = Line::from(Span::styled(
1563 " ".repeat(area.width as usize),
1564 highlight_style,
1565 ));
1566 frame.render_widget(Paragraph::new(bg_line), row_area);
1567 }
1568
1569 let base_style = if is_focused {
1570 Style::default().bg(colors.focused)
1571 } else {
1572 Style::default()
1573 };
1574
1575 let mut spans = vec![
1576 Span::styled(" ".repeat(indent as usize), base_style),
1577 Span::styled("[+] Add new", base_style.fg(colors.add_button)),
1578 ];
1579
1580 if is_focused {
1581 spans.push(Span::styled(
1582 " [Enter to add]",
1583 base_style.fg(colors.focused_fg).add_modifier(Modifier::DIM),
1584 ));
1585 }
1586
1587 frame.render_widget(Paragraph::new(Line::from(spans)), row_area);
1588 Some(row_area)
1589 } else {
1590 None
1591 };
1592
1593 MapLayout {
1594 entry_areas,
1595 add_row_area,
1596 full_area: area,
1597 }
1598}
1599
1600fn render_keybinding_list_partial(
1602 frame: &mut Frame,
1603 area: Rect,
1604 state: &crate::view::controls::KeybindingListState,
1605 colors: &crate::view::controls::KeybindingListColors,
1606 skip_rows: u16,
1607) -> crate::view::controls::KeybindingListLayout {
1608 use crate::view::controls::keybinding_list::format_key_combo;
1609 use crate::view::controls::FocusState;
1610 use ratatui::text::{Line, Span};
1611 use ratatui::widgets::Paragraph;
1612
1613 let empty_layout = crate::view::controls::KeybindingListLayout {
1614 entry_rects: Vec::new(),
1615 delete_rects: Vec::new(),
1616 add_rect: None,
1617 };
1618
1619 if area.height == 0 {
1620 return empty_layout;
1621 }
1622
1623 let indent = 2u16;
1624 let is_focused = state.focus == FocusState::Focused;
1625 let mut entry_rects = Vec::new();
1626 let mut delete_rects = Vec::new();
1627 let mut content_row = 0u16;
1628 let mut y = area.y;
1629
1630 if content_row >= skip_rows {
1632 let label_line = Line::from(vec![Span::styled(
1633 format!("{}:", state.label),
1634 Style::default().fg(colors.label_fg),
1635 )]);
1636 frame.render_widget(
1637 Paragraph::new(label_line),
1638 Rect::new(area.x, y, area.width, 1),
1639 );
1640 y += 1;
1641 }
1642 content_row += 1;
1643
1644 for (idx, binding) in state.bindings.iter().enumerate() {
1646 if y >= area.y + area.height {
1647 break;
1648 }
1649
1650 if content_row >= skip_rows {
1651 let entry_area = Rect::new(area.x + indent, y, area.width.saturating_sub(indent), 1);
1652 entry_rects.push(entry_area);
1653
1654 let is_entry_focused = is_focused && state.focused_index == Some(idx);
1655 let bg = if is_entry_focused {
1656 colors.focused_bg
1657 } else {
1658 Color::Reset
1659 };
1660
1661 let key_combo = format_key_combo(binding);
1662 let field_name = state
1664 .display_field
1665 .as_ref()
1666 .and_then(|p| p.strip_prefix('/'))
1667 .unwrap_or("action");
1668 let action = binding
1669 .get(field_name)
1670 .and_then(|a| a.as_str())
1671 .unwrap_or("(no action)");
1672
1673 let indicator = if is_entry_focused { "> " } else { " " };
1674 let (indicator_fg, key_fg, arrow_fg, action_fg, delete_fg) = if is_entry_focused {
1676 (
1677 colors.focused_fg,
1678 colors.focused_fg,
1679 colors.focused_fg,
1680 colors.focused_fg,
1681 colors.focused_fg,
1682 )
1683 } else {
1684 (
1685 colors.label_fg,
1686 colors.key_fg,
1687 colors.label_fg,
1688 colors.action_fg,
1689 colors.delete_fg,
1690 )
1691 };
1692 let line = Line::from(vec![
1693 Span::styled(indicator, Style::default().fg(indicator_fg).bg(bg)),
1694 Span::styled(
1695 format!("{:<20}", key_combo),
1696 Style::default().fg(key_fg).bg(bg),
1697 ),
1698 Span::styled(" → ", Style::default().fg(arrow_fg).bg(bg)),
1699 Span::styled(action, Style::default().fg(action_fg).bg(bg)),
1700 Span::styled(" [x]", Style::default().fg(delete_fg).bg(bg)),
1701 ]);
1702 frame.render_widget(Paragraph::new(line), entry_area);
1703
1704 let delete_x = entry_area.x + entry_area.width.saturating_sub(4);
1706 delete_rects.push(Rect::new(delete_x, y, 3, 1));
1707
1708 y += 1;
1709 }
1710 content_row += 1;
1711 }
1712
1713 let add_rect = if y < area.y + area.height && content_row >= skip_rows {
1715 let is_add_focused = is_focused && state.focused_index.is_none();
1716 let bg = if is_add_focused {
1717 colors.focused_bg
1718 } else {
1719 Color::Reset
1720 };
1721
1722 let indicator = if is_add_focused { "> " } else { " " };
1723 let (indicator_fg, add_fg) = if is_add_focused {
1725 (colors.focused_fg, colors.focused_fg)
1726 } else {
1727 (colors.label_fg, colors.add_fg)
1728 };
1729 let line = Line::from(vec![
1730 Span::styled(indicator, Style::default().fg(indicator_fg).bg(bg)),
1731 Span::styled("[+] Add new", Style::default().fg(add_fg).bg(bg)),
1732 ]);
1733 let add_area = Rect::new(area.x + indent, y, area.width.saturating_sub(indent), 1);
1734 frame.render_widget(Paragraph::new(line), add_area);
1735 Some(add_area)
1736 } else {
1737 None
1738 };
1739
1740 crate::view::controls::KeybindingListLayout {
1741 entry_rects,
1742 delete_rects,
1743 add_rect,
1744 }
1745}
1746
1747#[derive(Debug, Clone, Default)]
1749pub enum ControlLayoutInfo {
1750 Toggle(Rect),
1751 Number {
1752 decrement: Rect,
1753 increment: Rect,
1754 value: Rect,
1755 },
1756 Dropdown {
1757 button_area: Rect,
1758 option_areas: Vec<Rect>,
1759 scroll_offset: usize,
1760 },
1761 Text(Rect),
1762 TextList {
1763 rows: Vec<Rect>,
1764 },
1765 Map {
1766 entry_rows: Vec<Rect>,
1767 add_row_area: Option<Rect>,
1768 },
1769 ObjectArray {
1770 entry_rows: Vec<Rect>,
1771 },
1772 Json {
1773 edit_area: Rect,
1774 },
1775 #[default]
1776 Complex,
1777}
1778
1779#[allow(clippy::too_many_arguments)]
1781fn render_button(
1782 frame: &mut Frame,
1783 area: Rect,
1784 text: &str,
1785 focused_text: &str,
1786 is_focused: bool,
1787 is_hovered: bool,
1788 theme: &Theme,
1789 dimmed: bool,
1790) {
1791 if is_focused {
1792 let style = Style::default()
1793 .fg(theme.menu_highlight_fg)
1794 .bg(theme.menu_highlight_bg)
1795 .add_modifier(Modifier::BOLD);
1796 frame.render_widget(Paragraph::new(focused_text).style(style), area);
1797 } else if is_hovered {
1798 let style = Style::default()
1799 .fg(theme.menu_hover_fg)
1800 .bg(theme.menu_hover_bg);
1801 frame.render_widget(Paragraph::new(text).style(style), area);
1802 } else {
1803 let fg = if dimmed {
1804 theme.line_number_fg
1805 } else {
1806 theme.popup_text_fg
1807 };
1808 frame.render_widget(Paragraph::new(text).style(Style::default().fg(fg)), area);
1809 }
1810}
1811
1812fn render_footer(
1815 frame: &mut Frame,
1816 modal_area: Rect,
1817 state: &SettingsState,
1818 theme: &Theme,
1819 layout: &mut SettingsLayout,
1820 vertical: bool,
1821) {
1822 use super::layout::SettingsHit;
1823 use super::state::FocusPanel;
1824
1825 if modal_area.height < 4 || modal_area.width < 10 {
1827 return;
1828 }
1829
1830 if vertical {
1831 render_footer_vertical(frame, modal_area, state, theme, layout);
1832 return;
1833 }
1834
1835 let footer_y = modal_area.y + modal_area.height.saturating_sub(2);
1836 let footer_width = modal_area.width.saturating_sub(2);
1837 let footer_area = Rect::new(modal_area.x + 1, footer_y, footer_width, 1);
1838
1839 if footer_y > modal_area.y {
1841 let sep_y = footer_y.saturating_sub(1);
1842 let sep_area = Rect::new(modal_area.x + 1, sep_y, footer_width, 1);
1843 let sep_line: String = "─".repeat(sep_area.width as usize);
1844 frame.render_widget(
1845 Paragraph::new(sep_line).style(Style::default().fg(theme.split_separator_fg)),
1846 sep_area,
1847 );
1848 }
1849
1850 let footer_focused = state.focus_panel() == FocusPanel::Footer;
1852
1853 let layer_hovered = matches!(state.hover_hit, Some(SettingsHit::LayerButton));
1856 let reset_hovered = matches!(state.hover_hit, Some(SettingsHit::ResetButton));
1857 let save_hovered = matches!(state.hover_hit, Some(SettingsHit::SaveButton));
1858 let cancel_hovered = matches!(state.hover_hit, Some(SettingsHit::CancelButton));
1859 let edit_hovered = matches!(state.hover_hit, Some(SettingsHit::EditButton));
1860
1861 let layer_focused = footer_focused && state.footer_button_index == 0;
1862 let reset_focused = footer_focused && state.footer_button_index == 1;
1863 let save_focused = footer_focused && state.footer_button_index == 2;
1864 let cancel_focused = footer_focused && state.footer_button_index == 3;
1865 let edit_focused = footer_focused && state.footer_button_index == 4;
1866
1867 let save_label = t!("settings.btn_save").to_string();
1869 let cancel_label = t!("settings.btn_cancel").to_string();
1870 let reset_label = t!("settings.btn_reset").to_string();
1871 let edit_label = t!("settings.btn_edit").to_string();
1872
1873 let layer_text = format!("[ {} ]", state.target_layer_name());
1875 let layer_text_focused = format!(">[ {} ]", state.target_layer_name());
1876 let save_text = format!("[ {} ]", save_label);
1877 let save_text_focused = format!(">[ {} ]", save_label);
1878 let cancel_text = format!("[ {} ]", cancel_label);
1879 let cancel_text_focused = format!(">[ {} ]", cancel_label);
1880 let reset_text = format!("[ {} ]", reset_label);
1881 let reset_text_focused = format!(">[ {} ]", reset_label);
1882 let edit_text = format!("[ {} ]", edit_label);
1883 let edit_text_focused = format!(">[ {} ]", edit_label);
1884
1885 let cancel_width = str_width(if cancel_focused {
1887 &cancel_text_focused
1888 } else {
1889 &cancel_text
1890 }) as u16;
1891 let save_width = str_width(if save_focused {
1892 &save_text_focused
1893 } else {
1894 &save_text
1895 }) as u16;
1896 let reset_width = str_width(if reset_focused {
1897 &reset_text_focused
1898 } else {
1899 &reset_text
1900 }) as u16;
1901 let layer_width = str_width(if layer_focused {
1902 &layer_text_focused
1903 } else {
1904 &layer_text
1905 }) as u16;
1906 let edit_width = str_width(if edit_focused {
1907 &edit_text_focused
1908 } else {
1909 &edit_text
1910 }) as u16;
1911 let gap: u16 = 2;
1912
1913 let min_buttons_width = save_width + gap + cancel_width;
1916 let all_buttons_width =
1918 edit_width + gap + layer_width + gap + reset_width + gap + save_width + gap + cancel_width;
1919
1920 let available = footer_area.width;
1922 let show_edit = available >= all_buttons_width;
1923 let show_layer = available >= (layer_width + gap + reset_width + gap + min_buttons_width);
1924 let show_reset = available >= (reset_width + gap + min_buttons_width);
1925
1926 let cancel_x = footer_area
1928 .x
1929 .saturating_add(footer_area.width.saturating_sub(cancel_width));
1930 let save_x = cancel_x.saturating_sub(save_width + gap);
1931 let reset_x = if show_reset {
1932 save_x.saturating_sub(reset_width + gap)
1933 } else {
1934 0
1935 };
1936 let layer_x = if show_layer {
1937 reset_x.saturating_sub(layer_width + gap)
1938 } else {
1939 0
1940 };
1941 let edit_x = footer_area.x; if show_layer {
1946 let layer_area = Rect::new(layer_x, footer_y, layer_width, 1);
1947 render_button(
1948 frame,
1949 layer_area,
1950 &layer_text,
1951 &layer_text_focused,
1952 layer_focused,
1953 layer_hovered,
1954 theme,
1955 false,
1956 );
1957 layout.layer_button = Some(layer_area);
1958 }
1959
1960 if show_reset {
1962 let reset_area = Rect::new(reset_x, footer_y, reset_width, 1);
1963 render_button(
1964 frame,
1965 reset_area,
1966 &reset_text,
1967 &reset_text_focused,
1968 reset_focused,
1969 reset_hovered,
1970 theme,
1971 false,
1972 );
1973 layout.reset_button = Some(reset_area);
1974 }
1975
1976 let save_area = Rect::new(save_x, footer_y, save_width, 1);
1978 render_button(
1979 frame,
1980 save_area,
1981 &save_text,
1982 &save_text_focused,
1983 save_focused,
1984 save_hovered,
1985 theme,
1986 false,
1987 );
1988 layout.save_button = Some(save_area);
1989
1990 let cancel_area = Rect::new(cancel_x, footer_y, cancel_width, 1);
1992 render_button(
1993 frame,
1994 cancel_area,
1995 &cancel_text,
1996 &cancel_text_focused,
1997 cancel_focused,
1998 cancel_hovered,
1999 theme,
2000 false,
2001 );
2002 layout.cancel_button = Some(cancel_area);
2003
2004 if show_edit {
2006 let edit_area = Rect::new(edit_x, footer_y, edit_width, 1);
2007 render_button(
2008 frame,
2009 edit_area,
2010 &edit_text,
2011 &edit_text_focused,
2012 edit_focused,
2013 edit_hovered,
2014 theme,
2015 true, );
2017 layout.edit_button = Some(edit_area);
2018 }
2019
2020 let help_start_x = if show_edit {
2023 edit_x + edit_width + 2
2024 } else {
2025 footer_area.x
2026 };
2027 let help_end_x = if show_layer {
2028 layer_x
2029 } else if show_reset {
2030 reset_x
2031 } else {
2032 save_x
2033 };
2034 let help_width = help_end_x.saturating_sub(help_start_x + 1);
2035
2036 let help = if state.search_active {
2038 t!("settings.help_search").to_string()
2039 } else if footer_focused {
2040 t!("settings.help_footer").to_string()
2041 } else {
2042 t!("settings.help_default").to_string()
2043 };
2044 let help_style = Style::default().fg(theme.line_number_fg);
2045 frame.render_widget(
2046 Paragraph::new(help.as_str()).style(help_style),
2047 Rect::new(help_start_x, footer_y, help_width, 1),
2048 );
2049}
2050
2051fn render_footer_vertical(
2053 frame: &mut Frame,
2054 modal_area: Rect,
2055 state: &SettingsState,
2056 theme: &Theme,
2057 layout: &mut SettingsLayout,
2058) {
2059 use super::layout::SettingsHit;
2060 use super::state::FocusPanel;
2061
2062 let footer_height = 7u16;
2064 let footer_y = modal_area
2065 .y
2066 .saturating_add(modal_area.height.saturating_sub(footer_height));
2067 let footer_width = modal_area.width.saturating_sub(2);
2068
2069 let sep_y = footer_y;
2071 if sep_y > modal_area.y {
2072 let sep_line: String = "─".repeat(footer_width as usize);
2073 frame.render_widget(
2074 Paragraph::new(sep_line).style(Style::default().fg(theme.split_separator_fg)),
2075 Rect::new(modal_area.x + 1, sep_y, footer_width, 1),
2076 );
2077 }
2078
2079 let footer_focused = state.focus_panel() == FocusPanel::Footer;
2081
2082 let layer_hovered = matches!(state.hover_hit, Some(SettingsHit::LayerButton));
2084 let reset_hovered = matches!(state.hover_hit, Some(SettingsHit::ResetButton));
2085 let save_hovered = matches!(state.hover_hit, Some(SettingsHit::SaveButton));
2086 let cancel_hovered = matches!(state.hover_hit, Some(SettingsHit::CancelButton));
2087 let edit_hovered = matches!(state.hover_hit, Some(SettingsHit::EditButton));
2088
2089 let layer_focused = footer_focused && state.footer_button_index == 0;
2090 let reset_focused = footer_focused && state.footer_button_index == 1;
2091 let save_focused = footer_focused && state.footer_button_index == 2;
2092 let cancel_focused = footer_focused && state.footer_button_index == 3;
2093 let edit_focused = footer_focused && state.footer_button_index == 4;
2094
2095 let save_label = t!("settings.btn_save").to_string();
2097 let cancel_label = t!("settings.btn_cancel").to_string();
2098 let reset_label = t!("settings.btn_reset").to_string();
2099 let edit_label = t!("settings.btn_edit").to_string();
2100
2101 let layer_text = format!("[ {} ]", state.target_layer_name());
2103 let layer_text_focused = format!(">[ {} ]", state.target_layer_name());
2104 let save_text = format!("[ {} ]", save_label);
2105 let save_text_focused = format!(">[ {} ]", save_label);
2106 let cancel_text = format!("[ {} ]", cancel_label);
2107 let cancel_text_focused = format!(">[ {} ]", cancel_label);
2108 let reset_text = format!("[ {} ]", reset_label);
2109 let reset_text_focused = format!(">[ {} ]", reset_label);
2110 let edit_text = format!("[ {} ]", edit_label);
2111 let edit_text_focused = format!(">[ {} ]", edit_label);
2112
2113 let button_x = modal_area.x + 2;
2115 let mut y = sep_y + 1;
2116
2117 let layer_width = str_width(if layer_focused {
2119 &layer_text_focused
2120 } else {
2121 &layer_text
2122 }) as u16;
2123 let layer_area = Rect::new(button_x, y, layer_width.min(footer_width), 1);
2124 render_button(
2125 frame,
2126 layer_area,
2127 &layer_text,
2128 &layer_text_focused,
2129 layer_focused,
2130 layer_hovered,
2131 theme,
2132 false,
2133 );
2134 layout.layer_button = Some(layer_area);
2135 y += 1;
2136
2137 let save_width = str_width(if save_focused {
2139 &save_text_focused
2140 } else {
2141 &save_text
2142 }) as u16;
2143 let save_area = Rect::new(button_x, y, save_width.min(footer_width), 1);
2144 render_button(
2145 frame,
2146 save_area,
2147 &save_text,
2148 &save_text_focused,
2149 save_focused,
2150 save_hovered,
2151 theme,
2152 false,
2153 );
2154 layout.save_button = Some(save_area);
2155 y += 1;
2156
2157 let reset_width = str_width(if reset_focused {
2159 &reset_text_focused
2160 } else {
2161 &reset_text
2162 }) as u16;
2163 let reset_area = Rect::new(button_x, y, reset_width.min(footer_width), 1);
2164 render_button(
2165 frame,
2166 reset_area,
2167 &reset_text,
2168 &reset_text_focused,
2169 reset_focused,
2170 reset_hovered,
2171 theme,
2172 false,
2173 );
2174 layout.reset_button = Some(reset_area);
2175 y += 1;
2176
2177 let cancel_width = str_width(if cancel_focused {
2179 &cancel_text_focused
2180 } else {
2181 &cancel_text
2182 }) as u16;
2183 let cancel_area = Rect::new(button_x, y, cancel_width.min(footer_width), 1);
2184 render_button(
2185 frame,
2186 cancel_area,
2187 &cancel_text,
2188 &cancel_text_focused,
2189 cancel_focused,
2190 cancel_hovered,
2191 theme,
2192 false,
2193 );
2194 layout.cancel_button = Some(cancel_area);
2195 y += 1;
2196
2197 let edit_width = str_width(if edit_focused {
2199 &edit_text_focused
2200 } else {
2201 &edit_text
2202 }) as u16;
2203 let edit_area = Rect::new(button_x, y, edit_width.min(footer_width), 1);
2204 render_button(
2205 frame,
2206 edit_area,
2207 &edit_text,
2208 &edit_text_focused,
2209 edit_focused,
2210 edit_hovered,
2211 theme,
2212 true, );
2214 layout.edit_button = Some(edit_area);
2215}
2216
2217fn render_search_header(frame: &mut Frame, area: Rect, state: &SettingsState, theme: &Theme) {
2219 let search_style = Style::default().fg(theme.settings_selected_fg);
2220 let cursor_style = Style::default()
2221 .fg(theme.settings_selected_fg)
2222 .add_modifier(Modifier::REVERSED);
2223
2224 let result_count = state.search_results.len();
2226 let count_text = if state.search_query.is_empty() {
2227 String::new()
2228 } else if result_count == 0 {
2229 " (no results)".to_string()
2230 } else if result_count == 1 {
2231 " (1 result)".to_string()
2232 } else if state.search_max_visible >= result_count {
2233 format!(" ({} results)", result_count)
2235 } else {
2236 let first = state.search_scroll_offset + 1;
2238 let last = (state.search_scroll_offset + state.search_max_visible).min(result_count);
2239 format!(" ({}-{} of {})", first, last, result_count)
2240 };
2241
2242 let has_more_above = state.search_scroll_offset > 0;
2244 let has_more_below = state.search_scroll_offset + state.search_max_visible < result_count;
2245 let scroll_indicator = match (has_more_above, has_more_below) {
2246 (true, true) => " ↑↓",
2247 (true, false) => " ↑",
2248 (false, true) => " ↓",
2249 (false, false) => "",
2250 };
2251
2252 let count_style = Style::default().fg(theme.line_number_fg);
2253 let indicator_style = Style::default()
2254 .fg(theme.menu_active_fg)
2255 .add_modifier(Modifier::BOLD);
2256
2257 let spans = vec![
2258 Span::styled("> ", search_style),
2259 Span::styled(&state.search_query, search_style),
2260 Span::styled(" ", cursor_style), Span::styled(count_text, count_style),
2262 Span::styled(scroll_indicator, indicator_style),
2263 ];
2264 let line = Line::from(spans);
2265 frame.render_widget(Paragraph::new(line), area);
2266}
2267
2268fn render_search_hint(frame: &mut Frame, area: Rect, theme: &Theme) {
2270 let hint_style = Style::default().fg(theme.line_number_fg);
2271 let key_style = Style::default()
2272 .fg(theme.menu_active_fg)
2273 .add_modifier(Modifier::BOLD);
2274
2275 let spans = vec![
2276 Span::styled("Press ", hint_style),
2277 Span::styled("/", key_style),
2278 Span::styled(" to search settings...", hint_style),
2279 ];
2280 let line = Line::from(spans);
2281 frame.render_widget(Paragraph::new(line), area);
2282}
2283
2284fn render_search_results(
2286 frame: &mut Frame,
2287 area: Rect,
2288 state: &mut SettingsState,
2289 theme: &Theme,
2290 layout: &mut SettingsLayout,
2291) {
2292 let max_visible = (area.height.saturating_sub(3) / 3) as usize;
2294 state.search_max_visible = max_visible.max(1);
2295
2296 if state.search_scroll_offset >= state.search_results.len() {
2298 state.search_scroll_offset = state.search_results.len().saturating_sub(1);
2299 }
2300
2301 let needs_scrollbar = state.search_results.len() > state.search_max_visible;
2303 let scrollbar_width = if needs_scrollbar { 1 } else { 0 };
2304
2305 let content_area = Rect::new(
2307 area.x,
2308 area.y,
2309 area.width.saturating_sub(scrollbar_width),
2310 area.height,
2311 );
2312
2313 let mut y = content_area.y;
2314
2315 for (idx, result) in state
2316 .search_results
2317 .iter()
2318 .enumerate()
2319 .skip(state.search_scroll_offset)
2320 {
2321 if y >= content_area.y + content_area.height.saturating_sub(3) {
2322 break;
2323 }
2324
2325 let is_selected = idx == state.selected_search_result;
2326 let is_hovered = matches!(state.hover_hit, Some(SettingsHit::SearchResult(i)) if i == idx);
2327 let item_area = Rect::new(content_area.x, y, content_area.width, 3);
2328
2329 render_search_result_item(
2330 frame,
2331 item_area,
2332 result,
2333 is_selected,
2334 is_hovered,
2335 theme,
2336 layout,
2337 );
2338 y += 3;
2339 }
2340
2341 layout.search_results_area = Some(content_area);
2343
2344 if needs_scrollbar {
2346 let scrollbar_area = Rect::new(
2347 area.x + area.width - 1,
2348 area.y,
2349 1,
2350 area.height.saturating_sub(3), );
2352
2353 let scrollbar_state = ScrollbarState::new(
2354 state.search_results.len(),
2355 state.search_max_visible,
2356 state.search_scroll_offset,
2357 );
2358
2359 let colors = ScrollbarColors::from_theme(theme);
2360 render_scrollbar(frame, scrollbar_area, &scrollbar_state, &colors);
2361
2362 layout.search_scrollbar_area = Some(scrollbar_area);
2364 } else {
2365 layout.search_scrollbar_area = None;
2366 }
2367}
2368
2369fn render_search_result_item(
2371 frame: &mut Frame,
2372 area: Rect,
2373 result: &SearchResult,
2374 is_selected: bool,
2375 is_hovered: bool,
2376 theme: &Theme,
2377 layout: &mut SettingsLayout,
2378) {
2379 if is_selected {
2381 let bg_style = Style::default().bg(theme.settings_selected_bg);
2383 for row in 0..area.height.min(3) {
2384 let row_area = Rect::new(area.x, area.y + row, area.width, 1);
2385 frame.render_widget(Paragraph::new("").style(bg_style), row_area);
2386 }
2387 } else if is_hovered {
2388 let bg_style = Style::default().bg(theme.menu_hover_bg);
2390 for row in 0..area.height.min(3) {
2391 let row_area = Rect::new(area.x, area.y + row, area.width, 1);
2392 frame.render_widget(Paragraph::new("").style(bg_style), row_area);
2393 }
2394 }
2395
2396 let name_style = if is_selected {
2398 Style::default().fg(theme.settings_selected_fg)
2399 } else if is_hovered {
2400 Style::default().fg(theme.menu_hover_fg)
2401 } else {
2402 Style::default().fg(theme.popup_text_fg)
2403 };
2404
2405 let name_line = build_highlighted_text(
2407 &result.item.name,
2408 &result.name_matches,
2409 name_style,
2410 Style::default()
2411 .fg(theme.diagnostic_warning_fg)
2412 .add_modifier(Modifier::BOLD),
2413 );
2414 frame.render_widget(
2415 Paragraph::new(name_line),
2416 Rect::new(area.x, area.y, area.width, 1),
2417 );
2418
2419 let breadcrumb_style = Style::default()
2421 .fg(theme.line_number_fg)
2422 .add_modifier(Modifier::ITALIC);
2423 let breadcrumb = format!(" {} > {}", result.breadcrumb, result.item.path);
2424 let breadcrumb_line = Line::from(Span::styled(breadcrumb, breadcrumb_style));
2425 frame.render_widget(
2426 Paragraph::new(breadcrumb_line),
2427 Rect::new(area.x, area.y + 1, area.width, 1),
2428 );
2429
2430 if let Some(ref desc) = result.item.description {
2432 let desc_style = Style::default().fg(theme.line_number_fg);
2433 let truncated_desc = if desc.len() > area.width as usize - 2 {
2434 format!(" {}...", &desc[..area.width as usize - 5])
2435 } else {
2436 format!(" {}", desc)
2437 };
2438 frame.render_widget(
2439 Paragraph::new(truncated_desc).style(desc_style),
2440 Rect::new(area.x, area.y + 2, area.width, 1),
2441 );
2442 }
2443
2444 layout.add_search_result(result.page_index, result.item_index, area);
2446}
2447
2448fn build_highlighted_text(
2450 text: &str,
2451 matches: &[usize],
2452 normal_style: Style,
2453 highlight_style: Style,
2454) -> Line<'static> {
2455 if matches.is_empty() {
2456 return Line::from(Span::styled(text.to_string(), normal_style));
2457 }
2458
2459 let chars: Vec<char> = text.chars().collect();
2460 let mut spans = Vec::new();
2461 let mut current = String::new();
2462 let mut in_highlight = false;
2463
2464 for (idx, ch) in chars.iter().enumerate() {
2465 let should_highlight = matches.contains(&idx);
2466
2467 if should_highlight != in_highlight {
2468 if !current.is_empty() {
2469 let style = if in_highlight {
2470 highlight_style
2471 } else {
2472 normal_style
2473 };
2474 spans.push(Span::styled(current, style));
2475 current = String::new();
2476 }
2477 in_highlight = should_highlight;
2478 }
2479
2480 current.push(*ch);
2481 }
2482
2483 if !current.is_empty() {
2485 let style = if in_highlight {
2486 highlight_style
2487 } else {
2488 normal_style
2489 };
2490 spans.push(Span::styled(current, style));
2491 }
2492
2493 Line::from(spans)
2494}
2495
2496fn render_confirm_dialog(
2498 frame: &mut Frame,
2499 parent_area: Rect,
2500 state: &SettingsState,
2501 theme: &Theme,
2502) {
2503 let changes = state.get_change_descriptions();
2505 let dialog_width = 50.min(parent_area.width.saturating_sub(4));
2506 let dialog_height = (7 + changes.len() as u16)
2509 .min(20)
2510 .min(parent_area.height.saturating_sub(4));
2511
2512 let dialog_x = parent_area.x + (parent_area.width.saturating_sub(dialog_width)) / 2;
2514 let dialog_y = parent_area.y + (parent_area.height.saturating_sub(dialog_height)) / 2;
2515 let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
2516
2517 frame.render_widget(Clear, dialog_area);
2519
2520 let title = format!(" {} ", t!("confirm.unsaved_changes_title"));
2521 let block = Block::default()
2522 .title(title)
2523 .borders(Borders::ALL)
2524 .border_style(Style::default().fg(theme.diagnostic_warning_fg))
2525 .style(Style::default().bg(theme.popup_bg));
2526 frame.render_widget(block, dialog_area);
2527
2528 let inner = Rect::new(
2530 dialog_area.x + 2,
2531 dialog_area.y + 1,
2532 dialog_area.width.saturating_sub(4),
2533 dialog_area.height.saturating_sub(2),
2534 );
2535
2536 let mut y = inner.y;
2537
2538 let prompt = t!("confirm.unsaved_changes_prompt").to_string();
2540 let prompt_style = Style::default().fg(theme.popup_text_fg);
2541 frame.render_widget(
2542 Paragraph::new(prompt).style(prompt_style),
2543 Rect::new(inner.x, y, inner.width, 1),
2544 );
2545 y += 2;
2546
2547 let change_style = Style::default().fg(theme.popup_text_fg);
2549 for change in changes
2550 .iter()
2551 .take((dialog_height as usize).saturating_sub(7))
2552 {
2553 let truncated = if change.len() > inner.width as usize - 2 {
2554 format!("• {}...", &change[..inner.width as usize - 5])
2555 } else {
2556 format!("• {}", change)
2557 };
2558 frame.render_widget(
2559 Paragraph::new(truncated).style(change_style),
2560 Rect::new(inner.x, y, inner.width, 1),
2561 );
2562 y += 1;
2563 }
2564
2565 let button_y = dialog_area.y + dialog_area.height - 3;
2567
2568 let sep_line: String = "─".repeat(inner.width as usize);
2570 frame.render_widget(
2571 Paragraph::new(sep_line).style(Style::default().fg(theme.split_separator_fg)),
2572 Rect::new(inner.x, button_y - 1, inner.width, 1),
2573 );
2574
2575 let options = [
2577 t!("confirm.save_and_exit").to_string(),
2578 t!("confirm.discard").to_string(),
2579 t!("confirm.cancel").to_string(),
2580 ];
2581 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;
2583
2584 for (idx, label) in options.iter().enumerate() {
2585 let is_selected = idx == state.confirm_dialog_selection;
2586 let is_hovered = state.confirm_dialog_hover == Some(idx);
2587 let button_width = label.len() as u16 + 4;
2588
2589 let style = if is_selected {
2590 Style::default()
2591 .fg(theme.menu_highlight_fg)
2592 .bg(theme.menu_highlight_bg)
2593 .add_modifier(ratatui::style::Modifier::BOLD)
2594 } else if is_hovered {
2595 Style::default()
2596 .fg(theme.menu_hover_fg)
2597 .bg(theme.menu_hover_bg)
2598 } else {
2599 Style::default().fg(theme.popup_text_fg)
2600 };
2601
2602 let text = if is_selected {
2603 format!(">[ {} ]", label)
2604 } else {
2605 format!(" [ {} ]", label)
2606 };
2607 frame.render_widget(
2608 Paragraph::new(text).style(style),
2609 Rect::new(x, button_y, button_width + 1, 1),
2610 );
2611
2612 x += button_width + 3;
2613 }
2614
2615 let help = "←/→/Tab: Select Enter: Confirm Esc: Cancel";
2617 let help_style = Style::default().fg(theme.line_number_fg);
2618 frame.render_widget(
2619 Paragraph::new(help).style(help_style),
2620 Rect::new(inner.x, button_y + 1, inner.width, 1),
2621 );
2622}
2623
2624fn render_reset_dialog(frame: &mut Frame, parent_area: Rect, state: &SettingsState, theme: &Theme) {
2626 let changes = state.get_change_descriptions();
2627 let dialog_width = 50.min(parent_area.width.saturating_sub(4));
2628 let dialog_height = (7 + changes.len() as u16)
2631 .min(20)
2632 .min(parent_area.height.saturating_sub(4));
2633
2634 let dialog_x = parent_area.x + (parent_area.width.saturating_sub(dialog_width)) / 2;
2636 let dialog_y = parent_area.y + (parent_area.height.saturating_sub(dialog_height)) / 2;
2637 let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
2638
2639 frame.render_widget(Clear, dialog_area);
2641
2642 let block = Block::default()
2643 .title(" Reset All Changes ")
2644 .borders(Borders::ALL)
2645 .border_style(Style::default().fg(theme.diagnostic_warning_fg))
2646 .style(Style::default().bg(theme.popup_bg));
2647 frame.render_widget(block, dialog_area);
2648
2649 let inner = Rect::new(
2651 dialog_area.x + 2,
2652 dialog_area.y + 1,
2653 dialog_area.width.saturating_sub(4),
2654 dialog_area.height.saturating_sub(2),
2655 );
2656
2657 let mut y = inner.y;
2658
2659 let prompt_style = Style::default().fg(theme.popup_text_fg);
2661 frame.render_widget(
2662 Paragraph::new("Discard all pending changes?").style(prompt_style),
2663 Rect::new(inner.x, y, inner.width, 1),
2664 );
2665 y += 2;
2666
2667 let change_style = Style::default().fg(theme.popup_text_fg);
2669 for change in changes
2670 .iter()
2671 .take((dialog_height as usize).saturating_sub(7))
2672 {
2673 let truncated = if change.len() > inner.width as usize - 2 {
2674 format!("• {}...", &change[..inner.width as usize - 5])
2675 } else {
2676 format!("• {}", change)
2677 };
2678 frame.render_widget(
2679 Paragraph::new(truncated).style(change_style),
2680 Rect::new(inner.x, y, inner.width, 1),
2681 );
2682 y += 1;
2683 }
2684
2685 let button_y = dialog_area.y + dialog_area.height - 3;
2687
2688 let sep_line: String = "─".repeat(inner.width as usize);
2690 frame.render_widget(
2691 Paragraph::new(sep_line).style(Style::default().fg(theme.split_separator_fg)),
2692 Rect::new(inner.x, button_y - 1, inner.width, 1),
2693 );
2694
2695 let options = ["Reset", "Cancel"];
2697 let total_width: u16 = options.iter().map(|o| o.len() as u16 + 4).sum::<u16>() + 4;
2698 let mut x = inner.x + (inner.width.saturating_sub(total_width)) / 2;
2699
2700 for (idx, label) in options.iter().enumerate() {
2701 let is_selected = idx == state.reset_dialog_selection;
2702 let is_hovered = state.reset_dialog_hover == Some(idx);
2703 let button_width = label.len() as u16 + 4;
2704
2705 let style = if is_selected {
2706 Style::default()
2707 .fg(theme.menu_highlight_fg)
2708 .bg(theme.menu_highlight_bg)
2709 .add_modifier(ratatui::style::Modifier::BOLD)
2710 } else if is_hovered {
2711 Style::default()
2712 .fg(theme.menu_hover_fg)
2713 .bg(theme.menu_hover_bg)
2714 } else {
2715 Style::default().fg(theme.popup_text_fg)
2716 };
2717
2718 let text = if is_selected {
2719 format!(">[ {} ]", label)
2720 } else {
2721 format!(" [ {} ]", label)
2722 };
2723 frame.render_widget(
2724 Paragraph::new(text).style(style),
2725 Rect::new(x, button_y, button_width + 1, 1),
2726 );
2727
2728 x += button_width + 3;
2729 }
2730
2731 let help = "←/→/Tab: Select Enter: Confirm Esc: Cancel";
2733 let help_style = Style::default().fg(theme.line_number_fg);
2734 frame.render_widget(
2735 Paragraph::new(help).style(help_style),
2736 Rect::new(inner.x, button_y + 1, inner.width, 1),
2737 );
2738}
2739
2740fn render_entry_dialog(
2745 frame: &mut Frame,
2746 parent_area: Rect,
2747 state: &mut SettingsState,
2748 theme: &Theme,
2749) {
2750 let Some(dialog) = state.entry_dialog_mut() else {
2751 return;
2752 };
2753
2754 let dialog_width = (parent_area.width * 85 / 100).clamp(50, 90);
2756 let dialog_height = (parent_area.height * 90 / 100).max(15);
2757 let dialog_x = parent_area.x + (parent_area.width.saturating_sub(dialog_width)) / 2;
2758 let dialog_y = parent_area.y + (parent_area.height.saturating_sub(dialog_height)) / 2;
2759
2760 let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
2761
2762 frame.render_widget(Clear, dialog_area);
2764
2765 let title = format!(" {} ", dialog.title);
2766
2767 let block = Block::default()
2768 .title(title)
2769 .borders(Borders::ALL)
2770 .border_style(Style::default().fg(theme.popup_border_fg))
2771 .style(Style::default().bg(theme.popup_bg));
2772 frame.render_widget(block, dialog_area);
2773
2774 let inner = Rect::new(
2776 dialog_area.x + 2,
2777 dialog_area.y + 1,
2778 dialog_area.width.saturating_sub(4),
2779 dialog_area.height.saturating_sub(5), );
2781
2782 let max_label_width = (inner.width / 2).max(20);
2784 let label_col_width = dialog
2785 .items
2786 .iter()
2787 .map(|item| item.name.len() as u16 + 2) .filter(|&w| w <= max_label_width)
2789 .max()
2790 .unwrap_or(20)
2791 .min(max_label_width);
2792
2793 let total_content_height = dialog.total_content_height();
2795 let viewport_height = inner.height as usize;
2796
2797 dialog.viewport_height = viewport_height;
2799
2800 let scroll_offset = dialog.scroll_offset;
2801 let needs_scroll = total_content_height > viewport_height;
2802
2803 let mut content_y: usize = 0;
2805 let mut screen_y = inner.y;
2806
2807 let first_editable = dialog.first_editable_index;
2809 let has_readonly_items = first_editable > 0;
2810 let has_editable_items = first_editable < dialog.items.len();
2811 let needs_separator = has_readonly_items && has_editable_items;
2812
2813 for (idx, item) in dialog.items.iter().enumerate() {
2814 if needs_separator && idx == first_editable {
2816 let separator_start = content_y;
2818 let separator_end = content_y + 1;
2819
2820 if separator_end > scroll_offset && screen_y < inner.y + inner.height {
2821 let skip_sep = if separator_start < scroll_offset {
2823 1
2824 } else {
2825 0
2826 };
2827 if skip_sep == 0 {
2828 let sep_style = Style::default().fg(theme.line_number_fg);
2829 let separator_line = "─".repeat(inner.width.saturating_sub(2) as usize);
2830 frame.render_widget(
2831 Paragraph::new(separator_line).style(sep_style),
2832 Rect::new(inner.x + 1, screen_y, inner.width.saturating_sub(2), 1),
2833 );
2834 screen_y += 1;
2835 }
2836 }
2837 content_y = separator_end;
2838 }
2839
2840 let control_height = item.control.control_height() as usize;
2841
2842 let item_start = content_y;
2844 let item_end = content_y + control_height;
2845
2846 if item_end <= scroll_offset {
2848 content_y = item_end;
2849 continue;
2850 }
2851
2852 if screen_y >= inner.y + inner.height {
2854 break;
2855 }
2856
2857 let skip_rows = if item_start < scroll_offset {
2859 (scroll_offset - item_start) as u16
2860 } else {
2861 0
2862 };
2863
2864 let visible_height = control_height.saturating_sub(skip_rows as usize);
2866 let available_height = (inner.y + inner.height).saturating_sub(screen_y) as usize;
2867 let render_height = visible_height.min(available_height);
2868
2869 if render_height == 0 {
2870 content_y = item_end;
2871 continue;
2872 }
2873
2874 let is_readonly = item.read_only;
2876 let is_focused = !is_readonly && !dialog.focus_on_buttons && dialog.selected_item == idx;
2877 let is_hovered = !is_readonly && dialog.hover_item == Some(idx);
2878
2879 if is_focused || is_hovered {
2881 let bg_style = if is_focused {
2883 Style::default().bg(theme.settings_selected_bg)
2884 } else {
2885 Style::default().bg(theme.menu_hover_bg)
2886 };
2887 for row in 0..render_height as u16 {
2888 let row_area = Rect::new(inner.x, screen_y + row, inner.width, 1);
2889 frame.render_widget(Paragraph::new("").style(bg_style), row_area);
2890 }
2891 }
2892
2893 let focus_indicator_width: u16 = 3;
2896
2897 if is_focused && skip_rows == 0 {
2899 let indicator_style = Style::default()
2900 .fg(theme.settings_selected_fg)
2901 .add_modifier(Modifier::BOLD);
2902 frame.render_widget(
2903 Paragraph::new(">").style(indicator_style),
2904 Rect::new(inner.x, screen_y, 1, 1),
2905 );
2906 }
2907
2908 if item.modified && skip_rows == 0 {
2910 let modified_style = Style::default().fg(theme.settings_selected_fg);
2911 frame.render_widget(
2912 Paragraph::new("●").style(modified_style),
2913 Rect::new(inner.x + 1, screen_y, 1, 1),
2914 );
2915 }
2916
2917 let control_area = Rect::new(
2919 inner.x + focus_indicator_width,
2920 screen_y,
2921 inner.width.saturating_sub(focus_indicator_width),
2922 render_height as u16,
2923 );
2924
2925 let _layout = render_control(
2927 frame,
2928 control_area,
2929 &item.control,
2930 &item.name,
2931 skip_rows,
2932 theme,
2933 Some(label_col_width.saturating_sub(focus_indicator_width)),
2934 item.read_only,
2935 );
2936
2937 screen_y += render_height as u16;
2938 content_y = item_end;
2939 }
2940
2941 if needs_scroll {
2943 use crate::view::ui::scrollbar::{render_scrollbar, ScrollbarColors, ScrollbarState};
2944
2945 let scrollbar_x = dialog_area.x + dialog_area.width - 3;
2946 let scrollbar_area = Rect::new(scrollbar_x, inner.y, 1, inner.height);
2947 let scrollbar_state =
2948 ScrollbarState::new(total_content_height, viewport_height, scroll_offset);
2949 let scrollbar_colors = ScrollbarColors::from_theme(theme);
2950 render_scrollbar(frame, scrollbar_area, &scrollbar_state, &scrollbar_colors);
2951 }
2952
2953 let button_y = dialog_area.y + dialog_area.height - 2;
2955 let buttons: Vec<&str> = if dialog.is_new || dialog.no_delete {
2957 vec!["[ Save ]", "[ Cancel ]"]
2958 } else {
2959 vec!["[ Save ]", "[ Delete ]", "[ Cancel ]"]
2960 };
2961 let button_width: u16 = buttons.iter().map(|b: &&str| b.len() as u16 + 2).sum();
2962 let button_x = dialog_area.x + (dialog_area.width.saturating_sub(button_width)) / 2;
2963
2964 let mut x = button_x;
2965 for (idx, label) in buttons.iter().enumerate() {
2966 let is_selected = dialog.focus_on_buttons && dialog.focused_button == idx;
2967 let is_hovered = dialog.hover_button == Some(idx);
2968 let is_delete = !dialog.is_new && !dialog.no_delete && idx == 1;
2969 if is_selected {
2971 let indicator_style = Style::default()
2972 .fg(theme.settings_selected_fg)
2973 .add_modifier(Modifier::BOLD);
2974 frame.render_widget(
2975 Paragraph::new(">").style(indicator_style),
2976 Rect::new(x, button_y, 1, 1),
2977 );
2978 x += 2;
2979 }
2980 let style = if is_selected {
2981 Style::default()
2982 .fg(theme.menu_highlight_fg)
2983 .add_modifier(Modifier::BOLD | Modifier::REVERSED)
2984 } else if is_hovered {
2985 Style::default()
2986 .fg(theme.menu_hover_fg)
2987 .bg(theme.menu_hover_bg)
2988 } else if is_delete {
2989 Style::default().fg(theme.diagnostic_error_fg)
2990 } else {
2991 Style::default().fg(theme.editor_fg)
2992 };
2993 frame.render_widget(
2994 Paragraph::new(*label).style(style),
2995 Rect::new(x, button_y, label.len() as u16, 1),
2996 );
2997 x += label.len() as u16 + 2;
2998 }
2999
3000 let is_editing_json = dialog.editing_text && dialog.is_editing_json();
3003 let (has_invalid_json, is_json_control) = dialog
3004 .current_item()
3005 .map(|item| match &item.control {
3006 SettingControl::Text(state) => (!state.is_valid(), false),
3007 SettingControl::Json(state) => (!state.is_valid(), is_editing_json),
3008 _ => (false, false),
3009 })
3010 .unwrap_or((false, false));
3011
3012 let help_area = Rect::new(
3014 dialog_area.x + 2,
3015 button_y + 1,
3016 dialog_area.width.saturating_sub(4),
3017 1,
3018 );
3019
3020 if has_invalid_json && !is_json_control {
3021 let warning = "⚠ Invalid JSON - fix before leaving field";
3023 let warning_style = Style::default().fg(theme.diagnostic_warning_fg);
3024 frame.render_widget(Paragraph::new(warning).style(warning_style), help_area);
3025 } else if has_invalid_json && is_json_control {
3026 let warning = "⚠ Invalid JSON";
3028 let warning_style = Style::default().fg(theme.diagnostic_warning_fg);
3029 frame.render_widget(Paragraph::new(warning).style(warning_style), help_area);
3030 } else if is_json_control {
3031 let help = "↑↓←→:Move Enter:Newline Tab/Esc:Exit";
3033 let help_style = Style::default().fg(theme.line_number_fg);
3034 frame.render_widget(Paragraph::new(help).style(help_style), help_area);
3035 } else {
3036 let help = "↑↓:Navigate Tab:Fields/Buttons Enter:Edit/Confirm Esc:Cancel";
3037 let help_style = Style::default().fg(theme.line_number_fg);
3038 frame.render_widget(Paragraph::new(help).style(help_style), help_area);
3039 }
3040}
3041
3042fn render_help_overlay(frame: &mut Frame, parent_area: Rect, theme: &Theme) {
3044 let help_items = [
3046 (
3047 "Navigation",
3048 vec![
3049 ("↑ / ↓", "Move up/down"),
3050 ("Tab", "Switch between categories and settings"),
3051 ("Enter", "Activate/toggle setting"),
3052 ],
3053 ),
3054 (
3055 "Search",
3056 vec![
3057 ("/", "Start search"),
3058 ("Esc", "Cancel search"),
3059 ("↑ / ↓", "Navigate results"),
3060 ("Enter", "Jump to result"),
3061 ],
3062 ),
3063 (
3064 "Actions",
3065 vec![
3066 ("Ctrl+S", "Save settings"),
3067 ("Esc", "Close settings"),
3068 ("?", "Toggle this help"),
3069 ],
3070 ),
3071 ];
3072
3073 let dialog_width = 50.min(parent_area.width.saturating_sub(4));
3075 let dialog_height = 20.min(parent_area.height.saturating_sub(4));
3076
3077 let dialog_x = parent_area.x + (parent_area.width.saturating_sub(dialog_width)) / 2;
3079 let dialog_y = parent_area.y + (parent_area.height.saturating_sub(dialog_height)) / 2;
3080 let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
3081
3082 frame.render_widget(Clear, dialog_area);
3084
3085 let block = Block::default()
3086 .title(" Keyboard Shortcuts ")
3087 .borders(Borders::ALL)
3088 .border_style(Style::default().fg(theme.menu_highlight_fg))
3089 .style(Style::default().bg(theme.popup_bg));
3090 frame.render_widget(block, dialog_area);
3091
3092 let inner = Rect::new(
3094 dialog_area.x + 2,
3095 dialog_area.y + 1,
3096 dialog_area.width.saturating_sub(4),
3097 dialog_area.height.saturating_sub(2),
3098 );
3099
3100 let mut y = inner.y;
3101
3102 for (section_name, bindings) in &help_items {
3103 if y >= inner.y + inner.height.saturating_sub(1) {
3104 break;
3105 }
3106
3107 let header_style = Style::default()
3109 .fg(theme.menu_active_fg)
3110 .add_modifier(Modifier::BOLD);
3111 frame.render_widget(
3112 Paragraph::new(*section_name).style(header_style),
3113 Rect::new(inner.x, y, inner.width, 1),
3114 );
3115 y += 1;
3116
3117 for (key, description) in bindings {
3118 if y >= inner.y + inner.height.saturating_sub(1) {
3119 break;
3120 }
3121
3122 let key_style = Style::default()
3123 .fg(theme.diagnostic_info_fg)
3124 .add_modifier(Modifier::BOLD);
3125 let desc_style = Style::default().fg(theme.popup_text_fg);
3126
3127 let line = Line::from(vec![
3128 Span::styled(format!(" {:12}", key), key_style),
3129 Span::styled(*description, desc_style),
3130 ]);
3131 frame.render_widget(Paragraph::new(line), Rect::new(inner.x, y, inner.width, 1));
3132 y += 1;
3133 }
3134
3135 y += 1; }
3137
3138 let footer_y = dialog_area.y + dialog_area.height - 2;
3140 let footer = "Press ? or Esc or Enter to close";
3141 let footer_style = Style::default().fg(theme.line_number_fg);
3142 let centered_x = inner.x + (inner.width.saturating_sub(footer.len() as u16)) / 2;
3143 frame.render_widget(
3144 Paragraph::new(footer).style(footer_style),
3145 Rect::new(centered_x, footer_y, footer.len() as u16, 1),
3146 );
3147}
3148
3149#[cfg(test)]
3150mod tests {
3151 use super::*;
3152
3153 #[test]
3155 fn test_control_layout_info() {
3156 let toggle = ControlLayoutInfo::Toggle(Rect::new(0, 0, 10, 1));
3157 assert!(matches!(toggle, ControlLayoutInfo::Toggle(_)));
3158
3159 let number = ControlLayoutInfo::Number {
3160 decrement: Rect::new(0, 0, 3, 1),
3161 increment: Rect::new(4, 0, 3, 1),
3162 value: Rect::new(8, 0, 5, 1),
3163 };
3164 assert!(matches!(number, ControlLayoutInfo::Number { .. }));
3165 }
3166}