Skip to main content

retroglyph_core/terminal/
mod.rs

1//! [`Terminal`]: construction, sizing, resizing, cursor control, and raw grid/backend access.
2//!
3//! [`Terminal::present`] and event polling (starting at [`Terminal::poll`]) are the other two
4//! axes of `Terminal`'s API, defined in private submodules; this module holds everything that
5//! isn't specifically about presenting a frame or reading input.
6
7use crate::backend::{Backend, CursorStyle};
8use crate::event::Event;
9use crate::grid::{Grid, Pos, Rect, Size};
10use crate::surface::Surface;
11use alloc::collections::VecDeque;
12use alloc::vec::Vec;
13use ixy::HasSize;
14
15mod input;
16mod present;
17mod retain;
18
19/// A double-buffered terminal generic over a [`Backend`].
20///
21/// Owns the current and previous frame grids and the backend's lifecycle (resize, present,
22/// events). Drawing itself goes entirely through [`Surface`]: see [`draw`](Self::draw) for the
23/// common case (draw a frame, then present it) and [`surface`](Self::surface) for manual control
24/// over presenting.
25///
26/// # Out-of-bounds drawing
27///
28/// [`Surface`] clips any write that falls outside its own area rather than panicking; see
29/// [`Surface`]'s own "out-of-bounds drawing" documentation.
30///
31/// # Examples
32///
33/// ```
34/// use retroglyph_core::backend::Headless;
35/// use retroglyph_core::color::Color;
36/// use retroglyph_core::terminal::Terminal;
37///
38/// let mut term = Terminal::new(Headless::new(20, 5));
39/// term.draw(|surface| {
40///     surface.put((2, 1), '@', retroglyph_core::color::Style::new().fg(Color::GREEN));
41/// })
42/// .unwrap();
43/// ```
44pub struct Terminal<B: Backend> {
45    current: Grid,
46    previous: Grid,
47    /// Single-layer scratch buffers used only when the backend does not
48    /// composite layers itself and more than one layer is in play. `present`
49    /// flattens `current` into `flattened_current`, diffs it against
50    /// `flattened_previous`, and sends the result. Lazily allocated on first use
51    /// (see `present`'s flatten branch) so compositing backends, and cell
52    /// backends that never draw past layer 0, never pay for them.
53    flattened_current: Option<Grid>,
54    flattened_previous: Option<Grid>,
55    backend: B,
56    /// Events waiting to be handed out by [`poll`](Self::poll) before the backend is polled
57    /// again: the [`has_input`](Self::has_input)/[`wait_for_input`](Self::wait_for_input) lookahead
58    /// buffers a single event here, and [`requeue_events`](Self::requeue_events) lets a wrapper
59    /// (e.g. `PerfOverlayApp`) hand back events it drained but didn't consume, entirely through
60    /// `Terminal` itself rather than a backend-specific input path.
61    queued_events: VecDeque<Event>,
62    /// `true` when the flatten buffers no longer reflect the last frame sent to
63    /// the backend (because the single-layer fast path bypassed them). The next
64    /// multi-layer present clears `flattened_previous` first so it does a full
65    /// redraw instead of diffing against stale data.
66    flattened_stale: bool,
67    /// Incremented every time [`present`](Self::present) is called.
68    ///
69    /// Lets embedding drivers detect whether application code already presented during a frame,
70    /// so they can skip a redundant driver-side present.
71    present_count: u64,
72    /// Layers marked by [`retain_layer`](Self::retain_layer) to be re-synced from `previous`
73    /// instead of diffed as a real redraw on the next [`present`](Self::present).
74    ///
75    /// Indexed by layer id; `retained_layers[id]` is `true` if that layer's `previous` content
76    /// should be copied into `current` before diffing. Reset to all `false` once consumed at the
77    /// start of `present` (it's a one-shot opt-in, not a sticky mode) and on
78    /// [`resize`](Self::resize).
79    retained_layers: Vec<bool>,
80    /// Layers marked by [`drop_layer`](Self::drop_layer) to be deallocated, on both `current` and
81    /// `previous`, once `present` no longer needs them for this frame's diff.
82    ///
83    /// Indexed by layer id, same convention as `retained_layers`. Consumed (and reset to all
84    /// `false`) after `present`'s diff has been computed and sent to the backend, but before the
85    /// current/previous swap: deallocating any earlier would make the layer invisible to the diff
86    /// (`diff`/`flatten_into` only walk `current`'s own `max_layer`), silently dropping the erase
87    /// the backend needs instead of sending it (retroglyph#1028).
88    dropped_layers: Vec<bool>,
89}
90
91impl<B: Backend> Terminal<B> {
92    /// Create a terminal with the given backend.
93    /// Grid dimensions are queried from the backend.
94    ///
95    /// # Panics
96    ///
97    /// Panics if the backend reports a width of 0 (e.g. a minimized window, or a surface queried
98    /// before the first configure); see [`Grid::new`]. A reported height of 0 is fine.
99    #[must_use]
100    pub fn new(backend: B) -> Self {
101        let size = backend.size();
102        let current = Grid::new(size.width(), size.height());
103        let previous = Grid::new(size.width(), size.height());
104        Self {
105            current,
106            previous,
107            flattened_current: None,
108            flattened_previous: None,
109            backend,
110            queued_events: VecDeque::new(),
111            flattened_stale: false,
112            present_count: 0,
113            retained_layers: Vec::new(),
114            dropped_layers: Vec::new(),
115        }
116    }
117
118    /// A [`Surface`] scoped to the whole terminal on layer 0, for manual control over presenting
119    /// (e.g. partial updates spread across several calls, or conditionally skipping a present).
120    /// Most callers want [`draw`](Self::draw) instead.
121    pub const fn surface(&mut self) -> Surface<'_> {
122        let area = self.area();
123        Surface::new(&mut self.current, area, 0)
124    }
125
126    /// Returns the current grid dimensions.
127    #[must_use]
128    pub const fn size(&self) -> Size {
129        self.current.size()
130    }
131
132    /// Returns the full drawing surface as a [`Rect`] at the origin.
133    ///
134    /// Equivalent to `Rect::new(0, 0, width, height)`. Handy for passing the
135    /// whole terminal to layout helpers or region-based drawing.
136    #[must_use]
137    pub const fn area(&self) -> Rect {
138        self.current.size().to_rect()
139    }
140
141    /// Resize both grids to `width` × `height` cells.
142    ///
143    /// Unlike [`new`](Self::new), a `width` of 0 does not panic here: a terminal can be resized
144    /// down to zero columns (a minimized or zero-width window) and back up again, and the
145    /// single-layer present path keeps working at zero width. A `height` of 0 is likewise fine.
146    ///
147    /// Content within the overlapping region is preserved in the current grid.
148    /// The previous grid is cleared so the next [`present`](Self::present) redraws
149    /// the entire new surface rather than diffing stale data.
150    ///
151    /// # Panics
152    ///
153    /// A zero-width terminal only supports the single-layer fast path. If any layer above 0 is
154    /// allocated when [`present`](Self::present) runs at zero width, `present` panics while
155    /// building its flatten buffers (see [`Grid::new`]); either avoid multi-layer drawing at zero
156    /// width, or [`drop_layer`](Self::drop_layer) every layer above 0 first.
157    pub fn resize(&mut self, width: u16, height: u16) {
158        self.current.resize(width, height);
159        self.previous.resize(width, height);
160        // Only resize the flatten buffers if they've actually been allocated (see their field
161        // docs); an unallocated buffer has nothing to preserve and will be sized correctly by
162        // `present` on first use anyway.
163        if let Some(flattened_current) = &mut self.flattened_current {
164            flattened_current.resize(width, height);
165        }
166        if let Some(flattened_previous) = &mut self.flattened_previous {
167            flattened_previous.resize(width, height);
168        }
169        // Clearing previous forces a full redraw next present(), ensuring no
170        // stale cells bleed into the resized layout.
171        self.previous.clear_all();
172        if let Some(flattened_previous) = &mut self.flattened_previous {
173            flattened_previous.clear_all();
174        }
175        // Defensive: `resize` already clears `previous` unconditionally, so the next `present`
176        // would just copy empty content forward for a still-marked layer. Dropping pending
177        // retention here too keeps that a non-event rather than relying on it.
178        self.retained_layers.clear();
179        // Same reasoning: a pending `drop_layer` deallocation is meaningless once `resize` has
180        // already reallocated every layer's buffers at the new dimensions.
181        self.dropped_layers.clear();
182        self.backend.resize(Size::new(width, height));
183    }
184
185    /// Show or hide the cursor.
186    ///
187    /// Forwards to [`Cursor::set_cursor_visible`](crate::backend::Cursor::set_cursor_visible) on
188    /// the backend.
189    pub fn set_cursor_visible(&mut self, visible: bool) {
190        self.backend.set_cursor_visible(visible);
191    }
192
193    /// Move the cursor to a position.
194    ///
195    /// Forwards to [`Cursor::set_cursor_position`](crate::backend::Cursor::set_cursor_position)
196    /// on the backend.
197    pub fn set_cursor_position(&mut self, position: Pos) {
198        self.backend.set_cursor_position(position);
199    }
200
201    /// Set the cursor's shape (and blink behavior).
202    ///
203    /// Forwards to [`Cursor::set_cursor_style`](crate::backend::Cursor::set_cursor_style) on the
204    /// backend.
205    pub fn set_cursor_style(&mut self, style: CursorStyle) {
206        self.backend.set_cursor_style(style);
207    }
208
209    /// Returns a reference to the current grid.
210    #[must_use]
211    pub const fn grid(&self) -> &Grid {
212        &self.current
213    }
214
215    /// Returns a mutable reference to the current grid, with no clipping or layer scoping.
216    ///
217    /// Escape hatch for whole-grid operations that don't fit [`Surface`]'s clipped,
218    /// single-layer model (e.g. [`Grid::blit`]). Most drawing should go through
219    /// [`draw`](Self::draw)/[`surface`](Self::surface) instead.
220    pub const fn grid_mut(&mut self) -> &mut Grid {
221        &mut self.current
222    }
223
224    /// Returns a reference to the backend.
225    #[must_use]
226    pub const fn backend(&self) -> &B {
227        &self.backend
228    }
229
230    /// Returns a mutable reference to the backend.
231    pub const fn backend_mut(&mut self) -> &mut B {
232        &mut self.backend
233    }
234}
235
236impl<B: Backend> core::fmt::Debug for Terminal<B> {
237    /// Prints `size` and `present_count`; elides the frame buffers and the backend.
238    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
239        f.debug_struct("Terminal")
240            .field("size", &self.size())
241            .field("present_count", &self.present_count)
242            .finish_non_exhaustive()
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use crate::backend::Headless;
250    use crate::color::Style;
251    use crate::tile::Tile;
252
253    #[test]
254    fn test_terminal_grid_mut() {
255        let backend = Headless::new(10, 10);
256        let mut terminal = Terminal::new(backend);
257
258        assert_eq!(terminal.grid()[Pos::new(0, 0)].glyph(), ' ');
259
260        terminal
261            .grid_mut()
262            .put_tile(0, (0, 0), Tile::new('X', Style::default()));
263
264        assert_eq!(terminal.grid()[Pos::new(0, 0)].glyph(), 'X');
265    }
266
267    #[test]
268    fn test_terminal_size() {
269        let term = Terminal::new(Headless::new(40, 20));
270        assert_eq!(term.size(), Size::new(40, 20));
271    }
272
273    #[test]
274    fn test_terminal_area() {
275        let term = Terminal::new(Headless::new(40, 20));
276        assert_eq!(term.area(), Rect::new(0, 0, 40, 20));
277    }
278
279    #[test]
280    #[should_panic(expected = "Grid width must be at least 1")]
281    fn test_terminal_new_zero_width_backend_panics() {
282        let _ = Terminal::new(Headless::new(0, 5));
283    }
284
285    #[test]
286    fn test_terminal_new_zero_height_backend_does_not_panic() {
287        let term = Terminal::new(Headless::new(5, 0));
288        assert_eq!(term.size(), Size::new(5, 0));
289    }
290
291    #[test]
292    #[should_panic(expected = "Grid width must be at least 1")]
293    fn test_terminal_new_zero_by_zero_backend_panics() {
294        let _ = Terminal::new(Headless::new(0, 0));
295    }
296
297    #[test]
298    fn test_terminal_resize_to_zero_by_zero_is_allowed() {
299        let mut term = Terminal::new(Headless::new(10, 10));
300        term.resize(0, 0);
301        assert_eq!(term.size(), Size::new(0, 0));
302        // Resizing back up afterwards still works.
303        term.resize(10, 10);
304        assert_eq!(term.size(), Size::new(10, 10));
305    }
306
307    #[test]
308    #[should_panic(expected = "Grid width must be at least 1")]
309    fn test_terminal_present_multi_layer_at_zero_width_panics() {
310        // Pins the `# Panics` case documented on `resize`: once layer 1 is allocated, resizing
311        // down to zero width and presenting hits `present`'s multi-layer flatten branch, which
312        // rebuilds its buffers with `Grid::new` at the current size and panics (retroglyph#1130).
313        //
314        // Allocate layer 1 through `surface()` rather than `draw()`: `draw` also presents, which
315        // swaps `current`/`previous` and clears the new `current`, undoing the allocation before
316        // this test can resize. A `put` on a layer at zero width would also miss: it is clipped
317        // out by the (now zero-width) surface area and never allocates the layer at all.
318        let mut term = Terminal::new(Headless::new(10, 10));
319        term.surface()
320            .on_layer(1)
321            .put((0, 0), 'B', Style::default());
322        term.resize(0, 10);
323        let _ = term.present();
324    }
325
326    #[test]
327    fn test_terminal_cursor_passthroughs_forward_to_backend() {
328        let mut term = Terminal::new(Headless::new(10, 10));
329
330        term.set_cursor_visible(true);
331        assert!(term.backend().cursor_visible());
332
333        term.set_cursor_position(Pos::new(3, 4));
334        assert_eq!(term.backend().cursor_position(), Pos::new(3, 4));
335
336        term.set_cursor_style(CursorStyle::SteadyBar);
337        assert_eq!(term.backend().cursor_style(), CursorStyle::SteadyBar);
338    }
339
340    #[test]
341    fn test_terminal_resize_changes_dimensions() {
342        let mut term = Terminal::new(Headless::new(10, 10));
343        term.resize(30, 15);
344        assert_eq!(term.size(), Size::new(30, 15));
345        assert_eq!(term.grid().width(), 30);
346        assert_eq!(term.grid().height(), 15);
347    }
348
349    #[test]
350    fn test_terminal_resize_preserves_current_content() {
351        // Writes through `surface()` rather than `draw()`, so `current` is inspected before any
352        // `present()` clears it: `draw()` always presents, which would swap this content out to
353        // `previous` and clear the new `current` before the assertions below could see it.
354        let mut term = Terminal::new(Headless::new(10, 10));
355        term.surface().put((2, 2), 'X', Style::default());
356        term.resize(20, 20);
357        assert_eq!(term.grid()[Pos::new(2, 2)].glyph(), 'X');
358        assert_eq!(term.grid()[Pos::new(15, 15)].glyph(), ' ');
359    }
360
361    #[test]
362    fn test_terminal_resize_event_auto_applies() {
363        let mut term = Terminal::new(Headless::new(10, 10));
364        term.backend_mut().push_event(Event::Resize(80, 25));
365        let event = term.poll(core::time::Duration::ZERO);
366        assert_eq!(event, Some(Event::Resize(80, 25)));
367        assert_eq!(term.size(), Size::new(80, 25));
368    }
369
370    #[test]
371    fn test_terminal_resize_after_flatten_buffers_allocated() {
372        // Draw to layer 1 first so `present` takes the flatten path and lazily allocates
373        // `flattened_current`/`flattened_previous` (see their field docs). `resize` must then
374        // resize and clear those buffers too, not just `current`/`previous`, or a later present
375        // would diff against stale, wrongly-sized flattened content.
376        let mut term = Terminal::new(Headless::new(3, 3));
377        term.draw(|s| {
378            s.put((0, 0), 'A', Style::default());
379            s.on_layer(1).put((1, 1), 'B', Style::default());
380        })
381        .expect("draw failed");
382
383        term.resize(5, 5);
384        assert_eq!(term.size(), Size::new(5, 5));
385
386        // A full redraw is expected after resize; if the flattened buffers weren't resized and
387        // cleared alongside `current`/`previous`, this would panic on mismatched grid sizes or
388        // silently under-diff instead of redrawing everything.
389        term.draw(|s| {
390            s.put((0, 0), 'A', Style::default());
391            s.on_layer(1).put((1, 1), 'B', Style::default());
392        })
393        .expect("draw failed");
394        assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), 'A');
395        assert_eq!(term.backend().grid()[Pos::new(1, 1)].glyph(), 'B');
396    }
397
398    #[test]
399    fn test_terminal_resize_new_cells_accessible() {
400        // Resize to a larger area, then draw into the newly created region.
401        let mut term = Terminal::new(Headless::new(3, 3));
402        term.draw(|s| s.put((0, 0), 'A', Style::default()))
403            .expect("draw failed");
404
405        term.resize(5, 5);
406
407        // Draw into the expanded region and verify it reaches the backend.
408        term.draw(|s| s.put((4, 4), 'B', Style::default()))
409            .expect("draw failed");
410
411        assert_eq!(term.backend().grid()[Pos::new(4, 4)].glyph(), 'B');
412        // (0,0) was not redrawn this frame; backend retains 'A' from before resize.
413        assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), 'A');
414    }
415
416    #[test]
417    fn test_terminal_resize_clears_pending_retention() {
418        use crate::surface::Layer;
419
420        // `resize` clearing `previous` alone can't distinguish "retention cleared" from
421        // "retention still active": a retained blit from an already-blank `previous` looks the
422        // same as no retention at all. So this redraws `World` with new content right after the
423        // resize: if retention had survived, `present`'s pre-diff blit would silently overwrite
424        // that fresh draw with `previous`'s (blank) content before the diff ever ran, leaving the
425        // backend showing stale pre-resize content instead of the new frame.
426        let mut term = Terminal::new(Headless::new(3, 1));
427        term.draw(|s| s.on_tier(Layer::World).put((0, 0), 'W', Style::default()))
428            .expect("draw failed");
429
430        term.retain_layer(Layer::World);
431        term.resize(3, 1);
432
433        term.draw(|s| s.on_tier(Layer::World).put((0, 0), 'X', Style::default()))
434            .expect("draw failed");
435        assert_eq!(
436            term.backend().grid()[Pos::new(0, 0)].glyph(),
437            'X',
438            "pending retention must not survive resize: a still-active blit from the \
439             (resize-cleared) previous frame would have overwritten this fresh draw"
440        );
441    }
442}