Skip to main content

saorsa_tui/
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        crate::test_env::without_no_color(|| {
225            let mut backend = TestBackend::new(10, 5);
226            let renderer = Renderer::new(ColorSupport::TrueColor, false);
227            let mut ctx = RenderContext::with_size(Size::new(10, 5), renderer);
228
229            let style = Style::new().fg(Color::Named(NamedColor::Red)).bold(true);
230            ctx.buffer_mut().set(0, 0, Cell::new("X", style));
231            let _ = ctx.end_frame(&mut backend);
232
233            let output = backend.buffer();
234            let output_str = String::from_utf8_lossy(output);
235            assert!(output_str.contains("\x1b[31m")); // red fg
236            assert!(output_str.contains("\x1b[1m")); // bold
237            assert!(output_str.contains('X'));
238        });
239    }
240
241    // --- New compositor integration tests ---
242
243    #[test]
244    fn compositor_none_by_default() {
245        let renderer = Renderer::new(ColorSupport::TrueColor, false);
246        let ctx = RenderContext::with_size(Size::new(10, 5), renderer);
247        assert!(ctx.compositor().is_none());
248    }
249
250    #[test]
251    fn compositor_none_from_new() {
252        let backend = TestBackend::new(80, 24);
253        let result = RenderContext::new(&backend);
254        assert!(result.is_ok());
255        match result {
256            Ok(ctx) => assert!(ctx.compositor().is_none()),
257            Err(_) => unreachable!(),
258        }
259    }
260
261    #[test]
262    fn with_compositor_sets_compositor() {
263        let renderer = Renderer::new(ColorSupport::TrueColor, false);
264        let compositor = Compositor::new(10, 5);
265        let ctx = RenderContext::with_size(Size::new(10, 5), renderer).with_compositor(compositor);
266        assert!(ctx.compositor().is_some());
267    }
268
269    #[test]
270    fn compositor_accessor_returns_reference() {
271        let renderer = Renderer::new(ColorSupport::TrueColor, false);
272        let compositor = Compositor::new(80, 24);
273        let ctx = RenderContext::with_size(Size::new(80, 24), renderer).with_compositor(compositor);
274        match ctx.compositor() {
275            Some(c) => {
276                assert_eq!(c.screen_size(), Size::new(80, 24));
277            }
278            None => unreachable!(),
279        }
280    }
281
282    #[test]
283    fn compositor_mut_allows_mutation() {
284        let renderer = Renderer::new(ColorSupport::TrueColor, false);
285        let compositor = Compositor::new(80, 24);
286        let mut ctx =
287            RenderContext::with_size(Size::new(80, 24), renderer).with_compositor(compositor);
288
289        // Add a layer through the mutable accessor
290        match ctx.compositor_mut() {
291            Some(c) => {
292                let region = Rect::new(0, 0, 10, 5);
293                c.add_widget(1, region, 0, vec![vec![Segment::new("test")]]);
294                assert_eq!(c.layer_count(), 1);
295            }
296            None => unreachable!(),
297        }
298    }
299
300    #[test]
301    fn end_frame_with_compositor_composes_before_diff() {
302        let mut backend = TestBackend::new(20, 5);
303        let renderer = Renderer::new(ColorSupport::TrueColor, false);
304        let mut compositor = Compositor::new(20, 5);
305
306        // Add a layer with text
307        let region = Rect::new(0, 0, 20, 5);
308        compositor.add_widget(1, region, 0, vec![vec![Segment::new("Hello")]]);
309
310        let mut ctx =
311            RenderContext::with_size(Size::new(20, 5), renderer).with_compositor(compositor);
312
313        // Don't write directly to the buffer — let the compositor handle it
314        let result = ctx.end_frame(&mut backend);
315        assert!(result.is_ok());
316
317        let output = backend.buffer();
318        let output_str = String::from_utf8_lossy(output);
319        // The compositor should have written "Hello" into the buffer
320        assert!(output_str.contains('H'));
321        assert!(output_str.contains("ello"));
322    }
323
324    #[test]
325    fn compositor_z_ordering_in_render_context() {
326        let mut backend = TestBackend::new(20, 5);
327        let renderer = Renderer::new(ColorSupport::TrueColor, false);
328        let mut compositor = Compositor::new(20, 5);
329
330        // Background layer (z=0) with "BACKGROUND"
331        let bg_region = Rect::new(0, 0, 20, 5);
332        compositor.add_widget(1, bg_region, 0, vec![vec![Segment::new("BACKGROUND")]]);
333
334        // Overlay layer (z=10) at same position with "TOP"
335        let fg_region = Rect::new(0, 0, 20, 5);
336        compositor.add_widget(2, fg_region, 10, vec![vec![Segment::new("TOP")]]);
337
338        let mut ctx =
339            RenderContext::with_size(Size::new(20, 5), renderer).with_compositor(compositor);
340
341        let result = ctx.end_frame(&mut backend);
342        assert!(result.is_ok());
343
344        // The buffer should have the overlay text at (0,0) since z=10 > z=0
345        match ctx.buffer().get(0, 0) {
346            Some(cell) => {
347                assert_eq!(cell.grapheme, "T");
348            }
349            None => unreachable!(),
350        }
351    }
352
353    #[test]
354    fn handle_resize_updates_compositor() {
355        let renderer = Renderer::new(ColorSupport::TrueColor, false);
356        let compositor = Compositor::new(10, 5);
357        let mut ctx =
358            RenderContext::with_size(Size::new(10, 5), renderer).with_compositor(compositor);
359
360        ctx.handle_resize(Size::new(40, 20));
361
362        assert_eq!(ctx.size(), Size::new(40, 20));
363        match ctx.compositor() {
364            Some(c) => {
365                assert_eq!(c.screen_size(), Size::new(40, 20));
366            }
367            None => unreachable!(),
368        }
369    }
370
371    #[test]
372    fn integration_widget_segments_through_compositor() {
373        let mut backend = TestBackend::new(40, 10);
374        let renderer = Renderer::new(ColorSupport::TrueColor, false);
375        let mut compositor = Compositor::new(40, 10);
376
377        // Widget 1: title bar at top
378        let title_region = Rect::new(0, 0, 40, 1);
379        let title_style = Style::new().fg(Color::Named(NamedColor::Green)).bold(true);
380        let title_seg = Segment::styled("Title Bar", title_style);
381        compositor.add_widget(1, title_region, 0, vec![vec![title_seg]]);
382
383        // Widget 2: content area
384        let content_region = Rect::new(0, 1, 40, 9);
385        let content_seg = Segment::new("Content here");
386        compositor.add_widget(2, content_region, 0, vec![vec![content_seg]]);
387
388        let mut ctx =
389            RenderContext::with_size(Size::new(40, 10), renderer).with_compositor(compositor);
390
391        let result = ctx.end_frame(&mut backend);
392        assert!(result.is_ok());
393
394        // Check title bar rendered at row 0
395        match ctx.buffer().get(0, 0) {
396            Some(cell) => {
397                assert_eq!(cell.grapheme, "T");
398                assert!(cell.style.bold);
399            }
400            None => unreachable!(),
401        }
402
403        // Check content rendered at row 1
404        match ctx.buffer().get(0, 1) {
405            Some(cell) => {
406                assert_eq!(cell.grapheme, "C");
407            }
408            None => unreachable!(),
409        }
410    }
411}