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