Skip to main content

thndrs_lib/cli/renderer/
row.rs

1//! Row, block, and frame primitives shared by projection and Ratatui rendering.
2//!
3//! A [`Row`] is one terminal row after wrapping and padding decisions. A
4//! [`Frame`] is the complete live-region render.
5
6use super::style::{CellStyle, Span};
7
8/// Stable identity for a group of rows that belong to the same transcript
9/// entry. The alternate viewport uses this metadata to preserve a reader's
10/// position without parsing rendered text.
11#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
12pub struct RowGroupId {
13    /// Index into [`crate::app::App::transcript`] for the entry that produced
14    /// this row.
15    pub entry_index: usize,
16}
17
18/// One terminal row: a sequence of styled spans padded to a known width.
19#[derive(Clone, Debug, Default, Eq, PartialEq)]
20pub struct Row {
21    pub spans: Vec<Span>,
22    /// Width in display columns the row is padded/truncated to.
23    pub width: usize,
24    /// Optional transcript entry grouping metadata for viewport navigation.
25    pub group_id: Option<RowGroupId>,
26}
27
28impl Row {
29    /// Create a row from spans, padded to `width` using `pad_style`.
30    pub fn padded(spans: Vec<Span>, width: usize, pad_style: CellStyle) -> Self {
31        let spans = super::layout::pad_row(spans, width, pad_style);
32        Row { spans, width, group_id: None }
33    }
34
35    /// Create an empty (blank) row of `width` columns.
36    pub fn blank(width: usize, style: CellStyle) -> Self {
37        if width == 0 {
38            return Row { spans: Vec::new(), width: 0, group_id: None };
39        }
40        Row { spans: vec![Span::styled(" ".repeat(width), style)], width, group_id: None }
41    }
42
43    /// Visible width (column count) of the row's spans.
44    #[cfg(test)]
45    pub fn text_width(&self) -> usize {
46        super::layout::spans_width(&self.spans)
47    }
48
49    /// Plain-text rendering of the row (for snapshot readability).
50    #[cfg(test)]
51    pub fn text(&self) -> String {
52        let mut out = String::new();
53        for span in &self.spans {
54            out.push_str(&span.text);
55        }
56        out
57    }
58}
59
60/// The complete live-region render.
61///
62/// A frame is an ordered list of rows representing the current live region:
63/// active streaming block, dynamic status, prompt input, accessories, and
64/// static status. Frames are diffed and redrawn each tick.
65#[derive(Clone, Debug, Default, Eq, PartialEq)]
66pub struct Frame {
67    pub rows: Vec<Row>,
68    pub width: usize,
69    pub cursor: Option<CursorCoord>,
70    /// When false, the cursor is hidden (blink off phase).
71    pub cursor_visible: bool,
72}
73
74/// A (row, column) coordinate within a frame, used for cursor placement.
75#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
76pub struct CursorCoord {
77    pub row: usize,
78    pub col: usize,
79}
80
81impl CursorCoord {
82    pub fn new(row: usize, col: usize) -> Self {
83        CursorCoord { row, col }
84    }
85}
86
87impl Frame {
88    /// Create an empty frame at `width`.
89    pub fn new(width: usize) -> Self {
90        Frame { rows: Vec::new(), width, cursor: None, cursor_visible: true }
91    }
92
93    /// Append a row.
94    pub fn push(&mut self, row: Row) {
95        self.rows.push(row);
96    }
97
98    /// Set the cursor coordinate.
99    pub fn set_cursor(&mut self, cursor: CursorCoord) {
100        self.cursor = Some(cursor);
101    }
102
103    /// Number of rows.
104    pub fn len(&self) -> usize {
105        self.rows.len()
106    }
107
108    /// Whether the frame has no rows.
109    pub fn is_empty(&self) -> bool {
110        self.rows.is_empty()
111    }
112
113    /// Plain-text rendering, one line per row.
114    #[cfg(test)]
115    pub fn render_text(&self) -> String {
116        let mut out = String::new();
117        for row in &self.rows {
118            out.push_str(&row.text());
119            out.push('\n');
120        }
121        out
122    }
123
124    /// Debug rendering showing styled spans per row.
125    ///
126    /// Each row is rendered as `│ <text>` followed by inline style annotations.
127    #[cfg(test)]
128    pub fn render_styled(&self) -> String {
129        let mut out = String::new();
130        for (i, row) in self.rows.iter().enumerate() {
131            if i > 0 {
132                out.push('\n');
133            }
134            out.push_str("│ ");
135            for span in &row.spans {
136                out.push_str(&span.text);
137            }
138
139            out.push_str("  #");
140            for span in &row.spans {
141                if !span.text.is_empty() {
142                    out.push_str(&format!(" [{}]={}", span.style, span.text.replace('\n', "\\n")));
143                }
144            }
145        }
146        out
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use crate::renderer::style::Color;
154
155    #[test]
156    fn row_padded_fills_width() {
157        let row = Row::padded(vec![Span::plain("hi")], 10, CellStyle::default());
158        assert_eq!(row.text_width(), 10);
159        assert_eq!(row.width, 10);
160    }
161
162    #[test]
163    fn frame_render_text_one_line_per_row() {
164        let mut frame = Frame::new(10);
165        frame.push(Row::padded(vec![Span::plain("a")], 5, CellStyle::default()));
166        frame.push(Row::padded(vec![Span::plain("b")], 5, CellStyle::default()));
167        let text = frame.render_text();
168        assert!(text.contains("a"));
169        assert!(text.contains("b"));
170        assert_eq!(text.matches('\n').count(), 2);
171    }
172
173    #[test]
174    fn frame_render_styled_includes_style_annotations() {
175        let mut frame = Frame::new(10);
176        frame.push(Row::padded(
177            vec![Span::styled("hi", CellStyle::new().fg(Color::Red))],
178            6,
179            CellStyle::default(),
180        ));
181        let styled = frame.render_styled();
182        assert!(styled.contains("fg=red"));
183        assert!(styled.contains("hi"));
184    }
185
186    #[test]
187    fn frame_set_cursor_stores_coord() {
188        let mut frame = Frame::new(10);
189        frame.set_cursor(CursorCoord::new(2, 3));
190        assert_eq!(frame.cursor, Some(CursorCoord { row: 2, col: 3 }));
191    }
192}