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 ratatui::layout::{Constraint, Layout, Rect};
20use ratatui::style::{Color, Modifier, Style};
21use ratatui::text::{Line, Span};
22use ratatui::widgets::{Block, Borders, Clear, Paragraph};
23use ratatui::Frame;
24
25#[allow(clippy::too_many_arguments)]
29fn build_selection_spans(
30 display_text: &str,
31 display_len: usize,
32 line_idx: usize,
33 start_row: usize,
34 start_col: usize,
35 end_row: usize,
36 end_col: usize,
37 text_color: Color,
38 selection_bg: Color,
39) -> Vec<Span<'static>> {
40 let chars: Vec<char> = display_text.chars().collect();
41 let char_count = chars.len();
42
43 let (sel_start, sel_end) = if line_idx < start_row || line_idx > end_row {
45 (char_count, char_count)
47 } else if line_idx == start_row && line_idx == end_row {
48 let start = byte_to_char_idx(display_text, start_col).min(char_count);
50 let end = byte_to_char_idx(display_text, end_col).min(char_count);
51 (start, end)
52 } else if line_idx == start_row {
53 let start = byte_to_char_idx(display_text, start_col).min(char_count);
55 (start, char_count)
56 } else if line_idx == end_row {
57 let end = byte_to_char_idx(display_text, end_col).min(char_count);
59 (0, end)
60 } else {
61 (0, char_count)
63 };
64
65 let mut spans = Vec::new();
66 let normal_style = Style::default().fg(text_color);
67 let selected_style = Style::default().fg(text_color).bg(selection_bg);
68
69 if sel_start >= sel_end || sel_start >= char_count {
70 let padded = format!("{:width$}", display_text, width = display_len);
72 spans.push(Span::styled(padded, normal_style));
73 } else {
74 if sel_start > 0 {
76 let before: String = chars[..sel_start].iter().collect();
77 spans.push(Span::styled(before, normal_style));
78 }
79
80 let selected: String = chars[sel_start..sel_end].iter().collect();
82 spans.push(Span::styled(selected, selected_style));
83
84 if sel_end < char_count {
86 let after: String = chars[sel_end..].iter().collect();
87 spans.push(Span::styled(after, normal_style));
88 }
89
90 let current_len = char_count;
92 if current_len < display_len {
93 let padding = " ".repeat(display_len - current_len);
94 spans.push(Span::styled(padding, normal_style));
95 }
96 }
97
98 spans
99}
100
101fn byte_to_char_idx(s: &str, byte_offset: usize) -> usize {
103 s.char_indices()
104 .take_while(|(i, _)| *i < byte_offset)
105 .count()
106}
107
108pub fn render_settings(
110 frame: &mut Frame,
111 area: Rect,
112 state: &mut SettingsState,
113 theme: &Theme,
114) -> SettingsLayout {
115 let modal_width = (area.width * 80 / 100).min(100);
117 let modal_height = area.height * 90 / 100;
118 let modal_x = (area.width.saturating_sub(modal_width)) / 2;
119 let modal_y = (area.height.saturating_sub(modal_height)) / 2;
120
121 let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
122
123 frame.render_widget(Clear, modal_area);
125
126 let title = if state.has_changes() {
127 format!(" Settings [{}] • (modified) ", state.target_layer_name())
128 } else {
129 format!(" Settings [{}] ", state.target_layer_name())
130 };
131
132 let block = Block::default()
133 .title(title.as_str())
134 .borders(Borders::ALL)
135 .border_style(Style::default().fg(theme.popup_border_fg))
136 .style(Style::default().bg(theme.popup_bg));
137 frame.render_widget(block, modal_area);
138
139 let inner_area = Rect::new(
141 modal_area.x + 1,
142 modal_area.y + 1,
143 modal_area.width.saturating_sub(2),
144 modal_area.height.saturating_sub(2),
145 );
146
147 let narrow_mode = inner_area.width < 60;
150
151 let search_area = Rect::new(inner_area.x, inner_area.y, inner_area.width, 1);
153 let search_header_height = if state.search_active {
154 render_search_header(
155 frame,
156 Rect::new(inner_area.x, inner_area.y, inner_area.width, 2),
157 state,
158 theme,
159 );
160 2
161 } else {
162 render_search_hint(frame, search_area, theme);
163 1
164 };
165 let content_area = Rect::new(
166 inner_area.x,
167 inner_area.y + search_header_height,
168 inner_area.width,
169 inner_area.height.saturating_sub(search_header_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_entry = state.showing_entry_dialog();
186 let has_help = state.showing_help;
187
188 if has_confirm {
190 if !has_entry && !has_help {
191 crate::view::dimming::apply_dimming(frame, modal_area);
192 }
193 render_confirm_dialog(frame, modal_area, state, theme);
194 }
195
196 if has_entry {
198 if !has_help {
199 crate::view::dimming::apply_dimming(frame, modal_area);
200 }
201 render_entry_dialog(frame, modal_area, state, theme);
202 }
203
204 if has_help {
206 crate::view::dimming::apply_dimming(frame, modal_area);
207 render_help_overlay(frame, modal_area, theme);
208 }
209
210 layout
211}
212
213fn render_horizontal_layout(
215 frame: &mut Frame,
216 content_area: Rect,
217 modal_area: Rect,
218 state: &mut SettingsState,
219 theme: &Theme,
220 layout: &mut SettingsLayout,
221) {
222 let chunks =
224 Layout::horizontal([Constraint::Length(25), Constraint::Min(40)]).split(content_area);
225
226 let categories_area = chunks[0];
227 let settings_area = chunks[1];
228
229 render_categories(frame, categories_area, state, theme, layout);
231
232 let separator_area = Rect::new(
234 categories_area.x + categories_area.width,
235 categories_area.y,
236 1,
237 categories_area.height,
238 );
239 render_separator_with_selection(
240 frame,
241 separator_area,
242 theme,
243 state.selected_category,
244 state.pages.len(),
245 );
246
247 let horizontal_padding = 2;
249 let settings_inner = Rect::new(
250 settings_area.x + horizontal_padding,
251 settings_area.y,
252 settings_area.width.saturating_sub(horizontal_padding),
253 settings_area.height,
254 );
255
256 if state.search_active && !state.search_results.is_empty() {
257 render_search_results(frame, settings_inner, state, theme, layout);
258 } else {
259 render_settings_panel(frame, settings_inner, state, theme, layout);
260 }
261
262 render_footer(frame, modal_area, state, theme, layout, false);
264}
265
266fn render_vertical_layout(
268 frame: &mut Frame,
269 content_area: Rect,
270 modal_area: Rect,
271 state: &mut SettingsState,
272 theme: &Theme,
273 layout: &mut SettingsLayout,
274) {
275 let footer_height = 7;
277
278 let main_height = content_area.height.saturating_sub(footer_height);
280 let category_height = 3u16.min(main_height);
281 let settings_height = main_height.saturating_sub(category_height + 1); let categories_area = Rect::new(
285 content_area.x,
286 content_area.y,
287 content_area.width,
288 category_height,
289 );
290
291 let sep_y = content_area.y + category_height;
293
294 let settings_area = Rect::new(
296 content_area.x,
297 sep_y + 1,
298 content_area.width,
299 settings_height,
300 );
301
302 render_categories_horizontal(frame, categories_area, state, theme, layout);
304
305 if sep_y < content_area.y + content_area.height {
307 let sep_line: String = "─".repeat(content_area.width as usize);
308 frame.render_widget(
309 Paragraph::new(sep_line).style(Style::default().fg(theme.split_separator_fg)),
310 Rect::new(content_area.x, sep_y, content_area.width, 1),
311 );
312 }
313
314 if state.search_active && !state.search_results.is_empty() {
316 render_search_results(frame, settings_area, state, theme, layout);
317 } else {
318 render_settings_panel(frame, settings_area, state, theme, layout);
319 }
320
321 render_footer(frame, modal_area, state, theme, layout, true);
323}
324
325fn render_categories_horizontal(
327 frame: &mut Frame,
328 area: Rect,
329 state: &SettingsState,
330 theme: &Theme,
331 layout: &mut SettingsLayout,
332) {
333 use super::state::FocusPanel;
334
335 if area.height == 0 || area.width == 0 {
336 return;
337 }
338
339 let is_focused = state.focus_panel == FocusPanel::Categories;
340
341 let mut spans = Vec::new();
343 let mut total_width = 0u16;
344
345 for (i, page) in state.pages.iter().enumerate() {
346 let is_selected = i == state.selected_category;
347 let has_modified = page.items.iter().any(|item| item.modified);
348
349 let indicator = if has_modified { "● " } else { " " };
350 let name = &page.name;
351
352 let style = if is_selected && is_focused {
353 Style::default()
354 .fg(theme.menu_highlight_fg)
355 .bg(theme.menu_highlight_bg)
356 .add_modifier(Modifier::BOLD)
357 } else if is_selected {
358 Style::default()
359 .fg(theme.menu_highlight_fg)
360 .add_modifier(Modifier::BOLD)
361 } else {
362 Style::default().fg(theme.popup_text_fg)
363 };
364
365 let indicator_style = if has_modified {
366 Style::default().fg(theme.menu_highlight_fg)
367 } else {
368 style
369 };
370
371 if i > 0 {
373 spans.push(Span::styled(
374 " │ ",
375 Style::default().fg(theme.split_separator_fg),
376 ));
377 total_width += 3;
378 }
379
380 spans.push(Span::styled(indicator, indicator_style));
381 spans.push(Span::styled(name.as_str(), style));
382 total_width += (indicator.len() + name.len()) as u16;
383
384 let cat_x = area.x + total_width.saturating_sub((indicator.len() + name.len()) as u16);
386 let cat_width = (indicator.len() + name.len()) as u16;
387 layout
388 .categories
389 .push((i, Rect::new(cat_x, area.y, cat_width, 1)));
390 }
391
392 let line = Line::from(spans);
394 frame.render_widget(Paragraph::new(line), area);
395
396 if area.height >= 2 {
398 let hint = "←→: Switch category";
399 let hint_style = Style::default().fg(theme.line_number_fg);
400 frame.render_widget(
401 Paragraph::new(hint).style(hint_style),
402 Rect::new(area.x, area.y + 1, area.width, 1),
403 );
404 }
405}
406
407fn render_categories(
409 frame: &mut Frame,
410 area: Rect,
411 state: &SettingsState,
412 theme: &Theme,
413 layout: &mut SettingsLayout,
414) {
415 use super::layout::SettingsHit;
416 use super::state::FocusPanel;
417
418 for (idx, page) in state.pages.iter().enumerate() {
419 if idx as u16 >= area.height {
420 break;
421 }
422
423 let is_selected = idx == state.selected_category;
424 let is_hovered = matches!(state.hover_hit, Some(SettingsHit::Category(i)) if i == idx);
425 let row_area = Rect::new(area.x, area.y + idx as u16, area.width, 1);
426
427 layout.add_category(idx, row_area);
428
429 let style = if is_selected {
430 if state.focus_panel == FocusPanel::Categories {
431 Style::default()
432 .fg(theme.menu_highlight_fg)
433 .bg(theme.menu_highlight_bg)
434 } else {
435 Style::default().fg(theme.menu_fg).bg(theme.selection_bg)
436 }
437 } else if is_hovered {
438 Style::default()
440 .fg(theme.menu_hover_fg)
441 .bg(theme.menu_hover_bg)
442 } else {
443 Style::default().fg(theme.popup_text_fg)
444 };
445
446 let has_changes = page.items.iter().any(|i| i.modified);
448 let modified_indicator = if has_changes { "●" } else { " " };
449
450 let selection_indicator = if is_selected && state.focus_panel == FocusPanel::Categories {
452 ">"
453 } else {
454 " "
455 };
456
457 let text = format!(
458 "{}{} {}",
459 selection_indicator, modified_indicator, page.name
460 );
461 let line = Line::from(Span::styled(text, style));
462 frame.render_widget(Paragraph::new(line), row_area);
463 }
464}
465
466fn render_separator_with_selection(
468 frame: &mut Frame,
469 area: Rect,
470 theme: &Theme,
471 selected_category: usize,
472 category_count: usize,
473) {
474 let sep_style = Style::default().fg(theme.split_separator_fg);
475 let highlight_style = Style::default().fg(theme.menu_highlight_fg);
476
477 for y in 0..area.height {
478 let cell = Rect::new(area.x, area.y + y, 1, 1);
479 let row_idx = y as usize;
480
481 let (char, style) = if row_idx == selected_category && row_idx < category_count {
483 ("┤", highlight_style)
485 } else {
486 ("│", sep_style)
487 };
488
489 let sep = Paragraph::new(char).style(style);
490 frame.render_widget(sep, cell);
491 }
492}
493
494struct RenderContext {
496 selected_item: usize,
497 settings_focused: bool,
498 hover_hit: Option<SettingsHit>,
499}
500
501fn render_settings_panel(
503 frame: &mut Frame,
504 area: Rect,
505 state: &mut SettingsState,
506 theme: &Theme,
507 layout: &mut SettingsLayout,
508) {
509 let page = match state.current_page() {
510 Some(p) => p,
511 None => return,
512 };
513
514 let mut y = area.y;
516 let header_start_y = y;
517
518 let title_style = Style::default()
520 .fg(theme.menu_active_fg)
521 .add_modifier(Modifier::BOLD);
522 let title = Line::from(Span::styled(&page.name, title_style));
523 frame.render_widget(Paragraph::new(title), Rect::new(area.x, y, area.width, 1));
524 y += 1;
525
526 if let Some(ref desc) = page.description {
528 let desc_style = Style::default().fg(theme.line_number_fg);
529 let desc_line = Line::from(Span::styled(desc, desc_style));
530 frame.render_widget(
531 Paragraph::new(desc_line),
532 Rect::new(area.x, y, area.width, 1),
533 );
534 y += 1;
535 }
536
537 y += 1; let header_height = (y - header_start_y) as usize;
540 let items_start_y = y;
541
542 let available_height = area.height.saturating_sub(header_height as u16 + 1);
544
545 let page = state.pages.get(state.selected_category).unwrap();
547 state.scroll_panel.set_viewport(available_height);
548 state.scroll_panel.update_content_height(&page.items);
549
550 use super::state::FocusPanel;
552 let render_ctx = RenderContext {
553 selected_item: state.selected_item,
554 settings_focused: state.focus_panel == FocusPanel::Settings,
555 hover_hit: state.hover_hit,
556 };
557
558 let items_area = Rect::new(area.x, items_start_y, area.width, available_height.max(1));
560
561 let page = state.pages.get(state.selected_category).unwrap();
563
564 let max_label_width = page
566 .items
567 .iter()
568 .filter_map(|item| {
569 match &item.control {
571 SettingControl::Toggle(s) => Some(s.label.len() as u16),
572 SettingControl::Number(s) => Some(s.label.len() as u16),
573 SettingControl::Dropdown(s) => Some(s.label.len() as u16),
574 SettingControl::Text(s) => Some(s.label.len() as u16),
575 _ => None,
577 }
578 })
579 .max();
580
581 let panel_layout = state.scroll_panel.render(
583 frame,
584 items_area,
585 &page.items,
586 |frame, info, item| {
587 render_setting_item_pure(
588 frame,
589 info.area,
590 item,
591 info.index,
592 info.skip_top,
593 &render_ctx,
594 theme,
595 max_label_width,
596 )
597 },
598 theme,
599 );
600
601 let page = state.pages.get(state.selected_category).unwrap();
603 for item_info in panel_layout.item_layouts {
604 layout.add_item(
605 item_info.index,
606 page.items[item_info.index].path.clone(),
607 item_info.area,
608 item_info.layout,
609 );
610 }
611
612 layout.settings_panel_area = Some(panel_layout.content_area);
614
615 if let Some(sb_area) = panel_layout.scrollbar_area {
617 layout.scrollbar_area = Some(sb_area);
618 }
619}
620
621fn wrap_text(text: &str, width: usize) -> Vec<String> {
623 if width == 0 || text.is_empty() {
624 return vec![text.to_string()];
625 }
626
627 let mut lines = Vec::new();
628 let mut current_line = String::new();
629 let mut current_len = 0;
630
631 for word in text.split_whitespace() {
632 let word_len = word.chars().count();
633
634 if current_len == 0 {
635 current_line = word.to_string();
637 current_len = word_len;
638 } else if current_len + 1 + word_len <= width {
639 current_line.push(' ');
641 current_line.push_str(word);
642 current_len += 1 + word_len;
643 } else {
644 lines.push(current_line);
646 current_line = word.to_string();
647 current_len = word_len;
648 }
649 }
650
651 if !current_line.is_empty() {
652 lines.push(current_line);
653 }
654
655 if lines.is_empty() {
656 lines.push(String::new());
657 }
658
659 lines
660}
661
662#[allow(clippy::too_many_arguments)]
668fn render_setting_item_pure(
669 frame: &mut Frame,
670 area: Rect,
671 item: &super::items::SettingItem,
672 idx: usize,
673 skip_top: u16,
674 ctx: &RenderContext,
675 theme: &Theme,
676 label_width: Option<u16>,
677) -> ControlLayoutInfo {
678 let is_selected = ctx.settings_focused && idx == ctx.selected_item;
679
680 let is_item_hovered = match ctx.hover_hit {
682 Some(SettingsHit::Item(i)) => i == idx,
683 Some(SettingsHit::ControlToggle(i)) => i == idx,
684 Some(SettingsHit::ControlDecrement(i)) => i == idx,
685 Some(SettingsHit::ControlIncrement(i)) => i == idx,
686 Some(SettingsHit::ControlDropdown(i)) => i == idx,
687 Some(SettingsHit::ControlText(i)) => i == idx,
688 Some(SettingsHit::ControlTextListRow(i, _)) => i == idx,
689 Some(SettingsHit::ControlMapRow(i, _)) => i == idx,
690 _ => false,
691 };
692
693 let is_focused_or_hovered = is_selected || is_item_hovered;
694
695 let focus_indicator_width: u16 = 3;
698
699 let content_height = if is_focused_or_hovered {
701 item.content_height_expanded(area.width.saturating_sub(focus_indicator_width))
702 } else {
703 item.content_height()
704 };
705 let visible_content_height = content_height.saturating_sub(skip_top);
707
708 if is_focused_or_hovered {
710 let bg_style = if is_selected {
711 Style::default().bg(theme.current_line_bg)
712 } else {
713 Style::default().bg(theme.menu_hover_bg)
714 };
715 for row in 0..visible_content_height.min(area.height) {
716 let row_area = Rect::new(area.x, area.y + row, area.width, 1);
717 frame.render_widget(Paragraph::new("").style(bg_style), row_area);
718 }
719 }
720
721 if is_selected && skip_top == 0 {
723 let indicator_style = Style::default()
724 .fg(theme.menu_highlight_fg)
725 .add_modifier(Modifier::BOLD);
726 frame.render_widget(
727 Paragraph::new(">").style(indicator_style),
728 Rect::new(area.x, area.y, 1, 1),
729 );
730 }
731
732 if item.modified && skip_top == 0 {
734 let modified_style = Style::default().fg(theme.menu_highlight_fg);
735 frame.render_widget(
736 Paragraph::new("●").style(modified_style),
737 Rect::new(area.x + 1, area.y, 1, 1),
738 );
739 }
740
741 let control_height = item.control.control_height();
743 let visible_control_height = control_height.saturating_sub(skip_top);
744 let control_area = Rect::new(
745 area.x + focus_indicator_width,
746 area.y,
747 area.width.saturating_sub(focus_indicator_width),
748 visible_control_height.min(area.height),
749 );
750
751 let layout = render_control(
753 frame,
754 control_area,
755 &item.control,
756 &item.name,
757 skip_top,
758 theme,
759 label_width.map(|w| w.saturating_sub(focus_indicator_width)),
760 item.read_only,
761 );
762
763 let desc_start_row = control_height.saturating_sub(skip_top);
766
767 let layer_label = match item.layer_source {
770 crate::config_io::ConfigLayer::System => None, crate::config_io::ConfigLayer::User => Some("user"),
772 crate::config_io::ConfigLayer::Project => Some("project"),
773 crate::config_io::ConfigLayer::Session => Some("session"),
774 };
775
776 if let Some(ref description) = item.description {
777 if desc_start_row < area.height {
778 let desc_x = area.x + focus_indicator_width;
779 let desc_y = area.y + desc_start_row;
780 let desc_width = area.width.saturating_sub(focus_indicator_width);
781 let desc_style = Style::default().fg(theme.line_number_fg);
782 let max_width = desc_width.saturating_sub(2) as usize;
783
784 if is_focused_or_hovered && description.len() > max_width {
785 let wrapped_lines = wrap_text(description, max_width);
787 let available_rows = area.height.saturating_sub(desc_start_row) as usize;
788
789 for (i, line) in wrapped_lines.iter().take(available_rows).enumerate() {
790 frame.render_widget(
791 Paragraph::new(line.as_str()).style(desc_style),
792 Rect::new(desc_x, desc_y + i as u16, desc_width, 1),
793 );
794 }
795 } else {
796 let mut display_desc = if description.len() > max_width.saturating_sub(12) {
798 format!(
799 "{}...",
800 &description[..max_width.saturating_sub(15).max(10)]
801 )
802 } else {
803 description.clone()
804 };
805 if let Some(layer) = layer_label {
807 display_desc.push_str(&format!(" ({})", layer));
808 }
809 frame.render_widget(
810 Paragraph::new(display_desc).style(desc_style),
811 Rect::new(desc_x, desc_y, desc_width, 1),
812 );
813 }
814 }
815 } else if let Some(layer) = layer_label {
816 if desc_start_row < area.height && is_focused_or_hovered {
818 let desc_x = area.x + focus_indicator_width;
819 let desc_y = area.y + desc_start_row;
820 let desc_width = area.width.saturating_sub(focus_indicator_width);
821 let layer_style = Style::default().fg(theme.line_number_fg);
822 frame.render_widget(
823 Paragraph::new(format!("({})", layer)).style(layer_style),
824 Rect::new(desc_x, desc_y, desc_width, 1),
825 );
826 }
827 }
828
829 layout
830}
831
832#[allow(clippy::too_many_arguments)]
840fn render_control(
841 frame: &mut Frame,
842 area: Rect,
843 control: &SettingControl,
844 name: &str,
845 skip_rows: u16,
846 theme: &Theme,
847 label_width: Option<u16>,
848 read_only: bool,
849) -> ControlLayoutInfo {
850 match control {
851 SettingControl::Toggle(state) => {
853 if skip_rows > 0 {
854 return ControlLayoutInfo::Toggle(Rect::default());
855 }
856 let colors = ToggleColors::from_theme(theme);
857 let toggle_layout = render_toggle_aligned(frame, area, state, &colors, label_width);
858 ControlLayoutInfo::Toggle(toggle_layout.full_area)
859 }
860
861 SettingControl::Number(state) => {
862 if skip_rows > 0 {
863 return ControlLayoutInfo::Number {
864 decrement: Rect::default(),
865 increment: Rect::default(),
866 value: Rect::default(),
867 };
868 }
869 let colors = NumberInputColors::from_theme(theme);
870 let num_layout = render_number_input_aligned(frame, area, state, &colors, label_width);
871 ControlLayoutInfo::Number {
872 decrement: num_layout.decrement_area,
873 increment: num_layout.increment_area,
874 value: num_layout.value_area,
875 }
876 }
877
878 SettingControl::Dropdown(state) => {
879 if skip_rows > 0 {
880 return ControlLayoutInfo::Dropdown(Rect::default());
881 }
882 let colors = DropdownColors::from_theme(theme);
883 let drop_layout = render_dropdown_aligned(frame, area, state, &colors, label_width);
884 ControlLayoutInfo::Dropdown(drop_layout.button_area)
885 }
886
887 SettingControl::Text(state) => {
888 if skip_rows > 0 {
889 return ControlLayoutInfo::Text(Rect::default());
890 }
891 if read_only {
892 let label_w = label_width.unwrap_or(20);
894 let label_style = Style::default().fg(theme.editor_fg);
895 let value_style = Style::default().fg(theme.line_number_fg);
896 let label = format!("{}: ", state.label);
897 let value = &state.value;
898
899 let label_area = Rect::new(area.x, area.y, label_w, 1);
900 let value_area = Rect::new(
901 area.x + label_w,
902 area.y,
903 area.width.saturating_sub(label_w),
904 1,
905 );
906
907 frame.render_widget(Paragraph::new(label.clone()).style(label_style), label_area);
908 frame.render_widget(
909 Paragraph::new(value.as_str()).style(value_style),
910 value_area,
911 );
912 ControlLayoutInfo::Text(Rect::default()) } else {
914 let colors = TextInputColors::from_theme(theme);
915 let text_layout =
916 render_text_input_aligned(frame, area, state, &colors, 30, label_width);
917 ControlLayoutInfo::Text(text_layout.input_area)
918 }
919 }
920
921 SettingControl::TextList(state) => {
923 let colors = TextListColors::from_theme(theme);
924 let list_layout = render_text_list_partial(frame, area, state, &colors, 30, skip_rows);
925 ControlLayoutInfo::TextList {
926 rows: list_layout.rows.iter().map(|r| r.text_area).collect(),
927 }
928 }
929
930 SettingControl::Map(state) => {
931 let colors = MapColors::from_theme(theme);
932 let map_layout = render_map_partial(frame, area, state, &colors, 20, skip_rows);
933 ControlLayoutInfo::Map {
934 entry_rows: map_layout.entry_areas.iter().map(|e| e.row_area).collect(),
935 add_row_area: map_layout.add_row_area,
936 }
937 }
938
939 SettingControl::ObjectArray(state) => {
940 let colors = crate::view::controls::KeybindingListColors {
941 label_fg: theme.editor_fg,
942 key_fg: theme.help_key_fg,
943 action_fg: theme.syntax_function,
944 focused_bg: theme.selection_bg,
945 delete_fg: theme.diagnostic_error_fg,
946 add_fg: theme.syntax_string,
947 };
948 let kb_layout = render_keybinding_list_partial(frame, area, state, &colors, skip_rows);
949 ControlLayoutInfo::ObjectArray {
950 entry_rows: kb_layout.entry_rects,
951 }
952 }
953
954 SettingControl::Json(state) => {
955 render_json_control(frame, area, state, name, skip_rows, theme)
956 }
957
958 SettingControl::Complex { type_name } => {
959 if skip_rows > 0 {
960 return ControlLayoutInfo::Complex;
961 }
962 let label_style = Style::default().fg(theme.editor_fg);
964 let value_style = Style::default().fg(theme.line_number_fg);
965
966 let label = Span::styled(format!("{}: ", name), label_style);
967 let value = Span::styled(
968 format!("<{} - edit in config.toml>", type_name),
969 value_style,
970 );
971
972 frame.render_widget(Paragraph::new(Line::from(vec![label, value])), area);
973 ControlLayoutInfo::Complex
974 }
975 }
976}
977
978fn render_json_control(
980 frame: &mut Frame,
981 area: Rect,
982 state: &super::items::JsonEditState,
983 name: &str,
984 skip_rows: u16,
985 theme: &Theme,
986) -> ControlLayoutInfo {
987 use crate::view::controls::FocusState;
988
989 let empty_layout = ControlLayoutInfo::Json {
990 edit_area: Rect::default(),
991 };
992
993 if area.height == 0 || area.width < 10 {
994 return empty_layout;
995 }
996
997 let is_focused = state.focus == FocusState::Focused;
998 let is_valid = state.is_valid();
999
1000 let label_color = if is_focused {
1001 theme.menu_highlight_fg
1002 } else {
1003 theme.editor_fg
1004 };
1005
1006 let text_color = theme.editor_fg;
1007 let border_color = if !is_valid {
1008 theme.diagnostic_error_fg
1009 } else if is_focused {
1010 theme.menu_highlight_fg
1011 } else {
1012 theme.split_separator_fg
1013 };
1014
1015 let mut y = area.y;
1016 let mut content_row = 0u16;
1017
1018 if content_row >= skip_rows {
1020 let label_line = Line::from(vec![Span::styled(
1021 format!("{}:", name),
1022 Style::default().fg(label_color),
1023 )]);
1024 frame.render_widget(
1025 Paragraph::new(label_line),
1026 Rect::new(area.x, y, area.width, 1),
1027 );
1028 y += 1;
1029 }
1030 content_row += 1;
1031
1032 let indent = 2u16;
1033 let edit_width = area.width.saturating_sub(indent + 1);
1034 let edit_x = area.x + indent;
1035 let edit_start_y = y;
1036
1037 let lines = state.lines();
1039 let total_lines = lines.len();
1040 for line_idx in 0..total_lines {
1041 let actual_line_idx = line_idx;
1042
1043 if content_row < skip_rows {
1044 content_row += 1;
1045 continue;
1046 }
1047
1048 if y >= area.y + area.height {
1049 break;
1050 }
1051
1052 let line_content = lines.get(actual_line_idx).map(|s| s.as_str()).unwrap_or("");
1053
1054 let display_len = edit_width.saturating_sub(2) as usize;
1056 let display_text: String = line_content.chars().take(display_len).collect();
1057
1058 let selection = state.selection_range();
1060 let (cursor_row, cursor_col) = state.cursor_pos();
1061
1062 let content_spans = if is_focused {
1064 if let Some(((start_row, start_col), (end_row, end_col))) = selection {
1065 build_selection_spans(
1066 &display_text,
1067 display_len,
1068 actual_line_idx,
1069 start_row,
1070 start_col,
1071 end_row,
1072 end_col,
1073 text_color,
1074 theme.selection_bg,
1075 )
1076 } else {
1077 vec![Span::styled(
1078 format!("{:width$}", display_text, width = display_len),
1079 Style::default().fg(text_color),
1080 )]
1081 }
1082 } else {
1083 vec![Span::styled(
1084 format!("{:width$}", display_text, width = display_len),
1085 Style::default().fg(text_color),
1086 )]
1087 };
1088
1089 let mut spans = vec![
1091 Span::raw(" ".repeat(indent as usize)),
1092 Span::styled("│", Style::default().fg(border_color)),
1093 ];
1094 spans.extend(content_spans);
1095 spans.push(Span::styled("│", Style::default().fg(border_color)));
1096 let line = Line::from(spans);
1097
1098 frame.render_widget(Paragraph::new(line), Rect::new(area.x, y, area.width, 1));
1099
1100 if is_focused && actual_line_idx == cursor_row {
1102 let cursor_x = edit_x + 1 + cursor_col.min(display_len) as u16;
1103 if cursor_x < area.x + area.width - 1 {
1104 let cursor_char = line_content.chars().nth(cursor_col).unwrap_or(' ');
1105 let cursor_span = Span::styled(
1106 cursor_char.to_string(),
1107 Style::default()
1108 .fg(theme.cursor)
1109 .add_modifier(Modifier::REVERSED),
1110 );
1111 frame.render_widget(
1112 Paragraph::new(Line::from(vec![cursor_span])),
1113 Rect::new(cursor_x, y, 1, 1),
1114 );
1115 }
1116 }
1117
1118 y += 1;
1119 content_row += 1;
1120 }
1121
1122 if !is_valid && y < area.y + area.height {
1124 let warning = Span::styled(
1125 " ⚠ Invalid JSON",
1126 Style::default().fg(theme.diagnostic_warning_fg),
1127 );
1128 frame.render_widget(
1129 Paragraph::new(Line::from(vec![warning])),
1130 Rect::new(area.x, y, area.width, 1),
1131 );
1132 }
1133
1134 let edit_height = y.saturating_sub(edit_start_y);
1135 ControlLayoutInfo::Json {
1136 edit_area: Rect::new(edit_x, edit_start_y, edit_width, edit_height),
1137 }
1138}
1139
1140fn render_text_list_partial(
1142 frame: &mut Frame,
1143 area: Rect,
1144 state: &crate::view::controls::TextListState,
1145 colors: &TextListColors,
1146 field_width: u16,
1147 skip_rows: u16,
1148) -> crate::view::controls::TextListLayout {
1149 use crate::view::controls::text_list::{TextListLayout, TextListRowLayout};
1150 use crate::view::controls::FocusState;
1151
1152 let empty_layout = TextListLayout {
1153 rows: Vec::new(),
1154 full_area: area,
1155 };
1156
1157 if area.height == 0 || area.width < 10 {
1158 return empty_layout;
1159 }
1160
1161 let label_color = match state.focus {
1162 FocusState::Focused => colors.focused,
1163 FocusState::Hovered => colors.focused,
1164 FocusState::Disabled => colors.disabled,
1165 FocusState::Normal => colors.label,
1166 };
1167
1168 let mut rows = Vec::new();
1169 let mut y = area.y;
1170 let mut content_row = 0u16; if skip_rows == 0 {
1174 let label_line = Line::from(vec![
1175 Span::styled(&state.label, Style::default().fg(label_color)),
1176 Span::raw(":"),
1177 ]);
1178 frame.render_widget(
1179 Paragraph::new(label_line),
1180 Rect::new(area.x, y, area.width, 1),
1181 );
1182 y += 1;
1183 }
1184 content_row += 1;
1185
1186 let indent = 2u16;
1187 let actual_field_width = field_width.min(area.width.saturating_sub(indent + 5));
1188
1189 for (idx, item) in state.items.iter().enumerate() {
1191 if y >= area.y + area.height {
1192 break;
1193 }
1194
1195 if content_row < skip_rows {
1197 content_row += 1;
1198 continue;
1199 }
1200
1201 let is_focused = state.focused_item == Some(idx) && state.focus == FocusState::Focused;
1202 let (border_color, text_color) = if is_focused {
1203 (colors.focused, colors.text)
1204 } else if state.focus == FocusState::Disabled {
1205 (colors.disabled, colors.disabled)
1206 } else {
1207 (colors.border, colors.text)
1208 };
1209
1210 let inner_width = actual_field_width.saturating_sub(2) as usize;
1211 let visible: String = item.chars().take(inner_width).collect();
1212 let padded = format!("{:width$}", visible, width = inner_width);
1213
1214 let line = Line::from(vec![
1215 Span::raw(" ".repeat(indent as usize)),
1216 Span::styled("[", Style::default().fg(border_color)),
1217 Span::styled(padded, Style::default().fg(text_color)),
1218 Span::styled("]", Style::default().fg(border_color)),
1219 Span::raw(" "),
1220 Span::styled("[x]", Style::default().fg(colors.remove_button)),
1221 ]);
1222
1223 let row_area = Rect::new(area.x, y, area.width, 1);
1224 frame.render_widget(Paragraph::new(line), row_area);
1225
1226 let text_area = Rect::new(area.x + indent, y, actual_field_width, 1);
1227 let button_area = Rect::new(area.x + indent + actual_field_width + 1, y, 3, 1);
1228 rows.push(TextListRowLayout {
1229 text_area,
1230 button_area,
1231 index: Some(idx),
1232 });
1233
1234 y += 1;
1235 content_row += 1;
1236 }
1237
1238 if y < area.y + area.height && content_row >= skip_rows {
1240 let is_add_focused = state.focused_item.is_none() && state.focus == FocusState::Focused;
1242
1243 if is_add_focused {
1244 let inner_width = actual_field_width.saturating_sub(2) as usize;
1246 let visible: String = state.new_item_text.chars().take(inner_width).collect();
1247 let padded = format!("{:width$}", visible, width = inner_width);
1248
1249 let line = Line::from(vec![
1250 Span::raw(" ".repeat(indent as usize)),
1251 Span::styled("[", Style::default().fg(colors.focused)),
1252 Span::styled(padded, Style::default().fg(colors.text)),
1253 Span::styled("]", Style::default().fg(colors.focused)),
1254 Span::raw(" "),
1255 Span::styled("[+]", Style::default().fg(colors.add_button)),
1256 ]);
1257 let row_area = Rect::new(area.x, y, area.width, 1);
1258 frame.render_widget(Paragraph::new(line), row_area);
1259
1260 if state.cursor <= inner_width {
1262 let cursor_x = area.x + indent + 1 + state.cursor as u16;
1263 let cursor_char = state.new_item_text.chars().nth(state.cursor).unwrap_or(' ');
1264 let cursor_area = Rect::new(cursor_x, y, 1, 1);
1265 let cursor_span = Span::styled(
1266 cursor_char.to_string(),
1267 Style::default()
1268 .fg(colors.focused)
1269 .add_modifier(ratatui::style::Modifier::REVERSED),
1270 );
1271 frame.render_widget(Paragraph::new(Line::from(vec![cursor_span])), cursor_area);
1272 }
1273
1274 rows.push(TextListRowLayout {
1275 text_area: Rect::new(area.x + indent, y, actual_field_width, 1),
1276 button_area: Rect::new(area.x + indent + actual_field_width + 1, y, 3, 1),
1277 index: None,
1278 });
1279 } else {
1280 let add_line = Line::from(vec![
1282 Span::raw(" ".repeat(indent as usize)),
1283 Span::styled("[+] Add new", Style::default().fg(colors.add_button)),
1284 ]);
1285 let row_area = Rect::new(area.x, y, area.width, 1);
1286 frame.render_widget(Paragraph::new(add_line), row_area);
1287
1288 rows.push(TextListRowLayout {
1289 text_area: Rect::new(area.x + indent, y, 11, 1), button_area: Rect::new(area.x + indent, y, 11, 1),
1291 index: None,
1292 });
1293 }
1294 }
1295
1296 TextListLayout {
1297 rows,
1298 full_area: area,
1299 }
1300}
1301
1302fn render_map_partial(
1304 frame: &mut Frame,
1305 area: Rect,
1306 state: &crate::view::controls::MapState,
1307 colors: &MapColors,
1308 key_width: u16,
1309 skip_rows: u16,
1310) -> crate::view::controls::MapLayout {
1311 use crate::view::controls::map_input::{MapEntryLayout, MapLayout};
1312 use crate::view::controls::FocusState;
1313
1314 let empty_layout = MapLayout {
1315 entry_areas: Vec::new(),
1316 add_row_area: None,
1317 full_area: area,
1318 };
1319
1320 if area.height == 0 || area.width < 15 {
1321 return empty_layout;
1322 }
1323
1324 let label_color = match state.focus {
1325 FocusState::Focused => colors.focused,
1326 FocusState::Hovered => colors.focused,
1327 FocusState::Disabled => colors.disabled,
1328 FocusState::Normal => colors.label,
1329 };
1330
1331 let mut entry_areas = Vec::new();
1332 let mut y = area.y;
1333 let mut content_row = 0u16;
1334
1335 if skip_rows == 0 {
1337 let label_line = Line::from(vec![
1338 Span::styled(&state.label, Style::default().fg(label_color)),
1339 Span::raw(":"),
1340 ]);
1341 frame.render_widget(
1342 Paragraph::new(label_line),
1343 Rect::new(area.x, y, area.width, 1),
1344 );
1345 y += 1;
1346 }
1347 content_row += 1;
1348
1349 let indent = 2u16;
1350
1351 if state.display_field.is_some() && y < area.y + area.height {
1353 if content_row >= skip_rows {
1354 let value_header = state
1356 .display_field
1357 .as_ref()
1358 .map(|f| {
1359 let name = f.trim_start_matches('/');
1360 let mut chars = name.chars();
1362 match chars.next() {
1363 None => String::new(),
1364 Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
1365 }
1366 })
1367 .unwrap_or_else(|| "Value".to_string());
1368
1369 let header_style = Style::default()
1370 .fg(colors.label)
1371 .add_modifier(Modifier::DIM);
1372 let header_line = Line::from(vec![
1373 Span::styled(" ".repeat(indent as usize), header_style),
1374 Span::styled(
1375 format!("{:width$}", "Name", width = key_width as usize),
1376 header_style,
1377 ),
1378 Span::raw(" "),
1379 Span::styled(value_header, header_style),
1380 ]);
1381 frame.render_widget(
1382 Paragraph::new(header_line),
1383 Rect::new(area.x, y, area.width, 1),
1384 );
1385 y += 1;
1386 }
1387 content_row += 1;
1388 }
1389
1390 for (idx, (key, value)) in state.entries.iter().enumerate() {
1392 if y >= area.y + area.height {
1393 break;
1394 }
1395
1396 if content_row < skip_rows {
1397 content_row += 1;
1398 continue;
1399 }
1400
1401 let is_focused = state.focused_entry == Some(idx) && state.focus == FocusState::Focused;
1402
1403 let row_area = Rect::new(area.x, y, area.width, 1);
1404
1405 if is_focused {
1407 let highlight_style = Style::default().bg(colors.focused);
1408 let bg_line = Line::from(Span::styled(
1409 " ".repeat(area.width as usize),
1410 highlight_style,
1411 ));
1412 frame.render_widget(Paragraph::new(bg_line), row_area);
1413 }
1414
1415 let (key_color, value_color) = if is_focused {
1416 (colors.label, colors.value_preview)
1417 } else if state.focus == FocusState::Disabled {
1418 (colors.disabled, colors.disabled)
1419 } else {
1420 (colors.key, colors.value_preview)
1421 };
1422
1423 let base_style = if is_focused {
1424 Style::default().bg(colors.focused)
1425 } else {
1426 Style::default()
1427 };
1428
1429 let value_preview = state.get_display_value(value);
1431 let max_preview_len = 20;
1432 let value_preview = if value_preview.len() > max_preview_len {
1433 format!("{}...", &value_preview[..max_preview_len - 3])
1434 } else {
1435 value_preview
1436 };
1437
1438 let display_key: String = key.chars().take(key_width as usize).collect();
1439 let mut spans = vec![
1440 Span::styled(" ".repeat(indent as usize), base_style),
1441 Span::styled(
1442 format!("{:width$}", display_key, width = key_width as usize),
1443 base_style.fg(key_color),
1444 ),
1445 Span::raw(" "),
1446 Span::styled(value_preview, base_style.fg(value_color)),
1447 ];
1448
1449 if is_focused {
1451 spans.push(Span::styled(
1452 " [Enter to edit]",
1453 base_style
1454 .fg(colors.value_preview)
1455 .add_modifier(Modifier::DIM),
1456 ));
1457 }
1458
1459 frame.render_widget(Paragraph::new(Line::from(spans)), row_area);
1460
1461 entry_areas.push(MapEntryLayout {
1462 index: idx,
1463 row_area,
1464 expand_area: Rect::default(), key_area: Rect::new(area.x + indent, y, key_width, 1),
1466 remove_area: Rect::new(area.x + indent + key_width + 1, y, 3, 1),
1467 });
1468
1469 y += 1;
1470 content_row += 1;
1471 }
1472
1473 let add_row_area = if !state.no_add && y < area.y + area.height && content_row >= skip_rows {
1475 let row_area = Rect::new(area.x, y, area.width, 1);
1476 let is_focused = state.focused_entry.is_none() && state.focus == FocusState::Focused;
1477
1478 if is_focused {
1480 let highlight_style = Style::default().bg(colors.focused);
1481 let bg_line = Line::from(Span::styled(
1482 " ".repeat(area.width as usize),
1483 highlight_style,
1484 ));
1485 frame.render_widget(Paragraph::new(bg_line), row_area);
1486 }
1487
1488 let base_style = if is_focused {
1489 Style::default().bg(colors.focused)
1490 } else {
1491 Style::default()
1492 };
1493
1494 let mut spans = vec![
1495 Span::styled(" ".repeat(indent as usize), base_style),
1496 Span::styled("[+] Add new", base_style.fg(colors.add_button)),
1497 ];
1498
1499 if is_focused {
1500 spans.push(Span::styled(
1501 " [Enter to add]",
1502 base_style
1503 .fg(colors.value_preview)
1504 .add_modifier(Modifier::DIM),
1505 ));
1506 }
1507
1508 frame.render_widget(Paragraph::new(Line::from(spans)), row_area);
1509 Some(row_area)
1510 } else {
1511 None
1512 };
1513
1514 MapLayout {
1515 entry_areas,
1516 add_row_area,
1517 full_area: area,
1518 }
1519}
1520
1521fn render_keybinding_list_partial(
1523 frame: &mut Frame,
1524 area: Rect,
1525 state: &crate::view::controls::KeybindingListState,
1526 colors: &crate::view::controls::KeybindingListColors,
1527 skip_rows: u16,
1528) -> crate::view::controls::KeybindingListLayout {
1529 use crate::view::controls::keybinding_list::format_key_combo;
1530 use crate::view::controls::FocusState;
1531 use ratatui::text::{Line, Span};
1532 use ratatui::widgets::Paragraph;
1533
1534 let empty_layout = crate::view::controls::KeybindingListLayout {
1535 entry_rects: Vec::new(),
1536 delete_rects: Vec::new(),
1537 add_rect: None,
1538 };
1539
1540 if area.height == 0 {
1541 return empty_layout;
1542 }
1543
1544 let indent = 2u16;
1545 let is_focused = state.focus == FocusState::Focused;
1546 let mut entry_rects = Vec::new();
1547 let mut delete_rects = Vec::new();
1548 let mut content_row = 0u16;
1549 let mut y = area.y;
1550
1551 if content_row >= skip_rows {
1553 let label_line = Line::from(vec![Span::styled(
1554 format!("{}:", state.label),
1555 Style::default().fg(colors.label_fg),
1556 )]);
1557 frame.render_widget(
1558 Paragraph::new(label_line),
1559 Rect::new(area.x, y, area.width, 1),
1560 );
1561 y += 1;
1562 }
1563 content_row += 1;
1564
1565 for (idx, binding) in state.bindings.iter().enumerate() {
1567 if y >= area.y + area.height {
1568 break;
1569 }
1570
1571 if content_row >= skip_rows {
1572 let entry_area = Rect::new(area.x + indent, y, area.width.saturating_sub(indent), 1);
1573 entry_rects.push(entry_area);
1574
1575 let is_entry_focused = is_focused && state.focused_index == Some(idx);
1576 let bg = if is_entry_focused {
1577 colors.focused_bg
1578 } else {
1579 Color::Reset
1580 };
1581
1582 let key_combo = format_key_combo(binding);
1583 let field_name = state
1585 .display_field
1586 .as_ref()
1587 .and_then(|p| p.strip_prefix('/'))
1588 .unwrap_or("action");
1589 let action = binding
1590 .get(field_name)
1591 .and_then(|a| a.as_str())
1592 .unwrap_or("(no action)");
1593
1594 let indicator = if is_entry_focused { "> " } else { " " };
1595 let line = Line::from(vec![
1596 Span::styled(indicator, Style::default().fg(colors.label_fg).bg(bg)),
1597 Span::styled(
1598 format!("{:<20}", key_combo),
1599 Style::default().fg(colors.key_fg).bg(bg),
1600 ),
1601 Span::styled(" → ", Style::default().fg(colors.label_fg).bg(bg)),
1602 Span::styled(action, Style::default().fg(colors.action_fg).bg(bg)),
1603 Span::styled(" [x]", Style::default().fg(colors.delete_fg).bg(bg)),
1604 ]);
1605 frame.render_widget(Paragraph::new(line), entry_area);
1606
1607 let delete_x = entry_area.x + entry_area.width.saturating_sub(4);
1609 delete_rects.push(Rect::new(delete_x, y, 3, 1));
1610
1611 y += 1;
1612 }
1613 content_row += 1;
1614 }
1615
1616 let add_rect = if y < area.y + area.height && content_row >= skip_rows {
1618 let is_add_focused = is_focused && state.focused_index.is_none();
1619 let bg = if is_add_focused {
1620 colors.focused_bg
1621 } else {
1622 Color::Reset
1623 };
1624
1625 let indicator = if is_add_focused { "> " } else { " " };
1626 let line = Line::from(vec![
1627 Span::styled(indicator, Style::default().fg(colors.label_fg).bg(bg)),
1628 Span::styled("[+] Add new", Style::default().fg(colors.add_fg).bg(bg)),
1629 ]);
1630 let add_area = Rect::new(area.x + indent, y, area.width.saturating_sub(indent), 1);
1631 frame.render_widget(Paragraph::new(line), add_area);
1632 Some(add_area)
1633 } else {
1634 None
1635 };
1636
1637 crate::view::controls::KeybindingListLayout {
1638 entry_rects,
1639 delete_rects,
1640 add_rect,
1641 }
1642}
1643
1644#[derive(Debug, Clone)]
1646pub enum ControlLayoutInfo {
1647 Toggle(Rect),
1648 Number {
1649 decrement: Rect,
1650 increment: Rect,
1651 value: Rect,
1652 },
1653 Dropdown(Rect),
1654 Text(Rect),
1655 TextList {
1656 rows: Vec<Rect>,
1657 },
1658 Map {
1659 entry_rows: Vec<Rect>,
1660 add_row_area: Option<Rect>,
1661 },
1662 ObjectArray {
1663 entry_rows: Vec<Rect>,
1664 },
1665 Json {
1666 edit_area: Rect,
1667 },
1668 Complex,
1669}
1670
1671#[allow(clippy::too_many_arguments)]
1673fn render_button(
1674 frame: &mut Frame,
1675 area: Rect,
1676 text: &str,
1677 focused_text: &str,
1678 is_focused: bool,
1679 is_hovered: bool,
1680 theme: &Theme,
1681 dimmed: bool,
1682) {
1683 if is_focused {
1684 let style = Style::default()
1685 .fg(theme.menu_highlight_fg)
1686 .bg(theme.menu_highlight_bg)
1687 .add_modifier(Modifier::BOLD);
1688 frame.render_widget(Paragraph::new(focused_text).style(style), area);
1689 } else if is_hovered {
1690 let style = Style::default()
1691 .fg(theme.menu_hover_fg)
1692 .bg(theme.menu_hover_bg);
1693 frame.render_widget(Paragraph::new(text).style(style), area);
1694 } else {
1695 let fg = if dimmed {
1696 theme.line_number_fg
1697 } else {
1698 theme.popup_text_fg
1699 };
1700 frame.render_widget(Paragraph::new(text).style(Style::default().fg(fg)), area);
1701 }
1702}
1703
1704fn render_footer(
1707 frame: &mut Frame,
1708 modal_area: Rect,
1709 state: &SettingsState,
1710 theme: &Theme,
1711 layout: &mut SettingsLayout,
1712 vertical: bool,
1713) {
1714 use super::layout::SettingsHit;
1715 use super::state::FocusPanel;
1716
1717 if modal_area.height < 4 || modal_area.width < 10 {
1719 return;
1720 }
1721
1722 if vertical {
1723 render_footer_vertical(frame, modal_area, state, theme, layout);
1724 return;
1725 }
1726
1727 let footer_y = modal_area.y + modal_area.height.saturating_sub(2);
1728 let footer_width = modal_area.width.saturating_sub(2);
1729 let footer_area = Rect::new(modal_area.x + 1, footer_y, footer_width, 1);
1730
1731 if footer_y > modal_area.y {
1733 let sep_y = footer_y.saturating_sub(1);
1734 let sep_area = Rect::new(modal_area.x + 1, sep_y, footer_width, 1);
1735 let sep_line: String = "─".repeat(sep_area.width as usize);
1736 frame.render_widget(
1737 Paragraph::new(sep_line).style(Style::default().fg(theme.split_separator_fg)),
1738 sep_area,
1739 );
1740 }
1741
1742 let footer_focused = state.focus_panel == FocusPanel::Footer;
1744
1745 let layer_hovered = matches!(state.hover_hit, Some(SettingsHit::LayerButton));
1748 let reset_hovered = matches!(state.hover_hit, Some(SettingsHit::ResetButton));
1749 let save_hovered = matches!(state.hover_hit, Some(SettingsHit::SaveButton));
1750 let cancel_hovered = matches!(state.hover_hit, Some(SettingsHit::CancelButton));
1751 let edit_hovered = matches!(state.hover_hit, Some(SettingsHit::EditButton));
1752
1753 let layer_focused = footer_focused && state.footer_button_index == 0;
1754 let reset_focused = footer_focused && state.footer_button_index == 1;
1755 let save_focused = footer_focused && state.footer_button_index == 2;
1756 let cancel_focused = footer_focused && state.footer_button_index == 3;
1757 let edit_focused = footer_focused && state.footer_button_index == 4;
1758
1759 let save_label = t!("settings.btn_save").to_string();
1761 let cancel_label = t!("settings.btn_cancel").to_string();
1762 let reset_label = t!("settings.btn_reset").to_string();
1763 let edit_label = t!("settings.btn_edit").to_string();
1764
1765 let layer_text = format!("[ {} ]", state.target_layer_name());
1767 let layer_text_focused = format!(">[ {} ]", state.target_layer_name());
1768 let save_text = format!("[ {} ]", save_label);
1769 let save_text_focused = format!(">[ {} ]", save_label);
1770 let cancel_text = format!("[ {} ]", cancel_label);
1771 let cancel_text_focused = format!(">[ {} ]", cancel_label);
1772 let reset_text = format!("[ {} ]", reset_label);
1773 let reset_text_focused = format!(">[ {} ]", reset_label);
1774 let edit_text = format!("[ {} ]", edit_label);
1775 let edit_text_focused = format!(">[ {} ]", edit_label);
1776
1777 let cancel_width = str_width(if cancel_focused {
1779 &cancel_text_focused
1780 } else {
1781 &cancel_text
1782 }) as u16;
1783 let save_width = str_width(if save_focused {
1784 &save_text_focused
1785 } else {
1786 &save_text
1787 }) as u16;
1788 let reset_width = str_width(if reset_focused {
1789 &reset_text_focused
1790 } else {
1791 &reset_text
1792 }) as u16;
1793 let layer_width = str_width(if layer_focused {
1794 &layer_text_focused
1795 } else {
1796 &layer_text
1797 }) as u16;
1798 let edit_width = str_width(if edit_focused {
1799 &edit_text_focused
1800 } else {
1801 &edit_text
1802 }) as u16;
1803 let gap: u16 = 2;
1804
1805 let min_buttons_width = save_width + gap + cancel_width;
1808 let all_buttons_width =
1810 edit_width + gap + layer_width + gap + reset_width + gap + save_width + gap + cancel_width;
1811
1812 let available = footer_area.width;
1814 let show_edit = available >= all_buttons_width;
1815 let show_layer = available >= (layer_width + gap + reset_width + gap + min_buttons_width);
1816 let show_reset = available >= (reset_width + gap + min_buttons_width);
1817
1818 let cancel_x = footer_area
1820 .x
1821 .saturating_add(footer_area.width.saturating_sub(cancel_width));
1822 let save_x = cancel_x.saturating_sub(save_width + gap);
1823 let reset_x = if show_reset {
1824 save_x.saturating_sub(reset_width + gap)
1825 } else {
1826 0
1827 };
1828 let layer_x = if show_layer {
1829 reset_x.saturating_sub(layer_width + gap)
1830 } else {
1831 0
1832 };
1833 let edit_x = footer_area.x; if show_layer {
1838 let layer_area = Rect::new(layer_x, footer_y, layer_width, 1);
1839 render_button(
1840 frame,
1841 layer_area,
1842 &layer_text,
1843 &layer_text_focused,
1844 layer_focused,
1845 layer_hovered,
1846 theme,
1847 false,
1848 );
1849 layout.layer_button = Some(layer_area);
1850 }
1851
1852 if show_reset {
1854 let reset_area = Rect::new(reset_x, footer_y, reset_width, 1);
1855 render_button(
1856 frame,
1857 reset_area,
1858 &reset_text,
1859 &reset_text_focused,
1860 reset_focused,
1861 reset_hovered,
1862 theme,
1863 false,
1864 );
1865 layout.reset_button = Some(reset_area);
1866 }
1867
1868 let save_area = Rect::new(save_x, footer_y, save_width, 1);
1870 render_button(
1871 frame,
1872 save_area,
1873 &save_text,
1874 &save_text_focused,
1875 save_focused,
1876 save_hovered,
1877 theme,
1878 false,
1879 );
1880 layout.save_button = Some(save_area);
1881
1882 let cancel_area = Rect::new(cancel_x, footer_y, cancel_width, 1);
1884 render_button(
1885 frame,
1886 cancel_area,
1887 &cancel_text,
1888 &cancel_text_focused,
1889 cancel_focused,
1890 cancel_hovered,
1891 theme,
1892 false,
1893 );
1894 layout.cancel_button = Some(cancel_area);
1895
1896 if show_edit {
1898 let edit_area = Rect::new(edit_x, footer_y, edit_width, 1);
1899 render_button(
1900 frame,
1901 edit_area,
1902 &edit_text,
1903 &edit_text_focused,
1904 edit_focused,
1905 edit_hovered,
1906 theme,
1907 true, );
1909 layout.edit_button = Some(edit_area);
1910 }
1911
1912 let help_start_x = if show_edit {
1915 edit_x + edit_width + 2
1916 } else {
1917 footer_area.x
1918 };
1919 let help_end_x = if show_layer {
1920 layer_x
1921 } else if show_reset {
1922 reset_x
1923 } else {
1924 save_x
1925 };
1926 let help_width = help_end_x.saturating_sub(help_start_x + 1);
1927
1928 let help = if state.search_active {
1930 t!("settings.help_search").to_string()
1931 } else if footer_focused {
1932 t!("settings.help_footer").to_string()
1933 } else {
1934 t!("settings.help_default").to_string()
1935 };
1936 let help_style = Style::default().fg(theme.line_number_fg);
1937 frame.render_widget(
1938 Paragraph::new(help.as_str()).style(help_style),
1939 Rect::new(help_start_x, footer_y, help_width, 1),
1940 );
1941}
1942
1943fn render_footer_vertical(
1945 frame: &mut Frame,
1946 modal_area: Rect,
1947 state: &SettingsState,
1948 theme: &Theme,
1949 layout: &mut SettingsLayout,
1950) {
1951 use super::layout::SettingsHit;
1952 use super::state::FocusPanel;
1953
1954 let footer_height = 7u16;
1956 let footer_y = modal_area
1957 .y
1958 .saturating_add(modal_area.height.saturating_sub(footer_height));
1959 let footer_width = modal_area.width.saturating_sub(2);
1960
1961 let sep_y = footer_y;
1963 if sep_y > modal_area.y {
1964 let sep_line: String = "─".repeat(footer_width as usize);
1965 frame.render_widget(
1966 Paragraph::new(sep_line).style(Style::default().fg(theme.split_separator_fg)),
1967 Rect::new(modal_area.x + 1, sep_y, footer_width, 1),
1968 );
1969 }
1970
1971 let footer_focused = state.focus_panel == FocusPanel::Footer;
1973
1974 let layer_hovered = matches!(state.hover_hit, Some(SettingsHit::LayerButton));
1976 let reset_hovered = matches!(state.hover_hit, Some(SettingsHit::ResetButton));
1977 let save_hovered = matches!(state.hover_hit, Some(SettingsHit::SaveButton));
1978 let cancel_hovered = matches!(state.hover_hit, Some(SettingsHit::CancelButton));
1979 let edit_hovered = matches!(state.hover_hit, Some(SettingsHit::EditButton));
1980
1981 let layer_focused = footer_focused && state.footer_button_index == 0;
1982 let reset_focused = footer_focused && state.footer_button_index == 1;
1983 let save_focused = footer_focused && state.footer_button_index == 2;
1984 let cancel_focused = footer_focused && state.footer_button_index == 3;
1985 let edit_focused = footer_focused && state.footer_button_index == 4;
1986
1987 let save_label = t!("settings.btn_save").to_string();
1989 let cancel_label = t!("settings.btn_cancel").to_string();
1990 let reset_label = t!("settings.btn_reset").to_string();
1991 let edit_label = t!("settings.btn_edit").to_string();
1992
1993 let layer_text = format!("[ {} ]", state.target_layer_name());
1995 let layer_text_focused = format!(">[ {} ]", state.target_layer_name());
1996 let save_text = format!("[ {} ]", save_label);
1997 let save_text_focused = format!(">[ {} ]", save_label);
1998 let cancel_text = format!("[ {} ]", cancel_label);
1999 let cancel_text_focused = format!(">[ {} ]", cancel_label);
2000 let reset_text = format!("[ {} ]", reset_label);
2001 let reset_text_focused = format!(">[ {} ]", reset_label);
2002 let edit_text = format!("[ {} ]", edit_label);
2003 let edit_text_focused = format!(">[ {} ]", edit_label);
2004
2005 let button_x = modal_area.x + 2;
2007 let mut y = sep_y + 1;
2008
2009 let layer_width = str_width(if layer_focused {
2011 &layer_text_focused
2012 } else {
2013 &layer_text
2014 }) as u16;
2015 let layer_area = Rect::new(button_x, y, layer_width.min(footer_width), 1);
2016 render_button(
2017 frame,
2018 layer_area,
2019 &layer_text,
2020 &layer_text_focused,
2021 layer_focused,
2022 layer_hovered,
2023 theme,
2024 false,
2025 );
2026 layout.layer_button = Some(layer_area);
2027 y += 1;
2028
2029 let save_width = str_width(if save_focused {
2031 &save_text_focused
2032 } else {
2033 &save_text
2034 }) as u16;
2035 let save_area = Rect::new(button_x, y, save_width.min(footer_width), 1);
2036 render_button(
2037 frame,
2038 save_area,
2039 &save_text,
2040 &save_text_focused,
2041 save_focused,
2042 save_hovered,
2043 theme,
2044 false,
2045 );
2046 layout.save_button = Some(save_area);
2047 y += 1;
2048
2049 let reset_width = str_width(if reset_focused {
2051 &reset_text_focused
2052 } else {
2053 &reset_text
2054 }) as u16;
2055 let reset_area = Rect::new(button_x, y, reset_width.min(footer_width), 1);
2056 render_button(
2057 frame,
2058 reset_area,
2059 &reset_text,
2060 &reset_text_focused,
2061 reset_focused,
2062 reset_hovered,
2063 theme,
2064 false,
2065 );
2066 layout.reset_button = Some(reset_area);
2067 y += 1;
2068
2069 let cancel_width = str_width(if cancel_focused {
2071 &cancel_text_focused
2072 } else {
2073 &cancel_text
2074 }) as u16;
2075 let cancel_area = Rect::new(button_x, y, cancel_width.min(footer_width), 1);
2076 render_button(
2077 frame,
2078 cancel_area,
2079 &cancel_text,
2080 &cancel_text_focused,
2081 cancel_focused,
2082 cancel_hovered,
2083 theme,
2084 false,
2085 );
2086 layout.cancel_button = Some(cancel_area);
2087 y += 1;
2088
2089 let edit_width = str_width(if edit_focused {
2091 &edit_text_focused
2092 } else {
2093 &edit_text
2094 }) as u16;
2095 let edit_area = Rect::new(button_x, y, edit_width.min(footer_width), 1);
2096 render_button(
2097 frame,
2098 edit_area,
2099 &edit_text,
2100 &edit_text_focused,
2101 edit_focused,
2102 edit_hovered,
2103 theme,
2104 true, );
2106 layout.edit_button = Some(edit_area);
2107}
2108
2109fn render_search_header(frame: &mut Frame, area: Rect, state: &SettingsState, theme: &Theme) {
2111 let search_style = Style::default().fg(theme.popup_text_fg);
2113 let cursor_style = Style::default()
2114 .fg(theme.menu_highlight_fg)
2115 .add_modifier(Modifier::UNDERLINED);
2116
2117 let spans = vec![
2118 Span::styled("🔍 ", search_style),
2119 Span::styled(&state.search_query, search_style),
2120 Span::styled("█", cursor_style), ];
2122 let line = Line::from(spans);
2123 frame.render_widget(
2124 Paragraph::new(line),
2125 Rect::new(area.x, area.y, area.width, 1),
2126 );
2127
2128 let result_count = state.search_results.len();
2130 let count_text = if result_count == 0 {
2131 if state.search_query.is_empty() {
2132 String::new()
2133 } else {
2134 "No results found".to_string()
2135 }
2136 } else if result_count == 1 {
2137 "1 result".to_string()
2138 } else {
2139 format!("{} results", result_count)
2140 };
2141
2142 let count_style = Style::default().fg(theme.line_number_fg);
2143 frame.render_widget(
2144 Paragraph::new(count_text).style(count_style),
2145 Rect::new(area.x, area.y + 1, area.width, 1),
2146 );
2147}
2148
2149fn render_search_hint(frame: &mut Frame, area: Rect, theme: &Theme) {
2151 let hint_style = Style::default().fg(theme.line_number_fg);
2152 let key_style = Style::default()
2153 .fg(theme.menu_highlight_fg)
2154 .add_modifier(Modifier::BOLD);
2155
2156 let spans = vec![
2157 Span::styled("🔍 ", hint_style),
2158 Span::styled("/", key_style),
2159 Span::styled(" to search settings...", hint_style),
2160 ];
2161 let line = Line::from(spans);
2162 frame.render_widget(Paragraph::new(line), area);
2163}
2164
2165fn render_search_results(
2167 frame: &mut Frame,
2168 area: Rect,
2169 state: &SettingsState,
2170 theme: &Theme,
2171 layout: &mut SettingsLayout,
2172) {
2173 let mut y = area.y;
2174
2175 for (idx, result) in state.search_results.iter().enumerate() {
2176 if y >= area.y + area.height.saturating_sub(3) {
2177 break;
2178 }
2179
2180 let is_selected = idx == state.selected_search_result;
2181 let item_area = Rect::new(area.x, y, area.width, 3);
2182
2183 render_search_result_item(frame, item_area, result, is_selected, theme, layout);
2184 y += 3;
2185 }
2186}
2187
2188fn render_search_result_item(
2190 frame: &mut Frame,
2191 area: Rect,
2192 result: &SearchResult,
2193 is_selected: bool,
2194 theme: &Theme,
2195 layout: &mut SettingsLayout,
2196) {
2197 if is_selected {
2199 let bg_style = Style::default().bg(theme.current_line_bg);
2200 for row in 0..area.height.min(3) {
2201 let row_area = Rect::new(area.x, area.y + row, area.width, 1);
2202 frame.render_widget(Paragraph::new("").style(bg_style), row_area);
2203 }
2204 }
2205
2206 let name_style = if is_selected {
2208 Style::default().fg(theme.menu_highlight_fg)
2209 } else {
2210 Style::default().fg(theme.popup_text_fg)
2211 };
2212
2213 let name_line = build_highlighted_text(
2215 &result.item.name,
2216 &result.name_matches,
2217 name_style,
2218 Style::default()
2219 .fg(theme.diagnostic_warning_fg)
2220 .add_modifier(Modifier::BOLD),
2221 );
2222 frame.render_widget(
2223 Paragraph::new(name_line),
2224 Rect::new(area.x, area.y, area.width, 1),
2225 );
2226
2227 let breadcrumb_style = Style::default()
2229 .fg(theme.line_number_fg)
2230 .add_modifier(Modifier::ITALIC);
2231 let breadcrumb = format!(" {} > {}", result.breadcrumb, result.item.path);
2232 let breadcrumb_line = Line::from(Span::styled(breadcrumb, breadcrumb_style));
2233 frame.render_widget(
2234 Paragraph::new(breadcrumb_line),
2235 Rect::new(area.x, area.y + 1, area.width, 1),
2236 );
2237
2238 if let Some(ref desc) = result.item.description {
2240 let desc_style = Style::default().fg(theme.line_number_fg);
2241 let truncated_desc = if desc.len() > area.width as usize - 2 {
2242 format!(" {}...", &desc[..area.width as usize - 5])
2243 } else {
2244 format!(" {}", desc)
2245 };
2246 frame.render_widget(
2247 Paragraph::new(truncated_desc).style(desc_style),
2248 Rect::new(area.x, area.y + 2, area.width, 1),
2249 );
2250 }
2251
2252 layout.add_search_result(result.page_index, result.item_index, area);
2254}
2255
2256fn build_highlighted_text(
2258 text: &str,
2259 matches: &[usize],
2260 normal_style: Style,
2261 highlight_style: Style,
2262) -> Line<'static> {
2263 if matches.is_empty() {
2264 return Line::from(Span::styled(text.to_string(), normal_style));
2265 }
2266
2267 let chars: Vec<char> = text.chars().collect();
2268 let mut spans = Vec::new();
2269 let mut current = String::new();
2270 let mut in_highlight = false;
2271
2272 for (idx, ch) in chars.iter().enumerate() {
2273 let should_highlight = matches.contains(&idx);
2274
2275 if should_highlight != in_highlight {
2276 if !current.is_empty() {
2277 let style = if in_highlight {
2278 highlight_style
2279 } else {
2280 normal_style
2281 };
2282 spans.push(Span::styled(current, style));
2283 current = String::new();
2284 }
2285 in_highlight = should_highlight;
2286 }
2287
2288 current.push(*ch);
2289 }
2290
2291 if !current.is_empty() {
2293 let style = if in_highlight {
2294 highlight_style
2295 } else {
2296 normal_style
2297 };
2298 spans.push(Span::styled(current, style));
2299 }
2300
2301 Line::from(spans)
2302}
2303
2304fn render_confirm_dialog(
2306 frame: &mut Frame,
2307 parent_area: Rect,
2308 state: &SettingsState,
2309 theme: &Theme,
2310) {
2311 let changes = state.get_change_descriptions();
2313 let dialog_width = 50.min(parent_area.width.saturating_sub(4));
2314 let dialog_height = (7 + changes.len() as u16)
2317 .min(20)
2318 .min(parent_area.height.saturating_sub(4));
2319
2320 let dialog_x = parent_area.x + (parent_area.width.saturating_sub(dialog_width)) / 2;
2322 let dialog_y = parent_area.y + (parent_area.height.saturating_sub(dialog_height)) / 2;
2323 let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
2324
2325 frame.render_widget(Clear, dialog_area);
2327
2328 let title = format!(" {} ", t!("confirm.unsaved_changes_title"));
2329 let block = Block::default()
2330 .title(title)
2331 .borders(Borders::ALL)
2332 .border_style(Style::default().fg(theme.diagnostic_warning_fg))
2333 .style(Style::default().bg(theme.popup_bg));
2334 frame.render_widget(block, dialog_area);
2335
2336 let inner = Rect::new(
2338 dialog_area.x + 2,
2339 dialog_area.y + 1,
2340 dialog_area.width.saturating_sub(4),
2341 dialog_area.height.saturating_sub(2),
2342 );
2343
2344 let mut y = inner.y;
2345
2346 let prompt = t!("confirm.unsaved_changes_prompt").to_string();
2348 let prompt_style = Style::default().fg(theme.popup_text_fg);
2349 frame.render_widget(
2350 Paragraph::new(prompt).style(prompt_style),
2351 Rect::new(inner.x, y, inner.width, 1),
2352 );
2353 y += 2;
2354
2355 let change_style = Style::default().fg(theme.popup_text_fg);
2357 for change in changes
2358 .iter()
2359 .take((dialog_height as usize).saturating_sub(7))
2360 {
2361 let truncated = if change.len() > inner.width as usize - 2 {
2362 format!("• {}...", &change[..inner.width as usize - 5])
2363 } else {
2364 format!("• {}", change)
2365 };
2366 frame.render_widget(
2367 Paragraph::new(truncated).style(change_style),
2368 Rect::new(inner.x, y, inner.width, 1),
2369 );
2370 y += 1;
2371 }
2372
2373 let button_y = dialog_area.y + dialog_area.height - 3;
2375
2376 let sep_line: String = "─".repeat(inner.width as usize);
2378 frame.render_widget(
2379 Paragraph::new(sep_line).style(Style::default().fg(theme.split_separator_fg)),
2380 Rect::new(inner.x, button_y - 1, inner.width, 1),
2381 );
2382
2383 let options = [
2385 t!("confirm.save_and_exit").to_string(),
2386 t!("confirm.discard").to_string(),
2387 t!("confirm.cancel").to_string(),
2388 ];
2389 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;
2391
2392 for (idx, label) in options.iter().enumerate() {
2393 let is_selected = idx == state.confirm_dialog_selection;
2394 let button_width = label.len() as u16 + 4;
2395
2396 let style = if is_selected {
2397 Style::default()
2398 .fg(theme.menu_highlight_fg)
2399 .bg(theme.menu_highlight_bg)
2400 .add_modifier(ratatui::style::Modifier::BOLD)
2401 } else {
2402 Style::default().fg(theme.popup_text_fg)
2403 };
2404
2405 let text = if is_selected {
2406 format!(">[ {} ]", label)
2407 } else {
2408 format!(" [ {} ]", label)
2409 };
2410 frame.render_widget(
2411 Paragraph::new(text).style(style),
2412 Rect::new(x, button_y, button_width + 1, 1),
2413 );
2414
2415 x += button_width + 3;
2416 }
2417
2418 let help = "←/→: Select Enter: Confirm Esc: Cancel";
2420 let help_style = Style::default().fg(theme.line_number_fg);
2421 frame.render_widget(
2422 Paragraph::new(help).style(help_style),
2423 Rect::new(inner.x, button_y + 1, inner.width, 1),
2424 );
2425}
2426
2427fn render_entry_dialog(
2432 frame: &mut Frame,
2433 parent_area: Rect,
2434 state: &mut SettingsState,
2435 theme: &Theme,
2436) {
2437 let Some(dialog) = state.entry_dialog_mut() else {
2438 return;
2439 };
2440
2441 let dialog_width = (parent_area.width * 85 / 100).clamp(50, 90);
2443 let dialog_height = (parent_area.height * 90 / 100).max(15);
2444 let dialog_x = parent_area.x + (parent_area.width.saturating_sub(dialog_width)) / 2;
2445 let dialog_y = parent_area.y + (parent_area.height.saturating_sub(dialog_height)) / 2;
2446
2447 let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
2448
2449 frame.render_widget(Clear, dialog_area);
2451
2452 let title = format!(" {} ", dialog.title);
2453
2454 let block = Block::default()
2455 .title(title)
2456 .borders(Borders::ALL)
2457 .border_style(Style::default().fg(theme.popup_border_fg))
2458 .style(Style::default().bg(theme.popup_bg));
2459 frame.render_widget(block, dialog_area);
2460
2461 let inner = Rect::new(
2463 dialog_area.x + 2,
2464 dialog_area.y + 1,
2465 dialog_area.width.saturating_sub(4),
2466 dialog_area.height.saturating_sub(5), );
2468
2469 let max_label_width = (inner.width / 2).max(20);
2471 let label_col_width = dialog
2472 .items
2473 .iter()
2474 .map(|item| item.name.len() as u16 + 2) .filter(|&w| w <= max_label_width)
2476 .max()
2477 .unwrap_or(20)
2478 .min(max_label_width);
2479
2480 let total_content_height = dialog.total_content_height();
2482 let viewport_height = inner.height as usize;
2483
2484 dialog.viewport_height = viewport_height;
2486
2487 let scroll_offset = dialog.scroll_offset;
2488 let needs_scroll = total_content_height > viewport_height;
2489
2490 let mut content_y: usize = 0;
2492 let mut screen_y = inner.y;
2493
2494 let first_editable = dialog.first_editable_index;
2496 let has_readonly_items = first_editable > 0;
2497 let has_editable_items = first_editable < dialog.items.len();
2498 let needs_separator = has_readonly_items && has_editable_items;
2499
2500 for (idx, item) in dialog.items.iter().enumerate() {
2501 if needs_separator && idx == first_editable {
2503 let separator_start = content_y;
2505 let separator_end = content_y + 1;
2506
2507 if separator_end > scroll_offset && screen_y < inner.y + inner.height {
2508 let skip_sep = if separator_start < scroll_offset {
2510 1
2511 } else {
2512 0
2513 };
2514 if skip_sep == 0 {
2515 let sep_style = Style::default().fg(theme.line_number_fg);
2516 let separator_line = "─".repeat(inner.width.saturating_sub(2) as usize);
2517 frame.render_widget(
2518 Paragraph::new(separator_line).style(sep_style),
2519 Rect::new(inner.x + 1, screen_y, inner.width.saturating_sub(2), 1),
2520 );
2521 screen_y += 1;
2522 }
2523 }
2524 content_y = separator_end;
2525 }
2526
2527 let control_height = item.control.control_height() as usize;
2528
2529 let item_start = content_y;
2531 let item_end = content_y + control_height;
2532
2533 if item_end <= scroll_offset {
2535 content_y = item_end;
2536 continue;
2537 }
2538
2539 if screen_y >= inner.y + inner.height {
2541 break;
2542 }
2543
2544 let skip_rows = if item_start < scroll_offset {
2546 (scroll_offset - item_start) as u16
2547 } else {
2548 0
2549 };
2550
2551 let visible_height = control_height.saturating_sub(skip_rows as usize);
2553 let available_height = (inner.y + inner.height).saturating_sub(screen_y) as usize;
2554 let render_height = visible_height.min(available_height);
2555
2556 if render_height == 0 {
2557 content_y = item_end;
2558 continue;
2559 }
2560
2561 let is_readonly = item.read_only;
2563 let is_focused = !is_readonly && !dialog.focus_on_buttons && dialog.selected_item == idx;
2564 let is_hovered = !is_readonly && dialog.hover_item == Some(idx);
2565
2566 if is_focused || is_hovered {
2568 let bg_style = if is_focused {
2569 Style::default().bg(theme.current_line_bg)
2570 } else {
2571 Style::default().bg(theme.menu_hover_bg)
2572 };
2573 for row in 0..render_height as u16 {
2574 let row_area = Rect::new(inner.x, screen_y + row, inner.width, 1);
2575 frame.render_widget(Paragraph::new("").style(bg_style), row_area);
2576 }
2577 }
2578
2579 let focus_indicator_width: u16 = 3;
2582
2583 if is_focused && skip_rows == 0 {
2585 let indicator_style = Style::default()
2586 .fg(theme.menu_highlight_fg)
2587 .add_modifier(Modifier::BOLD);
2588 frame.render_widget(
2589 Paragraph::new(">").style(indicator_style),
2590 Rect::new(inner.x, screen_y, 1, 1),
2591 );
2592 }
2593
2594 if item.modified && skip_rows == 0 {
2596 let modified_style = Style::default().fg(theme.menu_highlight_fg);
2597 frame.render_widget(
2598 Paragraph::new("●").style(modified_style),
2599 Rect::new(inner.x + 1, screen_y, 1, 1),
2600 );
2601 }
2602
2603 let control_area = Rect::new(
2605 inner.x + focus_indicator_width,
2606 screen_y,
2607 inner.width.saturating_sub(focus_indicator_width),
2608 render_height as u16,
2609 );
2610
2611 let _layout = render_control(
2613 frame,
2614 control_area,
2615 &item.control,
2616 &item.name,
2617 skip_rows,
2618 theme,
2619 Some(label_col_width.saturating_sub(focus_indicator_width)),
2620 item.read_only,
2621 );
2622
2623 screen_y += render_height as u16;
2624 content_y = item_end;
2625 }
2626
2627 if needs_scroll {
2629 use crate::view::ui::scrollbar::{render_scrollbar, ScrollbarColors, ScrollbarState};
2630
2631 let scrollbar_x = dialog_area.x + dialog_area.width - 3;
2632 let scrollbar_area = Rect::new(scrollbar_x, inner.y, 1, inner.height);
2633 let scrollbar_state =
2634 ScrollbarState::new(total_content_height, viewport_height, scroll_offset);
2635 let scrollbar_colors = ScrollbarColors::from_theme(theme);
2636 render_scrollbar(frame, scrollbar_area, &scrollbar_state, &scrollbar_colors);
2637 }
2638
2639 let button_y = dialog_area.y + dialog_area.height - 2;
2641 let buttons: Vec<&str> = if dialog.is_new || dialog.no_delete {
2643 vec!["[ Save ]", "[ Cancel ]"]
2644 } else {
2645 vec!["[ Save ]", "[ Delete ]", "[ Cancel ]"]
2646 };
2647 let button_width: u16 = buttons.iter().map(|b: &&str| b.len() as u16 + 2).sum();
2648 let button_x = dialog_area.x + (dialog_area.width.saturating_sub(button_width)) / 2;
2649
2650 let mut x = button_x;
2651 for (idx, label) in buttons.iter().enumerate() {
2652 let is_selected = dialog.focus_on_buttons && dialog.focused_button == idx;
2653 let is_hovered = dialog.hover_button == Some(idx);
2654 let is_delete = !dialog.is_new && !dialog.no_delete && idx == 1;
2655 let style = if is_selected {
2656 Style::default()
2657 .fg(theme.menu_highlight_fg)
2658 .add_modifier(Modifier::BOLD | Modifier::REVERSED)
2659 } else if is_hovered {
2660 Style::default()
2661 .fg(theme.menu_hover_fg)
2662 .bg(theme.menu_hover_bg)
2663 } else if is_delete {
2664 Style::default().fg(theme.diagnostic_error_fg)
2665 } else {
2666 Style::default().fg(theme.editor_fg)
2667 };
2668 frame.render_widget(
2669 Paragraph::new(*label).style(style),
2670 Rect::new(x, button_y, label.len() as u16, 1),
2671 );
2672 x += label.len() as u16 + 2;
2673 }
2674
2675 let is_editing_json = dialog.editing_text && dialog.is_editing_json();
2678 let (has_invalid_json, is_json_control) = dialog
2679 .current_item()
2680 .map(|item| match &item.control {
2681 SettingControl::Text(state) => (!state.is_valid(), false),
2682 SettingControl::Json(state) => (!state.is_valid(), is_editing_json),
2683 _ => (false, false),
2684 })
2685 .unwrap_or((false, false));
2686
2687 let help_area = Rect::new(
2689 dialog_area.x + 2,
2690 button_y + 1,
2691 dialog_area.width.saturating_sub(4),
2692 1,
2693 );
2694
2695 if has_invalid_json && !is_json_control {
2696 let warning = "⚠ Invalid JSON - fix before leaving field";
2698 let warning_style = Style::default().fg(theme.diagnostic_warning_fg);
2699 frame.render_widget(Paragraph::new(warning).style(warning_style), help_area);
2700 } else if has_invalid_json && is_json_control {
2701 let warning = "⚠ Invalid JSON";
2703 let warning_style = Style::default().fg(theme.diagnostic_warning_fg);
2704 frame.render_widget(Paragraph::new(warning).style(warning_style), help_area);
2705 } else if is_json_control {
2706 let help = "↑↓←→:Move Enter:Newline Tab/Esc:Exit";
2708 let help_style = Style::default().fg(theme.line_number_fg);
2709 frame.render_widget(Paragraph::new(help).style(help_style), help_area);
2710 } else {
2711 let help = "↑↓:Navigate Tab:Fields/Buttons Enter:Edit/Confirm Esc:Cancel";
2712 let help_style = Style::default().fg(theme.line_number_fg);
2713 frame.render_widget(Paragraph::new(help).style(help_style), help_area);
2714 }
2715}
2716
2717fn render_help_overlay(frame: &mut Frame, parent_area: Rect, theme: &Theme) {
2719 let help_items = [
2721 (
2722 "Navigation",
2723 vec![
2724 ("↑ / ↓", "Move up/down"),
2725 ("Tab", "Switch between categories and settings"),
2726 ("Enter", "Activate/toggle setting"),
2727 ],
2728 ),
2729 (
2730 "Search",
2731 vec![
2732 ("/", "Start search"),
2733 ("Esc", "Cancel search"),
2734 ("↑ / ↓", "Navigate results"),
2735 ("Enter", "Jump to result"),
2736 ],
2737 ),
2738 (
2739 "Actions",
2740 vec![
2741 ("Ctrl+S", "Save settings"),
2742 ("Esc", "Close settings"),
2743 ("?", "Toggle this help"),
2744 ],
2745 ),
2746 ];
2747
2748 let dialog_width = 50.min(parent_area.width.saturating_sub(4));
2750 let dialog_height = 20.min(parent_area.height.saturating_sub(4));
2751
2752 let dialog_x = parent_area.x + (parent_area.width.saturating_sub(dialog_width)) / 2;
2754 let dialog_y = parent_area.y + (parent_area.height.saturating_sub(dialog_height)) / 2;
2755 let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
2756
2757 frame.render_widget(Clear, dialog_area);
2759
2760 let block = Block::default()
2761 .title(" Keyboard Shortcuts ")
2762 .borders(Borders::ALL)
2763 .border_style(Style::default().fg(theme.menu_highlight_fg))
2764 .style(Style::default().bg(theme.popup_bg));
2765 frame.render_widget(block, dialog_area);
2766
2767 let inner = Rect::new(
2769 dialog_area.x + 2,
2770 dialog_area.y + 1,
2771 dialog_area.width.saturating_sub(4),
2772 dialog_area.height.saturating_sub(2),
2773 );
2774
2775 let mut y = inner.y;
2776
2777 for (section_name, bindings) in &help_items {
2778 if y >= inner.y + inner.height.saturating_sub(1) {
2779 break;
2780 }
2781
2782 let header_style = Style::default()
2784 .fg(theme.menu_active_fg)
2785 .add_modifier(Modifier::BOLD);
2786 frame.render_widget(
2787 Paragraph::new(*section_name).style(header_style),
2788 Rect::new(inner.x, y, inner.width, 1),
2789 );
2790 y += 1;
2791
2792 for (key, description) in bindings {
2793 if y >= inner.y + inner.height.saturating_sub(1) {
2794 break;
2795 }
2796
2797 let key_style = Style::default()
2798 .fg(theme.diagnostic_info_fg)
2799 .add_modifier(Modifier::BOLD);
2800 let desc_style = Style::default().fg(theme.popup_text_fg);
2801
2802 let line = Line::from(vec![
2803 Span::styled(format!(" {:12}", key), key_style),
2804 Span::styled(*description, desc_style),
2805 ]);
2806 frame.render_widget(Paragraph::new(line), Rect::new(inner.x, y, inner.width, 1));
2807 y += 1;
2808 }
2809
2810 y += 1; }
2812
2813 let footer_y = dialog_area.y + dialog_area.height - 2;
2815 let footer = "Press ? or Esc or Enter to close";
2816 let footer_style = Style::default().fg(theme.line_number_fg);
2817 let centered_x = inner.x + (inner.width.saturating_sub(footer.len() as u16)) / 2;
2818 frame.render_widget(
2819 Paragraph::new(footer).style(footer_style),
2820 Rect::new(centered_x, footer_y, footer.len() as u16, 1),
2821 );
2822}
2823
2824#[cfg(test)]
2825mod tests {
2826 use super::*;
2827
2828 #[test]
2830 fn test_control_layout_info() {
2831 let toggle = ControlLayoutInfo::Toggle(Rect::new(0, 0, 10, 1));
2832 assert!(matches!(toggle, ControlLayoutInfo::Toggle(_)));
2833
2834 let number = ControlLayoutInfo::Number {
2835 decrement: Rect::new(0, 0, 3, 1),
2836 increment: Rect::new(4, 0, 3, 1),
2837 value: Rect::new(8, 0, 5, 1),
2838 };
2839 assert!(matches!(number, ControlLayoutInfo::Number { .. }));
2840 }
2841}