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}