1use ratatui::buffer::Buffer;
11use ratatui::layout::{Constraint, Direction, Layout, Rect};
12use ratatui::style::{Color, Modifier, Style};
13use ratatui::text::{Line, Span};
14use ratatui::widgets::{Block, Borders, Paragraph, Widget};
15use unicode_width::UnicodeWidthStr;
16
17use super::truncate_to_cells;
18use crate::render::theme::Theme;
19use crate::render::wrap::wrap_styled_line;
20use mermaid_model::question::{OptionPreview, PendingQuestionSet, Question, QuestionSelection};
21
22pub struct QuestionModalWidget<'a> {
23 pub theme: &'a Theme,
24 pub set: &'a PendingQuestionSet,
25 pub width: u16,
28}
29
30fn question_column_width(set: &PendingQuestionSet, total_width: u16) -> usize {
39 let inner = total_width.saturating_sub(2) as usize;
40 if active_question_has_preview(set) {
41 inner * 48 / 100
42 } else {
43 inner
44 }
45 .max(8)
46}
47
48fn push_wrapped(lines: &mut Vec<Line<'static>>, line: Line<'static>, width: usize, hang: usize) {
53 lines.extend(wrap_styled_line(
54 line,
55 width,
56 hang.min(width.saturating_sub(1)),
57 ));
58}
59
60fn chip(label: &str, fg: Color, bg: Color) -> Span<'static> {
63 Span::styled(
64 format!(" {label} "),
65 Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD),
66 )
67}
68
69fn option_window(cursor: usize, n: usize, max: usize) -> (usize, usize) {
72 if n <= max {
73 return (0, n);
74 }
75 let c = cursor.min(n - 1);
76 let start = c.saturating_sub(max / 2).min(n - max);
77 (start, start + max)
78}
79
80fn input_placeholder(kind: &mermaid_domain::QuestionKind) -> &'static str {
81 match kind {
82 mermaid_domain::QuestionKind::Number { .. } => "a number",
83 mermaid_domain::QuestionKind::Date => "YYYY-MM-DD",
84 mermaid_domain::QuestionKind::Path { .. } => "a path",
85 _ => "type a value",
86 }
87}
88
89fn push_choice_lines(
92 lines: &mut Vec<Line<'static>>,
93 q: &Question,
94 sel: &QuestionSelection,
95 theme: &Theme,
96 width: usize,
97) {
98 let brand = theme.colors.brand.to_color();
99 let dim = theme.colors.text_disabled.to_color();
100 let white = theme.colors.text_primary.to_color();
101 let n = q.options.len();
102 let multi = q.is_multi();
103 const MAX_VISIBLE: usize = 8;
104 let (start, end) = option_window(sel.cursor, n, MAX_VISIBLE);
105 if start > 0 {
106 lines.push(Line::from(Span::styled(
107 format!(" ... {start} more above"),
108 Style::default().fg(dim),
109 )));
110 }
111 for i in start..end {
112 let opt = &q.options[i];
113 let focused = sel.cursor == i;
114 let checked = sel.chosen.contains(&i);
115 let mut spans: Vec<Span<'static>> = vec![
116 Span::styled(
117 if focused { "> " } else { " " },
118 Style::default().fg(brand),
119 ),
120 Span::styled(format!("{}. ", i + 1), Style::default().fg(dim)),
121 ];
122 if multi {
123 spans.push(Span::styled(
124 if checked { "[x] " } else { "[ ] " },
125 Style::default().fg(if checked { brand } else { dim }),
126 ));
127 }
128 let label_style = if focused {
129 Style::default().fg(brand).add_modifier(Modifier::BOLD)
130 } else if !multi && checked {
131 Style::default().fg(brand)
132 } else {
133 Style::default().fg(white).add_modifier(Modifier::BOLD)
134 };
135 let hang: usize = spans.iter().map(|s| s.content.width()).sum();
137 spans.push(Span::styled(opt.label.clone(), label_style));
138 push_wrapped(lines, Line::from(spans), width, hang);
139 if let Some(desc) = &opt.description {
140 push_wrapped(
141 lines,
142 Line::from(Span::styled(
143 format!(" {desc}"),
144 Style::default().fg(dim),
145 )),
146 width,
147 5,
148 );
149 }
150 }
151 if end < n {
152 lines.push(Line::from(Span::styled(
153 format!(" ... {} more below", n - end),
154 Style::default().fg(dim),
155 )));
156 }
157
158 let other_focused = sel.cursor == n;
160 let mut spans: Vec<Span<'static>> = vec![
161 Span::styled(
162 if other_focused { "> " } else { " " },
163 Style::default().fg(brand),
164 ),
165 Span::styled(format!("{}. ", n + 1), Style::default().fg(dim)),
166 ];
167 if multi {
168 let checked = !sel.other_text.trim().is_empty();
169 spans.push(Span::styled(
170 if checked { "[x] " } else { "[ ] " },
171 Style::default().fg(if checked { brand } else { dim }),
172 ));
173 }
174 let hang: usize = spans.iter().map(|s| s.content.width()).sum();
175 if sel.other_text.is_empty() {
176 spans.push(Span::styled("Type something", Style::default().fg(dim)));
177 } else {
178 spans.push(Span::styled(
179 sel.other_text.clone(),
180 Style::default().fg(white),
181 ));
182 }
183 push_wrapped(lines, Line::from(spans), width, hang);
184
185 if multi {
187 let focused = sel.cursor == n + 1;
188 let style = if focused {
189 Style::default().fg(brand).add_modifier(Modifier::BOLD)
190 } else {
191 Style::default().fg(white).add_modifier(Modifier::BOLD)
192 };
193 lines.push(Line::from(vec![
194 Span::styled(
195 if focused { "> " } else { " " },
196 Style::default().fg(brand),
197 ),
198 Span::styled("Submit", style),
199 ]));
200 }
201}
202
203fn push_input_lines(
206 lines: &mut Vec<Line<'static>>,
207 q: &Question,
208 sel: &QuestionSelection,
209 theme: &Theme,
210 max_width: usize,
211) {
212 let brand = theme.colors.brand.to_color();
213 let dim = theme.colors.text_disabled.to_color();
214 let white = theme.colors.text_primary.to_color();
215 let value = &sel.value;
216 let mut field: Vec<Span<'static>> = vec![Span::styled("> ", Style::default().fg(brand))];
217 if value.is_empty() {
218 field.push(Span::styled(
219 input_placeholder(&q.kind),
220 Style::default().fg(dim),
221 ));
222 } else {
223 field.push(Span::styled(value.clone(), Style::default().fg(white)));
224 }
225 field.push(Span::styled("_", Style::default().fg(brand)));
226 push_wrapped(lines, Line::from(field), max_width, 2);
227
228 if let mermaid_domain::QuestionKind::Number {
229 min: Some(lo),
230 max: Some(hi),
231 slider: true,
232 ..
233 } = &q.kind
234 && hi > lo
235 {
236 let cur: f64 = value.trim().parse().unwrap_or(*lo);
237 let frac = ((cur - lo) / (hi - lo)).clamp(0.0, 1.0);
238 let width = 20usize;
239 let filled = (frac * width as f64).round() as usize;
240 let bar = format!("[{}{}]", "#".repeat(filled), "-".repeat(width - filled));
241 lines.push(Line::from(Span::styled(
242 format!(" {bar}"),
243 Style::default().fg(brand),
244 )));
245 }
246
247 match mermaid_domain::validate_input(&q.kind, value) {
248 Err(e) => push_wrapped(
249 lines,
250 Line::from(Span::styled(
251 format!(" {e}"),
252 Style::default().fg(theme.colors.error.to_color()),
253 )),
254 max_width,
255 2,
256 ),
257 Ok(()) => lines.push(Line::from(Span::styled(
258 " Enter to submit",
259 Style::default().fg(dim),
260 ))),
261 }
262}
263
264fn push_rank_lines(
266 lines: &mut Vec<Line<'static>>,
267 q: &Question,
268 sel: &QuestionSelection,
269 theme: &Theme,
270 width: usize,
271) {
272 let brand = theme.colors.brand.to_color();
273 let dim = theme.colors.text_disabled.to_color();
274 let white = theme.colors.text_primary.to_color();
275 for (pos, &opt_idx) in mermaid_domain::rank_order(q, sel).iter().enumerate() {
276 let focused = sel.cursor == pos;
277 let grabbed = focused && sel.grabbed;
278 let prefix = if grabbed {
279 ">>"
280 } else if focused {
281 "> "
282 } else {
283 " "
284 };
285 let label_style = if focused {
286 Style::default().fg(brand).add_modifier(Modifier::BOLD)
287 } else {
288 Style::default().fg(white).add_modifier(Modifier::BOLD)
289 };
290 let marker = format!("{}. ", pos + 1);
291 let hang = prefix.width() + marker.width();
292 push_wrapped(
293 lines,
294 Line::from(vec![
295 Span::styled(prefix, Style::default().fg(brand)),
296 Span::styled(marker, Style::default().fg(dim)),
297 Span::styled(
298 q.options
299 .get(opt_idx)
300 .map(|o| o.label.clone())
301 .unwrap_or_default(),
302 label_style,
303 ),
304 ]),
305 width,
306 hang,
307 );
308 }
309}
310
311pub fn build_question_lines(
314 set: &PendingQuestionSet,
315 theme: &Theme,
316 width: usize,
317) -> Vec<Line<'static>> {
318 let brand = theme.colors.brand.to_color();
319 let dim = theme.colors.text_disabled.to_color();
320 let white = theme.colors.text_primary.to_color();
321 let nq = set.questions.len();
322 let has_memory = set.questions.iter().any(|q| q.memory_key.is_some());
323 let mut lines: Vec<Line<'static>> = Vec::new();
324
325 if nq > 1 {
326 push_tab_strip(&mut lines, set, theme);
327 }
328
329 if set.active >= nq {
330 push_review_lines(&mut lines, set, theme, width);
331 return lines;
332 }
333
334 let q = &set.questions[set.active];
336 let sel = &set.selections[set.active];
337
338 if nq == 1 {
342 lines.push(Line::from(chip(
343 &truncate_to_cells(&q.header, 12),
344 theme.colors.background.to_color(),
345 brand,
346 )));
347 }
348 push_wrapped(
349 &mut lines,
350 Line::from(Span::styled(
351 q.question.clone(),
352 Style::default().fg(white).add_modifier(Modifier::BOLD),
353 )),
354 width,
355 0,
356 );
357 lines.push(Line::from(""));
358
359 if q.is_input() {
360 push_input_lines(&mut lines, q, sel, theme, width);
361 } else if q.is_rank() {
362 push_rank_lines(&mut lines, q, sel, theme, width);
363 } else {
364 push_choice_lines(&mut lines, q, sel, theme, width);
365 }
366
367 if q.is_choice() {
369 push_notes_line(&mut lines, set, theme, width);
370 }
371
372 lines.push(Line::from(""));
374 push_wrapped(
375 &mut lines,
376 Line::from(Span::styled(
377 footer_hint(q, nq > 1, has_memory),
378 Style::default().fg(dim),
379 )),
380 width,
381 0,
382 );
383
384 lines
385}
386
387fn push_tab_strip(lines: &mut Vec<Line<'static>>, set: &PendingQuestionSet, theme: &Theme) {
390 let brand = theme.colors.brand.to_color();
391 let dim = theme.colors.text_disabled.to_color();
392 let nq = set.questions.len();
393 let mut spans: Vec<Span<'static>> = vec![Span::styled("< ", Style::default().fg(dim))];
394 for (qi, q) in set.questions.iter().enumerate() {
395 let label = truncate_to_cells(&q.header, 12);
396 if qi == set.active {
397 spans.push(chip(&label, theme.colors.background.to_color(), brand));
398 } else {
399 spans.push(Span::styled(format!(" {label} "), Style::default().fg(dim)));
400 }
401 spans.push(Span::raw(" "));
402 }
403 if set.active >= nq {
404 spans.push(chip("Submit", theme.colors.background.to_color(), brand));
405 } else {
406 spans.push(Span::styled(" Submit ", Style::default().fg(dim)));
407 }
408 spans.push(Span::styled(" >", Style::default().fg(dim)));
409 lines.push(Line::from(spans));
410 lines.push(Line::from(""));
411}
412
413fn push_review_lines(
416 lines: &mut Vec<Line<'static>>,
417 set: &PendingQuestionSet,
418 theme: &Theme,
419 width: usize,
420) {
421 let brand = theme.colors.brand.to_color();
422 let dim = theme.colors.text_disabled.to_color();
423 let white = theme.colors.text_primary.to_color();
424 let has_memory = set.questions.iter().any(|q| q.memory_key.is_some());
425 lines.push(Line::from(Span::styled(
426 "Review your answers",
427 Style::default().fg(white).add_modifier(Modifier::BOLD),
428 )));
429 lines.push(Line::from(""));
430 for (q, ans) in set.questions.iter().zip(set.build_answers()) {
431 push_wrapped(
432 lines,
433 Line::from(Span::styled(
434 format!("- {}", q.question),
435 Style::default().fg(white),
436 )),
437 width,
438 2,
439 );
440 let value = if ans.selected.is_empty() {
441 "(no selection)".to_string()
442 } else {
443 ans.selected.join(", ")
444 };
445 push_wrapped(
446 lines,
447 Line::from(Span::styled(
448 format!(" -> {value}"),
449 Style::default().fg(brand),
450 )),
451 width,
452 6,
453 );
454 }
455 if has_memory {
456 let mark = if set.remember { "[x]" } else { "[ ]" };
457 lines.push(Line::from(Span::styled(
458 format!("{mark} Remember my answers across sessions (r)"),
459 Style::default().fg(if set.remember { brand } else { dim }),
460 )));
461 }
462 lines.push(Line::from(""));
463 lines.push(Line::from(Span::styled(
464 "Ready to submit your answers?",
465 Style::default().fg(dim),
466 )));
467 for (i, opt) in ["1. Submit answers", "2. Cancel"].iter().enumerate() {
468 let focused = set.review_cursor == i;
469 let style = if focused {
470 Style::default().fg(brand).add_modifier(Modifier::BOLD)
471 } else {
472 Style::default().fg(white)
473 };
474 lines.push(Line::from(vec![
475 Span::styled(
476 if focused { "> " } else { " " },
477 Style::default().fg(brand),
478 ),
479 Span::styled((*opt).to_string(), style),
480 ]));
481 }
482 lines.push(Line::from(""));
483 let mut foot = String::from("Enter to select | Up/Down to navigate | c: chat");
484 if has_memory {
485 foot.push_str(" | r: remember");
486 }
487 foot.push_str(" | Esc to cancel");
488 push_wrapped(
489 lines,
490 Line::from(Span::styled(foot, Style::default().fg(dim))),
491 width,
492 0,
493 );
494}
495
496fn push_notes_line(
499 lines: &mut Vec<Line<'static>>,
500 set: &PendingQuestionSet,
501 theme: &Theme,
502 width: usize,
503) {
504 let brand = theme.colors.brand.to_color();
505 let dim = theme.colors.text_disabled.to_color();
506 let white = theme.colors.text_primary.to_color();
507 let sel = &set.selections[set.active];
508 lines.push(Line::from(""));
509 if set.editing_note {
510 push_wrapped(
511 lines,
512 Line::from(vec![
513 Span::styled("Notes: ", Style::default().fg(brand)),
514 Span::styled(sel.note.clone(), Style::default().fg(white)),
515 Span::styled("_", Style::default().fg(brand)),
516 ]),
517 width,
518 7,
519 );
520 } else if !sel.note.trim().is_empty() {
521 push_wrapped(
522 lines,
523 Line::from(vec![
524 Span::styled("Notes: ", Style::default().fg(dim)),
525 Span::styled(sel.note.clone(), Style::default().fg(white)),
526 ]),
527 width,
528 7,
529 );
530 } else {
531 lines.push(Line::from(Span::styled(
532 "Notes: press n to add notes",
533 Style::default().fg(dim),
534 )));
535 }
536}
537
538fn footer_hint(q: &mermaid_domain::Question, batched: bool, has_memory: bool) -> String {
540 let mut hint = if q.is_input() {
541 let mut h = String::from("Type to edit");
542 if matches!(q.kind, mermaid_domain::QuestionKind::Number { .. }) {
543 h.push_str(" | Up/Down to step");
544 }
545 h.push_str(" | Enter to submit");
546 h
547 } else if q.is_rank() {
548 String::from("Up/Down to move | Space to grab | Enter to submit")
549 } else {
550 String::from("Enter to select | Up/Down to navigate")
551 };
552 if batched {
553 hint.push_str(" | Tab to switch");
554 }
555 if q.is_choice() {
556 hint.push_str(" | n: notes | c: chat");
557 if has_memory {
558 hint.push_str(" | r: remember");
559 }
560 }
561 hint.push_str(" | Esc to cancel");
562 hint
563}
564
565fn focused_preview(set: &PendingQuestionSet) -> Option<&OptionPreview> {
568 if set.active >= set.questions.len() {
569 return None;
570 }
571 let q = &set.questions[set.active];
572 let cursor = set.selections[set.active].cursor;
573 q.options.get(cursor).and_then(|o| o.preview.as_ref())
574}
575
576fn active_question_has_preview(set: &PendingQuestionSet) -> bool {
579 set.active < set.questions.len()
580 && set.questions[set.active]
581 .options
582 .iter()
583 .any(|o| o.preview.is_some())
584}
585
586fn max_preview_lines(set: &PendingQuestionSet) -> usize {
589 if set.active >= set.questions.len() {
590 return 0;
591 }
592 set.questions[set.active]
593 .options
594 .iter()
595 .filter_map(|o| o.preview.as_ref())
596 .map(|p| p.content.lines().count())
597 .max()
598 .unwrap_or(0)
599}
600
601fn build_preview_lines(preview: &OptionPreview, theme: &Theme) -> Vec<Line<'static>> {
604 let add = theme.colors.success.to_color();
605 let rem = theme.colors.error.to_color();
606 let hunk = theme.colors.info.to_color();
607 let base = theme.colors.code_foreground.to_color();
608 preview
609 .content
610 .lines()
611 .map(|raw| {
612 let style = if preview.diff {
613 match raw.chars().next() {
614 Some('+') => Style::default().fg(add),
615 Some('-') => Style::default().fg(rem),
616 Some('@') => Style::default().fg(hunk),
617 _ => Style::default().fg(base),
618 }
619 } else {
620 Style::default().fg(base)
621 };
622 Line::from(Span::styled(raw.to_string(), style))
623 })
624 .collect()
625}
626
627#[must_use]
630pub fn question_modal_height(set: &PendingQuestionSet, theme: &Theme, total_width: u16) -> u16 {
631 let left = build_question_lines(set, theme, question_column_width(set, total_width)).len();
632 let content = if active_question_has_preview(set) {
633 left.max(max_preview_lines(set))
634 } else {
635 left
636 };
637 (content as u16).saturating_add(2)
638}
639
640impl<'a> Widget for QuestionModalWidget<'a> {
641 fn render(self, area: Rect, buf: &mut Buffer) {
642 let brand = self.theme.colors.brand.to_color();
643 let dim = self.theme.colors.text_disabled.to_color();
644 let block = Block::default()
645 .borders(Borders::ALL)
646 .border_style(Style::default().fg(brand));
647 let inner = block.inner(area);
648 block.render(area, buf);
649
650 if active_question_has_preview(self.set) {
651 let cols = Layout::default()
653 .direction(Direction::Horizontal)
654 .constraints([Constraint::Percentage(48), Constraint::Percentage(52)])
655 .split(inner);
656 Paragraph::new(build_question_lines(
657 self.set,
658 self.theme,
659 question_column_width(self.set, self.width),
660 ))
661 .render(cols[0], buf);
662 let preview_lines = focused_preview(self.set)
663 .map(|p| build_preview_lines(p, self.theme))
664 .unwrap_or_default();
665 Paragraph::new(preview_lines)
666 .block(
667 Block::default()
668 .borders(Borders::LEFT)
669 .border_style(Style::default().fg(dim)),
670 )
671 .render(cols[1], buf);
672 } else {
673 Paragraph::new(build_question_lines(
674 self.set,
675 self.theme,
676 question_column_width(self.set, self.width),
677 ))
678 .render(inner, buf);
679 }
680 }
681}
682
683#[cfg(test)]
684mod tests {
685 use super::*;
686 use mermaid_domain::{Question, QuestionOption, ToolCallId, TurnId};
687
688 fn opt_with_preview(label: &str, preview: Option<OptionPreview>) -> QuestionOption {
689 QuestionOption {
690 label: label.to_string(),
691 description: None,
692 recommended: false,
693 preview,
694 }
695 }
696
697 #[test]
698 fn diff_preview_colors_hunk_remove_and_add_lines() {
699 let theme = Theme::dark();
700 let preview = OptionPreview {
701 content: "@@ -1 +1 @@\n-old\n+new\n context".to_string(),
702 language: None,
703 diff: true,
704 };
705 let lines = build_preview_lines(&preview, &theme);
706 assert_eq!(lines.len(), 4);
707 assert_eq!(
708 lines[0].spans[0].style.fg,
709 Some(theme.colors.info.to_color())
710 );
711 assert_eq!(
712 lines[1].spans[0].style.fg,
713 Some(theme.colors.error.to_color())
714 );
715 assert_eq!(
716 lines[2].spans[0].style.fg,
717 Some(theme.colors.success.to_color())
718 );
719 }
720
721 #[test]
722 fn tall_preview_drives_modal_height() {
723 let body = std::iter::repeat_n("line", 20)
724 .collect::<Vec<_>>()
725 .join("\n");
726 let q = Question {
727 header: "H".to_string(),
728 question: "Q?".to_string(),
729 kind: mermaid_domain::QuestionKind::Select,
730 options: vec![
731 opt_with_preview(
732 "A",
733 Some(OptionPreview {
734 content: body,
735 language: None,
736 diff: false,
737 }),
738 ),
739 opt_with_preview("B", None),
740 ],
741 memory_key: None,
742 };
743 let set = PendingQuestionSet::new(TurnId(1), ToolCallId(1), vec![q]);
744 let theme = Theme::dark();
745 assert!(
747 question_modal_height(&set, &theme, 120) >= 22,
748 "expected height >= 22 to fit the preview"
749 );
750 }
751
752 fn q_with_header(header: &str) -> Question {
753 Question {
754 header: header.to_string(),
755 question: format!("Which {header}?"),
756 kind: mermaid_domain::QuestionKind::Select,
757 options: vec![opt_with_preview("A", None), opt_with_preview("B", None)],
758 memory_key: None,
759 }
760 }
761
762 fn lone_chip_lines(lines: &[Line<'static>], label: &str) -> usize {
766 lines
768 .iter()
769 .filter(|l| l.spans.len() == 1 && l.spans[0].content.as_ref().trim() == label)
770 .count()
771 }
772
773 #[test]
774 fn header_chip_shown_once_for_single_question() {
775 let set = PendingQuestionSet::new(TurnId(1), ToolCallId(1), vec![q_with_header("HDR")]);
778 let lines = build_question_lines(&set, &Theme::dark(), 120);
779 assert_eq!(lone_chip_lines(&lines, "HDR"), 1);
780 }
781
782 #[test]
783 fn header_chip_not_duplicated_above_title_when_batched() {
784 let set = PendingQuestionSet::new(
787 TurnId(1),
788 ToolCallId(1),
789 vec![q_with_header("HDR"), q_with_header("Other")],
790 );
791 let lines = build_question_lines(&set, &Theme::dark(), 120);
792 assert_eq!(lone_chip_lines(&lines, "HDR"), 0);
793 }
794
795 #[test]
800 fn long_option_text_wraps_inside_the_modal_instead_of_being_clipped() {
801 let desc = "Press Shift+Tab or run /safety ask so I can run PowerShell diagnostics and \
802 patch clipboard.rs to handle screenshots + file drops";
803 let q = Question {
804 header: "Safety".to_string(),
805 question: "I'm in read_only mode and can't run diagnostics or patch the clipboard \
806 code. How should I proceed?"
807 .to_string(),
808 kind: mermaid_domain::QuestionKind::Select,
809 options: vec![QuestionOption {
810 label: "Switch to ask/full_access (Recommended)".to_string(),
811 description: Some(desc.to_string()),
812 preview: None,
813 recommended: true,
814 }],
815 memory_key: None,
816 };
817 let set = PendingQuestionSet::new(TurnId(1), ToolCallId(1), vec![q]);
818 let theme = Theme::dark();
819 let width = 60usize;
820 let lines = build_question_lines(&set, &theme, width);
821 for line in &lines {
822 let w: usize = line.spans.iter().map(|s| s.content.as_ref().width()).sum();
823 assert!(
824 w <= width,
825 "line overflows the modal ({w} > {width}): {:?}",
826 line.spans
827 .iter()
828 .map(|s| s.content.as_ref())
829 .collect::<String>()
830 );
831 }
832 let all: String = lines
834 .iter()
835 .flat_map(|l| l.spans.iter())
836 .map(|s| s.content.as_ref())
837 .collect();
838 assert!(
839 all.contains("file drops"),
840 "the clipped tail must survive wrapping: {all}"
841 );
842 assert!(
844 question_modal_height(&set, &theme, (width + 2) as u16) as usize >= lines.len() + 2,
845 "the estimator must reserve room for every wrapped line"
846 );
847 }
848}