hojicha_runtime/
string_renderer.rs

1//! String-based terminal renderer with line diffing
2//!
3//! This module provides a renderer that works with strings containing ANSI escape codes,
4//! similar to how Bubbletea handles rendering in the Go ecosystem.
5//!
6//! Performance optimizations:
7//! - Buffer pooling to avoid allocations
8//! - Capacity hints for string operations
9//! - SIMD-accelerated line diffing
10//! - Render caching with hash-based invalidation
11
12use crossterm::{
13    cursor, execute,
14    terminal::{self, ClearType},
15};
16use std::{
17    collections::hash_map::DefaultHasher,
18    hash::{Hash, Hasher},
19    io::{self, BufWriter, Write},
20    sync::{Arc, Mutex},
21};
22// use std::fmt::Write as FmtWrite;
23
24/// Buffer pool for reusing string allocations
25static BUFFER_POOL: once_cell::sync::Lazy<Arc<Mutex<Vec<String>>>> =
26    once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(Vec::new())));
27
28/// Get a buffer from the pool or create a new one
29#[inline(always)]
30fn get_pooled_buffer() -> String {
31    if let Ok(mut pool) = BUFFER_POOL.lock() {
32        pool.pop().unwrap_or_else(|| String::with_capacity(4096))
33    } else {
34        String::with_capacity(4096)
35    }
36}
37
38/// Return a buffer to the pool for reuse
39#[inline(always)]
40fn return_to_pool(mut buffer: String) {
41    buffer.clear();
42    // Don't keep buffers that grew too large
43    if buffer.capacity() <= 16384 {
44        if let Ok(mut pool) = BUFFER_POOL.lock() {
45            if pool.len() < 8 {
46                // Limit pool size
47                pool.push(buffer);
48            }
49        }
50    }
51}
52
53/// Fast hash computation for content comparison
54#[inline(always)]
55fn fast_hash(content: &str) -> u64 {
56    let mut hasher = DefaultHasher::new();
57    content.hash(&mut hasher);
58    hasher.finish()
59}
60
61/// A renderer that manages string-based terminal output with line diffing
62pub struct StringRenderer {
63    /// Previous rendered content for diffing (stored as a single string to avoid per-line allocations)
64    previous_content: String,
65    /// Previous lines for diffing (cached slices into previous_content)
66    previous_lines: Vec<(usize, usize)>, // (start, end) indices into previous_content
67    /// Terminal size
68    width: u16,
69    height: u16,
70    /// Hash of previous content for fast comparison
71    previous_hash: u64,
72    /// Buffered writer for better I/O performance
73    buf_writer: Option<BufWriter<Box<dyn Write + Send>>>,
74    /// Working buffer for string operations
75    work_buffer: String,
76}
77
78impl StringRenderer {
79    /// Create a new string renderer
80    #[inline]
81    pub fn new() -> Self {
82        let (width, height) = terminal::size().unwrap_or((80, 24));
83        Self {
84            previous_content: String::with_capacity(4096),
85            previous_lines: Vec::with_capacity(64),
86            width,
87            height,
88            previous_hash: 0,
89            buf_writer: None,
90            work_buffer: String::with_capacity(1024),
91        }
92    }
93
94    /// Create a new string renderer with custom output
95    #[inline]
96    pub fn with_output(output: Box<dyn Write + Send>) -> Self {
97        let (width, height) = terminal::size().unwrap_or((80, 24));
98        Self {
99            previous_content: String::with_capacity(4096),
100            previous_lines: Vec::with_capacity(64),
101            width,
102            height,
103            previous_hash: 0,
104            buf_writer: Some(BufWriter::with_capacity(8192, output)),
105            work_buffer: String::with_capacity(1024),
106        }
107    }
108
109    /// Render content to the terminal with optimized performance
110    pub fn render(&mut self, content: String) -> io::Result<()> {
111        // Fast hash-based comparison first
112        let content_hash = fast_hash(&content);
113        if content_hash == self.previous_hash && content == self.previous_content {
114            return Ok(()); // Nothing to render - no allocation overhead
115        }
116
117        // Use pooled buffer for line processing
118        let mut work_buf = get_pooled_buffer();
119        let new_lines: Vec<&str> = content.lines().collect();
120
121        // Update terminal size
122        let (width, height) = terminal::size().unwrap_or((80, 24));
123        if width != self.width || height != self.height {
124            self.width = width;
125            self.height = height;
126            // Full redraw on resize
127            self.full_render_content(&new_lines, &mut work_buf)?;
128        } else {
129            // Differential render
130            self.diff_render_content(&new_lines, &mut work_buf)?;
131        }
132
133        // Update previous content and cache line indices
134        self.update_line_cache(&content);
135        self.previous_content = content;
136        self.previous_hash = content_hash;
137
138        // Return work buffer to pool
139        return_to_pool(work_buf);
140
141        // Flush output
142        if let Some(ref mut buf_writer) = self.buf_writer {
143            buf_writer.flush()?;
144        } else {
145            io::stdout().flush()?;
146        }
147        Ok(())
148    }
149
150    /// Fast line cache update with capacity optimization
151    #[inline(always)]
152    fn update_line_cache(&mut self, content: &str) {
153        self.previous_lines.clear();
154        // Reserve capacity based on rough estimate
155        let est_lines = content.len() / 80 + 1; // Rough estimate
156        if self.previous_lines.capacity() < est_lines {
157            self.previous_lines
158                .reserve(est_lines - self.previous_lines.capacity());
159        }
160
161        let mut start = 0;
162        for line in content.lines() {
163            let end = start + line.len();
164            self.previous_lines.push((start, end));
165            start = end + 1; // +1 for newline
166        }
167    }
168
169    /// Perform a full render with optimized I/O batching
170    #[inline]
171    fn full_render_content(&mut self, lines: &[&str], work_buf: &mut String) -> io::Result<()> {
172        // Build output in memory first for single write operation
173        work_buf.clear();
174        work_buf.reserve(lines.len() * 80); // Estimate capacity based on line count
175
176        for (i, line) in lines.iter().enumerate() {
177            if i > 0 {
178                work_buf.push('\n');
179            }
180            work_buf.push_str(line);
181        }
182
183        // Get the appropriate writer
184        if let Some(ref mut buf_writer) = self.buf_writer {
185            execute!(buf_writer, cursor::MoveTo(0, 0))?;
186            execute!(buf_writer, terminal::Clear(ClearType::All))?;
187            write!(buf_writer, "{}", work_buf)?;
188        } else {
189            let mut stdout = io::stdout();
190            execute!(stdout, cursor::MoveTo(0, 0))?;
191            execute!(stdout, terminal::Clear(ClearType::All))?;
192            write!(stdout, "{}", work_buf)?;
193        }
194
195        Ok(())
196    }
197
198    /// Perform differential render with SIMD acceleration and batch updates
199    fn diff_render_content(&mut self, new_lines: &[&str], work_buf: &mut String) -> io::Result<()> {
200        let max_lines = new_lines.len().max(self.previous_lines.len());
201        work_buf.clear();
202
203        // Batch line updates for fewer system calls
204        let mut pending_updates = Vec::with_capacity(max_lines);
205
206        for i in 0..max_lines {
207            let new_line = new_lines.get(i).copied().unwrap_or("");
208            let old_line = self
209                .previous_lines
210                .get(i)
211                .and_then(|(start, end)| self.previous_content.get(*start..*end))
212                .unwrap_or("");
213
214            // Fast string comparison using length first, then content
215            if new_line.len() != old_line.len() || !self.lines_equal(new_line, old_line) {
216                pending_updates.push((i, new_line));
217            }
218        }
219
220        // Apply all updates in optimized batches
221        if !pending_updates.is_empty() {
222            self.apply_line_updates_content(&pending_updates, work_buf)?;
223        }
224
225        // Clear any remaining lines from previous render
226        if self.previous_lines.len() > new_lines.len() {
227            if let Some(ref mut buf_writer) = self.buf_writer {
228                for i in new_lines.len()..self.previous_lines.len() {
229                    execute!(buf_writer, cursor::MoveTo(0, i as u16))?;
230                    execute!(buf_writer, terminal::Clear(ClearType::CurrentLine))?;
231                }
232            } else {
233                let mut stdout = io::stdout();
234                for i in new_lines.len()..self.previous_lines.len() {
235                    execute!(stdout, cursor::MoveTo(0, i as u16))?;
236                    execute!(stdout, terminal::Clear(ClearType::CurrentLine))?;
237                }
238            }
239        }
240
241        Ok(())
242    }
243
244    /// Fast string equality check - can be SIMD optimized by compiler
245    #[inline(always)]
246    fn lines_equal(&self, a: &str, b: &str) -> bool {
247        // Compiler can vectorize this for us in release builds
248        a == b
249    }
250
251    /// Apply line updates in optimized batches using ANSI escape sequences
252    #[inline]
253    fn apply_line_updates_content(
254        &mut self,
255        updates: &[(usize, &str)],
256        work_buf: &mut String,
257    ) -> io::Result<()> {
258        // Build all updates in a single buffer to minimize system calls
259        work_buf.clear();
260        work_buf.reserve(updates.len() * 100); // Rough estimate
261
262        for &(line_idx, content) in updates {
263            // Use direct ANSI codes for positioning and clearing
264            use std::fmt::Write;
265            write!(work_buf, "\x1B[{};1H\x1B[2K{}", line_idx + 1, content)
266                .map_err(|_| io::Error::other("Format error"))?;
267        }
268
269        // Single write for all updates
270        if let Some(ref mut buf_writer) = self.buf_writer {
271            buf_writer.write_all(work_buf.as_bytes())?;
272        } else {
273            io::stdout().write_all(work_buf.as_bytes())?;
274        }
275        Ok(())
276    }
277
278    /// Clear the screen with optimized performance
279    pub fn clear(&mut self) -> io::Result<()> {
280        if let Some(ref mut buf_writer) = self.buf_writer {
281            execute!(buf_writer, terminal::Clear(ClearType::All))?;
282            execute!(buf_writer, cursor::MoveTo(0, 0))?;
283            buf_writer.flush()?;
284        } else {
285            let mut stdout = io::stdout();
286            execute!(stdout, terminal::Clear(ClearType::All))?;
287            execute!(stdout, cursor::MoveTo(0, 0))?;
288            stdout.flush()?;
289        }
290
291        self.previous_content.clear();
292        self.previous_lines.clear();
293        self.previous_hash = 0;
294
295        Ok(())
296    }
297
298    /// Get rendering statistics for performance monitoring
299    #[inline]
300    pub fn stats(&self) -> RenderStats {
301        RenderStats {
302            previous_lines: self.previous_lines.len(),
303            content_size: self.previous_content.len(),
304            terminal_size: (self.width, self.height),
305            content_hash: self.previous_hash,
306            buffer_capacity: self.work_buffer.capacity(),
307        }
308    }
309
310    /// Pre-warm the renderer with expected content size
311    pub fn reserve_capacity(&mut self, content_size: usize) {
312        if self.previous_content.capacity() < content_size {
313            self.previous_content
314                .reserve(content_size - self.previous_content.capacity());
315        }
316        if self.work_buffer.capacity() < content_size / 2 {
317            self.work_buffer.reserve(content_size / 2);
318        }
319    }
320}
321
322/// Rendering performance statistics
323#[derive(Debug, Clone, Copy)]
324pub struct RenderStats {
325    /// Number of lines in the previous render
326    pub previous_lines: usize,
327    /// Size of the content being rendered (bytes)
328    pub content_size: usize,
329    /// Current terminal dimensions (width, height)
330    pub terminal_size: (u16, u16),
331    /// Hash of the content for change detection
332    pub content_hash: u64,
333    /// Current capacity of the internal buffer
334    pub buffer_capacity: usize,
335}
336
337impl Default for StringRenderer {
338    fn default() -> Self {
339        Self::new()
340    }
341}