use crossterm::event::KeyCode; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Clear; use ratatui::widgets::Paragraph; use ratatui::widgets::StatefulWidgetRef; use ratatui::widgets::Widget; use crate::bottom_pane::selection_popup_common::GenericDisplayRow; use crate::bottom_pane::selection_popup_common::render_rows; use crate::key_hint; use crate::render::renderable::Renderable; use super::RequestUserInputOverlay; impl Renderable for RequestUserInputOverlay { fn desired_height(&self, width: u16) -> u16 { let sections = self.layout_sections(Rect::new(0, 0, width, u16::MAX)); let mut height = sections .question_lines .len() .saturating_add(5) .saturating_add(self.notes_input_height(width) as usize) .saturating_add(sections.footer_lines as usize); if self.has_options() { height = height.saturating_add(2); } height = height.max(8); height as u16 } fn render(&self, area: Rect, buf: &mut Buffer) { self.render_ui(area, buf); } fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { self.cursor_pos_impl(area) } } impl RequestUserInputOverlay { /// Render the full request-user-input overlay. pub(super) fn render_ui(&self, area: Rect, buf: &mut Buffer) { if area.width == 0 || area.height == 0 { return; } let sections = self.layout_sections(area); // Progress header keeps the user oriented across multiple questions. let progress_line = if self.question_count() > 0 { let idx = self.current_index() + 1; let total = self.question_count(); Line::from(format!("Question {idx}/{total}").dim()) } else { Line::from("No questions".dim()) }; Paragraph::new(progress_line).render(sections.progress_area, buf); // Question title and wrapped prompt text. let question_header = self.current_question().map(|q| q.header.clone()); let header_line = if let Some(header) = question_header { Line::from(header.bold()) } else { Line::from("No questions".dim()) }; Paragraph::new(header_line).render(sections.header_area, buf); let question_y = sections.question_area.y; 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; } Paragraph::new(Line::from(line.clone())).render( Rect { x: sections.question_area.x, y: question_y.saturating_add(offset as u16), width: sections.question_area.width, height: 1, }, buf, ); } if sections.answer_title_area.height > 0 { let answer_label = "Answer"; let answer_title = if self.focus_is_options() || self.focus_is_notes_without_options() { answer_label.cyan().bold() } else { answer_label.dim() }; Paragraph::new(Line::from(answer_title)).render(sections.answer_title_area, buf); } // Build rows with selection markers for the shared selection renderer. let option_rows = self .current_question() .and_then(|question| question.options.as_ref()) .map(|options| { options .iter() .enumerate() .map(|(idx, opt)| { let selected = self .current_answer() .and_then(|answer| answer.selected) .is_some_and(|sel| sel == idx); let prefix = if selected { "(x)" } else { "( )" }; GenericDisplayRow { name: format!("{prefix} {}", opt.label), description: Some(opt.description.clone()), ..Default::default() } }) .collect::>() }) .unwrap_or_default(); if self.has_options() { let mut option_state = self .current_answer() .map(|answer| answer.option_state) .unwrap_or_default(); if sections.options_area.height > 0 { // Ensure the selected option is visible in the scroll window. option_state .ensure_visible(option_rows.len(), sections.options_area.height as usize); render_rows( sections.options_area, buf, &option_rows, &option_state, option_rows.len().max(1), "No options", ); } } if sections.notes_title_area.height > 0 { let notes_label = if self.has_options() && self .current_answer() .is_some_and(|answer| answer.selected.is_some()) { if let Some(label) = self.current_option_label() { format!("Notes for {label} (optional)") } else { "Notes (optional)".to_string() } } else { "Notes (optional)".to_string() }; let notes_title = if self.focus_is_notes() { notes_label.as_str().cyan().bold() } else { notes_label.as_str().dim() }; Paragraph::new(Line::from(notes_title)).render(sections.notes_title_area, buf); } if 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); if sections.footer_lines == 2 { // Status line for unanswered count when any question is empty. let warning = format!( "Unanswered: {} | Will submit as skipped", self.unanswered_count() ); Paragraph::new(Line::from(warning.dim())).render( Rect { x: area.x, y: footer_y, width: area.width, height: 1, }, buf, ); } let hint_y = footer_y.saturating_add(sections.footer_lines.saturating_sub(1)); // Footer hints (selection index + navigation keys). let mut hint_spans = Vec::new(); if self.has_options() { let options_len = self.options_len(); let option_index = self.selected_option_index().map_or(0, |idx| idx + 1); hint_spans.extend(vec![ format!("Option {option_index} of {options_len}").into(), " | ".into(), ]); } hint_spans.extend(vec![ key_hint::plain(KeyCode::Up).into(), "/".into(), key_hint::plain(KeyCode::Down).into(), " scroll | ".into(), key_hint::plain(KeyCode::Enter).into(), " next question | ".into(), ]); if self.question_count() > 1 { hint_spans.extend(vec![ key_hint::plain(KeyCode::PageUp).into(), " prev | ".into(), key_hint::plain(KeyCode::PageDown).into(), " next | ".into(), ]); } hint_spans.extend(vec![ key_hint::plain(KeyCode::Esc).into(), " interrupt".into(), ]); Paragraph::new(Line::from(hint_spans).dim()).render( Rect { x: area.x, y: hint_y, width: area.width, height: 1, }, buf, ); } /// Return the cursor position when editing notes, if visible. pub(super) fn cursor_pos_impl(&self, area: Rect) -> Option<(u16, u16)> { if !self.focus_is_notes() { return None; } let sections = self.layout_sections(area); let entry = self.current_notes_entry()?; let input_area = sections.notes_area; if input_area.width <= 2 || input_area.height == 0 { return None; } if input_area.height < 3 { // Inline notes layout uses a prefix and a single-line text area. let prefix = notes_prefix(); let prefix_width = prefix.len() as u16; if input_area.width <= prefix_width { return None; } let textarea_rect = Rect { x: input_area.x.saturating_add(prefix_width), y: input_area.y, width: input_area.width.saturating_sub(prefix_width), height: 1, }; let state = *entry.state.borrow(); return entry.text.cursor_pos_with_state(textarea_rect, state); } let text_area_height = input_area.height.saturating_sub(2); let textarea_rect = Rect { x: input_area.x.saturating_add(1), y: input_area.y.saturating_add(1), width: input_area.width.saturating_sub(2), height: text_area_height, }; let state = *entry.state.borrow(); entry.text.cursor_pos_with_state(textarea_rect, state) } /// Render the notes input box or inline notes field. fn render_notes_input(&self, area: Rect, buf: &mut Buffer) { let Some(entry) = self.current_notes_entry() else { return; }; if area.width < 2 || area.height == 0 { return; } if area.height < 3 { // Inline notes field for tight layouts. let prefix = notes_prefix(); let prefix_width = prefix.len() as u16; if area.width <= prefix_width { Paragraph::new(Line::from(prefix.dim())).render(area, buf); return; } Paragraph::new(Line::from(prefix.dim())).render( Rect { x: area.x, y: area.y, width: prefix_width, height: 1, }, buf, ); let textarea_rect = Rect { x: area.x.saturating_add(prefix_width), y: area.y, width: area.width.saturating_sub(prefix_width), height: 1, }; let mut state = entry.state.borrow_mut(); Clear.render(textarea_rect, buf); StatefulWidgetRef::render_ref(&(&entry.text), textarea_rect, buf, &mut state); if entry.text.text().is_empty() { Paragraph::new(Line::from(self.notes_placeholder().dim())) .render(textarea_rect, buf); } return; } // Draw a light ASCII frame around the notes area. let top_border = format!("+{}+", "-".repeat(area.width.saturating_sub(2) as usize)); let bottom_border = top_border.clone(); Paragraph::new(Line::from(top_border)).render( Rect { x: area.x, y: area.y, width: area.width, height: 1, }, buf, ); Paragraph::new(Line::from(bottom_border)).render( Rect { x: area.x, y: area.y.saturating_add(area.height.saturating_sub(1)), width: area.width, height: 1, }, buf, ); for row in 1..area.height.saturating_sub(1) { Line::from(vec![ "|".into(), " ".repeat(area.width.saturating_sub(2) as usize).into(), "|".into(), ]) .render( Rect { x: area.x, y: area.y.saturating_add(row), width: area.width, height: 1, }, buf, ); } let text_area_height = area.height.saturating_sub(2); let textarea_rect = Rect { x: area.x.saturating_add(1), y: area.y.saturating_add(1), width: area.width.saturating_sub(2), height: text_area_height, }; let mut state = entry.state.borrow_mut(); Clear.render(textarea_rect, buf); StatefulWidgetRef::render_ref(&(&entry.text), textarea_rect, buf, &mut state); if entry.text.text().is_empty() { Paragraph::new(Line::from(self.notes_placeholder().dim())).render(textarea_rect, buf); } } fn focus_is_options(&self) -> bool { matches!(self.focus, super::Focus::Options) } fn focus_is_notes(&self) -> bool { matches!(self.focus, super::Focus::Notes) } fn focus_is_notes_without_options(&self) -> bool { !self.has_options() && self.focus_is_notes() } } fn notes_prefix() -> &'static str { "Notes: " }