Skip to main content

rpi_tui/
text.rs

1//! Text component for simple text rendering.
2
3use std::sync::Mutex;
4
5use std::any::Any;
6
7use super::component::Component;
8
9/// Simple text component with optional padding.
10pub struct Text {
11    content: Mutex<String>,
12    padding_x: usize,
13    padding_y: usize,
14    /// Render cache keyed by (content, width). A finalized `Text` row is
15    /// re-rendered on every repaint of a long transcript; caching the wrapped
16    /// lines keeps that cheap. Mirrors upstream `Text.cachedText/cachedLines`.
17    cache: Mutex<Option<TextCache>>,
18}
19
20#[derive(Clone)]
21struct TextCache {
22    content: String,
23    width: usize,
24    theme_revision: u64,
25    lines: Vec<String>,
26}
27
28impl Text {
29    /// Create a new text component.
30    pub fn new(content: impl Into<String>, padding_x: usize, padding_y: usize) -> Self {
31        Self {
32            content: Mutex::new(content.into()),
33            padding_x,
34            padding_y,
35            cache: Mutex::new(None),
36        }
37    }
38
39    /// Set the text content.
40    pub fn set_text(&self, text: impl Into<String>) {
41        if let Ok(mut content) = self.content.lock() {
42            *content = text.into();
43        }
44        if let Ok(mut cache) = self.cache.lock() {
45            *cache = None;
46        }
47    }
48
49    /// Get the text content.
50    pub fn get_text(&self) -> String {
51        self.content.lock().map(|c| c.clone()).unwrap_or_default()
52    }
53}
54
55impl Component for Text {
56    fn render(&self, width: usize) -> Vec<String> {
57        let content = self.content.lock().map(|c| c.clone()).unwrap_or_default();
58        let theme_revision = crate::theme::theme_revision();
59
60        if let Ok(cache) = self.cache.lock() {
61            if let Some(cached) = cache.as_ref() {
62                if cached.width == width
63                    && cached.content == content
64                    && cached.theme_revision == theme_revision
65                {
66                    return cached.lines.clone();
67                }
68            }
69        }
70
71        let mut lines = Vec::new();
72
73        // Add top padding
74        for _ in 0..self.padding_y {
75            lines.push(String::new());
76        }
77
78        // Render each line with horizontal padding, WORD-WRAPPING to the
79        // content width (pi text.ts: wrapTextWithAnsi) instead of truncating —
80        // long model output flows onto continuation lines rather than being
81        // cut. Each wrapped line carries the padding prefix (matching pi's
82        // per-line padding).
83        let pad = " ".repeat(self.padding_x);
84        let content_width = width.saturating_sub(self.padding_x).max(1);
85        for line in content.lines() {
86            let wrapped = crate::utils::wrap_text_with_ansi(line, content_width);
87            for wl in wrapped {
88                if self.padding_x > 0 {
89                    lines.push(format!("{pad}{wl}"));
90                } else {
91                    lines.push(wl);
92                }
93            }
94        }
95
96        // Add bottom padding
97        for _ in 0..self.padding_y {
98            lines.push(String::new());
99        }
100
101        if lines.is_empty() {
102            lines.push(String::new());
103        }
104
105        if let Ok(mut cache) = self.cache.lock() {
106            *cache = Some(TextCache {
107                content,
108                width,
109                theme_revision,
110                lines: lines.clone(),
111            });
112        }
113        lines
114    }
115
116    fn invalidate(&self) {
117        if let Ok(mut cache) = self.cache.lock() {
118            *cache = None;
119        }
120    }
121
122    fn as_any(&self) -> &dyn Any {
123        self
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::component::Component;
131
132    /// Regression: rendering a line wider than the layout column used to
133    /// byte-slice `padded[..width]` and panic mid-character on multi-byte
134    /// chars — "end byte index 120 is not a char boundary; it is inside
135    /// '⏎' (bytes 119..122)". Truncation must never split a char: it keeps
136    /// whole chars whose visible width fits the column.
137    #[test]
138    fn render_truncates_multibyte_at_char_boundary() {
139        let t = Text::new("x⏎y", 0, 0);
140        // Width 1: only 'x' fits (the 3-byte ⏎ is dropped whole, never split).
141        let out = t.render(1);
142        assert_eq!(out[0], "x");
143        // Width 2: 'x' + '⏎' (⏎ is visible-width 1) — whole chars only.
144        let out = t.render(2);
145        assert_eq!(out[0], "x⏎");
146        // Width 3: everything fits unchanged.
147        let out = t.render(3);
148        assert_eq!(out[0], "x⏎y");
149    }
150
151    #[test]
152    fn render_keeps_ansi_and_truncates() {
153        let styled = "\x1b[31mred\x1b[0m text";
154        let t = Text::new(styled, 0, 0);
155        let out = t.render(3);
156        // No panic on ANSI content; visible width respected.
157        assert!(crate::ansi::visible_width(&out[0]) <= 3);
158    }
159}