1use crate::config::constants::ui;
2use crate::ui::markdown::render_markdown;
3use crate::ui::tui::types::SecurePromptConfig;
4use ratatui::{
5 prelude::*,
6 widgets::{Block, List, ListItem, Paragraph, Tabs, Wrap},
7};
8use unicode_width::UnicodeWidthStr;
9
10use super::layout::{ModalBodyContext, ModalRenderStyles, ModalSection};
11use super::state::{ModalListState, ModalSearchState, WizardModalState, WizardStepState};
12use crate::ui::tui::session::terminal_capabilities;
13use std::mem;
14
15fn markdown_to_plain_lines(text: &str) -> Vec<String> {
16 let mut lines = render_markdown(text)
17 .into_iter()
18 .map(|line| {
19 line.segments
20 .into_iter()
21 .map(|segment| segment.text)
22 .collect::<String>()
23 })
24 .collect::<Vec<_>>();
25
26 while lines.last().is_some_and(|line| line.trim().is_empty()) {
27 lines.pop();
28 }
29
30 if lines.is_empty() {
31 vec![String::new()]
32 } else {
33 lines
34 }
35}
36
37fn wrap_line_to_width(line: &str, width: usize) -> Vec<String> {
38 if width == 0 {
39 return vec![line.to_owned()];
40 }
41
42 if line.is_empty() {
43 return vec![String::new()];
44 }
45
46 let mut rows = Vec::new();
47 let mut current = String::new();
48 let mut current_width = 0usize;
49
50 for ch in line.chars() {
51 let ch_width = unicode_width::UnicodeWidthChar::width(ch)
52 .unwrap_or(0)
53 .max(1);
54 if current_width + ch_width > width && !current.is_empty() {
55 rows.push(std::mem::take(&mut current));
56 current_width = 0;
57 if ch.is_whitespace() {
58 continue;
59 }
60 }
61
62 current.push(ch);
63 current_width += ch_width;
64 }
65
66 if !current.is_empty() {
67 rows.push(current);
68 }
69
70 if rows.is_empty() {
71 vec![String::new()]
72 } else {
73 rows
74 }
75}
76
77fn render_markdown_lines_for_modal(text: &str, width: usize, style: Style) -> Vec<Line<'static>> {
78 let mut lines = Vec::new();
79 for line in markdown_to_plain_lines(text) {
80 let wrapped = wrap_line_to_width(line.as_str(), width);
81 for wrapped_line in wrapped {
82 lines.push(Line::from(Span::styled(wrapped_line, style)));
83 }
84 }
85
86 if lines.is_empty() {
87 vec![Line::default()]
88 } else {
89 lines
90 }
91}
92
93pub fn render_modal_list(
94 frame: &mut Frame<'_>,
95 area: Rect,
96 list: &mut ModalListState,
97 styles: &ModalRenderStyles,
98 footer_hint: Option<&str>,
99) {
100 if list.visible_indices.is_empty() {
101 list.list_state.select(None);
102 *list.list_state.offset_mut() = 0;
103 let message = Paragraph::new(Line::from(Span::styled(
104 ui::MODAL_LIST_NO_RESULTS_MESSAGE.to_owned(),
105 styles.detail,
106 )))
107 .block(modal_list_block(list, styles, footer_hint))
108 .wrap(Wrap { trim: true });
109 frame.render_widget(message, area);
110 return;
111 }
112
113 let viewport_rows = area.height.saturating_sub(2);
114 list.set_viewport_rows(viewport_rows);
115 list.ensure_visible(viewport_rows);
116 let content_width = area.width.saturating_sub(4) as usize;
117 let items = modal_list_items(list, styles, content_width);
118 let widget = List::new(items)
119 .block(modal_list_block(list, styles, footer_hint))
120 .highlight_style(styles.highlight)
121 .highlight_symbol(ui::MODAL_LIST_HIGHLIGHT_FULL)
122 .repeat_highlight_symbol(true);
123 frame.render_stateful_widget(widget, area, &mut list.list_state);
124}
125
126#[allow(dead_code)]
128pub fn render_wizard_tabs(
129 frame: &mut Frame<'_>,
130 area: Rect,
131 steps: &[WizardStepState],
132 current_step: usize,
133 styles: &ModalRenderStyles,
134) {
135 if area.height == 0 || area.width == 0 {
136 return;
137 }
138
139 let titles: Vec<Line<'static>> = steps
140 .iter()
141 .enumerate()
142 .map(|(i, step)| {
143 let icon = if step.completed { "✔" } else { "☐" };
144 let text = format!("{} {}", icon, step.title);
145 if i == current_step {
146 Line::from(text).style(styles.highlight)
147 } else if step.completed {
148 Line::from(text).style(styles.selectable)
149 } else {
150 Line::from(text).style(styles.detail)
151 }
152 })
153 .collect();
154
155 let tabs = Tabs::new(titles)
156 .select(Some(current_step))
157 .divider(" ")
158 .padding("← ", " →")
159 .highlight_style(styles.highlight);
160
161 frame.render_widget(tabs, area);
162}
163
164#[allow(dead_code)]
166pub fn render_wizard_modal_body(
167 frame: &mut Frame<'_>,
168 area: Rect,
169 wizard: &mut WizardModalState,
170 styles: &ModalRenderStyles,
171) {
172 if area.width == 0 || area.height == 0 {
173 return;
174 }
175
176 let is_multistep = wizard.mode == crate::ui::tui::types::WizardModalMode::MultiStep;
177 let current_step_state = wizard.steps.get(wizard.current_step);
178 let has_notes = current_step_state.is_some_and(|s| s.notes_active || !s.notes.is_empty());
179 let instruction_lines = wizard.instruction_lines();
180 let content_width = area.width.max(1) as usize;
181 let header_lines = if is_multistep {
182 render_markdown_lines_for_modal(
183 wizard.question_header().as_str(),
184 content_width,
185 styles.header,
186 )
187 } else {
188 Vec::new()
189 };
190 let question_lines = wizard
191 .steps
192 .get(wizard.current_step)
193 .map(|step| {
194 render_markdown_lines_for_modal(step.question.as_str(), content_width, styles.header)
195 })
196 .unwrap_or_else(|| vec![Line::default()]);
197
198 let mut constraints = Vec::new();
200 if is_multistep {
201 constraints.push(Constraint::Length(
202 header_lines.len().min(u16::MAX as usize) as u16,
203 ));
204 } else {
205 constraints.push(Constraint::Length(1));
206 }
207 if wizard.search.is_some() {
208 constraints.push(Constraint::Length(3));
209 }
210 constraints.push(Constraint::Length(
211 question_lines.len().max(1).min(u16::MAX as usize) as u16,
212 ));
213 constraints.push(Constraint::Min(3));
214 if has_notes {
215 constraints.push(Constraint::Length(1));
216 }
217 if !instruction_lines.is_empty() {
218 constraints.push(Constraint::Length(
219 instruction_lines.len().min(u16::MAX as usize) as u16,
220 ));
221 }
222
223 let chunks = Layout::vertical(constraints).split(area);
224
225 let mut idx = 0;
226 if is_multistep {
227 let header = Paragraph::new(header_lines).wrap(Wrap { trim: false });
228 frame.render_widget(header, chunks[idx]);
229 } else {
230 render_wizard_tabs(
231 frame,
232 chunks[idx],
233 &wizard.steps,
234 wizard.current_step,
235 styles,
236 );
237 }
238 idx += 1;
239
240 if let Some(search) = wizard.search.as_ref() {
241 render_modal_search(frame, chunks[idx], search, styles);
242 idx += 1;
243 }
244
245 let question = Paragraph::new(question_lines).wrap(Wrap { trim: false });
246 frame.render_widget(question, chunks[idx]);
247 idx += 1;
248
249 if let Some(step) = wizard.steps.get_mut(wizard.current_step) {
250 render_modal_list(frame, chunks[idx], &mut step.list, styles, None);
251 }
252 idx += 1;
253
254 if let Some(step) = wizard.steps.get(wizard.current_step)
255 && (step.notes_active || !step.notes.is_empty())
256 {
257 let label_text = step.freeform_label.as_deref().unwrap_or("›");
258 let mut spans = vec![Span::styled(format!("{} ", label_text), styles.header)];
259
260 if step.notes.is_empty() {
261 if let Some(placeholder) = step.freeform_placeholder.as_ref() {
262 spans.push(Span::styled(placeholder.clone(), styles.detail));
263 }
264 } else {
265 spans.push(Span::styled(step.notes.clone(), styles.selectable));
266 }
267
268 if step.notes_active {
269 spans.push(Span::styled("▌", styles.highlight));
270 }
271
272 let notes = Paragraph::new(Line::from(spans));
273 frame.render_widget(notes, chunks[idx]);
274 idx += 1;
275 }
276
277 if !instruction_lines.is_empty() && idx < chunks.len() {
278 let lines = instruction_lines
279 .into_iter()
280 .map(|line| Line::from(Span::styled(line, styles.hint)))
281 .collect::<Vec<_>>();
282 let instructions = Paragraph::new(lines);
283 frame.render_widget(instructions, chunks[idx]);
284 }
285}
286
287fn modal_list_block(
288 list: &ModalListState,
289 styles: &ModalRenderStyles,
290 footer_hint: Option<&str>,
291) -> Block<'static> {
292 let mut block = Block::bordered()
293 .border_type(terminal_capabilities::get_border_type())
294 .border_style(styles.border);
295 if let Some(summary) = modal_list_summary_line(list, styles, footer_hint) {
296 block = block.title_bottom(summary);
297 }
298 block
299}
300
301#[allow(clippy::const_is_empty)]
302fn modal_list_summary_line(
303 list: &ModalListState,
304 styles: &ModalRenderStyles,
305 footer_hint: Option<&str>,
306) -> Option<Line<'static>> {
307 if !list.filter_active() {
308 let density = if list.compact_rows() {
309 "Density: Compact"
310 } else {
311 "Density: Comfortable"
312 };
313 let message = match footer_hint {
314 Some(hint) if !hint.is_empty() => format!("{} • Alt+D {}", hint, density),
315 _ => format!("Alt+D {}", density),
316 };
317 return Some(Line::from(Span::styled(message, styles.hint)));
318 }
319
320 let mut spans = Vec::new();
321 if let Some(query) = list.filter_query().filter(|value| !value.is_empty()) {
322 spans.push(Span::styled(
323 format!("{}:", ui::MODAL_LIST_SUMMARY_FILTER_LABEL),
324 styles.detail,
325 ));
326 spans.push(Span::raw(" "));
327 spans.push(Span::styled(query.to_owned(), styles.selectable));
328 }
329
330 let matches = list.visible_selectable_count();
331 let total = list.total_selectable();
332 if matches == 0 {
333 if !spans.is_empty() {
334 spans.push(Span::styled(
335 ui::MODAL_LIST_SUMMARY_SEPARATOR.to_owned(),
336 styles.detail,
337 ));
338 }
339 spans.push(Span::styled(
340 ui::MODAL_LIST_SUMMARY_NO_MATCHES.to_owned(),
341 styles.search_match,
342 ));
343 if !ui::MODAL_LIST_SUMMARY_RESET_HINT.is_empty() {
344 spans.push(Span::styled(
345 format!(
346 "{}{}",
347 ui::MODAL_LIST_SUMMARY_SEPARATOR,
348 ui::MODAL_LIST_SUMMARY_RESET_HINT
349 ),
350 styles.hint,
351 ));
352 }
353 } else {
354 if !spans.is_empty() {
355 spans.push(Span::styled(
356 ui::MODAL_LIST_SUMMARY_SEPARATOR.to_owned(),
357 styles.detail,
358 ));
359 }
360 spans.push(Span::styled(
361 format!(
362 "{} {} {} {}",
363 ui::MODAL_LIST_SUMMARY_MATCHES_LABEL,
364 matches,
365 ui::MODAL_LIST_SUMMARY_TOTAL_LABEL,
366 total
367 ),
368 styles.detail,
369 ));
370 }
371
372 if spans.is_empty() {
373 None
374 } else {
375 Some(Line::from(spans))
376 }
377}
378
379pub fn render_modal_body(frame: &mut Frame<'_>, area: Rect, context: ModalBodyContext<'_, '_>) {
380 if area.width == 0 || area.height == 0 {
381 return;
382 }
383
384 let mut sections = Vec::new();
385 let has_instructions = context
386 .instructions
387 .iter()
388 .any(|line| !line.trim().is_empty());
389 if context.search.is_some() {
390 sections.push(ModalSection::Search);
391 }
392 if has_instructions {
393 sections.push(ModalSection::Instructions);
394 }
395 if context.secure_prompt.is_some() {
396 sections.push(ModalSection::Prompt);
397 }
398 if context.list.is_some() {
399 sections.push(ModalSection::List);
400 }
401
402 if sections.is_empty() {
403 return;
404 }
405
406 let mut constraints = Vec::new();
407 for section in §ions {
408 match section {
409 ModalSection::Search => constraints.push(Constraint::Length(3.min(area.height))),
410 ModalSection::Instructions => {
411 let visible_rows = context.instructions.len().max(1) as u16;
412 let height = visible_rows.saturating_add(2);
413 constraints.push(Constraint::Length(height.min(area.height)));
414 }
415 ModalSection::Prompt => constraints.push(Constraint::Length(3.min(area.height))),
416 ModalSection::List => constraints.push(Constraint::Min(3)),
417 }
418 }
419
420 let chunks = Layout::vertical(constraints).split(area);
421 let mut list_state = context.list;
422
423 for (section, chunk) in sections.into_iter().zip(chunks.iter()) {
424 match section {
425 ModalSection::Instructions => {
426 if chunk.height > 0 && has_instructions {
427 render_modal_instructions(frame, *chunk, context.instructions, context.styles);
428 }
429 }
430 ModalSection::Prompt => {
431 if let Some(config) = context.secure_prompt {
432 render_secure_prompt(frame, *chunk, config, context.input, context.cursor);
433 }
434 }
435 ModalSection::Search => {
436 if let Some(config) = context.search {
437 render_modal_search(frame, *chunk, config, context.styles);
438 }
439 }
440 ModalSection::List => {
441 if let Some(list_state) = list_state.as_deref_mut() {
442 render_modal_list(
443 frame,
444 *chunk,
445 list_state,
446 context.styles,
447 context.footer_hint,
448 );
449 }
450 }
451 }
452 }
453}
454
455fn render_modal_instructions(
456 frame: &mut Frame<'_>,
457 area: Rect,
458 instructions: &[String],
459 styles: &ModalRenderStyles,
460) {
461 fn wrap_instruction_lines(text: &str, width: usize) -> Vec<String> {
462 if width == 0 {
463 return vec![text.to_owned()];
464 }
465
466 let mut lines = Vec::new();
467 let mut current = String::new();
468
469 for word in text.split_whitespace() {
470 let word_width = UnicodeWidthStr::width(word);
471 if current.is_empty() {
472 current.push_str(word);
473 continue;
474 }
475
476 let current_width = UnicodeWidthStr::width(current.as_str());
477 let candidate_width = current_width.saturating_add(1).saturating_add(word_width);
478 if candidate_width > width {
479 lines.push(current);
480 current = word.to_owned();
481 } else {
482 current.push(' ');
483 current.push_str(word);
484 }
485 }
486
487 if !current.is_empty() {
488 lines.push(current);
489 }
490
491 if lines.is_empty() {
492 vec![text.to_owned()]
493 } else {
494 lines
495 }
496 }
497
498 if area.width == 0 || area.height == 0 {
499 return;
500 }
501
502 let mut items = Vec::new();
503 let mut first_content_rendered = false;
504 let content_width = area.width.saturating_sub(4) as usize;
505 let bullet_prefix = format!("{} ", ui::MODAL_INSTRUCTIONS_BULLET);
506 let bullet_indent = " ".repeat(UnicodeWidthStr::width(bullet_prefix.as_str()));
507
508 for line in instructions {
509 let trimmed = line.trim();
510 if trimmed.is_empty() {
511 items.push(ListItem::new(Line::default()));
512 continue;
513 }
514
515 let wrapped = wrap_instruction_lines(trimmed, content_width);
516 if wrapped.is_empty() {
517 items.push(ListItem::new(Line::default()));
518 continue;
519 }
520
521 if !first_content_rendered {
522 let mut lines = Vec::new();
523 for (index, segment) in wrapped.into_iter().enumerate() {
524 let style = if index == 0 {
525 styles.header
526 } else {
527 styles.instruction_body
528 };
529 lines.push(Line::from(Span::styled(segment, style)));
530 }
531 items.push(ListItem::new(lines));
532 first_content_rendered = true;
533 } else {
534 let mut lines = Vec::new();
535 for (index, segment) in wrapped.into_iter().enumerate() {
536 if index == 0 {
537 lines.push(Line::from(vec![
538 Span::styled(bullet_prefix.clone(), styles.instruction_bullet),
539 Span::styled(segment, styles.instruction_body),
540 ]));
541 } else {
542 lines.push(Line::from(vec![
543 Span::styled(bullet_indent.clone(), styles.instruction_bullet),
544 Span::styled(segment, styles.instruction_body),
545 ]));
546 }
547 }
548 items.push(ListItem::new(lines));
549 }
550 }
551
552 if items.is_empty() {
553 items.push(ListItem::new(Line::default()));
554 }
555
556 let block = Block::bordered()
557 .title(Span::styled(
558 ui::MODAL_INSTRUCTIONS_TITLE.to_owned(),
559 styles.instruction_title,
560 ))
561 .border_type(terminal_capabilities::get_border_type())
562 .border_style(styles.instruction_border);
563
564 let widget = List::new(items)
565 .block(block)
566 .style(styles.instruction_body)
567 .highlight_symbol("")
568 .repeat_highlight_symbol(false);
569
570 frame.render_widget(widget, area);
571}
572
573fn render_modal_search(
574 frame: &mut Frame<'_>,
575 area: Rect,
576 search: &ModalSearchState,
577 styles: &ModalRenderStyles,
578) {
579 if area.width == 0 || area.height == 0 {
580 return;
581 }
582
583 let mut spans = Vec::new();
584 if search.query.is_empty() {
585 if let Some(placeholder) = &search.placeholder {
586 spans.push(Span::styled(placeholder.clone(), styles.detail));
587 }
588 } else {
589 spans.push(Span::styled(search.query.clone(), styles.selectable));
590 }
591 spans.push(Span::styled("▌".to_owned(), styles.highlight));
592
593 let block = Block::bordered()
594 .title(Span::styled(search.label.clone(), styles.header))
595 .border_type(terminal_capabilities::get_border_type())
596 .border_style(styles.border);
597
598 let paragraph = Paragraph::new(Line::from(spans))
599 .block(block)
600 .wrap(Wrap { trim: true });
601 frame.render_widget(paragraph, area);
602}
603
604fn render_secure_prompt(
605 frame: &mut Frame<'_>,
606 area: Rect,
607 config: &SecurePromptConfig,
608 input: &str,
609 _cursor: usize,
610) {
611 if area.width == 0 || area.height == 0 {
612 return;
613 }
614
615 let display_text = if input.is_empty() {
616 config.placeholder.clone().unwrap_or_default()
617 } else if config.mask_input {
618 let grapheme_count = input.chars().count();
619 std::iter::repeat_n('•', grapheme_count).collect()
620 } else {
621 input.to_owned()
622 };
623
624 let label_paragraph = Paragraph::new(config.label.clone());
626 let label_area = Rect {
627 x: area.x,
628 y: area.y,
629 width: area.width,
630 height: 1.min(area.height),
631 };
632 frame.render_widget(label_paragraph, label_area);
633
634 if area.height > 1 {
636 let input_area = Rect {
637 x: area.x,
638 y: area.y + 1,
639 width: area.width,
640 height: (area.height - 1).max(1),
641 };
642
643 let input_paragraph = Paragraph::new(display_text);
644 frame.render_widget(input_paragraph, input_area);
645 }
646}
647
648pub(super) fn highlight_segments(
649 text: &str,
650 normal_style: Style,
651 highlight_style: Style,
652 terms: &[String],
653) -> Vec<Span<'static>> {
654 if text.is_empty() {
655 return vec![Span::styled(String::new(), normal_style)];
656 }
657
658 if terms.is_empty() {
659 return vec![Span::styled(text.to_owned(), normal_style)];
660 }
661
662 let lower = text.to_ascii_lowercase();
663 let mut char_offsets: Vec<usize> = text.char_indices().map(|(offset, _)| offset).collect();
664 char_offsets.push(text.len());
665 let char_count = char_offsets.len().saturating_sub(1);
666 if char_count == 0 {
667 return vec![Span::styled(text.to_owned(), normal_style)];
668 }
669
670 let mut highlight_flags = vec![false; char_count];
671 for term in terms {
672 let needle = term.as_str();
673 if needle.is_empty() {
674 continue;
675 }
676
677 let mut search_start = 0usize;
678 while search_start < lower.len() {
679 let Some(pos) = lower[search_start..].find(needle) else {
680 break;
681 };
682 let byte_start = search_start + pos;
683 let byte_end = byte_start + needle.len();
684 let start_index = char_offsets.partition_point(|offset| *offset < byte_start);
685 let end_index = char_offsets.partition_point(|offset| *offset < byte_end);
686 for flag in highlight_flags
687 .iter_mut()
688 .take(end_index.min(char_count))
689 .skip(start_index)
690 {
691 *flag = true;
692 }
693 search_start = byte_end;
694 }
695 }
696
697 let mut segments = Vec::new();
698 let mut current = String::new();
699 let mut current_highlight = highlight_flags.first().copied().unwrap_or(false);
700 for (idx, ch) in text.chars().enumerate() {
701 let highlight = highlight_flags.get(idx).copied().unwrap_or(false);
702 if idx == 0 {
703 current_highlight = highlight;
704 } else if highlight != current_highlight {
705 let style = if current_highlight {
706 highlight_style
707 } else {
708 normal_style
709 };
710 segments.push(Span::styled(mem::take(&mut current), style));
711 current_highlight = highlight;
712 }
713 current.push(ch);
714 }
715
716 if !current.is_empty() {
717 let style = if current_highlight {
718 highlight_style
719 } else {
720 normal_style
721 };
722 segments.push(Span::styled(current, style));
723 }
724
725 if segments.is_empty() {
726 segments.push(Span::styled(String::new(), normal_style));
727 }
728
729 segments
730}
731
732pub fn modal_list_items(
733 list: &ModalListState,
734 styles: &ModalRenderStyles,
735 content_width: usize,
736) -> Vec<ListItem<'static>> {
737 list.visible_indices
738 .iter()
739 .enumerate()
740 .map(|(visible_index, &index)| {
741 modal_list_item(list, visible_index, index, styles, content_width)
742 })
743 .collect()
744}
745
746fn modal_list_item(
747 list: &ModalListState,
748 _visible_index: usize,
749 item_index: usize,
750 styles: &ModalRenderStyles,
751 content_width: usize,
752) -> ListItem<'static> {
753 let item = match list.items.get(item_index) {
754 Some(i) => i,
755 None => {
756 tracing::warn!("modal list item index {item_index} out of bounds");
757 return ListItem::new("");
758 }
759 };
760 if item.is_divider {
761 let divider = if item.title.is_empty() {
762 ui::INLINE_BLOCK_HORIZONTAL.repeat(8)
763 } else {
764 item.title.clone()
765 };
766 return ListItem::new(vec![Line::from(Span::styled(divider, styles.divider))]);
767 }
768
769 let indent = " ".repeat(item.indent as usize);
770
771 let mut primary_spans = Vec::new();
772
773 if !indent.is_empty() {
774 primary_spans.push(Span::raw(indent.clone()));
775 }
776
777 if let Some(badge) = &item.badge {
778 let badge_label = format!("[{}]", badge);
779 primary_spans.push(Span::styled(badge_label, styles.badge));
780 primary_spans.push(Span::raw(" "));
781 }
782
783 let title_style = if item.selection.is_some() {
784 styles.selectable
785 } else if item.is_header() {
786 styles.header
787 } else {
788 styles.detail
789 };
790
791 let title_spans = highlight_segments(
792 item.title.as_str(),
793 title_style,
794 styles.search_match,
795 list.highlight_terms(),
796 );
797 primary_spans.extend(title_spans);
798
799 let mut lines = vec![Line::from(primary_spans)];
800
801 if let Some(subtitle) = &item.subtitle {
802 let indent_width = item.indent as usize * 2;
803 let wrapped_width = content_width.saturating_sub(indent_width).max(1);
804 let wrapped_lines = wrap_line_to_width(subtitle.as_str(), wrapped_width);
805
806 for wrapped in wrapped_lines {
807 let mut secondary_spans = Vec::new();
808 if !indent.is_empty() {
809 secondary_spans.push(Span::raw(indent.clone()));
810 }
811 let subtitle_spans = highlight_segments(
812 wrapped.as_str(),
813 styles.detail,
814 styles.search_match,
815 list.highlight_terms(),
816 );
817 secondary_spans.extend(subtitle_spans);
818 lines.push(Line::from(secondary_spans));
819 }
820 }
821
822 if !list.compact_rows() && item.selection.is_some() {
823 lines.push(Line::default());
824 }
825 ListItem::new(lines)
826}
827
828#[cfg(test)]
829mod tests {
830 use super::*;
831
832 fn line_text(line: &Line<'_>) -> String {
833 line.spans
834 .iter()
835 .map(|span| span.content.clone().into_owned())
836 .collect::<String>()
837 }
838
839 #[test]
840 fn render_markdown_lines_for_modal_wraps_long_questions() {
841 let lines = render_markdown_lines_for_modal(
842 "What user-visible outcome should this change deliver, and what constraints or non-goals must remain unchanged?",
843 40,
844 Style::default(),
845 );
846
847 assert!(lines.len() > 1, "long question should wrap across lines");
848 for line in &lines {
849 let text = line_text(line);
850 assert!(
851 UnicodeWidthStr::width(text.as_str()) <= 40,
852 "line exceeded modal width: {text}"
853 );
854 }
855 }
856
857 #[test]
858 fn render_markdown_lines_for_modal_renders_markdown_headings() {
859 let lines =
860 render_markdown_lines_for_modal("### Goal\n- Reduce prompt size", 80, Style::default());
861
862 let rendered = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
863 assert!(rendered.contains("Goal"));
864 assert!(!rendered.contains("### Goal"));
865 assert!(rendered.contains("Reduce prompt size"));
866 }
867}