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