retroglyph_core/terminal.rs
1//! Stateful terminal lifecycle and double-buffering.
2
3use crate::backend::{Backend, Output};
4use crate::event::Event;
5use crate::grid::{Grid, Rect, Size};
6use crate::surface::Surface;
7use core::time::Duration;
8
9/// A double-buffered terminal generic over a [`Backend`].
10///
11/// Owns the current and previous frame grids and the backend's lifecycle (resize, present,
12/// events). Drawing itself goes entirely through [`Surface`]: see [`draw`](Self::draw) for the
13/// common case (draw a frame, then present it) and [`surface`](Self::surface) for manual control
14/// over presenting.
15///
16/// # Out-of-bounds drawing
17///
18/// [`Surface`] clips any write that falls outside its own area rather than panicking; see
19/// [`Surface`]'s own "out-of-bounds drawing" documentation.
20///
21/// # Examples
22///
23/// ```
24/// use retroglyph_core::backend::Headless;
25/// use retroglyph_core::{Color, Terminal};
26///
27/// let mut term = Terminal::new(Headless::new(20, 5));
28/// term.draw(|surface| {
29/// surface.put((2, 1), '@', retroglyph_core::Style::new().fg(Color::GREEN));
30/// })
31/// .unwrap();
32/// ```
33pub struct Terminal<B: Backend> {
34 current: Grid,
35 previous: Grid,
36 /// Single-layer scratch buffers used only when the backend does not
37 /// composite layers itself. `present` flattens `current` into
38 /// `flattened_current`, diffs it against `flattened_previous`, and sends the
39 /// result. Unused (but allocated) for compositing backends.
40 flattened_current: Grid,
41 flattened_previous: Grid,
42 backend: B,
43 queued_event: Option<Event>,
44 /// `true` when the flatten buffers no longer reflect the last frame sent to
45 /// the backend (because the single-layer fast path bypassed them). The next
46 /// multi-layer present clears `flattened_previous` first so it does a full
47 /// redraw instead of diffing against stale data.
48 flattened_stale: bool,
49 /// Incremented every time [`present`](Self::present) is called.
50 ///
51 /// Lets embedding drivers detect whether application code already presented during a frame,
52 /// so they can skip a redundant driver-side present.
53 present_count: u64,
54}
55
56impl<B: Backend> Terminal<B> {
57 /// Create a terminal with the given backend.
58 /// Grid dimensions are queried from the backend.
59 #[must_use]
60 pub fn new(backend: B) -> Self {
61 let size = backend.size();
62 let current = Grid::new(size.width, size.height);
63 let previous = Grid::new(size.width, size.height);
64 let flattened_current = Grid::new(size.width, size.height);
65 let flattened_previous = Grid::new(size.width, size.height);
66 Self {
67 current,
68 previous,
69 flattened_current,
70 flattened_previous,
71 backend,
72 queued_event: None,
73 flattened_stale: false,
74 present_count: 0,
75 }
76 }
77
78 /// Draws one frame: `f` gets a [`Surface`] scoped to the whole terminal on layer 0, then the
79 /// frame is presented (see [`present`](Self::present)) once `f` returns.
80 ///
81 /// This is the common entry point for drawing: a caller that draws every frame regardless of
82 /// whether anything changed calls this once per frame. A caller that only wants to redraw
83 /// when its own state changed should gate the call to `draw` itself (e.g. `if
84 /// state.changed() { term.draw(|s| render(s, &state))?; }`) rather than rely on `draw`/
85 /// [`present`](Self::present) to no-op. Unlike some earlier revisions of this API, presenting
86 /// is unconditional here.
87 ///
88 /// # Errors
89 ///
90 /// Propagates errors from [`present`](Self::present).
91 pub fn draw(&mut self, f: impl FnOnce(&mut Surface<'_>)) -> Result<(), <B as Output>::Error> {
92 let area = self.area();
93 let mut surface = Surface::new(&mut self.current, area, 0);
94 f(&mut surface);
95 self.present()
96 }
97
98 /// A [`Surface`] scoped to the whole terminal on layer 0, for manual control over presenting
99 /// (e.g. partial updates spread across several calls, or conditionally skipping a present).
100 /// Most callers want [`draw`](Self::draw) instead.
101 pub const fn surface(&mut self) -> Surface<'_> {
102 let area = self.area();
103 Surface::new(&mut self.current, area, 0)
104 }
105
106 /// Returns the current grid dimensions.
107 #[must_use]
108 pub const fn size(&self) -> Size {
109 Size {
110 width: self.current.width(),
111 height: self.current.height(),
112 }
113 }
114
115 /// Returns the full drawing surface as a [`Rect`] at the origin.
116 ///
117 /// Equivalent to `Rect::new(0, 0, width, height)`. Handy for passing the
118 /// whole terminal to layout helpers or region-based drawing.
119 #[must_use]
120 pub const fn area(&self) -> Rect {
121 Rect::new(0, 0, self.current.width(), self.current.height())
122 }
123
124 /// Resize both grids to `width` × `height` cells.
125 ///
126 /// Content within the overlapping region is preserved in the current grid.
127 /// The previous grid is cleared so the next [`present`](Self::present) redraws
128 /// the entire new surface rather than diffing stale data.
129 pub fn resize(&mut self, width: u16, height: u16) {
130 self.current.resize(width, height);
131 self.previous.resize(width, height);
132 self.flattened_current.resize(width, height);
133 self.flattened_previous.resize(width, height);
134 // Clearing previous forces a full redraw next present(), ensuring no
135 // stale cells bleed into the resized layout.
136 self.previous.clear_all();
137 self.flattened_previous.clear_all();
138 self.backend.resize(Size { width, height });
139 }
140
141 /// Returns a reference to the current grid.
142 #[must_use]
143 pub const fn grid(&self) -> &Grid {
144 &self.current
145 }
146
147 /// Returns a mutable reference to the current grid, with no clipping or layer scoping.
148 ///
149 /// Escape hatch for whole-grid operations that don't fit [`Surface`]'s clipped,
150 /// single-layer model (e.g. [`Grid::blit`]). Most drawing should go through
151 /// [`draw`](Self::draw)/[`surface`](Self::surface) instead.
152 pub const fn grid_mut(&mut self) -> &mut Grid {
153 &mut self.current
154 }
155
156 /// Returns a reference to the backend.
157 #[must_use]
158 pub const fn backend(&self) -> &B {
159 &self.backend
160 }
161
162 /// Returns a mutable reference to the backend.
163 pub const fn backend_mut(&mut self) -> &mut B {
164 &mut self.backend
165 }
166
167 /// Number of times [`present`](Self::present) has been called so far.
168 ///
169 /// Wraps on overflow; intended for detecting whether `present` was called *at all* between two
170 /// points in time (compare a saved count against the current one), not as a precise total.
171 /// Embedding drivers (e.g. `retroglyph-window`'s windowed drivers) use this to decide whether
172 /// application code already presented during a frame, so they can skip a redundant
173 /// driver-side present.
174 #[must_use]
175 pub const fn present_count(&self) -> u64 {
176 self.present_count
177 }
178
179 /// Present the current frame: computes the diff against the previous frame, sends changed
180 /// cells to the backend, flushes, then swaps buffers. Always presents unconditionally, even
181 /// if nothing was drawn since the last call; most callers want [`draw`](Self::draw) instead
182 /// of calling this directly.
183 ///
184 /// When the backend requires a full frame (see
185 /// [`crate::Output::needs_full_frame`]), all cells from every allocated layer are
186 /// sent rather than just the diff, so pixel-based backends can clear and
187 /// redraw to avoid orphaned pixels from sub-cell offsets.
188 ///
189 /// After a present, the new current buffer is cleared so the next frame starts empty.
190 /// Callers should not draw into a frame and skip presenting it: the next [`draw`](Self::draw)
191 /// call starts from an empty grid regardless.
192 ///
193 /// # Immediate mode
194 ///
195 /// This is an immediate-mode API (the same trade [ratatui] makes): the
196 /// current buffer is wiped after every present, so each frame must redraw
197 /// its entire scene from scratch. Cells are **not** retained between
198 /// frames. The diff only bounds what is sent to the backend (terminal or
199 /// pixel I/O); it does not bound the CPU cost of your redraw.
200 ///
201 /// [ratatui]: https://docs.rs/ratatui
202 ///
203 /// # Errors
204 ///
205 /// Propagates errors from the backend's [`draw_layers`](crate::Output::draw_layers) or
206 /// [`flush`](crate::Output::flush) operations. Either failure returns before the
207 /// current/previous buffers are swapped, so the cells from the failed frame stay marked
208 /// dirty and are resent the next time `present` succeeds; the caller doesn't need to
209 /// redraw anything to recover, just call `draw`/`present` again.
210 pub fn present(&mut self) -> Result<(), <B as Output>::Error> {
211 self.present_count = self.present_count.wrapping_add(1);
212 if self.backend.composites_layers() {
213 // Pixel/GPU backends composite the raw layered stream themselves.
214 if self.backend.needs_full_frame() {
215 let all = self.current.layers();
216 self.backend.draw_layers(all)?;
217 } else {
218 let diff = self.current.diff(&self.previous);
219 self.backend.draw_layers(diff)?;
220 }
221 } else if self.current.max_layer() == 0 && self.previous.max_layer() == 0 {
222 // Fast path: only layer 0 is in play, so flattening would be an exact
223 // copy of `current`. Diff the real grids directly and skip the
224 // flatten buffers entirely.
225 let diff = self.current.diff(&self.previous);
226 self.backend.draw_layers(diff)?;
227 self.flattened_stale = true;
228 } else {
229 // Cell backends receive a pre-flattened, single-layer diff so layers
230 // 1+ appear everywhere, not just on pixel backends.
231 if self.flattened_stale {
232 // The previous frame used the fast path, so `flattened_previous`
233 // is stale. Clear it to force a full redraw this frame.
234 self.flattened_previous.clear_all();
235 self.flattened_stale = false;
236 }
237 self.current.flatten_into(&mut self.flattened_current);
238 let diff = self.flattened_current.diff(&self.flattened_previous);
239 self.backend.draw_layers(diff)?;
240 core::mem::swap(&mut self.flattened_current, &mut self.flattened_previous);
241 }
242 self.backend.flush()?;
243 core::mem::swap(&mut self.current, &mut self.previous);
244 self.current.clear_all();
245 Ok(())
246 }
247
248 /// Polls for an input event, waiting up to `timeout`.
249 ///
250 /// If an event was previously buffered by [`has_input`](Self::has_input), it is
251 /// returned immediately. Otherwise, the backend is polled for a new event.
252 ///
253 /// [`Event::Resize`] events are automatically applied: both grids are resized
254 /// before the event is returned to the caller, so the game loop can immediately
255 /// redraw at the new size.
256 pub fn poll(&mut self, timeout: Duration) -> Option<Event> {
257 let event = self
258 .queued_event
259 .take()
260 .or_else(|| self.backend.poll_event(timeout))?;
261 if let Event::Resize(w, h) = event {
262 self.resize(w, h);
263 }
264 Some(event)
265 }
266
267 /// Reads an input event, blocking indefinitely until one is available.
268 ///
269 /// Only call this on backends that genuinely block (e.g. crossterm, window). Backends
270 /// that never block (e.g. [`Headless`](crate::backend::Headless), which returns
271 /// immediately regardless of timeout) will panic here once their event queue is
272 /// empty; use [`poll`](Self::poll) or [`drain_events`](Self::drain_events) instead if
273 /// that is a possibility.
274 ///
275 /// # Panics
276 ///
277 /// Panics if the backend's [`poll_event`](crate::Input::poll_event) returns
278 /// `None` even with an unbounded timeout.
279 pub fn read_blocking(&mut self) -> Event {
280 self.poll(Duration::MAX)
281 .expect("read_blocking() called but no events available")
282 }
283
284 /// Drains all available events without blocking.
285 ///
286 /// Returns an iterator that yields every pending event — the internal queued event
287 /// followed by all events buffered in the backend. The iterator polls the backend
288 /// with zero timeout repeatedly until `None` is returned.
289 ///
290 /// This is needed for frame-based game loops (e.g. software backend + WASM, where
291 /// frames are gated by `requestAnimationFrame`). Multiple keypresses can arrive
292 /// between frames; draining all of them ensures accumulated input doesn't replay in
293 /// slow motion.
294 ///
295 /// Crossterm and headless backends can also use this, but the single-event `poll`
296 /// pattern works for them because their loops aren't frame-capped.
297 pub fn drain_events(&mut self) -> impl Iterator<Item = Event> + use<'_, B> {
298 struct DrainEvents<'a, B: Backend> {
299 terminal: &'a mut Terminal<B>,
300 }
301
302 impl<B: Backend> Iterator for DrainEvents<'_, B> {
303 type Item = Event;
304
305 fn next(&mut self) -> Option<Event> {
306 self.terminal.poll(Duration::ZERO)
307 }
308 }
309
310 impl<B: Backend> core::iter::FusedIterator for DrainEvents<'_, B> {}
311
312 DrainEvents { terminal: self }
313 }
314
315 /// Checks if a pending input event is available without blocking.
316 ///
317 /// If an event is already buffered, returns `true`. Otherwise, polls the backend
318 /// with zero timeout. If the backend returns an event, it is stored in the internal
319 /// buffer and `true` is returned; otherwise, returns `false`.
320 pub fn has_input(&mut self) -> bool {
321 if self.queued_event.is_some() {
322 true
323 } else if let Some(event) = self.backend.poll_event(Duration::ZERO) {
324 self.queued_event = Some(event);
325 true
326 } else {
327 false
328 }
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335 use crate::backend::Headless;
336 use crate::color::Color;
337 use crate::grid::Pos;
338 use crate::style::Style;
339 use crate::tile::Tile;
340
341 #[test]
342 fn test_terminal_grid_mut() {
343 let backend = Headless::new(10, 10);
344 let mut terminal = Terminal::new(backend);
345
346 assert_eq!(terminal.grid()[Pos::new(0, 0)].glyph(), ' ');
347
348 terminal
349 .grid_mut()
350 .put_tile(0, (0, 0), Tile::new('X', Style::default()));
351
352 assert_eq!(terminal.grid()[Pos::new(0, 0)].glyph(), 'X');
353 }
354
355 #[test]
356 fn test_terminal_poll_and_read() {
357 let backend = Headless::new(10, 10);
358 let mut terminal = Terminal::new(backend);
359
360 assert_eq!(terminal.poll(Duration::ZERO), None);
361
362 terminal.backend_mut().push_event(Event::Close);
363 assert_eq!(terminal.poll(Duration::ZERO), Some(Event::Close));
364
365 terminal.backend_mut().push_event(Event::Resize(80, 25));
366 assert_eq!(terminal.read_blocking(), Event::Resize(80, 25));
367 }
368
369 #[test]
370 fn test_terminal_has_input() {
371 let backend = Headless::new(10, 10);
372 let mut terminal = Terminal::new(backend);
373
374 assert!(!terminal.has_input());
375
376 terminal.backend_mut().push_event(Event::Close);
377 assert!(terminal.has_input());
378 assert!(terminal.has_input()); // Repeated calls should still be true
379
380 // Read/Poll should retrieve the buffered event
381 assert_eq!(terminal.poll(Duration::ZERO), Some(Event::Close));
382
383 // After taking, it should be false again
384 assert!(!terminal.has_input());
385 }
386
387 #[test]
388 #[should_panic(expected = "read_blocking() called but no events available")]
389 fn test_terminal_read_panic() {
390 let backend = Headless::new(10, 10);
391 let mut terminal = Terminal::new(backend);
392 let _ = terminal.read_blocking();
393 }
394
395 #[test]
396 fn test_draw_composites_layers_for_cell_backend() {
397 // A cell backend (Headless) must see layers 1+ composited, not
398 // dropped. Terrain on layer 0, entity on layer 1.
399 let mut term = Terminal::new(Headless::new(3, 1));
400 term.draw(|s| {
401 s.put((0, 0), '.', Style::default());
402 s.put((1, 0), '.', Style::default());
403 s.on_layer(1).put((1, 0), '@', Style::default());
404 })
405 .expect("draw failed");
406 assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), '.');
407 // Layer 1's glyph wins at (1, 0).
408 assert_eq!(term.backend().grid()[Pos::new(1, 0)].glyph(), '@');
409 }
410
411 #[test]
412 fn test_draw_explicit_space_on_higher_layer_erases_and_sets_bg() {
413 // An explicit space on a higher layer is opaque: it overwrites the
414 // glyph beneath (erase) and applies its background. This is the
415 // deliberate consequence of the explicit-EMPTY transparency model.
416 let mut term = Terminal::new(Headless::new(2, 1));
417 term.draw(|s| {
418 s.put((0, 0), 'x', Style::default());
419 s.on_layer(1).put((0, 0), ' ', Style::new().bg(Color::RED));
420 })
421 .expect("draw failed");
422 let cell = term.backend().grid()[Pos::new(0, 0)];
423 assert_eq!(cell.glyph(), ' ');
424 assert_eq!(cell.style().background(), Color::RED);
425 }
426
427 #[test]
428 fn test_draw_single_layer_fast_path_matches_backend() {
429 // Only layer 0 is ever touched: the fast path must still deliver the
430 // correct cells to a cell backend across multiple frames.
431 let mut term = Terminal::new(Headless::new(3, 1));
432 term.draw(|s| s.put((0, 0), 'a', Style::default()))
433 .expect("draw failed");
434 assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), 'a');
435
436 // Immediate mode: redraw 'a' and add 'c'.
437 term.draw(|s| {
438 s.put((0, 0), 'a', Style::default());
439 s.put((2, 0), 'c', Style::default());
440 })
441 .expect("draw failed");
442 assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), 'a');
443 assert_eq!(term.backend().grid()[Pos::new(2, 0)].glyph(), 'c');
444
445 // A cell that is not redrawn is erased (immediate mode).
446 term.draw(|s| s.put((0, 0), 'a', Style::default()))
447 .expect("draw failed");
448 assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), 'a');
449 assert_eq!(term.backend().grid()[Pos::new(2, 0)].glyph(), ' ');
450 }
451
452 #[test]
453 fn test_present_transition_single_to_multi_layer() {
454 // Start single-layer (fast path), then introduce layer 1. The frame
455 // that adds the layer must composite correctly despite the fast path
456 // having bypassed the flatten buffers.
457 let mut term = Terminal::new(Headless::new(2, 1));
458 term.draw(|s| {
459 s.put((0, 0), '.', Style::default());
460 s.put((1, 0), '.', Style::default());
461 })
462 .expect("draw failed");
463
464 term.draw(|s| {
465 s.put((0, 0), '.', Style::default());
466 s.put((1, 0), '.', Style::default());
467 s.on_layer(1).put((1, 0), '@', Style::default());
468 })
469 .expect("draw failed");
470 assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), '.');
471 assert_eq!(term.backend().grid()[Pos::new(1, 0)].glyph(), '@');
472 }
473
474 #[test]
475 fn test_present_untouched_higher_layer_is_transparent() {
476 // A higher layer that was allocated but not written at this cell must
477 // not disturb the lower layer's glyph or background.
478 let mut term = Terminal::new(Headless::new(2, 1));
479 term.draw(|s| {
480 s.put((0, 0), 'x', Style::default());
481 // Allocate layer 1 by writing elsewhere, leaving (0, 0) empty.
482 s.on_layer(1).put((1, 0), 'y', Style::default());
483 })
484 .expect("draw failed");
485 assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), 'x');
486 }
487
488 #[test]
489 fn test_terminal_size() {
490 let term = Terminal::new(Headless::new(40, 20));
491 assert_eq!(
492 term.size(),
493 Size {
494 width: 40,
495 height: 20
496 }
497 );
498 }
499
500 #[test]
501 fn test_terminal_area() {
502 let term = Terminal::new(Headless::new(40, 20));
503 assert_eq!(term.area(), Rect::new(0, 0, 40, 20));
504 }
505
506 #[test]
507 fn test_terminal_resize_changes_dimensions() {
508 let mut term = Terminal::new(Headless::new(10, 10));
509 term.resize(30, 15);
510 assert_eq!(
511 term.size(),
512 Size {
513 width: 30,
514 height: 15
515 }
516 );
517 assert_eq!(term.grid().width(), 30);
518 assert_eq!(term.grid().height(), 15);
519 }
520
521 #[test]
522 fn test_terminal_resize_preserves_current_content() {
523 // Writes through `surface()` rather than `draw()`, so `current` is inspected before any
524 // `present()` clears it: `draw()` always presents, which would swap this content out to
525 // `previous` and clear the new `current` before the assertions below could see it.
526 let mut term = Terminal::new(Headless::new(10, 10));
527 term.surface().put((2, 2), 'X', Style::default());
528 term.resize(20, 20);
529 assert_eq!(term.grid()[Pos::new(2, 2)].glyph(), 'X');
530 assert_eq!(term.grid()[Pos::new(15, 15)].glyph(), ' ');
531 }
532
533 #[test]
534 fn test_terminal_resize_event_auto_applies() {
535 let mut term = Terminal::new(Headless::new(10, 10));
536 term.backend_mut().push_event(Event::Resize(80, 25));
537 let event = term.poll(Duration::ZERO);
538 assert_eq!(event, Some(Event::Resize(80, 25)));
539 assert_eq!(
540 term.size(),
541 Size {
542 width: 80,
543 height: 25
544 }
545 );
546 }
547
548 #[test]
549 fn test_terminal_resize_new_cells_accessible() {
550 // Resize to a larger area, then draw into the newly created region.
551 let mut term = Terminal::new(Headless::new(3, 3));
552 term.draw(|s| s.put((0, 0), 'A', Style::default()))
553 .expect("draw failed");
554
555 term.resize(5, 5);
556
557 // Draw into the expanded region and verify it reaches the backend.
558 term.draw(|s| s.put((4, 4), 'B', Style::default()))
559 .expect("draw failed");
560
561 assert_eq!(term.backend().grid()[Pos::new(4, 4)].glyph(), 'B');
562 // (0,0) was not redrawn this frame; backend retains 'A' from before resize.
563 assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), 'A');
564 }
565
566 // --- unicode width ---
567
568 #[test]
569 fn test_put_wide_char_sets_continuation() {
570 let mut term = Terminal::new(Headless::new(10, 3));
571 term.surface().put((0, 0), '\u{4e2d}', Style::default()); // '中', width 2
572 assert_eq!(term.grid()[Pos::new(0, 0)].glyph(), '\u{4e2d}');
573 // With egc: spacer uses WIDE_CHAR_SPACER flag, glyph is space.
574 // Without egc: spacer is '\0'.
575 #[cfg(feature = "egc")]
576 {
577 use crate::tile::TileFlags;
578 assert!(
579 term.grid()[Pos::new(1, 0)]
580 .flags()
581 .contains(TileFlags::WIDE_CHAR_SPACER)
582 );
583 assert_eq!(term.grid()[Pos::new(1, 0)].glyph(), ' ');
584 }
585 #[cfg(not(feature = "egc"))]
586 assert_eq!(term.grid()[Pos::new(1, 0)].glyph(), '\0');
587 assert_eq!(term.grid()[Pos::new(2, 0)].glyph(), ' '); // untouched
588 }
589
590 #[test]
591 fn test_print_advances_by_char_width() {
592 let mut term = Terminal::new(Headless::new(10, 3));
593 term.surface().print((0, 0), "\u{4e2d}x", Style::default()); // '中' (2) then 'x' at col 2
594 assert_eq!(term.grid()[Pos::new(0, 0)].glyph(), '\u{4e2d}');
595 #[cfg(feature = "egc")]
596 {
597 use crate::tile::TileFlags;
598 assert!(
599 term.grid()[Pos::new(1, 0)]
600 .flags()
601 .contains(TileFlags::WIDE_CHAR_SPACER)
602 );
603 }
604 #[cfg(not(feature = "egc"))]
605 assert_eq!(term.grid()[Pos::new(1, 0)].glyph(), '\0');
606 assert_eq!(term.grid()[Pos::new(2, 0)].glyph(), 'x');
607 }
608
609 #[test]
610 fn test_put_accepts_a_pos_or_a_tuple() {
611 // `put` takes `impl Into<Pos>`, so a `Pos` and an equivalent `(u16, u16)` tuple must
612 // write the same cell.
613 let mut term = Terminal::new(Headless::new(10, 3));
614 let mut s = term.surface();
615 s.put(Pos::new(2, 1), 'X', Style::default());
616 s.put((3, 1), 'Y', Style::default());
617 assert_eq!(term.grid()[Pos::new(2, 1)].glyph(), 'X');
618 assert_eq!(term.grid()[Pos::new(3, 1)].glyph(), 'Y');
619 }
620
621 #[test]
622 fn test_put_offset_accepts_pos_and_offset_tuples() {
623 // `put_offset` takes `impl Into<Pos>` and `impl Into<Offset>`, so `Pos`/`Offset` values
624 // and equivalent tuples must produce the same tile.
625 let mut term = Terminal::new(Headless::new(4, 1));
626 let mut s = term.surface();
627 s.put_offset(
628 Pos::new(1, 0),
629 crate::grid::Offset::new(3, -2),
630 'X',
631 Style::default(),
632 );
633 s.put_offset((2, 0), (-1, 4), 'Y', Style::default());
634 assert_eq!(term.grid()[Pos::new(1, 0)].glyph(), 'X');
635 assert_eq!(term.grid()[Pos::new(1, 0)].dx(), 3);
636 assert_eq!(term.grid()[Pos::new(1, 0)].dy(), -2);
637
638 assert_eq!(term.grid()[Pos::new(2, 0)].glyph(), 'Y');
639 assert_eq!(term.grid()[Pos::new(2, 0)].dx(), -1);
640 assert_eq!(term.grid()[Pos::new(2, 0)].dy(), 4);
641 }
642
643 #[test]
644 fn test_put_wide_char_at_last_column_does_not_overflow() {
645 // Wide char placed at the last column: can't place a spacer.
646 // write_grapheme silently refuses rather than leaving an orphan.
647 let mut term = Terminal::new(Headless::new(4, 1));
648 term.surface().put((3, 0), '\u{4e2d}', Style::default()); // col 3 is last; need col 4 for spacer
649 assert_eq!(term.grid()[Pos::new(3, 0)].glyph(), ' '); // nothing written
650 }
651
652 // --- styled spans ---
653
654 #[test]
655 fn test_print_styled_basic() {
656 use crate::text::{Line, Span};
657 let mut term = Terminal::new(Headless::new(20, 3));
658 let line = Line::from(vec![
659 Span::raw("HP: "),
660 Span::styled("100", Style::new().fg(Color::GREEN)),
661 ]);
662 term.surface().print_line((0, 0), &line);
663 assert_eq!(term.grid()[Pos::new(0, 0)].glyph(), 'H');
664 assert_eq!(term.grid()[Pos::new(3, 0)].glyph(), ' ');
665 assert_eq!(term.grid()[Pos::new(4, 0)].glyph(), '1');
666 assert_eq!(term.grid()[Pos::new(4, 0)].style.fg, Color::GREEN);
667 assert_eq!(term.grid()[Pos::new(6, 0)].glyph(), '0');
668 }
669
670 #[test]
671 fn test_print_styled_wide_chars() {
672 use crate::text::Line;
673 let mut term = Terminal::new(Headless::new(10, 3));
674 let line = Line::from(vec![crate::text::Span::raw("\u{4e2d}x")]);
675 term.surface().print_line((0, 0), &line);
676 assert_eq!(term.grid()[Pos::new(0, 0)].glyph(), '\u{4e2d}');
677 #[cfg(feature = "egc")]
678 {
679 use crate::tile::TileFlags;
680 assert!(
681 term.grid()[Pos::new(1, 0)]
682 .flags()
683 .contains(TileFlags::WIDE_CHAR_SPACER)
684 );
685 }
686 #[cfg(not(feature = "egc"))]
687 assert_eq!(term.grid()[Pos::new(1, 0)].glyph(), '\0');
688 assert_eq!(term.grid()[Pos::new(2, 0)].glyph(), 'x');
689 }
690
691 #[test]
692 fn test_print_str_styled_applies_style_to_every_cell() {
693 let mut term = Terminal::new(Headless::new(20, 3));
694 term.surface()
695 .print((0, 0), "HP", Style::new().fg(Color::GREEN));
696 assert_eq!(term.grid()[Pos::new(0, 0)].glyph(), 'H');
697 assert_eq!(term.grid()[Pos::new(0, 0)].style.fg, Color::GREEN);
698 assert_eq!(term.grid()[Pos::new(1, 0)].glyph(), 'P');
699 assert_eq!(term.grid()[Pos::new(1, 0)].style.fg, Color::GREEN);
700 }
701}