Skip to main content

oxicode_vtui/presentation/
renderable.rs

1// SPDX-License-Identifier: Apache-2.0
2//
3// Derived from OpenAI Codex's `codex-rs/tui/src/render/renderable.rs`
4// (commit 9ded177ce7c1c0bd2047f902936c177612ab3434, 2026-08-16).
5//
6// The implementation is intentionally narrowed to oxicode's presentation
7// boundary: a measured view can be composed vertically without inheriting
8// Codex protocol, configuration, or application dependencies.
9
10//! Measured ratatui cells used to compose the transcript and bottom pane.
11
12use ratatui::{
13    buffer::Buffer,
14    layout::Rect,
15    text::Line,
16    widgets::{Paragraph, Widget},
17};
18
19/// A view that can report its height before it is allocated a rectangle.
20///
21/// This is the essential boundary used by Codex's chat surface: transcript
22/// cells, the active streaming cell, and the bottom pane are independently
23/// measurable. Keeping it here prevents the next renderer from becoming a
24/// second agent state machine.
25pub trait Renderable {
26    /// Paint this view into its already-allocated rectangle.
27    fn render(&self, area: Rect, buf: &mut Buffer);
28
29    /// Return the number of rows needed at `width` columns.
30    fn desired_height(&self, width: u16) -> u16;
31}
32
33/// A vertical composition of independently measured views.
34#[derive(Default)]
35pub struct Column<'a> {
36    children: Vec<Box<dyn Renderable + 'a>>,
37}
38
39impl<'a> Column<'a> {
40    /// Create an empty column.
41    pub fn new() -> Self {
42        Self::default()
43    }
44
45    /// Append a child in display order.
46    pub fn push(&mut self, child: impl Renderable + 'a) {
47        self.children.push(Box::new(child));
48    }
49
50    /// Return whether the column has no children.
51    pub fn is_empty(&self) -> bool {
52        self.children.is_empty()
53    }
54}
55
56impl Renderable for Column<'_> {
57    fn render(&self, area: Rect, buf: &mut Buffer) {
58        let mut y = area.y;
59        for child in &self.children {
60            if y >= area.bottom() {
61                break;
62            }
63            let height = child.desired_height(area.width).min(area.bottom() - y);
64            if height == 0 {
65                continue;
66            }
67            let child_area = Rect::new(area.x, y, area.width, height);
68            child.render(child_area, buf);
69            y = y.saturating_add(height);
70        }
71    }
72
73    fn desired_height(&self, width: u16) -> u16 {
74        self.children.iter().fold(0_u16, |height, child| {
75            height.saturating_add(child.desired_height(width))
76        })
77    }
78}
79
80/// A plain, wrapping text cell. Higher-level transcript cells can build
81/// styled `Line`s and delegate wrapping/measurement here.
82#[derive(Clone, Debug, Default)]
83pub struct TextCell {
84    lines: Vec<Line<'static>>,
85}
86
87impl TextCell {
88    /// Construct a cell from display lines.
89    pub fn new(lines: Vec<Line<'static>>) -> Self {
90        Self { lines }
91    }
92}
93
94impl Renderable for TextCell {
95    fn render(&self, area: Rect, buf: &mut Buffer) {
96        if !area.is_empty() {
97            Paragraph::new(self.lines.clone()).render(area, buf);
98        }
99    }
100
101    fn desired_height(&self, width: u16) -> u16 {
102        if width == 0 {
103            return 0;
104        }
105        self.lines.iter().fold(0_u16, |height, line| {
106            let rows = (line.width() as u16).max(1).div_ceil(width);
107            height.saturating_add(rows)
108        })
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use ratatui::{style::Style, text::Span};
116
117    #[test]
118    fn column_measures_and_clips_children_to_its_area() {
119        let mut column = Column::new();
120        column.push(TextCell::new(vec![Line::from("one")]));
121        column.push(TextCell::new(vec![Line::from(Span::styled(
122            "two",
123            Style::default(),
124        ))]));
125
126        assert_eq!(column.desired_height(10), 2);
127        let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 1));
128        column.render(Rect::new(0, 0, 10, 1), &mut buffer);
129        assert_eq!(buffer[(0, 0)].symbol(), "o");
130    }
131
132    #[test]
133    fn text_cell_measures_wrapped_lines() {
134        let cell = TextCell::new(vec![Line::from("123456")]);
135        assert_eq!(cell.desired_height(4), 2);
136    }
137}