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}