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}