Skip to main content

retroglyph/
terminal.rs

1//! Stateful terminal management and double-buffering.
2
3use crate::backend::Backend;
4use crate::color::Color;
5use crate::event::Event;
6use crate::grid::{Grid, Rect, Size};
7use crate::style::{CellModifier, Style};
8use crate::text::Line;
9use crate::tile::Tile;
10use core::time::Duration;
11#[cfg(not(feature = "egc"))]
12use unicode_width::UnicodeWidthChar;
13
14/// The main entry point for `rg`.
15///
16/// Generic over the backend. Owns a double-buffered grid and provides
17/// a stateful drawing API.
18pub struct Terminal<B: Backend> {
19    current: Grid,
20    previous: Grid,
21    backend: B,
22    drawing_style: Style,
23    queued_event: Option<Event>,
24    /// The layer that `put`, `put_styled`, and `put_offset` write to.
25    active_layer: u8,
26}
27
28impl<B: Backend> Terminal<B> {
29    /// Create a terminal with the given backend.
30    /// Grid dimensions are queried from the backend.
31    #[must_use]
32    pub fn new(backend: B) -> Self {
33        let size = backend.size();
34        let current = Grid::new(size.width, size.height);
35        let previous = Grid::new(size.width, size.height);
36        Self {
37            current,
38            previous,
39            backend,
40            drawing_style: Style::default(),
41            queued_event: None,
42            active_layer: 0,
43        }
44    }
45
46    /// Sets the active drawing layer (0-255). Returns `&mut Self` for chaining.
47    ///
48    /// All subsequent `put`, `put_styled`, and `put_offset` calls write to this
49    /// layer until `layer()` is called again.
50    ///
51    /// `print` and `print_styled` currently target layer 0 only.
52    pub const fn layer(&mut self, layer: u8) -> &mut Self {
53        self.active_layer = layer;
54        self
55    }
56
57    /// Sets the foreground color for the stateful API.
58    pub const fn fg(&mut self, color: Color) -> &mut Self {
59        self.drawing_style.fg = color;
60        self
61    }
62
63    /// Sets the background color for the stateful API.
64    pub const fn bg(&mut self, color: Color) -> &mut Self {
65        self.drawing_style.bg = color;
66        self
67    }
68
69    /// Sets text modifiers for the stateful API.
70    pub const fn modifier(&mut self, modifier: CellModifier) -> &mut Self {
71        self.drawing_style.modifiers = modifier;
72        self
73    }
74
75    /// Resets the drawing style to defaults.
76    pub fn reset_style(&mut self) -> &mut Self {
77        self.drawing_style = Style::default();
78        self
79    }
80
81    /// Returns the current drawing style.
82    #[must_use]
83    pub const fn style(&self) -> Style {
84        self.drawing_style
85    }
86
87    /// Returns the current grid dimensions.
88    #[must_use]
89    pub const fn size(&self) -> Size {
90        Size {
91            width: self.current.width(),
92            height: self.current.height(),
93        }
94    }
95
96    /// Resize both grids to `width` × `height` cells.
97    ///
98    /// Content within the overlapping region is preserved in the current grid.
99    /// The previous grid is cleared so the next [`present`](Self::present) redraws
100    /// the entire new surface rather than diffing stale data.
101    pub fn resize(&mut self, width: u16, height: u16) {
102        self.current.resize(width, height);
103        self.previous.resize(width, height);
104        // Clearing previous forces a full redraw next present(), ensuring no
105        // stale cells bleed into the resized layout.
106        self.previous.clear_all();
107        self.backend.resize(Size { width, height });
108    }
109
110    /// Place a character at `(x, y)` on the active layer with the current style.
111    ///
112    /// If `ch` is a wide character (e.g. CJK or emoji) that occupies two columns,
113    /// the adjacent cell at `(x + 1, y)` is set to a zero-width continuation
114    /// marker so it is not rendered independently.
115    ///
116    /// Wide-character handling and grapheme clusters are supported on layer 0.
117    /// On layers > 0, wide characters are stored as a single tile (no spacer).
118    /// Sub-cell offsets are always visual only — use [`put_offset`](Self::put_offset)
119    /// for offset writes.
120    pub fn put(&mut self, x: u16, y: u16, ch: char) {
121        let style = self.drawing_style;
122        #[cfg(feature = "egc")]
123        {
124            if self.active_layer == 0 {
125                let mut buf = [0u8; 4];
126                let s = ch.encode_utf8(&mut buf);
127                self.current.write_grapheme(x, y, s, style);
128                return;
129            }
130        }
131        let tile = Tile {
132            glyph: ch,
133            style,
134            ..Tile::default()
135        };
136        self.current.put_tile(self.active_layer, x, y, tile);
137    }
138
139    /// Returns a reference to the current grid.
140    #[must_use]
141    pub const fn grid(&self) -> &Grid {
142        &self.current
143    }
144
145    /// Returns a mutable reference to the current grid.
146    pub const fn grid_mut(&mut self) -> &mut Grid {
147        &mut self.current
148    }
149
150    /// Returns a reference to the backend.
151    #[must_use]
152    pub const fn backend(&self) -> &B {
153        &self.backend
154    }
155
156    /// Returns a mutable reference to the backend.
157    pub const fn backend_mut(&mut self) -> &mut B {
158        &mut self.backend
159    }
160
161    /// Clear the active layer.
162    pub fn clear(&mut self) {
163        self.current.clear(self.active_layer);
164    }
165
166    /// Clear every allocated layer.
167    pub fn clear_all(&mut self) {
168        self.current.clear_all();
169    }
170
171    /// Clear a rectangular region.
172    pub fn clear_region(&mut self, rect: Rect) {
173        for y in rect.top()..rect.bottom() {
174            for x in rect.left()..rect.right() {
175                if let Some(cell) = self.current.checked_get_mut(x, y) {
176                    *cell = Tile::default();
177                }
178            }
179        }
180    }
181
182    /// Place a character on the active layer with an explicit style.
183    ///
184    /// Wide characters are handled identically to [`put`](Self::put).
185    pub fn put_styled(&mut self, x: u16, y: u16, ch: char, style: Style) {
186        #[cfg(feature = "egc")]
187        {
188            if self.active_layer == 0 {
189                let mut buf = [0u8; 4];
190                let s = ch.encode_utf8(&mut buf);
191                self.current.write_grapheme(x, y, s, style);
192                return;
193            }
194        }
195        let tile = Tile {
196            glyph: ch,
197            style,
198            ..Tile::default()
199        };
200        self.current.put_tile(self.active_layer, x, y, tile);
201    }
202
203    /// Place a character at `(x, y)` with a sub-cell pixel offset `(dx, dy)`.
204    ///
205    /// Uses the current style and active layer. Sub-cell offsets are visual
206    /// only — they do not affect grid logic or hit-testing. Backends that
207    /// cannot represent pixel offsets (e.g. `CrosstermBackend`) ignore them.
208    pub fn put_offset(&mut self, x: u16, y: u16, dx: i16, dy: i16, ch: char) {
209        let tile = Tile {
210            glyph: ch,
211            style: self.drawing_style,
212            dx,
213            dy,
214            ..Tile::default()
215        };
216        self.current.put_tile(self.active_layer, x, y, tile);
217    }
218
219    /// Print a string starting at `(x, y)` with the current style.
220    ///
221    /// `\n` advances to the next row at the original `x`. Wide characters
222    /// (CJK, emoji) advance the cursor by 2 columns. Characters that would
223    /// extend beyond the grid width wrap to the next row.
224    pub fn print(&mut self, x: u16, y: u16, text: &str) {
225        let style = self.drawing_style;
226        #[cfg(feature = "egc")]
227        self.print_str_egc(x, y, text, style);
228        #[cfg(not(feature = "egc"))]
229        self.print_str_chars(x, y, text, style);
230    }
231
232    /// Print a [`Line`] of styled spans starting at `(x, y)`.
233    ///
234    /// Each span's style is applied independently. The terminal's current
235    /// drawing style is not modified. Wide characters advance the cursor by
236    /// 2 columns. Rendering stops at the grid boundary.
237    pub fn print_styled(&mut self, x: u16, y: u16, line: &Line) {
238        #[cfg(feature = "egc")]
239        {
240            use unicode_segmentation::UnicodeSegmentation;
241            use unicode_width::UnicodeWidthStr;
242            let mut cur_x = x;
243            for span in &line.spans {
244                for grapheme in span.content.graphemes(true) {
245                    if grapheme == "\n" {
246                        break;
247                    }
248                    #[allow(clippy::cast_possible_truncation)]
249                    let w = grapheme.width() as u16;
250                    if w == 0 {
251                        continue;
252                    }
253                    if cur_x >= self.current.width() {
254                        break;
255                    }
256                    self.current.write_grapheme(cur_x, y, grapheme, span.style);
257                    cur_x += w;
258                }
259            }
260        }
261        #[cfg(not(feature = "egc"))]
262        {
263            use unicode_width::UnicodeWidthChar;
264            let mut cur_x = x;
265            for span in &line.spans {
266                for ch in span.content.chars() {
267                    if ch == '\n' {
268                        break;
269                    }
270                    #[allow(clippy::cast_possible_truncation)]
271                    let w = UnicodeWidthChar::width(ch).unwrap_or(1) as u16;
272                    if usize::from(cur_x) >= usize::from(self.current.width()) {
273                        break;
274                    }
275                    let tile = Tile {
276                        glyph: ch,
277                        style: span.style,
278                        ..Tile::default()
279                    };
280                    self.current.put_tile(self.active_layer, cur_x, y, tile);
281                    cur_x += w;
282                }
283            }
284        }
285    }
286
287    /// Render a [`Line`] of styled text into a bounded rectangle.
288    ///
289    /// Performs greedy word-wrapping at `rect`'s width, then positions the
290    /// resulting lines according to `h_align` and `v_align`. Lines that
291    /// overflow `rect`'s height are silently clipped.
292    ///
293    /// This is a convenience wrapper around [`TextLayout`](crate::layout::TextLayout).
294    ///
295    /// Only available when the `egc` feature is enabled.
296    #[cfg(feature = "egc")]
297    pub fn print_box(
298        &mut self,
299        rect: Rect,
300        line: &Line,
301        h_align: crate::layout::HAlign,
302        v_align: crate::layout::VAlign,
303    ) {
304        crate::layout::TextLayout::new(line)
305            .rect(rect)
306            .h_align(h_align)
307            .v_align(v_align)
308            .render(self);
309    }
310
311    /// Present the current frame.
312    ///
313    /// Computes diff, sends changed cells to the backend, flushes, then swaps buffers.
314    ///
315    /// When the backend requires a full frame (see
316    /// [`crate::Backend::needs_full_frame`]), all cells from every allocated layer are
317    /// sent rather than just the diff, so pixel-based backends can clear and
318    /// redraw to avoid orphaned pixels from sub-cell offsets.
319    ///
320    /// # Note
321    /// The back buffer is **not** cleared automatically after presentation.
322    /// If you want a blank frame, call `clear()` at the start of your loop.
323    pub fn present(&mut self) {
324        if self.backend.needs_full_frame() {
325            let all = self.current.layers();
326            self.backend.draw_layers(all);
327        } else {
328            let diff = self.current.diff(&self.previous);
329            self.backend.draw_layers(diff);
330        }
331        self.backend.flush();
332        core::mem::swap(&mut self.current, &mut self.previous);
333    }
334
335    /// Polls for an input event, waiting up to `timeout`.
336    ///
337    /// If an event was previously buffered by [`has_input`](Self::has_input), it is
338    /// returned immediately. Otherwise, the backend is polled for a new event.
339    ///
340    /// [`Event::Resize`] events are automatically applied: both grids are resized
341    /// before the event is returned to the caller, so the game loop can immediately
342    /// redraw at the new size.
343    pub fn poll(&mut self, timeout: Duration) -> Option<Event> {
344        let event = self
345            .queued_event
346            .take()
347            .or_else(|| self.backend.poll_event(timeout))?;
348        if let Event::Resize(w, h) = event {
349            self.resize(w, h);
350        }
351        Some(event)
352    }
353
354    /// Reads an input event, blocking indefinitely until one is available.
355    ///
356    /// # Panics
357    ///
358    /// Panics if no event is available. This matches the expected behavior
359    /// for headless backend tests when the event queue is empty.
360    pub fn read(&mut self) -> Event {
361        self.poll(Duration::MAX)
362            .expect("read() called but no events available")
363    }
364
365    /// Checks if a pending input event is available without blocking.
366    ///
367    /// If an event is already buffered, returns `true`. Otherwise, polls the backend
368    /// with zero timeout. If the backend returns an event, it is stored in the internal
369    /// buffer and `true` is returned; otherwise, returns `false`.
370    pub fn has_input(&mut self) -> bool {
371        if self.queued_event.is_some() {
372            true
373        } else if let Some(event) = self.backend.poll_event(Duration::ZERO) {
374            self.queued_event = Some(event);
375            true
376        } else {
377            false
378        }
379    }
380
381    /// String printing implementation used when `egc` is enabled.
382    #[cfg(feature = "egc")]
383    fn print_str_egc(&mut self, x: u16, y: u16, text: &str, style: Style) {
384        use unicode_segmentation::UnicodeSegmentation;
385        use unicode_width::UnicodeWidthStr;
386        let mut cur_x = x;
387        let mut cur_y = y;
388        for grapheme in text.graphemes(true) {
389            if grapheme == "\n" {
390                cur_x = x;
391                cur_y += 1;
392                continue;
393            }
394            #[allow(clippy::cast_possible_truncation)]
395            let w = grapheme.width() as u16;
396            if w == 0 {
397                continue;
398            }
399            self.current.write_grapheme(cur_x, cur_y, grapheme, style);
400            cur_x += w;
401            if cur_x >= self.current.width() {
402                cur_x = x;
403                cur_y += 1;
404            }
405        }
406    }
407
408    /// String printing implementation used when `egc` is disabled.
409    #[cfg(not(feature = "egc"))]
410    fn print_str_chars(&mut self, x: u16, y: u16, text: &str, style: Style) {
411        let mut cur_x = x;
412        let mut cur_y = y;
413        for c in text.chars() {
414            if c == '\n' {
415                cur_x = x;
416                cur_y += 1;
417            } else {
418                #[allow(clippy::cast_possible_truncation)]
419                let w = UnicodeWidthChar::width(c).unwrap_or(1) as u16;
420                let tile = Tile {
421                    glyph: c,
422                    style,
423                    ..Tile::default()
424                };
425                self.current.put_tile(self.active_layer, cur_x, cur_y, tile);
426                cur_x += w;
427                if usize::from(cur_x) >= usize::from(self.current.width()) {
428                    cur_x = x;
429                    cur_y += 1;
430                }
431            }
432        }
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use crate::backend::Headless;
440    use crate::tile::Tile;
441
442    #[test]
443    fn test_terminal_grid_mut() {
444        let backend = Headless::new(10, 10);
445        let mut terminal = Terminal::new(backend);
446
447        assert_eq!(terminal.grid().get(0, 0).glyph(), ' ');
448
449        terminal
450            .grid_mut()
451            .put(0, 0, Tile::new('X', Style::default()));
452
453        assert_eq!(terminal.grid().get(0, 0).glyph(), 'X');
454    }
455
456    #[test]
457    fn test_terminal_poll_and_read() {
458        let backend = Headless::new(10, 10);
459        let mut terminal = Terminal::new(backend);
460
461        assert_eq!(terminal.poll(Duration::ZERO), None);
462
463        terminal.backend_mut().push_event(Event::Close);
464        assert_eq!(terminal.poll(Duration::ZERO), Some(Event::Close));
465
466        terminal.backend_mut().push_event(Event::Resize(80, 25));
467        assert_eq!(terminal.read(), Event::Resize(80, 25));
468    }
469
470    #[test]
471    fn test_terminal_has_input() {
472        let backend = Headless::new(10, 10);
473        let mut terminal = Terminal::new(backend);
474
475        assert!(!terminal.has_input());
476
477        terminal.backend_mut().push_event(Event::Close);
478        assert!(terminal.has_input());
479        assert!(terminal.has_input()); // Repeated calls should still be true
480
481        // Read/Poll should retrieve the buffered event
482        assert_eq!(terminal.poll(Duration::ZERO), Some(Event::Close));
483
484        // After taking, it should be false again
485        assert!(!terminal.has_input());
486    }
487
488    #[test]
489    #[should_panic(expected = "read() called but no events available")]
490    fn test_terminal_read_panic() {
491        let backend = Headless::new(10, 10);
492        let mut terminal = Terminal::new(backend);
493        let _ = terminal.read();
494    }
495
496    // --- resize ---
497
498    #[test]
499    fn test_terminal_size() {
500        let term = Terminal::new(Headless::new(40, 20));
501        assert_eq!(
502            term.size(),
503            Size {
504                width: 40,
505                height: 20
506            }
507        );
508    }
509
510    #[test]
511    fn test_terminal_resize_changes_dimensions() {
512        let mut term = Terminal::new(Headless::new(10, 10));
513        term.resize(30, 15);
514        assert_eq!(
515            term.size(),
516            Size {
517                width: 30,
518                height: 15
519            }
520        );
521        assert_eq!(term.grid().width(), 30);
522        assert_eq!(term.grid().height(), 15);
523    }
524
525    #[test]
526    fn test_terminal_resize_preserves_current_content() {
527        let mut term = Terminal::new(Headless::new(10, 10));
528        term.put(2, 2, 'X');
529        term.resize(20, 20);
530        assert_eq!(term.grid().get(2, 2).glyph(), 'X');
531        assert_eq!(term.grid().get(15, 15).glyph(), ' ');
532    }
533
534    #[test]
535    fn test_terminal_resize_event_auto_applies() {
536        let mut term = Terminal::new(Headless::new(10, 10));
537        term.backend_mut().push_event(Event::Resize(80, 25));
538        let event = term.poll(Duration::ZERO);
539        assert_eq!(event, Some(Event::Resize(80, 25)));
540        assert_eq!(
541            term.size(),
542            Size {
543                width: 80,
544                height: 25
545            }
546        );
547    }
548
549    #[test]
550    fn test_terminal_resize_new_cells_accessible() {
551        // Resize to a larger area, then draw in the newly created region.
552        let mut term = Terminal::new(Headless::new(3, 3));
553        term.put(0, 0, 'A');
554        term.present();
555
556        term.resize(5, 5);
557
558        // Draw into the expanded region and verify it reaches the backend.
559        term.put(4, 4, 'B');
560        term.present();
561
562        assert_eq!(term.backend().grid().get(4, 4).glyph(), 'B');
563        // (0,0) was not redrawn this frame; backend retains 'A' from before resize.
564        assert_eq!(term.backend().grid().get(0, 0).glyph(), 'A');
565    }
566
567    // --- unicode width ---
568
569    #[test]
570    fn test_put_wide_char_sets_continuation() {
571        let mut term = Terminal::new(Headless::new(10, 3));
572        term.put(0, 0, '\u{4e2d}'); // '中', width 2
573        assert_eq!(term.grid().get(0, 0).glyph(), '\u{4e2d}');
574        // With egc: spacer uses WIDE_CHAR_SPACER flag, glyph is space.
575        // Without egc: spacer is '\0'.
576        #[cfg(feature = "egc")]
577        {
578            use crate::tile::TileFlags;
579            assert!(
580                term.grid()
581                    .get(1, 0)
582                    .flags()
583                    .contains(TileFlags::WIDE_CHAR_SPACER)
584            );
585            assert_eq!(term.grid().get(1, 0).glyph(), ' ');
586        }
587        #[cfg(not(feature = "egc"))]
588        assert_eq!(term.grid().get(1, 0).glyph(), '\0');
589        assert_eq!(term.grid().get(2, 0).glyph(), ' '); // untouched
590    }
591
592    #[test]
593    fn test_print_advances_by_char_width() {
594        let mut term = Terminal::new(Headless::new(10, 3));
595        term.print(0, 0, "\u{4e2d}x"); // '中' (2) then 'x' at col 2
596        assert_eq!(term.grid().get(0, 0).glyph(), '\u{4e2d}');
597        #[cfg(feature = "egc")]
598        {
599            use crate::tile::TileFlags;
600            assert!(
601                term.grid()
602                    .get(1, 0)
603                    .flags()
604                    .contains(TileFlags::WIDE_CHAR_SPACER)
605            );
606        }
607        #[cfg(not(feature = "egc"))]
608        assert_eq!(term.grid().get(1, 0).glyph(), '\0');
609        assert_eq!(term.grid().get(2, 0).glyph(), 'x');
610    }
611
612    #[test]
613    fn test_put_wide_char_at_last_column_does_not_overflow() {
614        // Wide char placed at the last column: can't place a spacer.
615        // write_grapheme silently refuses rather than leaving an orphan.
616        let mut term = Terminal::new(Headless::new(4, 1));
617        term.put(3, 0, '\u{4e2d}'); // col 3 is last; need col 4 for spacer
618        assert_eq!(term.grid().get(3, 0).glyph(), ' '); // nothing written
619    }
620
621    // --- styled spans ---
622
623    #[test]
624    fn test_print_styled_basic() {
625        use crate::text::{Line, Span};
626        let mut term = Terminal::new(Headless::new(20, 3));
627        let line = Line::from(vec![
628            Span::raw("HP: "),
629            Span::styled("100", Style::new().fg(Color::GREEN)),
630        ]);
631        term.print_styled(0, 0, &line);
632        assert_eq!(term.grid().get(0, 0).glyph(), 'H');
633        assert_eq!(term.grid().get(3, 0).glyph(), ' ');
634        assert_eq!(term.grid().get(4, 0).glyph(), '1');
635        assert_eq!(term.grid().get(4, 0).style.fg, Color::GREEN);
636        assert_eq!(term.grid().get(6, 0).glyph(), '0');
637    }
638
639    #[test]
640    fn test_print_styled_does_not_modify_drawing_style() {
641        use crate::text::{Line, Span};
642        let mut term = Terminal::new(Headless::new(20, 3));
643        term.fg(Color::RED);
644        let line = Line::from(vec![Span::styled("hi", Style::new().fg(Color::BLUE))]);
645        term.print_styled(0, 0, &line);
646        // Drawing style must be unchanged.
647        assert_eq!(term.style().fg, Color::RED);
648    }
649
650    #[test]
651    fn test_print_styled_wide_chars() {
652        use crate::text::{Line, Span};
653        let mut term = Terminal::new(Headless::new(10, 3));
654        let line = Line::from(vec![Span::raw("\u{4e2d}x")]);
655        term.print_styled(0, 0, &line);
656        assert_eq!(term.grid().get(0, 0).glyph(), '\u{4e2d}');
657        #[cfg(feature = "egc")]
658        {
659            use crate::tile::TileFlags;
660            assert!(
661                term.grid()
662                    .get(1, 0)
663                    .flags()
664                    .contains(TileFlags::WIDE_CHAR_SPACER)
665            );
666        }
667        #[cfg(not(feature = "egc"))]
668        assert_eq!(term.grid().get(1, 0).glyph(), '\0');
669        assert_eq!(term.grid().get(2, 0).glyph(), 'x');
670    }
671}