Skip to main content

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}