Skip to main content

saorsa_core/
render_context.rs

1//! Render context — manages the frame lifecycle and rendering pipeline.
2//!
3//! The `RenderContext` holds the current and previous frame buffers,
4//! diffs them, and renders the changes to the terminal. It optionally
5//! integrates a [`Compositor`] to resolve overlapping widget layers
6//! before diffing.
7
8use crate::buffer::ScreenBuffer;
9use crate::compositor::Compositor;
10use crate::error::Result;
11use crate::geometry::Size;
12use crate::renderer::Renderer;
13use crate::terminal::Terminal;
14
15/// Manages the double-buffered rendering pipeline.
16///
17/// Each frame:
18/// 1. `begin_frame()` — swap buffers, clear the current buffer
19/// 2. Application writes to the current buffer
20/// 3. `end_frame()` — optionally compose layers, diff, render, and write to terminal
21pub struct RenderContext {
22    current: ScreenBuffer,
23    previous: ScreenBuffer,
24    renderer: Renderer,
25    size: Size,
26    compositor: Option<Compositor>,
27}
28
29impl RenderContext {
30    /// Create a new render context for the given terminal.
31    pub fn new(terminal: &dyn Terminal) -> Result<Self> {
32        let size = terminal.size()?;
33        let caps = terminal.capabilities();
34        let renderer = Renderer::new(caps.color, caps.synchronized_output);
35        Ok(Self {
36            current: ScreenBuffer::new(size),
37            previous: ScreenBuffer::new(size),
38            renderer,
39            size,
40            compositor: None,
41        })
42    }
43
44    /// Create a render context with explicit size and capabilities (for testing).
45    pub fn with_size(size: Size, renderer: Renderer) -> Self {
46        Self {
47            current: ScreenBuffer::new(size),
48            previous: ScreenBuffer::new(size),
49            renderer,
50            size,
51            compositor: None,
52        }
53    }
54
55    /// Set the compositor for this render context (builder pattern).
56    ///
57    /// When a compositor is present, `end_frame()` will call
58    /// `compositor.compose()` on the current buffer before diffing.
59    #[must_use]
60    pub fn with_compositor(mut self, compositor: Compositor) -> Self {
61        self.compositor = Some(compositor);
62        self
63    }
64
65    /// Get a reference to the compositor, if one is set.
66    pub fn compositor(&self) -> Option<&Compositor> {
67        self.compositor.as_ref()
68    }
69
70    /// Get a mutable reference to the compositor, if one is set.
71    pub fn compositor_mut(&mut self) -> Option<&mut Compositor> {
72        self.compositor.as_mut()
73    }
74
75    /// Get the current buffer dimensions.
76    pub fn size(&self) -> Size {
77        self.size
78    }
79
80    /// Get a mutable reference to the current buffer for writing.
81    pub fn buffer_mut(&mut self) -> &mut ScreenBuffer {
82        &mut self.current
83    }
84
85    /// Get a reference to the current buffer.
86    pub fn buffer(&self) -> &ScreenBuffer {
87        &self.current
88    }
89
90    /// Begin a new frame: swap current → previous and clear the current buffer.
91    pub fn begin_frame(&mut self) {
92        std::mem::swap(&mut self.current, &mut self.previous);
93        self.current.clear();
94    }
95
96    /// End the frame: optionally compose layers, diff current vs previous,
97    /// render to escape sequences, write to terminal and flush.
98    ///
99    /// If a compositor is present, it composes all layers into the current
100    /// buffer before the diff step.
101    pub fn end_frame(&mut self, terminal: &mut dyn Terminal) -> Result<()> {
102        // Compose layers into the buffer if a compositor is set
103        if let Some(ref compositor) = self.compositor {
104            compositor.compose(&mut self.current);
105        }
106
107        let changes = self.current.diff(&self.previous);
108        let output = self.renderer.render(&changes);
109        if !output.is_empty() {
110            terminal.write_raw(output.as_bytes())?;
111            terminal.flush()?;
112        }
113        Ok(())
114    }
115
116    /// Handle a terminal resize: update buffers, size, and compositor dimensions.
117    pub fn handle_resize(&mut self, new_size: Size) {
118        self.size = new_size;
119        self.current.resize(new_size);
120        self.previous.resize(new_size);
121        if let Some(ref mut compositor) = self.compositor {
122            compositor.resize(new_size.width, new_size.height);
123        }
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::cell::Cell;
131    use crate::color::{Color, NamedColor};
132    use crate::compositor::Compositor;
133    use crate::geometry::Rect;
134    use crate::segment::Segment;
135    use crate::style::Style;
136    use crate::terminal::{ColorSupport, TestBackend};
137
138    #[test]
139    fn create_from_test_backend() {
140        let backend = TestBackend::new(80, 24);
141        let ctx = RenderContext::new(&backend);
142        assert!(ctx.is_ok());
143        let ctx = ctx.ok();
144        assert!(ctx.is_some());
145        let ctx = ctx.as_ref();
146        assert_eq!(ctx.map(|c| c.size()), Some(Size::new(80, 24)));
147    }
148
149    #[test]
150    fn begin_frame_clears_current() {
151        let renderer = Renderer::new(ColorSupport::TrueColor, false);
152        let mut ctx = RenderContext::with_size(Size::new(10, 5), renderer);
153
154        // Write something
155        ctx.buffer_mut().set(0, 0, Cell::new("A", Style::default()));
156        assert_eq!(
157            ctx.buffer().get(0, 0).map(|c| c.grapheme.as_str()),
158            Some("A")
159        );
160
161        // Begin frame: current should be cleared
162        ctx.begin_frame();
163        assert!(ctx.buffer().get(0, 0).is_some_and(|c| c.is_blank()));
164    }
165
166    #[test]
167    fn end_frame_writes_to_terminal() {
168        let mut backend = TestBackend::new(10, 5);
169        let renderer = Renderer::new(ColorSupport::TrueColor, false);
170        let mut ctx = RenderContext::with_size(Size::new(10, 5), renderer);
171
172        // Write a cell
173        ctx.buffer_mut().set(0, 0, Cell::new("A", Style::default()));
174
175        // End frame should write escape sequences to the backend
176        let result = ctx.end_frame(&mut backend);
177        assert!(result.is_ok());
178
179        let output = backend.buffer();
180        assert!(!output.is_empty());
181        // Should contain cursor positioning and the character
182        let output_str = String::from_utf8_lossy(output);
183        assert!(output_str.contains('A'));
184    }
185
186    #[test]
187    fn second_frame_only_diffs() {
188        let mut backend = TestBackend::new(10, 5);
189        let renderer = Renderer::new(ColorSupport::TrueColor, false);
190        let mut ctx = RenderContext::with_size(Size::new(10, 5), renderer);
191
192        // Frame 1: write "A" at (0,0)
193        ctx.buffer_mut().set(0, 0, Cell::new("A", Style::default()));
194        let _ = ctx.end_frame(&mut backend);
195        backend.clear_buffer();
196
197        // Frame 2: keep "A" at (0,0), add "B" at (1,0)
198        ctx.begin_frame();
199        ctx.buffer_mut().set(0, 0, Cell::new("A", Style::default()));
200        ctx.buffer_mut().set(1, 0, Cell::new("B", Style::default()));
201        let _ = ctx.end_frame(&mut backend);
202
203        let output = backend.buffer();
204        let output_str = String::from_utf8_lossy(output);
205        // Should contain "B" (the new cell) but "A" should also be in the
206        // diff since it changed from blank (after begin_frame clear) to "A" —
207        // actually begin_frame clears current, so all cells are new
208        assert!(output_str.contains('B'));
209    }
210
211    #[test]
212    fn handle_resize() {
213        let renderer = Renderer::new(ColorSupport::TrueColor, false);
214        let mut ctx = RenderContext::with_size(Size::new(10, 5), renderer);
215        assert_eq!(ctx.size(), Size::new(10, 5));
216
217        ctx.handle_resize(Size::new(20, 10));
218        assert_eq!(ctx.size(), Size::new(20, 10));
219        assert_eq!(ctx.buffer().size(), Size::new(20, 10));
220    }
221
222    #[test]
223    fn styled_cell_rendering() {
224        let mut backend = TestBackend::new(10, 5);
225        let renderer = Renderer::new(ColorSupport::TrueColor, false);
226        let mut ctx = RenderContext::with_size(Size::new(10, 5), renderer);
227
228        let style = Style::new().fg(Color::Named(NamedColor::Red)).bold(true);
229        ctx.buffer_mut().set(0, 0, Cell::new("X", style));
230        let _ = ctx.end_frame(&mut backend);
231
232        let output = backend.buffer();
233        let output_str = String::from_utf8_lossy(output);
234        assert!(output_str.contains("\x1b[31m")); // red fg
235        assert!(output_str.contains("\x1b[1m")); // bold
236        assert!(output_str.contains('X'));
237    }
238
239    // --- New compositor integration tests ---
240
241    #[test]
242    fn compositor_none_by_default() {
243        let renderer = Renderer::new(ColorSupport::TrueColor, false);
244        let ctx = RenderContext::with_size(Size::new(10, 5), renderer);
245        assert!(ctx.compositor().is_none());
246    }
247
248    #[test]
249    fn compositor_none_from_new() {
250        let backend = TestBackend::new(80, 24);
251        let result = RenderContext::new(&backend);
252        assert!(result.is_ok());
253        match result {
254            Ok(ctx) => assert!(ctx.compositor().is_none()),
255            Err(_) => unreachable!(),
256        }
257    }
258
259    #[test]
260    fn with_compositor_sets_compositor() {
261        let renderer = Renderer::new(ColorSupport::TrueColor, false);
262        let compositor = Compositor::new(10, 5);
263        let ctx = RenderContext::with_size(Size::new(10, 5), renderer).with_compositor(compositor);
264        assert!(ctx.compositor().is_some());
265    }
266
267    #[test]
268    fn compositor_accessor_returns_reference() {
269        let renderer = Renderer::new(ColorSupport::TrueColor, false);
270        let compositor = Compositor::new(80, 24);
271        let ctx = RenderContext::with_size(Size::new(80, 24), renderer).with_compositor(compositor);
272        match ctx.compositor() {
273            Some(c) => {
274                assert_eq!(c.screen_size(), Size::new(80, 24));
275            }
276            None => unreachable!(),
277        }
278    }
279
280    #[test]
281    fn compositor_mut_allows_mutation() {
282        let renderer = Renderer::new(ColorSupport::TrueColor, false);
283        let compositor = Compositor::new(80, 24);
284        let mut ctx =
285            RenderContext::with_size(Size::new(80, 24), renderer).with_compositor(compositor);
286
287        // Add a layer through the mutable accessor
288        match ctx.compositor_mut() {
289            Some(c) => {
290                let region = Rect::new(0, 0, 10, 5);
291                c.add_widget(1, region, 0, vec![vec![Segment::new("test")]]);
292                assert_eq!(c.layer_count(), 1);
293            }
294            None => unreachable!(),
295        }
296    }
297
298    #[test]
299    fn end_frame_with_compositor_composes_before_diff() {
300        let mut backend = TestBackend::new(20, 5);
301        let renderer = Renderer::new(ColorSupport::TrueColor, false);
302        let mut compositor = Compositor::new(20, 5);
303
304        // Add a layer with text
305        let region = Rect::new(0, 0, 20, 5);
306        compositor.add_widget(1, region, 0, vec![vec![Segment::new("Hello")]]);
307
308        let mut ctx =
309            RenderContext::with_size(Size::new(20, 5), renderer).with_compositor(compositor);
310
311        // Don't write directly to the buffer — let the compositor handle it
312        let result = ctx.end_frame(&mut backend);
313        assert!(result.is_ok());
314
315        let output = backend.buffer();
316        let output_str = String::from_utf8_lossy(output);
317        // The compositor should have written "Hello" into the buffer
318        assert!(output_str.contains('H'));
319        assert!(output_str.contains("ello"));
320    }
321
322    #[test]
323    fn compositor_z_ordering_in_render_context() {
324        let mut backend = TestBackend::new(20, 5);
325        let renderer = Renderer::new(ColorSupport::TrueColor, false);
326        let mut compositor = Compositor::new(20, 5);
327
328        // Background layer (z=0) with "BACKGROUND"
329        let bg_region = Rect::new(0, 0, 20, 5);
330        compositor.add_widget(1, bg_region, 0, vec![vec![Segment::new("BACKGROUND")]]);
331
332        // Overlay layer (z=10) at same position with "TOP"
333        let fg_region = Rect::new(0, 0, 20, 5);
334        compositor.add_widget(2, fg_region, 10, vec![vec![Segment::new("TOP")]]);
335
336        let mut ctx =
337            RenderContext::with_size(Size::new(20, 5), renderer).with_compositor(compositor);
338
339        let result = ctx.end_frame(&mut backend);
340        assert!(result.is_ok());
341
342        // The buffer should have the overlay text at (0,0) since z=10 > z=0
343        match ctx.buffer().get(0, 0) {
344            Some(cell) => {
345                assert_eq!(cell.grapheme, "T");
346            }
347            None => unreachable!(),
348        }
349    }
350
351    #[test]
352    fn handle_resize_updates_compositor() {
353        let renderer = Renderer::new(ColorSupport::TrueColor, false);
354        let compositor = Compositor::new(10, 5);
355        let mut ctx =
356            RenderContext::with_size(Size::new(10, 5), renderer).with_compositor(compositor);
357
358        ctx.handle_resize(Size::new(40, 20));
359
360        assert_eq!(ctx.size(), Size::new(40, 20));
361        match ctx.compositor() {
362            Some(c) => {
363                assert_eq!(c.screen_size(), Size::new(40, 20));
364            }
365            None => unreachable!(),
366        }
367    }
368
369    #[test]
370    fn integration_widget_segments_through_compositor() {
371        let mut backend = TestBackend::new(40, 10);
372        let renderer = Renderer::new(ColorSupport::TrueColor, false);
373        let mut compositor = Compositor::new(40, 10);
374
375        // Widget 1: title bar at top
376        let title_region = Rect::new(0, 0, 40, 1);
377        let title_style = Style::new().fg(Color::Named(NamedColor::Green)).bold(true);
378        let title_seg = Segment::styled("Title Bar", title_style);
379        compositor.add_widget(1, title_region, 0, vec![vec![title_seg]]);
380
381        // Widget 2: content area
382        let content_region = Rect::new(0, 1, 40, 9);
383        let content_seg = Segment::new("Content here");
384        compositor.add_widget(2, content_region, 0, vec![vec![content_seg]]);
385
386        let mut ctx =
387            RenderContext::with_size(Size::new(40, 10), renderer).with_compositor(compositor);
388
389        let result = ctx.end_frame(&mut backend);
390        assert!(result.is_ok());
391
392        // Check title bar rendered at row 0
393        match ctx.buffer().get(0, 0) {
394            Some(cell) => {
395                assert_eq!(cell.grapheme, "T");
396                assert!(cell.style.bold);
397            }
398            None => unreachable!(),
399        }
400
401        // Check content rendered at row 1
402        match ctx.buffer().get(0, 1) {
403            Some(cell) => {
404                assert_eq!(cell.grapheme, "C");
405            }
406            None => unreachable!(),
407        }
408    }
409}