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