1use crate::config::constants::ui;
2use crate::ui::markdown::render_markdown;
3use crate::ui::tui::session::inline_list::{
4 InlineListRenderOptions, InlineListRow, render_inline_list_with_options, row_height,
5 selection_padding, selection_padding_width,
6};
7use crate::ui::tui::session::list_panel::{
8 SharedListPanelSections, SharedListPanelStyles, SharedListWidgetModel, SharedSearchField,
9 render_shared_list_panel, render_shared_search_field,
10};
11use crate::ui::tui::types::{InlineListSelection, SecurePromptConfig};
12use ratatui::{
13 prelude::*,
14 widgets::{Paragraph, Tabs, Wrap},
15};
16use unicode_width::UnicodeWidthStr;
17
18use super::layout::{ModalBodyContext, ModalRenderStyles, ModalSection};
19use super::state::{ModalListState, ModalSearchState, WizardModalState, WizardStepState};
20use crate::ui::tui::session::wrapping;
21use std::mem;
22
23fn modal_text_area_aligned_with_list(area: Rect) -> Rect {
24 let gutter = selection_padding_width().min(area.width as usize) as u16;
25 if gutter == 0 || area.width <= gutter {
26 area
27 } else {
28 Rect {
29 x: area.x.saturating_add(gutter),
30 width: area.width.saturating_sub(gutter),
31 ..area
32 }
33 }
34}
35
36fn markdown_to_plain_lines(text: &str) -> Vec<String> {
37 let mut lines = render_markdown(text)
38 .into_iter()
39 .map(|line| {
40 line.segments
41 .into_iter()
42 .map(|segment| segment.text)
43 .collect::<String>()
44 })
45 .collect::<Vec<_>>();
46
47 while lines.last().is_some_and(|line| line.trim().is_empty()) {
48 lines.pop();
49 }
50
51 if lines.is_empty() {
52 vec![String::new()]
53 } else {
54 lines
55 }
56}
57
58fn wrap_line_to_width(line: &str, width: usize) -> Vec<String> {
59 if width == 0 {
60 return vec![line.to_owned()];
61 }
62
63 if line.is_empty() {
64 return vec![String::new()];
65 }
66
67 let mut rows = Vec::new();
68 let mut current = String::new();
69 let mut current_width = 0usize;
70
71 for ch in line.chars() {
72 let ch_width = unicode_width::UnicodeWidthChar::width(ch)
73 .unwrap_or(0)
74 .max(1);
75 if current_width + ch_width > width && !current.is_empty() {
76 rows.push(mem::take(&mut current));
77 current_width = 0;
78 if ch.is_whitespace() {
79 continue;
80 }
81 }
82
83 current.push(ch);
84 current_width += ch_width;
85 }
86
87 if !current.is_empty() {
88 rows.push(current);
89 }
90
91 if rows.is_empty() {
92 vec![String::new()]
93 } else {
94 rows
95 }
96}
97
98fn render_markdown_lines_for_modal(text: &str, width: usize, style: Style) -> Vec<Line<'static>> {
99 let mut lines = Vec::new();
100 for line in markdown_to_plain_lines(text) {
101 let line_ratatui = Line::from(Span::styled(line, style));
102 let wrapped = wrapping::wrap_line_preserving_urls(line_ratatui, width);
103 lines.extend(wrapped);
104 }
105
106 if lines.is_empty() {
107 vec![Line::default()]
108 } else {
109 lines
110 }
111}
112
113#[derive(Clone, Debug)]
114pub struct ModalInlineEditor {
115 item_index: usize,
116 label: String,
117 text: String,
118 placeholder: Option<String>,
119 active: bool,
120}
121
122struct ModalListPanelModel<'a> {
123 list: &'a mut ModalListState,
124 styles: &'a ModalRenderStyles,
125 inline_editor: Option<&'a ModalInlineEditor>,
126}
127
128impl SharedListWidgetModel for ModalListPanelModel<'_> {
129 fn rows(&self, width: u16) -> Vec<(InlineListRow, u16)> {
130 if self.list.visible_indices.is_empty() {
131 return vec![(
132 InlineListRow::single(
133 Line::from(Span::styled(
134 ui::MODAL_LIST_NO_RESULTS_MESSAGE.to_owned(),
135 self.styles.detail,
136 )),
137 self.styles.detail,
138 ),
139 1_u16,
140 )];
141 }
142
143 let selection_gutter = selection_padding_width() as u16;
144 let content_width = width.saturating_sub(selection_gutter) as usize;
145 self.list
146 .visible_indices
147 .iter()
148 .enumerate()
149 .map(|(visible_index, &item_index)| {
150 let lines = modal_list_item_lines(
151 self.list,
152 visible_index,
153 item_index,
154 self.styles,
155 content_width,
156 self.inline_editor,
157 );
158 (
159 InlineListRow {
160 lines: lines.clone(),
161 style: self.styles.selectable,
162 },
163 row_height(&lines),
164 )
165 })
166 .collect()
167 }
168
169 fn selected(&self) -> Option<usize> {
170 self.list.list_state.selected()
171 }
172
173 fn set_selected(&mut self, selected: Option<usize>) {
174 self.list.list_state.select(selected);
175 }
176
177 fn set_scroll_offset(&mut self, offset: usize) {
178 *self.list.list_state.offset_mut() = offset;
179 }
180
181 fn set_viewport_rows(&mut self, rows: u16) {
182 self.list.set_viewport_rows(rows);
183 self.list.ensure_visible(rows);
184 }
185}
186
187pub fn render_modal_list(
188 frame: &mut Frame<'_>,
189 area: Rect,
190 list: &mut ModalListState,
191 styles: &ModalRenderStyles,
192 footer_hint: Option<&str>,
193 inline_editor: Option<&ModalInlineEditor>,
194) {
195 if area.width == 0 || area.height == 0 {
196 return;
197 }
198
199 let summary = modal_list_summary_line(list, styles, footer_hint);
200 let mut panel_model = ModalListPanelModel {
201 list,
202 styles,
203 inline_editor,
204 };
205 let sections = SharedListPanelSections {
206 header: Vec::new(),
207 info: summary.into_iter().collect(),
208 search: None,
209 };
210 render_shared_list_panel(
211 frame,
212 area,
213 sections,
214 SharedListPanelStyles {
215 base_style: styles.selectable,
216 selected_style: Some(styles.highlight),
217 text_style: styles.detail,
218 },
219 &mut panel_model,
220 );
221}
222
223#[allow(dead_code)]
225pub fn render_wizard_tabs(
226 frame: &mut Frame<'_>,
227 area: Rect,
228 steps: &[WizardStepState],
229 current_step: usize,
230 styles: &ModalRenderStyles,
231) {
232 if area.height == 0 || area.width == 0 {
233 return;
234 }
235
236 if steps.len() <= 1 {
237 let label = steps
238 .first()
239 .map(|step| {
240 if step.completed {
241 format!("✔ {}", step.title)
242 } else {
243 step.title.clone()
244 }
245 })
246 .unwrap_or_default();
247 frame.render_widget(
248 Paragraph::new(Line::from(Span::styled(label, styles.highlight)))
249 .wrap(Wrap { trim: true }),
250 area,
251 );
252 return;
253 }
254
255 let titles: Vec<Line<'static>> = steps
256 .iter()
257 .enumerate()
258 .map(|(i, step)| {
259 let icon = if step.completed { "✔" } else { "☐" };
260 let text = format!("{} {}", icon, step.title);
261 if i == current_step {
262 Line::from(text).style(styles.highlight)
263 } else if step.completed {
264 Line::from(text).style(styles.selectable)
265 } else {
266 Line::from(text).style(styles.detail)
267 }
268 })
269 .collect();
270
271 let tabs = Tabs::new(titles)
272 .select(Some(current_step))
273 .divider(" │ ")
274 .padding("", "")
275 .highlight_style(styles.highlight);
276
277 frame.render_widget(tabs, area);
278}
279
280fn inline_editor_for_step(step: &WizardStepState) -> Option<ModalInlineEditor> {
281 let selected_visible = step.list.list_state.selected()?;
282 let item_index = *step.list.visible_indices.get(selected_visible)?;
283 let item = step.list.items.get(item_index)?;
284
285 match item.selection.as_ref() {
286 Some(InlineListSelection::RequestUserInputAnswer {
287 selected, other, ..
288 }) if selected.is_empty() && other.is_some() => Some(ModalInlineEditor {
289 item_index,
290 label: step
291 .freeform_label
292 .clone()
293 .unwrap_or_else(|| "Custom note".to_string()),
294 text: step.notes.clone(),
295 placeholder: step.freeform_placeholder.clone(),
296 active: step.notes_active,
297 }),
298 _ => None,
299 }
300}
301
302#[allow(dead_code)]
304pub fn render_wizard_modal_body(
305 frame: &mut Frame<'_>,
306 area: Rect,
307 wizard: &mut WizardModalState,
308 styles: &ModalRenderStyles,
309) {
310 if area.width == 0 || area.height == 0 {
311 return;
312 }
313
314 let is_multistep = wizard.mode == crate::ui::tui::types::WizardModalMode::MultiStep;
315 let text_alignment_fn: fn(Rect) -> Rect = if is_multistep {
316 |rect| rect
317 } else {
318 modal_text_area_aligned_with_list
319 };
320 let content_width = text_alignment_fn(area).width.max(1) as usize;
321 let current_step_state = wizard.steps.get(wizard.current_step);
322 let inline_editor = current_step_state.and_then(inline_editor_for_step);
323 let has_notes = current_step_state.is_some_and(|s| s.notes_active || !s.notes.is_empty())
324 && inline_editor.is_none();
325 let instruction_lines = wizard.instruction_lines();
326 let header_lines = if is_multistep {
327 render_markdown_lines_for_modal(
328 wizard.question_header().as_str(),
329 content_width,
330 styles.header,
331 )
332 } else {
333 Vec::new()
334 };
335 let question_lines = wizard
336 .steps
337 .get(wizard.current_step)
338 .map(|step| {
339 render_markdown_lines_for_modal(step.question.as_str(), content_width, styles.header)
340 })
341 .unwrap_or_else(|| vec![Line::default()]);
342
343 let mut info_lines = question_lines;
344 if let Some(step) = wizard.steps.get(wizard.current_step)
345 && has_notes
346 {
347 let label_text = step.freeform_label.as_deref().unwrap_or("›");
348 let mut spans = vec![Span::styled(format!("{} ", label_text), styles.header)];
349
350 if step.notes.is_empty() {
351 if let Some(placeholder) = step.freeform_placeholder.as_ref() {
352 spans.push(Span::styled(placeholder.clone(), styles.detail));
353 }
354 } else {
355 spans.push(Span::styled(step.notes.clone(), styles.selectable));
356 }
357
358 if step.notes_active {
359 spans.push(Span::styled("▌", styles.highlight));
360 }
361 info_lines.push(Line::from(spans));
362 }
363
364 info_lines.extend(
365 instruction_lines
366 .into_iter()
367 .map(|line| Line::from(Span::styled(line, styles.hint))),
368 );
369
370 let mut constraints = Vec::new();
372 if is_multistep {
373 constraints.push(Constraint::Length(
374 header_lines.len().min(u16::MAX as usize) as u16,
375 ));
376 } else {
377 constraints.push(Constraint::Length(1));
378 }
379 constraints.push(Constraint::Length(
380 info_lines.len().max(1).min(u16::MAX as usize) as u16,
381 ));
382 if wizard.search.is_some() {
383 constraints.push(Constraint::Length(1));
384 }
385 constraints.push(Constraint::Min(3));
386
387 let chunks = Layout::vertical(constraints).split(area);
388
389 let mut idx = 0;
390 if is_multistep {
391 let header_area = text_alignment_fn(chunks[idx]);
392 let header = Paragraph::new(header_lines).wrap(Wrap { trim: false });
393 frame.render_widget(header, header_area);
394 } else {
395 let tabs_area = text_alignment_fn(chunks[idx]);
396 render_wizard_tabs(frame, tabs_area, &wizard.steps, wizard.current_step, styles);
397 }
398 idx += 1;
399
400 let info = Paragraph::new(info_lines).wrap(Wrap { trim: false });
401 frame.render_widget(info, text_alignment_fn(chunks[idx]));
402 idx += 1;
403
404 if let Some(search) = wizard.search.as_ref()
405 && idx < chunks.len()
406 {
407 render_modal_search(frame, text_alignment_fn(chunks[idx]), search, styles);
408 idx += 1;
409 }
410
411 if let Some(step) = wizard.steps.get_mut(wizard.current_step)
412 && idx < chunks.len()
413 {
414 render_modal_list(
415 frame,
416 chunks[idx],
417 &mut step.list,
418 styles,
419 None,
420 inline_editor.as_ref(),
421 );
422 }
423}
424
425#[allow(clippy::const_is_empty)]
426fn modal_list_summary_line(
427 list: &ModalListState,
428 styles: &ModalRenderStyles,
429 footer_hint: Option<&str>,
430) -> Option<Line<'static>> {
431 if !list.filter_active() {
432 let message = list.non_filter_summary_text(footer_hint)?;
433 return Some(Line::from(Span::styled(message, styles.hint)));
434 }
435
436 let mut spans = Vec::new();
437 let matches = list.visible_selectable_count();
438 let total = list.total_selectable();
439 if matches == 0 {
440 spans.push(Span::styled(
441 ui::MODAL_LIST_SUMMARY_NO_MATCHES.to_owned(),
442 styles.search_match,
443 ));
444 if !ui::MODAL_LIST_SUMMARY_RESET_HINT.is_empty() {
445 spans.push(Span::styled(
446 format!(
447 "{}{}",
448 ui::MODAL_LIST_SUMMARY_SEPARATOR,
449 ui::MODAL_LIST_SUMMARY_RESET_HINT
450 ),
451 styles.hint,
452 ));
453 }
454 } else {
455 spans.push(Span::styled(
456 format!(
457 "{} {} {} {}",
458 ui::MODAL_LIST_SUMMARY_MATCHES_LABEL,
459 matches,
460 ui::MODAL_LIST_SUMMARY_TOTAL_LABEL,
461 total
462 ),
463 styles.detail,
464 ));
465 }
466
467 if spans.is_empty() {
468 None
469 } else {
470 Some(Line::from(spans))
471 }
472}
473
474pub fn render_modal_body(frame: &mut Frame<'_>, area: Rect, context: ModalBodyContext<'_, '_>) {
475 if area.width == 0 || area.height == 0 {
476 return;
477 }
478
479 let mut sections = Vec::new();
480 let has_instructions = context
481 .instructions
482 .iter()
483 .any(|line| !line.trim().is_empty());
484 if has_instructions {
485 sections.push(ModalSection::Instructions);
486 }
487 if context.secure_prompt.is_some() {
488 sections.push(ModalSection::Prompt);
489 }
490 if context.search.is_some() {
491 sections.push(ModalSection::Search);
492 }
493 if context.list.is_some() {
494 sections.push(ModalSection::List);
495 }
496
497 if sections.is_empty() {
498 return;
499 }
500
501 let mut constraints = Vec::new();
502 for section in §ions {
503 match section {
504 ModalSection::Search => constraints.push(Constraint::Length(1.min(area.height))),
505 ModalSection::Instructions => {
506 let visible_rows = context.instructions.len().clamp(1, 6) as u16;
507 let instruction_title_rows = if ui::MODAL_INSTRUCTIONS_TITLE.is_empty() {
508 0
509 } else {
510 1
511 };
512 let height = visible_rows.saturating_add(instruction_title_rows);
513 constraints.push(Constraint::Length(height.min(area.height)));
514 }
515 ModalSection::Prompt => constraints.push(Constraint::Length(2.min(area.height))),
516 ModalSection::List => constraints.push(Constraint::Min(1)),
517 }
518 }
519
520 let chunks = Layout::vertical(constraints).split(area);
521 let mut list_state = context.list;
522
523 for (section, chunk) in sections.into_iter().zip(chunks.iter()) {
524 match section {
525 ModalSection::Instructions => {
526 if chunk.height > 0 && has_instructions {
527 render_modal_instructions(frame, *chunk, context.instructions, context.styles);
528 }
529 }
530 ModalSection::Prompt => {
531 if let Some(config) = context.secure_prompt {
532 render_secure_prompt(frame, *chunk, config, context.input, context.cursor);
533 }
534 }
535 ModalSection::Search => {
536 if let Some(config) = context.search {
537 render_modal_search(frame, *chunk, config, context.styles);
538 }
539 }
540 ModalSection::List => {
541 if let Some(list_state) = list_state.as_deref_mut() {
542 render_modal_list(
543 frame,
544 *chunk,
545 list_state,
546 context.styles,
547 context.footer_hint,
548 None,
549 );
550 }
551 }
552 }
553 }
554}
555
556fn render_modal_instructions(
557 frame: &mut Frame<'_>,
558 area: Rect,
559 instructions: &[String],
560 styles: &ModalRenderStyles,
561) {
562 fn wrap_instruction_lines(text: &str, width: usize) -> Vec<String> {
563 if width == 0 {
564 return vec![text.to_owned()];
565 }
566
567 let mut lines = Vec::new();
568 let mut current = String::new();
569
570 for word in text.split_whitespace() {
571 let word_width = UnicodeWidthStr::width(word);
572 if current.is_empty() {
573 current.push_str(word);
574 continue;
575 }
576
577 let current_width = UnicodeWidthStr::width(current.as_str());
578 let candidate_width = current_width.saturating_add(1).saturating_add(word_width);
579 if candidate_width > width {
580 lines.push(current);
581 current = word.to_owned();
582 } else {
583 current.push(' ');
584 current.push_str(word);
585 }
586 }
587
588 if !current.is_empty() {
589 lines.push(current);
590 }
591
592 if lines.is_empty() {
593 vec![text.to_owned()]
594 } else {
595 lines
596 }
597 }
598
599 if area.width == 0 || area.height == 0 {
600 return;
601 }
602
603 let mut items: Vec<Vec<Line<'static>>> = Vec::new();
604 let mut first_content_rendered = false;
605 let content_width = area.width.saturating_sub(2) as usize;
606 let bullet_prefix = format!("{} ", ui::MODAL_INSTRUCTIONS_BULLET);
607 let bullet_indent = " ".repeat(UnicodeWidthStr::width(bullet_prefix.as_str()));
608
609 for line in instructions {
610 let trimmed = line.trim();
611 if trimmed.is_empty() {
612 items.push(vec![Line::default()]);
613 continue;
614 }
615
616 let wrapped = wrap_instruction_lines(trimmed, content_width);
617 if wrapped.is_empty() {
618 items.push(vec![Line::default()]);
619 continue;
620 }
621
622 if !first_content_rendered {
623 let mut lines = Vec::new();
624 for (index, segment) in wrapped.into_iter().enumerate() {
625 let style = if index == 0 {
626 styles.header
627 } else {
628 styles.instruction_body
629 };
630 lines.push(Line::from(Span::styled(segment, style)));
631 }
632 items.push(lines);
633 first_content_rendered = true;
634 } else {
635 let mut lines = Vec::new();
636 for (index, segment) in wrapped.into_iter().enumerate() {
637 if index == 0 {
638 lines.push(Line::from(vec![
639 Span::styled(bullet_prefix.clone(), styles.instruction_bullet),
640 Span::styled(segment, styles.instruction_body),
641 ]));
642 } else {
643 lines.push(Line::from(vec![
644 Span::styled(bullet_indent.clone(), styles.instruction_bullet),
645 Span::styled(segment, styles.instruction_body),
646 ]));
647 }
648 }
649 items.push(lines);
650 }
651 }
652
653 if items.is_empty() {
654 items.push(vec![Line::default()]);
655 }
656
657 let mut rendered_items = Vec::new();
658 if !ui::MODAL_INSTRUCTIONS_TITLE.is_empty() {
659 rendered_items.push((
660 InlineListRow::single(
661 Line::from(Span::styled(
662 ui::MODAL_INSTRUCTIONS_TITLE.to_owned(),
663 styles.instruction_title,
664 )),
665 styles.instruction_title,
666 ),
667 1_u16,
668 ));
669 }
670
671 rendered_items.extend(items.into_iter().map(|lines| {
672 (
673 InlineListRow {
674 lines: lines.clone(),
675 style: styles.instruction_body,
676 },
677 row_height(&lines),
678 )
679 }));
680
681 let _ = render_inline_list_with_options(
682 frame,
683 area,
684 rendered_items,
685 None,
686 InlineListRenderOptions {
687 base_style: styles.instruction_body,
688 selected_style: None,
689 scroll_padding: ui::INLINE_LIST_SCROLL_PADDING,
690 infinite_scrolling: false,
691 },
692 );
693}
694
695fn render_modal_search(
696 frame: &mut Frame<'_>,
697 area: Rect,
698 search: &ModalSearchState,
699 styles: &ModalRenderStyles,
700) {
701 if area.width == 0 || area.height == 0 {
702 return;
703 }
704
705 let search = SharedSearchField {
706 label: search.label.clone(),
707 placeholder: search.placeholder.clone(),
708 query: search.query.clone(),
709 };
710 render_shared_search_field(
711 frame,
712 area,
713 &search,
714 styles.header,
715 styles.selectable,
716 styles.detail,
717 styles.highlight,
718 );
719}
720
721fn render_secure_prompt(
722 frame: &mut Frame<'_>,
723 area: Rect,
724 config: &SecurePromptConfig,
725 input: &str,
726 _cursor: usize,
727) {
728 if area.width == 0 || area.height == 0 {
729 return;
730 }
731
732 let display_text = if input.is_empty() {
733 config.placeholder.clone().unwrap_or_default()
734 } else if config.mask_input {
735 let grapheme_count = input.chars().count();
736 std::iter::repeat_n('•', grapheme_count).collect()
737 } else {
738 input.to_owned()
739 };
740
741 let label_paragraph = Paragraph::new(config.label.clone());
743 let label_area = Rect {
744 x: area.x,
745 y: area.y,
746 width: area.width,
747 height: 1.min(area.height),
748 };
749 frame.render_widget(label_paragraph, label_area);
750
751 if area.height > 1 {
753 let input_area = Rect {
754 x: area.x,
755 y: area.y + 1,
756 width: area.width,
757 height: (area.height - 1).max(1),
758 };
759
760 let input_paragraph = Paragraph::new(display_text);
761 frame.render_widget(input_paragraph, input_area);
762 }
763}
764
765pub(super) fn highlight_segments(
766 text: &str,
767 normal_style: Style,
768 highlight_style: Style,
769 terms: &[String],
770) -> Vec<Span<'static>> {
771 if text.is_empty() {
772 return vec![Span::styled(String::new(), normal_style)];
773 }
774
775 if terms.is_empty() {
776 return vec![Span::styled(text.to_owned(), normal_style)];
777 }
778
779 let lower = text.to_ascii_lowercase();
780 let mut char_offsets: Vec<usize> = text.char_indices().map(|(offset, _)| offset).collect();
781 char_offsets.push(text.len());
782 let char_count = char_offsets.len().saturating_sub(1);
783 if char_count == 0 {
784 return vec![Span::styled(text.to_owned(), normal_style)];
785 }
786
787 let mut highlight_flags = vec![false; char_count];
788 for term in terms {
789 let needle = term.as_str();
790 if needle.is_empty() {
791 continue;
792 }
793
794 let mut search_start = 0usize;
795 while search_start < lower.len() {
796 let Some(pos) = lower[search_start..].find(needle) else {
797 break;
798 };
799 let byte_start = search_start + pos;
800 let byte_end = byte_start + needle.len();
801 let start_index = char_offsets.partition_point(|offset| *offset < byte_start);
802 let end_index = char_offsets.partition_point(|offset| *offset < byte_end);
803 for flag in highlight_flags
804 .iter_mut()
805 .take(end_index.min(char_count))
806 .skip(start_index)
807 {
808 *flag = true;
809 }
810 search_start = byte_end;
811 }
812 }
813
814 let mut segments = Vec::new();
815 let mut current = String::new();
816 let mut current_highlight = highlight_flags.first().copied().unwrap_or(false);
817 for (idx, ch) in text.chars().enumerate() {
818 let highlight = highlight_flags.get(idx).copied().unwrap_or(false);
819 if idx == 0 {
820 current_highlight = highlight;
821 } else if highlight != current_highlight {
822 let style = if current_highlight {
823 highlight_style
824 } else {
825 normal_style
826 };
827 segments.push(Span::styled(mem::take(&mut current), style));
828 current_highlight = highlight;
829 }
830 current.push(ch);
831 }
832
833 if !current.is_empty() {
834 let style = if current_highlight {
835 highlight_style
836 } else {
837 normal_style
838 };
839 segments.push(Span::styled(current, style));
840 }
841
842 if segments.is_empty() {
843 segments.push(Span::styled(String::new(), normal_style));
844 }
845
846 segments
847}
848
849pub fn modal_list_item_lines(
850 list: &ModalListState,
851 _visible_index: usize,
852 item_index: usize,
853 styles: &ModalRenderStyles,
854 content_width: usize,
855 inline_editor: Option<&ModalInlineEditor>,
856) -> Vec<Line<'static>> {
857 let item = match list.items.get(item_index) {
858 Some(i) => i,
859 None => {
860 tracing::warn!("modal list item index {item_index} out of bounds");
861 return vec![Line::default()];
862 }
863 };
864 if item.is_divider {
865 let divider = if item.title.is_empty() {
866 ui::INLINE_BLOCK_HORIZONTAL.repeat(8)
867 } else {
868 item.title.clone()
869 };
870 return vec![Line::from(Span::styled(divider, styles.divider))];
871 }
872
873 let indent = " ".repeat(item.indent as usize);
874 let selection_padding = selection_padding();
875
876 let mut primary_spans = Vec::new();
877 if !selection_padding.is_empty() {
878 primary_spans.push(Span::raw(selection_padding.clone()));
879 }
880
881 if !indent.is_empty() {
882 primary_spans.push(Span::raw(indent.clone()));
883 }
884
885 if let Some(badge) = &item.badge {
886 let badge_label = format!("[{}]", badge);
887 primary_spans.push(Span::styled(
888 badge_label,
889 modal_badge_style(badge.as_str(), styles),
890 ));
891 primary_spans.push(Span::raw(" "));
892 }
893
894 let title_style = if item.selection.is_some() {
895 styles.selectable
896 } else if item.is_header() {
897 styles.header
898 } else {
899 styles.detail
900 };
901
902 let title_spans = highlight_segments(
903 item.title.as_str(),
904 title_style,
905 styles.search_match,
906 list.highlight_terms(),
907 );
908 primary_spans.extend(title_spans);
909
910 let mut lines = vec![Line::from(primary_spans)];
911
912 if let Some(subtitle) = &item.subtitle {
913 let indent_width = item.indent as usize * 2;
914 let wrapped_width = content_width.saturating_sub(indent_width).max(1);
915 let wrapped_lines = wrap_line_to_width(subtitle.as_str(), wrapped_width);
916
917 for wrapped in wrapped_lines {
918 let mut secondary_spans = Vec::new();
919 if !selection_padding.is_empty() {
920 secondary_spans.push(Span::raw(selection_padding.clone()));
921 }
922 if !indent.is_empty() {
923 secondary_spans.push(Span::raw(indent.clone()));
924 }
925 let subtitle_spans = highlight_segments(
926 wrapped.as_str(),
927 styles.detail,
928 styles.search_match,
929 list.highlight_terms(),
930 );
931 secondary_spans.extend(subtitle_spans);
932 lines.push(Line::from(secondary_spans));
933 }
934 }
935
936 if let Some(editor) = inline_editor
937 && editor.item_index == item_index
938 {
939 let mut editor_spans = Vec::new();
940 if !selection_padding.is_empty() {
941 editor_spans.push(Span::raw(selection_padding.clone()));
942 }
943 if !indent.is_empty() {
944 editor_spans.push(Span::raw(indent.clone()));
945 }
946
947 editor_spans.push(Span::styled(format!("{} ", editor.label), styles.header));
948 if editor.text.is_empty() {
949 if let Some(placeholder) = editor.placeholder.as_ref() {
950 editor_spans.push(Span::styled(placeholder.clone(), styles.detail));
951 }
952 } else {
953 editor_spans.push(Span::styled(editor.text.clone(), styles.selectable));
954 }
955
956 if editor.active {
957 editor_spans.push(Span::styled("▌", styles.highlight));
958 }
959
960 lines.push(Line::from(editor_spans));
961 }
962
963 if !list.compact_rows() && item.selection.is_some() {
964 lines.push(Line::default());
965 }
966 lines
967}
968
969fn modal_badge_style(badge: &str, styles: &ModalRenderStyles) -> Style {
970 match badge {
971 "Active" | "Action" => styles.header.add_modifier(Modifier::BOLD),
972 "Read-only" => styles.detail.add_modifier(Modifier::ITALIC),
973 _ => styles.badge,
974 }
975}
976
977#[cfg(test)]
978mod tests {
979 use super::*;
980 use crate::ui::tui::InlineListItem;
981 use ratatui::{Terminal, backend::TestBackend};
982
983 fn line_text(line: &Line<'_>) -> String {
984 line.spans
985 .iter()
986 .map(|span| span.content.clone().into_owned())
987 .collect::<String>()
988 }
989
990 fn modal_render_styles() -> ModalRenderStyles {
991 ModalRenderStyles {
992 border: Style::default(),
993 highlight: Style::default(),
994 badge: Style::default(),
995 header: Style::default(),
996 selectable: Style::default(),
997 detail: Style::default(),
998 search_match: Style::default(),
999 title: Style::default(),
1000 divider: Style::default(),
1001 instruction_border: Style::default(),
1002 instruction_title: Style::default(),
1003 instruction_bullet: Style::default(),
1004 instruction_body: Style::default(),
1005 hint: Style::default(),
1006 }
1007 }
1008
1009 fn render_modal_lines(search: ModalSearchState) -> Vec<String> {
1010 let styles = modal_render_styles();
1011 let mut list = ModalListState::new(
1012 vec![InlineListItem {
1013 title: "Alpha".to_string(),
1014 subtitle: Some("First item".to_string()),
1015 badge: Some("OpenAI".to_string()),
1016 indent: 0,
1017 selection: Some(InlineListSelection::Model(0)),
1018 search_value: Some("alpha".to_string()),
1019 }],
1020 None,
1021 );
1022 let instructions = vec!["Choose a model".to_string()];
1023 let backend = TestBackend::new(80, 8);
1024 let mut terminal = Terminal::new(backend).expect("test terminal");
1025
1026 terminal
1027 .draw(|frame| {
1028 render_modal_body(
1029 frame,
1030 Rect::new(0, 0, 80, 8),
1031 ModalBodyContext {
1032 instructions: &instructions,
1033 footer_hint: None,
1034 list: Some(&mut list),
1035 styles: &styles,
1036 secure_prompt: None,
1037 search: Some(&search),
1038 input: "",
1039 cursor: 0,
1040 },
1041 );
1042 })
1043 .expect("modal render should succeed");
1044
1045 let buffer = terminal.backend().buffer();
1046 (0..buffer.area.height)
1047 .map(|y| {
1048 (0..buffer.area.width)
1049 .filter_map(|x| buffer.cell((x, y)).map(|cell| cell.symbol().to_string()))
1050 .collect::<String>()
1051 .trim_end()
1052 .to_string()
1053 })
1054 .collect()
1055 }
1056
1057 #[test]
1058 fn render_markdown_lines_for_modal_wraps_long_questions() {
1059 let lines = render_markdown_lines_for_modal(
1060 "What user-visible outcome should this change deliver, and what constraints or non-goals must remain unchanged?",
1061 40,
1062 Style::default(),
1063 );
1064
1065 assert!(lines.len() > 1, "long question should wrap across lines");
1066 for line in &lines {
1067 let text = line_text(line);
1068 assert!(
1069 UnicodeWidthStr::width(text.as_str()) <= 40,
1070 "line exceeded modal width: {text}"
1071 );
1072 }
1073 }
1074
1075 #[test]
1076 fn render_markdown_lines_for_modal_renders_markdown_headings() {
1077 let lines =
1078 render_markdown_lines_for_modal("### Goal\n- Reduce prompt size", 80, Style::default());
1079
1080 let rendered = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
1081 assert!(rendered.contains("Goal"));
1082 assert!(!rendered.contains("### Goal"));
1083 assert!(rendered.contains("Reduce prompt size"));
1084 }
1085
1086 #[test]
1087 fn config_list_summary_uses_navigation_hint_instead_of_density() {
1088 let list = ModalListState::new(
1089 vec![InlineListItem {
1090 title: "Autonomous mode".to_string(),
1091 subtitle: Some("agent.autonomous_mode = on".to_string()),
1092 badge: Some("Toggle".to_string()),
1093 indent: 0,
1094 selection: Some(InlineListSelection::ConfigAction(
1095 "agent.autonomous_mode:toggle".to_string(),
1096 )),
1097 search_value: None,
1098 }],
1099 None,
1100 );
1101
1102 let styles = ModalRenderStyles {
1103 border: Style::default(),
1104 highlight: Style::default(),
1105 badge: Style::default(),
1106 header: Style::default(),
1107 selectable: Style::default(),
1108 detail: Style::default(),
1109 search_match: Style::default(),
1110 title: Style::default(),
1111 divider: Style::default(),
1112 instruction_border: Style::default(),
1113 instruction_title: Style::default(),
1114 instruction_bullet: Style::default(),
1115 instruction_body: Style::default(),
1116 hint: Style::default(),
1117 };
1118
1119 let summary = modal_list_summary_line(&list, &styles, None)
1120 .expect("expected summary line for config list");
1121 let text = line_text(&summary);
1122 assert!(text.contains("Navigation:"));
1123 assert!(!text.contains("Alt+D"));
1124 assert!(!text.contains("Density:"));
1125 }
1126
1127 #[test]
1128 fn non_config_list_summary_omits_density_hint() {
1129 let list = ModalListState::new(
1130 vec![InlineListItem {
1131 title: "gpt-5".to_string(),
1132 subtitle: Some("General reasoning".to_string()),
1133 badge: None,
1134 indent: 0,
1135 selection: Some(InlineListSelection::Model(0)),
1136 search_value: Some("gpt-5".to_string()),
1137 }],
1138 None,
1139 );
1140
1141 let styles = ModalRenderStyles {
1142 border: Style::default(),
1143 highlight: Style::default(),
1144 badge: Style::default(),
1145 header: Style::default(),
1146 selectable: Style::default(),
1147 detail: Style::default(),
1148 search_match: Style::default(),
1149 title: Style::default(),
1150 divider: Style::default(),
1151 instruction_border: Style::default(),
1152 instruction_title: Style::default(),
1153 instruction_bullet: Style::default(),
1154 instruction_body: Style::default(),
1155 hint: Style::default(),
1156 };
1157
1158 let summary = modal_list_summary_line(&list, &styles, None);
1159 assert!(summary.is_none(), "density summary should be hidden");
1160 }
1161
1162 #[test]
1163 fn modal_text_area_alignment_reserves_selection_gutter() {
1164 let area = Rect::new(10, 3, 20, 4);
1165 let aligned = modal_text_area_aligned_with_list(area);
1166 let gutter = selection_padding_width() as u16;
1167
1168 assert_eq!(aligned.x, area.x + gutter);
1169 assert_eq!(aligned.width, area.width - gutter);
1170 assert_eq!(aligned.y, area.y);
1171 assert_eq!(aligned.height, area.height);
1172 }
1173
1174 #[test]
1175 fn modal_text_area_alignment_keeps_narrow_areas_unchanged() {
1176 let gutter = selection_padding_width() as u16;
1177 let area = Rect::new(2, 1, gutter, 2);
1178 let aligned = modal_text_area_aligned_with_list(area);
1179 assert_eq!(aligned, area);
1180 }
1181
1182 #[test]
1183 fn modal_search_field_renders_placeholder_inside_brackets() {
1184 let lines = render_modal_lines(ModalSearchState {
1185 label: "Search models".to_string(),
1186 placeholder: Some("provider, name, id".to_string()),
1187 query: String::new(),
1188 });
1189
1190 let search_line = lines
1191 .iter()
1192 .find(|line| line.contains("Search models:"))
1193 .expect("search line should render");
1194 assert!(search_line.contains("[provider, name, id"));
1195 assert!(search_line.contains("]"));
1196 }
1197
1198 #[test]
1199 fn modal_search_field_renders_query_above_list() {
1200 let lines = render_modal_lines(ModalSearchState {
1201 label: "Search models".to_string(),
1202 placeholder: Some("provider, name, id".to_string()),
1203 query: "openrouter".to_string(),
1204 });
1205
1206 let search_index = lines
1207 .iter()
1208 .position(|line| line.contains("Search models: [openrouter"))
1209 .expect("search query should render");
1210 let item_index = lines
1211 .iter()
1212 .position(|line| line.contains("Alpha"))
1213 .expect("list item should render");
1214
1215 assert!(lines[search_index].contains("Esc clears"));
1216 assert!(search_index < item_index);
1217 }
1218
1219 #[test]
1220 fn filtered_modal_summary_shows_matches_without_repeating_query() {
1221 let list = ModalListState::new(
1222 vec![InlineListItem {
1223 title: "gpt-5".to_string(),
1224 subtitle: Some("General reasoning".to_string()),
1225 badge: None,
1226 indent: 0,
1227 selection: Some(InlineListSelection::Model(0)),
1228 search_value: Some("gpt-5".to_string()),
1229 }],
1230 None,
1231 );
1232 let styles = modal_render_styles();
1233 let mut list = list;
1234 list.apply_search("gpt");
1235
1236 let summary = modal_list_summary_line(&list, &styles, None).expect("summary should exist");
1237 let text = line_text(&summary);
1238
1239 assert!(text.contains("Matches 1 of 1"));
1240 assert!(!text.contains("gpt"));
1241 assert!(!text.contains("Filter:"));
1242 }
1243}