Skip to main content

retroglyph_core/surface/draw/
text.rs

1//! [`print`](Surface::print) and friends: text writing, wrapping, and alignment.
2
3use crate::color::Style;
4use crate::grid::{Pos, Rect};
5use crate::text::Line;
6#[cfg(not(feature = "egc"))]
7use unicode_width::UnicodeWidthChar;
8
9use super::Surface;
10
11impl Surface<'_> {
12    /// Print `text` starting at `pos` in `style`.
13    ///
14    /// `\n` advances to the next row at the original column. Text that would extend beyond this
15    /// surface's clip wraps to the next row at the original column; cells outside the clip
16    /// (either axis) are dropped. When the `egc` feature is enabled, `text` is split into
17    /// extended grapheme clusters (so combining marks and ZWJ sequences write as one cell each);
18    /// otherwise it is split by `char`.
19    ///
20    /// # Examples
21    ///
22    /// ```
23    /// use retroglyph_core::backend::Headless;
24    /// use retroglyph_core::color::Style;
25    /// use retroglyph_core::terminal::Terminal;
26    ///
27    /// let mut term = Terminal::new(Headless::new(6, 3));
28    /// term.draw(|s| s.print((0, 0), "hello wrapped world", Style::default()))
29    ///     .unwrap();
30    ///
31    /// // Wraps back to column 0 every 6 cells; the surface is only 3 rows tall, so
32    /// // the remainder past row 2 is clipped rather than growing the grid.
33    /// assert_eq!(
34    ///     term.backend().format_view(),
35    ///     "hello·\nwrappe\nd·worl\n",
36    /// );
37    /// ```
38    pub fn print(&mut self, pos: impl Into<Pos>, text: &str, style: Style) {
39        let pos = pos.into();
40        #[cfg(feature = "egc")]
41        self.print_egc(pos, text, style);
42        #[cfg(not(feature = "egc"))]
43        self.print_chars(pos, text, style);
44    }
45
46    /// [`print`](Self::print) implementation used when `egc` is enabled: splits on extended
47    /// grapheme clusters rather than `char`.
48    #[cfg(feature = "egc")]
49    fn print_egc(&mut self, pos: Pos, text: &str, style: Style) {
50        use unicode_segmentation::UnicodeSegmentation;
51        use unicode_width::UnicodeWidthStr;
52
53        let right = self.wrap_right();
54        let mut cx = pos.x;
55        let mut cy = pos.y;
56        for grapheme in text.graphemes(true) {
57            if grapheme == "\n" {
58                cx = pos.x;
59                cy = cy.saturating_add(1);
60                continue;
61            }
62            // A single grapheme's display width is 0, 1, or 2 per `unicode-width` (see
63            // `Tile::width`'s doc comment), never anywhere near `u16::MAX`.
64            #[allow(clippy::cast_possible_truncation)]
65            let w = grapheme.width() as u16;
66            if w == 0 {
67                continue;
68            }
69            self.put_grapheme(cx, cy, grapheme, style);
70            cx = cx.saturating_add(w);
71            if i64::from(cx) >= right {
72                cx = pos.x;
73                cy = cy.saturating_add(1);
74            }
75        }
76    }
77
78    /// [`print`](Self::print) implementation used when `egc` is disabled: splits on `char`.
79    #[cfg(not(feature = "egc"))]
80    fn print_chars(&mut self, pos: Pos, text: &str, style: Style) {
81        let right = self.wrap_right();
82        let mut cx = pos.x;
83        let mut cy = pos.y;
84        for ch in text.chars() {
85            if ch == '\n' {
86                cx = pos.x;
87                cy = cy.saturating_add(1);
88                continue;
89            }
90            // A single char's display width is 0, 1, or 2 per `unicode-width` (see `Tile::width`'s
91            // doc comment), never anywhere near `u16::MAX`.
92            #[allow(clippy::cast_possible_truncation)]
93            let w = UnicodeWidthChar::width(ch).unwrap_or(1) as u16;
94            if w == 0 {
95                continue;
96            }
97            self.put((cx, cy), ch, style);
98            cx = cx.saturating_add(w);
99            if i64::from(cx) >= right {
100                cx = pos.x;
101                cy = cy.saturating_add(1);
102            }
103        }
104    }
105
106    /// Print `line`'s styled spans starting at `pos`, one row, each span in its own style.
107    /// Stops once a span would start past this surface's clip.
108    ///
109    /// # Examples
110    ///
111    /// ```
112    /// use retroglyph_core::backend::Headless;
113    /// use retroglyph_core::text::{Line, Span};
114    /// use retroglyph_core::terminal::Terminal;
115    ///
116    /// let mut term = Terminal::new(Headless::new(5, 2));
117    /// let line = Line::from(vec![Span::raw("hello"), Span::raw("world")]);
118    /// term.draw(|s| s.print_line((0, 0), &line)).unwrap();
119    ///
120    /// // The first span exactly fills the one-row area. The second span would start at
121    /// // column 5, past the area, so it is skipped entirely rather than wrapped onto the
122    /// // next row the way `print` would wrap.
123    /// assert_eq!(term.backend().format_view(), "hello\n·····\n");
124    /// ```
125    pub fn print_line(&mut self, pos: impl Into<Pos>, line: &Line) {
126        use unicode_width::UnicodeWidthStr;
127
128        let pos = pos.into();
129        let right = self.wrap_right();
130        let mut cx = pos.x;
131        for span in &line.spans {
132            if i64::from(cx) >= right {
133                break;
134            }
135            self.print((cx, pos.y), &span.content, span.style);
136            // A single span wider than `u16::MAX` columns would already be unaddressable in this
137            // crate's `u16` coordinate space; `cx` still saturates rather than overflowing even if
138            // this cast wraps.
139            #[allow(clippy::cast_possible_truncation)]
140            let w = UnicodeWidthStr::width(span.content.as_str()) as u16;
141            cx = cx.saturating_add(w);
142        }
143    }
144
145    /// [`print`](Self::print), horizontally aligned within `rect` (clipped to this surface's own
146    /// clip) and measured in display columns (via `unicode_width`), not bytes.
147    ///
148    /// `rect` is local to this surface's own [`area`](Self::area), the same convention as
149    /// [`fill_rect`](Self::fill_rect) and [`clear_region`](Self::clear_region) (not absolute grid
150    /// coordinates, the convention [`clip`](Self::clip)/[`scope`](Self::scope) use for their own
151    /// `rect`): `(0, 0)` is `area`'s own top-left, so a widget's own `area().at_origin()` can be
152    /// passed straight in.
153    ///
154    /// Wants a per-frame redrawn UI label (a status line, a centred title bar) that should not
155    /// allocate: unlike [`TextLayout`](crate::layout::TextLayout), which only accepts a
156    /// [`Line`] (forcing an allocation to build one for every call), this
157    /// takes `&str` directly.
158    ///
159    /// The starting column is computed with saturating arithmetic, so `text` wider than `rect`
160    /// does not panic or underflow: it simply left-aligns and lets [`print`](Self::print) clip
161    /// the overflow, for every [`HAlign`](crate::layout::HAlign) (matching how
162    /// [`HAlign::Center`](crate::layout::HAlign::Center) itself saturates in
163    /// [`TextLayout`](crate::layout::TextLayout)).
164    ///
165    /// Not gated behind the `egc` feature: unlike `TextLayout`, this needs nothing from it, so
166    /// it's reachable from any crate that only measures with `unicode-width`, including
167    /// `retroglyph-ui` without opting into `egc`.
168    ///
169    /// # Examples
170    ///
171    /// ```
172    /// use retroglyph_core::backend::Headless;
173    /// use retroglyph_core::layout::HAlign;
174    /// use retroglyph_core::color::Style;
175    /// use retroglyph_core::grid::Rect;
176    /// use retroglyph_core::terminal::Terminal;
177    ///
178    /// let mut term = Terminal::new(Headless::new(6, 1));
179    /// term.draw(|s| {
180    ///     s.print_aligned(Rect::new(0, 0, 6, 1), "hi", HAlign::Center, Style::default())
181    /// })
182    /// .unwrap();
183    ///
184    /// // "hi" is 2 columns wide in a 6-column rect: (6 - 2) / 2 == 2 columns of left padding.
185    /// assert_eq!(term.backend().format_view(), "··hi··\n");
186    /// ```
187    pub fn print_aligned(
188        &mut self,
189        rect: Rect,
190        text: &str,
191        align: crate::layout::HAlign,
192        style: Style,
193    ) {
194        use unicode_width::UnicodeWidthStr;
195
196        // A single line's display width is never anywhere near `u16::MAX` (see `print_line`'s
197        // own use of this same cast for a single span).
198        #[allow(clippy::cast_possible_truncation)]
199        let text_width = UnicodeWidthStr::width(text) as u16;
200        let x_offset = align.offset(rect.width(), text_width);
201        let pos = (rect.left().saturating_add(x_offset), rect.top());
202        // `rect` (like `pos` here) is local to `self.area` and deliberately independent of any
203        // outstanding `translate`, matching a widget's own `area().at_origin()`. `print` itself
204        // subtracts `origin_offset` again (via `shift`), so a translated surface would subtract
205        // it twice and drop the text entirely unless it's cancelled first: hand `print` a view
206        // whose `origin_offset` is zeroed out rather than adjusting `pos` by hand, which would
207        // need signed arithmetic that a `u16`-based `Pos` can't always represent losslessly.
208        let undo = (
209            0i32.saturating_sub(self.origin_offset.0),
210            0i32.saturating_sub(self.origin_offset.1),
211        );
212        self.translate(undo).print(pos, text, style);
213    }
214}