Skip to main content

pi/modes/interactive/
view.rs

1//! View composition: exact-order stack + buffer rendering + golden snapshot helpers.
2//!
3//! [`compose`] builds the ordered component stack for one frame:
4//!
5//! ```text
6//! header → resources → chat → pending → status
7//!        → widgets above → editor → widgets below → footer
8//! ```
9//!
10//! [`render_view`] measures each section, allocates vertical rects, and renders
11//! into one Ratatui [`Buffer`]. Everything runs inside
12//! [`super::theme::with_theme`] so the `fn`-pointer component themes resolve
13//! against the view's theme. No terminal, no stdout — fully snapshot-testable.
14
15use std::collections::BTreeMap;
16
17use ratatui::buffer::Buffer;
18use ratatui::layout::Rect;
19#[cfg(test)]
20use ratatui::style::{Color, Modifier};
21
22use pi_ext::adapters::{SlotComponent, tui_overlay_spec};
23use pi_tui::component::Component;
24use pi_tui::components::Text;
25use pi_tui::focus::Focusable;
26
27use super::footer;
28use super::header;
29use super::messages::{self, MessageView};
30use super::progress;
31use super::startup;
32use super::state::{FocusArea, OverlayKind, ViewState, WidgetSlot};
33use super::status;
34use super::theme::{self, MarkdownTheme, ResolvedTheme, markdown_theme};
35
36/// One composed section: a label + the boxed component.
37pub struct ComposedSection {
38    /// Section label (for golden-snapshot headers / debugging).
39    pub label: &'static str,
40    /// The component.
41    pub component: Box<dyn Component>,
42}
43
44/// The full composed view: ordered sections + optional overlay.
45pub struct ComposedView {
46    /// Ordered sections (header → resources → chat → pending → status →
47    /// widgets above → editor → widgets below → footer).
48    pub sections: Vec<ComposedSection>,
49    /// Optional overlay rendered on top (shortcut help / changelog / login / …).
50    pub overlay: Option<Box<dyn Component>>,
51    /// Extension overlay layout specification, when the overlay is host-owned.
52    pub overlay_spec: Option<pi_tui::layout::OverlaySpec>,
53}
54
55/// Compose the full view-model into ordered sections for `state`.
56///
57/// The caller renders via [`render_view`] or walks sections directly. The
58/// theme is installed thread-locally for the duration of composition.
59#[must_use]
60pub fn compose(state: &ViewState) -> ComposedView {
61    theme::with_theme(state.theme.clone(), || compose_inner(state))
62}
63
64fn compose_inner(state: &ViewState) -> ComposedView {
65    let md_theme = markdown_theme();
66    let mut sections: Vec<ComposedSection> = Vec::new();
67
68    // 1. Header
69    if !state.quiet {
70        sections.push(ComposedSection {
71            label: "header",
72            component: header::build_header(&state.header, md_theme.clone(), &state.theme),
73        });
74    }
75
76    // 2. Loaded resources
77    sections.push(ComposedSection {
78        label: "resources",
79        component: startup::build_resources(&state.resources, &state.theme),
80    });
81
82    // 3. Startup diagnostics (rendered with resources, above chat)
83    sections.push(ComposedSection {
84        label: "diagnostics",
85        component: startup::build_diagnostics(&state.diagnostics, &state.theme),
86    });
87
88    // 4. Chat messages
89    sections.push(ComposedSection {
90        label: "chat",
91        component: build_chat(state, &md_theme),
92    });
93
94    // 5. Pending queue
95    sections.push(ComposedSection {
96        label: "pending",
97        component: progress::build_pending(&state.pending, &state.theme),
98    });
99
100    // 6. Status indicator
101    sections.push(ComposedSection {
102        label: "status",
103        component: build_status_section(state),
104    });
105
106    // 7. Widgets above editor
107    sections.push(ComposedSection {
108        label: "widgets-above",
109        component: build_widget_stack(&state.widgets_above, &state.theme),
110    });
111
112    // 8. Editor (or active selector / overlay replacement)
113    sections.push(ComposedSection {
114        label: "editor",
115        component: build_editor_section(state),
116    });
117
118    // 9. Widgets below editor
119    sections.push(ComposedSection {
120        label: "widgets-below",
121        component: build_widget_stack(&state.widgets_below, &state.theme),
122    });
123
124    // 10. Footer
125    sections.push(ComposedSection {
126        label: "footer",
127        component: footer::build_footer(&state.footer, &state.theme, state.width),
128    });
129
130    let overlay = build_overlay(state, &md_theme);
131    let overlay_spec = state
132        .extension_overlay_slot
133        .as_ref()
134        .and_then(|slot| slot.overlay_options.as_ref())
135        .map(tui_overlay_spec);
136
137    ComposedView {
138        sections,
139        overlay,
140        overlay_spec,
141    }
142}
143
144/// Build the chat container from all message view-models.
145fn build_chat(state: &ViewState, md_theme: &MarkdownTheme) -> Box<dyn Component> {
146    let renderers: BTreeMap<String, Box<dyn super::tool_renderer::CustomToolRenderer>> =
147        BTreeMap::new();
148    let mut stack = messages::ColumnStack::new();
149    for msg in &state.messages {
150        let comps = build_message(msg, &renderers, md_theme, &state.theme);
151        for c in comps {
152            stack.push(c);
153        }
154    }
155    if state.messages.is_empty() && !state.streaming {
156        // Empty-state hint.
157        stack.push(Box::new(Text::with_padding(
158            state.theme.fg(
159                super::theme::ThemeColor::Dim,
160                "No messages yet. Type below to begin.",
161            ),
162            1,
163            0,
164        )));
165    }
166    Box::new(stack)
167}
168
169/// Build the component stack for one message view-model.
170fn build_message(
171    msg: &MessageView,
172    renderers: &BTreeMap<String, Box<dyn super::tool_renderer::CustomToolRenderer>>,
173    md_theme: &MarkdownTheme,
174    th: &ResolvedTheme,
175) -> Vec<Box<dyn Component>> {
176    match msg {
177        MessageView::User(v) => vec![messages::build_user(v, md_theme, th)],
178        MessageView::Assistant(v) => messages::build_assistant(v, md_theme, th),
179        MessageView::Tool(v) => messages::build_tool(v, renderers, th),
180        MessageView::Bash(v) => vec![messages::build_bash(v, th)],
181        MessageView::Custom(v) => vec![messages::build_custom(v, md_theme, th)],
182        MessageView::Compaction(v) => vec![messages::build_compaction(v, md_theme, th)],
183        MessageView::Branch(v) => vec![messages::build_branch(v, md_theme, th)],
184        MessageView::Skill(v) => vec![messages::build_skill(v, md_theme, th)],
185    }
186}
187
188/// Build the status section (active indicator or idle).
189fn build_status_section(state: &ViewState) -> Box<dyn Component> {
190    if let Some(status) = state.status.as_ref() {
191        status::build_status(status, &state.theme)
192    } else {
193        status::build_idle(state.width)
194    }
195}
196
197/// Build the editor area (input, or a selector replacing it, or a progress block).
198fn build_editor_section(state: &ViewState) -> Box<dyn Component> {
199    // Progress overlays (compaction/retry/auth/bash) replace the editor.
200    if state.focus == FocusArea::Selector {
201        // Selectors are rendered as overlays in `build_overlay`; here we show
202        // a thin placeholder so the editor slot keeps its height contract.
203        return Box::new(Text::with_padding(
204            state.theme.fg(super::theme::ThemeColor::Dim, "…"),
205            0,
206            0,
207        ));
208    }
209    let editor = &state.editor;
210    let display = if editor.text.is_empty() {
211        state
212            .theme
213            .fg(super::theme::ThemeColor::Dim, &editor.placeholder)
214    } else {
215        editor.text.clone()
216    };
217    let marker = editor.paste_marker.as_deref().unwrap_or("");
218    Box::new(Text::with_padding(format!("{display}{marker}"), 1, 0))
219}
220
221/// Build a vertical widget stack from pre-rendered slot lines.
222fn build_widget_stack(slots: &[WidgetSlot], _th: &ResolvedTheme) -> Box<dyn Component> {
223    let mut stack = messages::ColumnStack::new();
224    for widget in slots {
225        let mut component = SlotComponent::new(widget.slot.clone());
226        component.set_focused(widget.focused);
227        stack.push(Box::new(component));
228    }
229    if stack.is_empty() {
230        stack.push(Box::new(pi_tui::components::Spacer::new(0)));
231    }
232    Box::new(stack)
233}
234
235/// Build the overlay component (selectors render here in the reference's
236/// editor-replace model; help/changelog/login render as overlays).
237fn build_overlay(state: &ViewState, md_theme: &MarkdownTheme) -> Option<Box<dyn Component>> {
238    let overlay = state.overlay.as_ref()?;
239    let comp: Box<dyn Component> = match overlay.kind {
240        OverlayKind::ShortcutHelp => startup::build_shortcut_overlay(
241            &startup::default_shortcut_hints(),
242            &state.extension_shortcuts,
243            &state.theme,
244        ),
245        OverlayKind::Changelog => {
246            startup::build_changelog(&overlay.lines.join("\n"), md_theme.clone(), &state.theme)
247        }
248        OverlayKind::FirstTimeSetup => {
249            startup::build_first_time_setup(0, md_theme.clone(), &state.theme)
250        }
251        OverlayKind::Login => {
252            let mut stack = messages::ColumnStack::new();
253            for line in &overlay.lines {
254                stack.push(Box::new(Text::with_padding(line.clone(), 1, 0)));
255            }
256            Box::new(stack)
257        }
258        OverlayKind::Extension => {
259            let slot = state.extension_overlay_slot.as_ref()?;
260            let mut component = SlotComponent::new(slot.clone());
261            component.set_focused(
262                state.focus == FocusArea::Overlay
263                    && !slot
264                        .overlay_options
265                        .as_ref()
266                        .is_some_and(|options| options.non_capturing),
267            );
268            Box::new(component)
269        }
270    };
271    Some(comp)
272}
273
274// ---------------------------------------------------------------------------
275// Buffer rendering
276// ---------------------------------------------------------------------------
277
278/// Render the composed view into a fresh buffer of `width` × `height`.
279///
280/// Sections are stacked top-to-bottom; each is measured at `width` and
281/// allocated a vertical rect. The overlay (if any) is rendered last at the top.
282/// Sections that would overflow `height` are truncated (later sections drop).
283#[must_use]
284pub fn render_view(state: &ViewState, width: u16, height: u16) -> Buffer {
285    render_view_with_height(state, width, height)
286}
287
288/// Render into a buffer sized to exactly the measured content height (no fixed
289/// height cap). Useful for golden snapshots that want the full content.
290#[must_use]
291pub fn render_view_with_height(state: &ViewState, width: u16, height: u16) -> Buffer {
292    let composed = compose(state);
293    let area = Rect::new(0, 0, width.max(1), height.max(1));
294    let mut buf = Buffer::empty(area);
295    let mut y = 0u16;
296    // Consume sections so each boxed component can be rendered by value.
297    for mut section in composed.sections {
298        let mut h = section.component.measure(width.max(1));
299        if h == 0 {
300            continue;
301        }
302        if y.saturating_add(h) > height {
303            h = height.saturating_sub(y);
304            if h == 0 {
305                break;
306            }
307        }
308        let rect = Rect::new(0, y, width.max(1), h);
309        let mut comp = section.component;
310        comp.render(rect, &mut buf);
311        y = y.saturating_add(h);
312        if y >= height {
313            break;
314        }
315    }
316    if let Some(mut overlay) = composed.overlay {
317        let measured = overlay.measure(width.max(1)).min(height);
318        let rect = composed.overlay_spec.as_ref().map_or_else(
319            || Rect::new(0, 0, width.max(1), measured),
320            |spec| {
321                let layout = pi_tui::layout::resolve_overlay_layout(
322                    spec,
323                    measured,
324                    width.max(1),
325                    height.max(1),
326                );
327                let overlay_height = layout
328                    .max_height
329                    .map_or(measured, |max_height| measured.min(max_height))
330                    .min(height.saturating_sub(layout.row));
331                Rect::new(layout.col, layout.row, layout.width, overlay_height)
332            },
333        );
334        if rect.height > 0 {
335            overlay.render(rect, &mut buf);
336        }
337    }
338    buf
339}
340
341/// Render a single component into a buffer at `width`, measuring its height.
342///
343/// Test helper for golden snapshots of individual sections.
344#[cfg(test)]
345#[must_use]
346pub fn render_component(comp: &mut dyn Component, width: u16) -> Buffer {
347    let h = comp.measure(width.max(1)).max(1);
348    let area = Rect::new(0, 0, width.max(1), h);
349    let mut buf = Buffer::empty(area);
350    comp.render(area, &mut buf);
351    buf
352}
353
354/// Snapshot the visible cell symbols of a buffer region (plain text, no ANSI).
355///
356/// One `String` per row; wide-cell skips and trailing spaces preserved.
357#[cfg(test)]
358#[must_use]
359pub fn snapshot_buffer_plain(buf: &Buffer, width: u16, height: u16) -> Vec<String> {
360    use ratatui::buffer::CellDiffOption;
361    let mut out = Vec::with_capacity(usize::from(height));
362    for row in 0..height {
363        let mut line = String::new();
364        for x in 0..width {
365            if let Some(cell) = buf.cell((x, row)) {
366                if cell.diff_option == CellDiffOption::Skip {
367                    continue;
368                }
369                line.push_str(cell.symbol());
370            } else {
371                line.push(' ');
372            }
373        }
374        out.push(line);
375    }
376    out
377}
378
379/// Snapshot a buffer region to ANSI-styled text (SGR codes re-emitted per cell).
380///
381/// Produces one string per row with truecolor/256 SGR sequences reconstructed
382/// from the cell styles — the "ANSI snapshot" required by the golden suite.
383#[cfg(test)]
384#[must_use]
385pub fn snapshot_buffer_ansi(
386    buf: &Buffer,
387    width: u16,
388    height: u16,
389    mode: super::theme::ColorMode,
390) -> Vec<String> {
391    use ratatui::style::{Color, Modifier};
392    let mut out = Vec::with_capacity(usize::from(height));
393    for row in 0..height {
394        let mut line = String::new();
395        let mut previous_foreground: Option<Color> = None;
396        let mut previous_background: Option<Color> = None;
397        let mut previous_style_modifiers = Modifier::empty();
398        let mut previous_style_set = false;
399        for x in 0..width {
400            if let Some(cell) = buf.cell((x, row)) {
401                let style = cell.style();
402                let foreground = style.fg;
403                let background = style.bg;
404                let style_modifiers = style.add_modifier;
405                if !previous_style_set
406                    || foreground != previous_foreground
407                    || background != previous_background
408                    || style_modifiers != previous_style_modifiers
409                {
410                    line.push_str("\x1b[0m");
411                    if let Some(c) = foreground {
412                        push_color(&mut line, c, true, mode);
413                    }
414                    if let Some(c) = background {
415                        push_color(&mut line, c, false, mode);
416                    }
417                    push_style_modifiers(&mut line, style_modifiers);
418                    previous_foreground = foreground;
419                    previous_background = background;
420                    previous_style_modifiers = style_modifiers;
421                    previous_style_set = true;
422                }
423                line.push_str(cell.symbol());
424            } else {
425                line.push(' ');
426            }
427        }
428        if previous_style_set {
429            line.push_str("\x1b[0m");
430        }
431        out.push(line);
432    }
433    out
434}
435
436#[cfg(test)]
437fn push_color(
438    out: &mut String,
439    color: ratatui::style::Color,
440    fg: bool,
441    mode: super::theme::ColorMode,
442) {
443    use std::fmt::Write as _;
444    let prefix = if fg { 38 } else { 48 };
445    match color {
446        Color::Rgb(r, g, b) => match mode {
447            super::theme::ColorMode::Truecolor => {
448                let _ = write!(out, "\x1b[{prefix};2;{r};{g};{b}m");
449            }
450            super::theme::ColorMode::Palette256 => {
451                let idx = super::theme::rgb_to_256(super::theme::Rgb(r, g, b));
452                let _ = write!(out, "\x1b[{prefix};5;{idx}m");
453            }
454        },
455        Color::Indexed(i) => {
456            let _ = write!(out, "\x1b[{prefix};5;{i}m");
457        }
458        c => {
459            let idx = basic_color_index(c);
460            if idx < 16 {
461                let _ = write!(out, "\x1b[{prefix};5;{idx}m");
462            }
463        }
464    }
465}
466
467#[cfg(test)]
468fn basic_color_index(c: Color) -> u8 {
469    match c {
470        Color::Black => 0,
471        Color::Red => 1,
472        Color::Green => 2,
473        Color::Yellow => 3,
474        Color::Blue => 4,
475        Color::Magenta => 5,
476        Color::Cyan => 6,
477        Color::Gray => 7,
478        Color::DarkGray => 8,
479        Color::LightRed => 9,
480        Color::LightGreen => 10,
481        Color::LightYellow => 11,
482        Color::LightBlue => 12,
483        Color::LightMagenta => 13,
484        Color::LightCyan => 14,
485        Color::White => 15,
486        _ => 255,
487    }
488}
489
490#[cfg(test)]
491fn push_style_modifiers(out: &mut String, style_modifiers: Modifier) {
492    use ratatui::style::Modifier;
493    if style_modifiers.contains(Modifier::BOLD) {
494        out.push_str("\x1b[1m");
495    }
496    if style_modifiers.contains(Modifier::DIM) {
497        out.push_str("\x1b[2m");
498    }
499    if style_modifiers.contains(Modifier::ITALIC) {
500        out.push_str("\x1b[3m");
501    }
502    if style_modifiers.contains(Modifier::UNDERLINED) {
503        out.push_str("\x1b[4m");
504    }
505    if style_modifiers.contains(Modifier::REVERSED) {
506        out.push_str("\x1b[7m");
507    }
508    if style_modifiers.contains(Modifier::CROSSED_OUT) {
509        out.push_str("\x1b[9m");
510    }
511}