use ratatui::buffer::Buffer;use ratatui::layout::Rect;use ratatui::style::Stylize;use ratatui::text::Line;use ratatui::text::Span;use ratatui::widgets::Paragraph;use ratatui::widgets::Widget;use std::borrow::Cow;use std::time::Instant;use unicode_width::UnicodeWidthChar;use unicode_width::UnicodeWidthStr;use crate::bottom_pane::popup_consts::standard_popup_hint_line;use crate::bottom_pane::scroll_state::ScrollState;use crate::bottom_pane::selection_popup_common::measure_rows_height;use crate::bottom_pane::selection_popup_common::menu_surface_inset;use crate::bottom_pane::selection_popup_common::menu_surface_padding_height;use crate::bottom_pane::selection_popup_common::render_menu_surface;use crate::bottom_pane::selection_popup_common::render_rows;use crate::bottom_pane::selection_popup_common::wrap_styled_line;use crate::render::renderable::Renderable;use super::DESIRED_SPACERS_BETWEEN_SECTIONS;use super::RequestUserInputOverlay;use super::TIP_SEPARATOR;const MIN_OVERLAY_HEIGHT: usize = 8;const PROGRESS_ROW_HEIGHT: usize = 1;const SPACER_ROWS_WITH_NOTES: usize = 1;const SPACER_ROWS_NO_OPTIONS: usize = 0;struct UnansweredConfirmationData { title_line: Line<'static>, subtitle_line: Line<'static>, hint_line: Line<'static>, rows: Vec<crate::bottom_pane::selection_popup_common::GenericDisplayRow>, state: ScrollState,}struct UnansweredConfirmationLayout { header_lines: Vec<Line<'static>>, hint_lines: Vec<Line<'static>>, rows: Vec<crate::bottom_pane::selection_popup_common::GenericDisplayRow>, state: ScrollState,}fn line_to_owned(line: Line<'_>) -> Line<'static> { Line { style: line.style, alignment: line.alignment, spans: line .spans .into_iter() .map(|span| Span { style: span.style, content: Cow::Owned(span.content.into_owned()), }) .collect(), }}impl Renderable for RequestUserInputOverlay { fn desired_height(&self, width: u16) -> u16 { if self.confirm_unanswered_active() { return self.unanswered_confirmation_height(width); } let outer = Rect::new(0, 0, width, u16::MAX); let inner = menu_surface_inset(outer); let inner_width = inner.width.max(1); let has_options = self.has_options(); let question_height = self.wrapped_question_lines(inner_width).len(); let options_height = if has_options { self.options_preferred_height(inner_width) as usize } else { 0 }; let notes_visible = !has_options || self.notes_ui_visible(); let notes_height = if notes_visible { self.notes_input_height(inner_width) as usize } else { 0 }; // When notes are visible, the composer already separates options from the footer. // Without notes, we keep extra spacing so the footer hints don't crowd the options. let spacer_rows = if has_options { if notes_visible { SPACER_ROWS_WITH_NOTES } else { DESIRED_SPACERS_BETWEEN_SECTIONS as usize } } else { SPACER_ROWS_NO_OPTIONS }; let footer_height = self.footer_required_height(inner_width) as usize; // Tight minimum height: progress + question + (optional) titles/options // + notes composer + footer + menu padding. let mut height = question_height .saturating_add(options_height) .saturating_add(spacer_rows) .saturating_add(notes_height) .saturating_add(footer_height) .saturating_add(PROGRESS_ROW_HEIGHT); // progress height = height.saturating_add(menu_surface_padding_height() as usize); height.max(MIN_OVERLAY_HEIGHT) as u16 } fn render(&self, area: Rect, buf: &mut Buffer) { self.render_ui_at(area, buf, Instant::now()); } fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { self.cursor_pos_impl(area) }}impl RequestUserInputOverlay { fn unanswered_confirmation_data(&self) -> UnansweredConfirmationData { let unanswered = self.unanswered_question_count(); let subtitle = format!( "{unanswered} unanswered question{}", if unanswered == 1 { "" } else { "s" } ); UnansweredConfirmationData { title_line: Line::from(super::UNANSWERED_CONFIRM_TITLE.bold()), subtitle_line: Line::from(subtitle.dim()), hint_line: standard_popup_hint_line(), rows: self.unanswered_confirmation_rows(), state: self.confirm_unanswered.unwrap_or_default(), } } fn unanswered_confirmation_layout(&self, width: u16) -> UnansweredConfirmationLayout { let data = self.unanswered_confirmation_data(); let content_width = width.max(1); let mut header_lines = wrap_styled_line(&data.title_line, content_width); let mut subtitle_lines = wrap_styled_line(&data.subtitle_line, content_width); header_lines.append(&mut subtitle_lines); let header_lines = header_lines.into_iter().map(line_to_owned).collect(); let hint_lines = wrap_styled_line(&data.hint_line, content_width) .into_iter() .map(line_to_owned) .collect(); UnansweredConfirmationLayout { header_lines, hint_lines, rows: data.rows, state: data.state, } } fn unanswered_confirmation_height(&self, width: u16) -> u16 { let outer = Rect::new(0, 0, width, u16::MAX); let inner = menu_surface_inset(outer); let inner_width = inner.width.max(1); let layout = self.unanswered_confirmation_layout(inner_width); let rows_height = measure_rows_height( &layout.rows, &layout.state, layout.rows.len().max(1), inner_width.max(1), ); let height = layout.header_lines.len() as u16 + 1 + rows_height + 1 + layout.hint_lines.len() as u16 + menu_surface_padding_height(); height.max(MIN_OVERLAY_HEIGHT as u16) } fn render_unanswered_confirmation(&self, area: Rect, buf: &mut Buffer) { let content_area = render_menu_surface(area, buf); if content_area.width == 0 || content_area.height == 0 { return; } let width = content_area.width.max(1); let layout = self.unanswered_confirmation_layout(width); let mut cursor_y = content_area.y; for line in layout.header_lines { if cursor_y >= content_area.y + content_area.height { return; } Paragraph::new(line).render( Rect { x: content_area.x, y: cursor_y, width: content_area.width, height: 1, }, buf, ); cursor_y = cursor_y.saturating_add(1); } if cursor_y < content_area.y + content_area.height { cursor_y = cursor_y.saturating_add(1); } let remaining = content_area .height .saturating_sub(cursor_y.saturating_sub(content_area.y)); if remaining == 0 { return; } let hint_height = layout.hint_lines.len() as u16; let spacer_before_hint = u16::from(remaining > hint_height); let rows_height = remaining.saturating_sub(hint_height + spacer_before_hint); let rows_area = Rect { x: content_area.x, y: cursor_y, width: content_area.width, height: rows_height, }; render_rows( rows_area, buf, &layout.rows, &layout.state, layout.rows.len().max(1), "No choices", ); cursor_y = cursor_y.saturating_add(rows_height); if spacer_before_hint > 0 { cursor_y = cursor_y.saturating_add(1); } for (offset, line) in layout.hint_lines.into_iter().enumerate() { let y = cursor_y.saturating_add(offset as u16); if y >= content_area.y + content_area.height { break; } Paragraph::new(line).render( Rect { x: content_area.x, y, width: content_area.width, height: 1, }, buf, ); } } pub(super) fn render_ui_at(&self, area: Rect, buf: &mut Buffer, now: Instant) { if area.width == 0 || area.height == 0 { return; } if self.confirm_unanswered_active() { self.render_unanswered_confirmation(area, buf); return; } // Paint the same menu surface used by other bottom-pane overlays and // then render the overlay content inside its inset area. let content_area = render_menu_surface(area, buf); if content_area.width == 0 || content_area.height == 0 { return; } let sections = self.layout_sections(content_area); let notes_visible = self.notes_ui_visible(); // Progress header keeps the user oriented across multiple questions. let progress_line = if let Some(countdown) = self.auto_resolution_countdown_text_at(now) { Line::from(vec![ self.progress_prefix_text().dim(), " · ".dim(), countdown.red(), ]) } else { Line::from(self.progress_prefix_text().dim()) }; Paragraph::new(progress_line).render(sections.progress_area, buf); // Question prompt text. let question_y = sections.question_area.y; let answered = self.is_question_answered(self.current_index(), &self.composer.current_text()); for (offset, line) in sections.question_lines.iter().enumerate() { if question_y.saturating_add(offset as u16) >= sections.question_area.y + sections.question_area.height { break; } let question_line = if answered { Line::from(line.clone()) } else { Line::from(line.clone()).cyan() }; Paragraph::new(question_line).render( Rect { x: sections.question_area.x, y: question_y.saturating_add(offset as u16), width: sections.question_area.width, height: 1, }, buf, ); } // Build rows with selection markers for the shared selection renderer. let option_rows = self.option_rows(); if self.has_options() { let mut options_state = self .current_answer() .map(|answer| answer.options_state) .unwrap_or_default(); if sections.options_area.height > 0 { // Ensure the selected option is visible in the scroll window. options_state .ensure_visible(option_rows.len(), sections.options_area.height as usize); render_rows_bottom_aligned( sections.options_area, buf, &option_rows, &options_state, option_rows.len().max(1), "No options", ); } } if notes_visible && sections.notes_area.height > 0 { self.render_notes_input(sections.notes_area, buf); } let footer_y = sections .notes_area .y .saturating_add(sections.notes_area.height); let footer_area = Rect { x: content_area.x, y: footer_y, width: content_area.width, height: sections.footer_lines, }; if footer_area.height == 0 { return; } let options_hidden = self.has_options() && sections.options_area.height > 0 && self.options_required_height(content_area.width) > sections.options_area.height; let option_tip = if options_hidden { let selected = self.selected_option_index().unwrap_or(0).saturating_add(1); let total = self.options_len(); Some(super::FooterTip::new(format!("option {selected}/{total}"))) } else { None }; let tip_lines = self.footer_tip_lines_with_prefix(footer_area.width, option_tip); for (row_idx, tips) in tip_lines .into_iter() .take(footer_area.height as usize) .enumerate() { let mut spans = Vec::new(); for (tip_idx, tip) in tips.into_iter().enumerate() { if tip_idx > 0 { spans.push(TIP_SEPARATOR.into()); } if tip.highlight { spans.push(tip.text.cyan().bold().not_dim()); } else { spans.push(tip.text.into()); } } let line = Line::from(spans).dim(); let line = truncate_line_word_boundary_with_ellipsis(line, footer_area.width as usize); let row_area = Rect { x: footer_area.x, y: footer_area.y.saturating_add(row_idx as u16), width: footer_area.width, height: 1, }; Paragraph::new(line).render(row_area, buf); } } /// Return the cursor position when editing notes, if visible. pub(super) fn cursor_pos_impl(&self, area: Rect) -> Option<(u16, u16)> { if self.confirm_unanswered_active() { return None; } let has_options = self.has_options(); let notes_visible = self.notes_ui_visible(); if !self.focus_is_notes() { return None; } if has_options && !notes_visible { return None; } let content_area = menu_surface_inset(area); if content_area.width == 0 || content_area.height == 0 { return None; } let sections = self.layout_sections(content_area); let input_area = sections.notes_area; if input_area.width == 0 || input_area.height == 0 { return None; } self.composer.cursor_pos(input_area) } /// Render the notes composer. fn render_notes_input(&self, area: Rect, buf: &mut Buffer) { if area.width == 0 || area.height == 0 { return; } let is_secret = self .current_question() .is_some_and(|question| question.is_secret); if is_secret { self.composer.render_with_mask(area, buf, Some('*')); } else { self.composer.render(area, buf); } }}fn line_width(line: &Line<'_>) -> usize { line.iter() .map(|span| UnicodeWidthStr::width(span.content.as_ref())) .sum()}/// Render rows into `area`, bottom-aligning the visible rows when fewer than/// `area.height` lines are produced.////// This keeps footer spacing stable by anchoring the options block to the/// bottom of its allocated region.fn render_rows_bottom_aligned( area: Rect, buf: &mut Buffer, rows: &[crate::bottom_pane::selection_popup_common::GenericDisplayRow], state: &ScrollState, max_results: usize, empty_message: &str,) { if area.width == 0 || area.height == 0 { return; } let scratch_area = Rect::new(0, 0, area.width, area.height); let mut scratch = Buffer::empty(scratch_area); for y in 0..area.height { for x in 0..area.width { scratch[(x, y)] = buf[(area.x + x, area.y + y)].clone(); } } let rendered_height = render_rows( scratch_area, &mut scratch, rows, state, max_results, empty_message, ); let visible_height = rendered_height.min(area.height); let y_offset = area.height.saturating_sub(visible_height); for y in 0..visible_height { for x in 0..area.width { buf[(area.x + x, area.y + y_offset + y)] = scratch[(x, y)].clone(); } }}/// Truncate a styled line to `max_width`, preferring a word boundary, and append an ellipsis.////// This walks spans character-by-character, tracking the last width-safe position and the last/// whitespace boundary within the available width (excluding the ellipsis width). If the line/// overflows, it truncates at the last word boundary when possible (falling back to the last/// fitting character), trims trailing whitespace, then appends an ellipsis styled to match the/// last visible span (or the line style if nothing was kept).fn truncate_line_word_boundary_with_ellipsis( line: Line<'static>, max_width: usize,) -> Line<'static> { if max_width == 0 { return Line::from(Vec::<Span<'static>>::new()); } if line_width(&line) <= max_width { return line; } let ellipsis = "…"; let ellipsis_width = UnicodeWidthStr::width(ellipsis); if ellipsis_width >= max_width { return Line::from(ellipsis); } let limit = max_width.saturating_sub(ellipsis_width); #[derive(Clone, Copy)] struct BreakPoint { span_idx: usize, byte_end: usize, } // Track display width as we scan, along with the best "cut here" positions. let mut used = 0usize; let mut last_fit: Option<BreakPoint> = None; let mut last_word_break: Option<BreakPoint> = None; let mut overflowed = false; 'outer: for (span_idx, span) in line.spans.iter().enumerate() { let text = span.content.as_ref(); for (byte_idx, ch) in text.char_indices() { let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); if used.saturating_add(ch_width) > limit { overflowed = true; break 'outer; } used = used.saturating_add(ch_width); let bp = BreakPoint { span_idx, byte_end: byte_idx + ch.len_utf8(), }; last_fit = Some(bp); if ch.is_whitespace() { last_word_break = Some(bp); } } } // If we never overflowed, the original line already fits. if !overflowed { return line; } // Prefer breaking on whitespace; otherwise fall back to the last fitting character. let chosen_break = last_word_break.or(last_fit); let Some(chosen_break) = chosen_break else { return Line::from(ellipsis); }; let line_style = line.style; let mut spans_out: Vec<Span<'static>> = Vec::new(); for (idx, span) in line.spans.into_iter().enumerate() { if idx < chosen_break.span_idx { spans_out.push(span); continue; } if idx == chosen_break.span_idx { let text = span.content.into_owned(); let truncated = text[..chosen_break.byte_end].to_string(); if !truncated.is_empty() { spans_out.push(Span::styled(truncated, span.style)); } } break; } while let Some(last) = spans_out.last_mut() { let trimmed = last .content .trim_end_matches(char::is_whitespace) .to_string(); if trimmed.is_empty() { spans_out.pop(); } else { last.content = trimmed.into(); break; } } let ellipsis_style = spans_out .last() .map(|span| span.style) .unwrap_or(line_style); spans_out.push(Span::styled(ellipsis, ellipsis_style)); Line::from(spans_out).style(line_style)}