Codex Handbook
tui/src/bottom_pane/request_user_input/render.rs 578 lines
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)}