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