Skip to main content

flywheel/actor/
renderer.rs

1//! Renderer Actor: Dedicated thread for rendering to the terminal.
2//!
3//! This actor owns the terminal and double buffers. It receives render
4//! commands from the main loop and performs the actual diffing and
5//! output flushing.
6
7use super::messages::RenderCommand;
8use crate::buffer::diff::{render_diff, render_full, DiffState};
9use crate::buffer::Buffer;
10use crate::layout::Rect;
11use crossbeam_channel::Receiver;
12use std::io::{self, Stdout, Write};
13use std::sync::atomic::{AtomicBool, Ordering};
14use std::sync::Arc;
15use std::thread::{self, JoinHandle};
16use std::time::{Duration, Instant};
17
18/// Renderer actor that handles terminal output.
19pub struct RendererActor {
20    /// Handle to the render thread.
21    handle: Option<JoinHandle<()>>,
22    /// Flag to signal shutdown.
23    shutdown: Arc<AtomicBool>,
24}
25
26/// Render statistics for debugging/profiling.
27#[derive(Debug, Clone, Default)]
28pub struct RenderStats {
29    /// Total frames rendered.
30    pub frames: u64,
31    /// Total cells changed across all frames.
32    #[allow(dead_code)]
33    pub cells_changed: u64,
34    /// Total bytes written to terminal.
35    pub bytes_written: u64,
36    /// Average render time in microseconds.
37    pub avg_render_us: u64,
38    /// Last render time in microseconds.
39    pub last_render_us: u64,
40}
41
42/// Internal renderer state.
43struct Renderer {
44    /// Current (visible) buffer.
45    current: Buffer,
46    /// Next (being drawn) buffer.
47    next: Buffer,
48    /// Diff state for cursor/color tracking.
49    diff_state: DiffState,
50    /// Pre-allocated output buffer.
51    output: Vec<u8>,
52    /// Terminal stdout handle.
53    stdout: Stdout,
54    /// Render statistics.
55    stats: RenderStats,
56    /// Dirty rectangles for next render.
57    dirty_rects: Vec<Rect>,
58    /// Whether a full redraw is needed.
59    needs_full_redraw: bool,
60    /// Cursor position (None = hidden).
61    cursor_x: Option<u16>,
62    cursor_y: u16,
63}
64
65impl Renderer {
66    /// Create a new renderer with the given dimensions.
67    fn new(width: u16, height: u16) -> Self {
68        let current = Buffer::new(width, height);
69        let next = Buffer::new(width, height);
70
71        Self {
72            current,
73            next,
74            diff_state: DiffState::new(),
75            output: Vec::with_capacity(65536),
76            stdout: io::stdout(),
77            stats: RenderStats::default(),
78            dirty_rects: Vec::new(),
79            needs_full_redraw: true,
80            cursor_x: None,
81            cursor_y: 0,
82        }
83    }
84
85    /// Get a mutable reference to the next buffer.
86    #[allow(dead_code)]
87    pub const fn buffer_mut(&mut self) -> &mut Buffer {
88        &mut self.next
89    }
90
91    /// Mark the entire screen as dirty.
92    const fn mark_full_dirty(&mut self) {
93        self.needs_full_redraw = true;
94    }
95
96    /// Add a dirty rectangle.
97    #[allow(dead_code)]
98    fn mark_dirty(&mut self, rect: Rect) {
99        self.dirty_rects.push(rect);
100    }
101
102    /// Perform a render cycle.
103    fn render(&mut self) -> io::Result<()> {
104        let start = Instant::now();
105        self.output.clear();
106
107        if self.needs_full_redraw {
108            // Full redraw
109            render_full(&self.next, &mut self.output);
110            self.needs_full_redraw = false;
111            self.diff_state.reset();
112        } else {
113            // Diff-based update
114            let _result = render_diff(
115                &self.current,
116                &self.next,
117                &self.dirty_rects,
118                &mut self.output,
119                &mut self.diff_state,
120            );
121        }
122
123        self.dirty_rects.clear();
124
125        // Handle cursor position
126        if let Some(x) = self.cursor_x {
127            // Show cursor at position
128            let _ = write!(
129                &mut self.output,
130                "\x1b[{};{}H\x1b[?25h",
131                self.cursor_y + 1,
132                x + 1
133            );
134        } else {
135            // Hide cursor
136            self.output.extend_from_slice(b"\x1b[?25l");
137        }
138
139        // Flush to terminal in a single write
140        if !self.output.is_empty() {
141            self.stdout.write_all(&self.output)?;
142            self.stdout.flush()?;
143        }
144
145        // Swap buffers
146        self.current.copy_from(&self.next);
147
148        // Update stats
149        let elapsed = start.elapsed();
150        self.stats.frames += 1;
151        self.stats.bytes_written += self.output.len() as u64;
152        self.stats.last_render_us = u64::try_from(elapsed.as_micros()).unwrap_or(u64::MAX);
153
154        // Smoothed average
155        if self.stats.avg_render_us == 0 {
156            self.stats.avg_render_us = self.stats.last_render_us;
157        } else {
158            self.stats.avg_render_us =
159                (self.stats.avg_render_us * 15 + self.stats.last_render_us) / 16;
160        }
161
162        Ok(())
163    }
164
165    /// Write raw bytes directly to the terminal.
166    ///
167    /// This is used by the Fast Path to bypass the buffer diffing.
168    /// After a raw write, we must invalidate the diff state to ensure
169    /// subsequent renders correctly handle cells that were modified.
170    fn write_raw(&mut self, bytes: &[u8]) -> io::Result<()> {
171        self.stdout.write_all(bytes)?;
172        self.stdout.flush()?;
173        self.stats.bytes_written += bytes.len() as u64;
174        
175        // CRITICAL: Invalidate current buffer state.
176        // RawOutput bypasses our double-buffering, so `current` no longer
177        // reflects what's actually on screen. Force next render to be full.
178        // This is the trade-off: Fast Path is only truly "fast" when
179        // followed by more Fast Path writes. Mixing Fast and Slow requires
180        // a full redraw to resync.
181        self.needs_full_redraw = true;
182        self.diff_state.reset();
183        
184        Ok(())
185    }
186
187    /// Resize buffers.
188    fn resize(&mut self, width: u16, height: u16) {
189        self.current.resize(width, height);
190        self.next.resize(width, height);
191        self.mark_full_dirty();
192    }
193
194    /// Set cursor position.
195    const fn set_cursor(&mut self, x: Option<u16>, y: u16) {
196        self.cursor_x = x;
197        self.cursor_y = y;
198    }
199}
200
201impl RendererActor {
202    /// Spawn the renderer actor thread.
203    ///
204    /// # Arguments
205    ///
206    /// * `receiver` - Channel to receive render commands from.
207    /// * `width` - Initial terminal width.
208    /// * `height` - Initial terminal height.
209    ///
210    /// # Returns
211    ///
212    /// The renderer actor handle.
213    #[allow(clippy::missing_panics_doc)]
214    pub fn spawn(receiver: Receiver<RenderCommand>, width: u16, height: u16) -> Self {
215        let shutdown = Arc::new(AtomicBool::new(false));
216        let shutdown_clone = shutdown.clone();
217
218        let handle = thread::Builder::new()
219            .name("flywheel-render".to_string())
220            .spawn(move || {
221                if let Err(e) = Self::run_loop(&receiver, &shutdown_clone, width, height) {
222                    eprintln!("Render thread error: {e}");
223                }
224            })
225            .expect("Failed to spawn render thread");
226
227        Self {
228            handle: Some(handle),
229            shutdown,
230        }
231    }
232
233    /// Signal the render thread to shutdown.
234    pub fn shutdown(&self) {
235        self.shutdown.store(true, Ordering::Relaxed);
236    }
237
238    /// Wait for the render thread to finish.
239    pub fn join(mut self) {
240        if let Some(handle) = self.handle.take() {
241            let _ = handle.join();
242        }
243    }
244
245    /// Main render loop.
246    fn run_loop(
247        receiver: &Receiver<RenderCommand>,
248        shutdown: &Arc<AtomicBool>,
249        width: u16,
250        height: u16,
251    ) -> io::Result<()> {
252        let mut renderer = Renderer::new(width, height);
253
254        loop {
255            // Check for shutdown
256            if shutdown.load(Ordering::Relaxed) {
257                break;
258            }
259
260            // Wait for command with timeout
261            if let Ok(command) = receiver.recv_timeout(Duration::from_millis(16)) {
262                match command {
263                    RenderCommand::FullRedraw(buffer) => {
264                        renderer.next = *buffer;
265                        renderer.mark_full_dirty();
266                        renderer.render()?;
267                    }
268                    RenderCommand::Update(buffer) => {
269                        renderer.next = *buffer;
270                        renderer.render()?;
271                    }
272                    RenderCommand::Resize { width, height } => {
273                        renderer.resize(width, height);
274                    }
275                    RenderCommand::SetCursor { x, y } => {
276                        renderer.set_cursor(x, y);
277                    }
278                    RenderCommand::RawOutput { bytes } => {
279                        renderer.write_raw(&bytes)?;
280                    }
281                    RenderCommand::Shutdown => {
282                        break;
283                    }
284                }
285            } else {
286                 // Timeout: loop again to check shutdown or run idle tasks
287                 // (e.g. continuous animation if we had it, but here just wait)
288            }
289        }
290
291        Ok(())
292    }
293}