travelagent 1.11.1

Agent-first TUI code review tool
//! Mental-model capture modal — rendered centered over the frame when
//! `InputMode::MentalModelEdit` is active (Phase I2, Sparring Review).
//!
//! Four prompts, one focused at a time. Tab/Shift+Tab cycle; Ctrl+S
//! saves into `ReviewSession.mental_model`; Esc discards.
//!
//! The renderer intentionally stays minimal today: a bordered popup
//! with four labelled Paragraph widgets, the focused one highlighted.
//! Richer features (word-wrap, per-field char counters, syntax hints)
//! can come in follow-up PRs — the goal here is to ship a working
//! capture surface that a reviewer can use end-to-end.

use ratatui::{
    Frame,
    layout::{Constraint, Flex, Layout, Rect},
    style::{Modifier, Style},
    text::{Line, Span, Text},
    widgets::{Block, Borders, Clear, Paragraph, Wrap},
};

use crate::app::{App, MENTAL_MODEL_LABELS};
use crate::theme::Theme;
use crate::ui::styles;

/// Render the mental-model modal centered over `area`. Callers should
/// gate on `app.nav.input_mode == InputMode::MentalModelEdit` before
/// invoking.
pub fn render(frame: &mut Frame, area: Rect, app: &App, theme: &Theme) {
    // Make the modal roomy enough that reflection doesn't feel cramped
    // but still fits a typical 80-col terminal comfortably.
    let desired_width: u16 = 76.min(area.width.saturating_sub(4));
    let desired_height: u16 = 24.min(area.height.saturating_sub(4));
    let popup_area = centered_rect(desired_width, desired_height, area);

    frame.render_widget(Clear, popup_area);

    let byte_limit = app.mental_model_byte_limit;
    let title = format!(" Mental Model — {byte_limit} bytes/field ");
    let block = Block::default()
        .title(title)
        .borders(Borders::ALL)
        .style(styles::popup_style(theme))
        .border_style(styles::border_style(theme, true));

    let inner = block.inner(popup_area);
    frame.render_widget(block, popup_area);

    if inner.height < 8 {
        return;
    }

    // One row per prompt. See `field_constraints` for the rule that
    // keeps the fourth row absorbing the `inner.height % 4` remainder.
    let rows = Layout::vertical(field_constraints(inner.height)).split(inner);

    for (idx, row) in rows.iter().enumerate() {
        render_field(frame, *row, idx, app, theme);
    }
}

fn render_field(frame: &mut Frame, area: Rect, idx: usize, app: &App, theme: &Theme) {
    let focused = app.mental_model_edit.focused == idx;
    let label = MENTAL_MODEL_LABELS[idx];
    let body = &app.mental_model_edit.drafts[idx];

    // Per-field bordered block — a thin divider between prompts so the
    // user sees at a glance where one field ends and the next starts.
    let mut block = Block::default()
        .title(format!(
            " {} {}/{} ",
            label,
            body.len(),
            app.mental_model_byte_limit
        ))
        .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT | Borders::BOTTOM);
    if focused {
        block = block.border_style(
            Style::default()
                .fg(theme.fg_primary)
                .add_modifier(Modifier::BOLD),
        );
    } else {
        block = block.border_style(Style::default().fg(theme.fg_secondary));
    }

    let inner = block.inner(area);
    frame.render_widget(block, area);

    // Split on newlines so `Enter` in the buffer actually wraps onto the
    // next visual row. A single `Line` collapses `'\n'` into a single
    // span and the rendering looks garbled; build one `Line` per line
    // and append the cursor glyph to the last one when this field is
    // focused.
    let mut lines: Vec<Line<'static>> = body
        .split('\n')
        .map(|segment| {
            Line::from(Span::styled(
                segment.to_string(),
                Style::default().fg(theme.fg_primary),
            ))
        })
        .collect();
    if focused && let Some(last) = lines.last_mut() {
        last.spans.push(Span::styled(
            "\u{2588}".to_string(),
            Style::default().fg(theme.fg_primary),
        ));
    }
    let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false });
    frame.render_widget(paragraph, inner);
}

/// Row-height constraints for the four mental-model prompt rows.
///
/// The first three rows get an equal share of `inner_height / 4`; the
/// last row uses `Constraint::Fill(1)` so it absorbs the
/// `inner_height % 4` remainder rather than leaving dead space when
/// `inner_height` isn't divisible by 4. Shared between `render()` and
/// the layout tests so the two can't drift.
fn field_constraints(inner_height: u16) -> [Constraint; 4] {
    let field_height = inner_height / 4;
    [
        Constraint::Length(field_height),
        Constraint::Length(field_height),
        Constraint::Length(field_height),
        Constraint::Fill(1),
    ]
}

fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
    let horiz = Layout::horizontal([Constraint::Length(width)])
        .flex(Flex::Center)
        .split(area);
    let vert = Layout::vertical([Constraint::Length(height)])
        .flex(Flex::Center)
        .split(horiz[0]);
    vert[0]
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Apply the renderer's partitioning rule to `inner` so we can
    /// assert `Rect` layout without a TestBackend. The rule itself
    /// lives in `field_constraints` — tests and `render()` share it,
    /// so they can't silently drift.
    fn split_body(inner: Rect) -> std::rc::Rc<[Rect]> {
        Layout::vertical(field_constraints(inner.height)).split(inner)
    }

    #[test]
    fn layout_divides_into_four_rows() {
        let inner = Rect::new(0, 0, 74, 20); // divisible by 4
        let rows = split_body(inner);
        assert_eq!(rows.len(), 4);
        for row in rows.iter() {
            assert_eq!(row.height, 5, "each row gets a quarter of 20 rows");
        }
    }

    #[test]
    fn layout_last_row_absorbs_remainder_on_non_divisible_heights() {
        // Prior bug: `[Constraint::Length(height/4); 4]` on a height of
        // 22 allocated 5*4 = 20 rows and left 2 dead. The `Fill(1)` on
        // the last slot now claims those rows.
        let inner = Rect::new(0, 0, 74, 22);
        let rows = split_body(inner);
        assert_eq!(rows.len(), 4);
        assert_eq!(rows[0].height, 5);
        assert_eq!(rows[1].height, 5);
        assert_eq!(rows[2].height, 5);
        assert_eq!(
            rows[3].height, 7,
            "fourth row must absorb the 22 % 4 = 2 leftover rows"
        );
        // And the total still sums to `inner.height` — no visual gap.
        let total: u16 = rows.iter().map(|r| r.height).sum();
        assert_eq!(total, inner.height);
    }

    #[test]
    fn layout_rows_cover_inner_without_gaps() {
        // Walk a few representative heights to confirm the rows tile
        // `inner` end-to-end. A regression (e.g. swapping Fill → Length)
        // that re-introduces dead space would trip this.
        for height in [8, 9, 10, 11, 12, 17, 21, 24] {
            let inner = Rect::new(0, 0, 74, height);
            let rows = split_body(inner);
            let total: u16 = rows.iter().map(|r| r.height).sum();
            assert_eq!(
                total, height,
                "rows must tile inner exactly at height={height}"
            );
            // Adjacent rows must be contiguous (row_n.y + row_n.height == row_{n+1}.y).
            for pair in rows.windows(2) {
                assert_eq!(
                    pair[0].y + pair[0].height,
                    pair[1].y,
                    "row gap detected at height={height}"
                );
            }
        }
    }

    #[test]
    fn centered_rect_sits_inside_area() {
        let outer = Rect::new(0, 0, 100, 50);
        let centered = centered_rect(40, 20, outer);
        assert_eq!(centered.width, 40);
        assert_eq!(centered.height, 20);
        assert!(centered.x >= outer.x);
        assert!(centered.y >= outer.y);
        assert!(centered.x + centered.width <= outer.x + outer.width);
        assert!(centered.y + centered.height <= outer.y + outer.height);
    }
}