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}