Skip to main content

monospace_core/shape/
text.rs

1//! The text block: lines of literal glyphs, split from one string. See _The initial set_ in
2//! [`docs/model.md`](../../../../docs/model.md).
3
4use unicode_segmentation::UnicodeSegmentation;
5
6use crate::{Cell, Glyph, Pos, Shape, Size, Surface};
7
8/// A block of text: its lines, each a run of literal glyphs. No position and no stroke.
9///
10/// **The lines are split once, at construction, and never again.** A shape holding a string would
11/// have to split it while drawing, which makes drawing fallible or silently drops what it cannot
12/// write; holding glyphs means a line that could not be written cannot be built at all.
13///
14/// A block has no position, because the figure that holds it owns where it goes: a stored point
15/// would be a second copy of that answer and the first thing to go stale when the figure moves.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Text {
18    /// The lines, top to bottom.
19    lines: Vec<Vec<Glyph>>,
20}
21
22impl Text {
23    /// Splits `content` on `'\n'`; one [`Glyph`] per extended grapheme cluster.
24    ///
25    /// `None` when any line holds any other control character. A `'\r'` included: a CRLF is refused
26    /// rather than normalized, and the caller writes `'\n'`.
27    #[must_use]
28    pub fn new(content: &str) -> Option<Self> {
29        let lines = content
30            .split('\n')
31            .map(|line| line.graphemes(true).map(Glyph::new).collect())
32            .collect::<Option<Vec<_>>>()?;
33
34        Some(Self { lines })
35    }
36
37    /// The lines, top to bottom.
38    #[must_use]
39    pub fn lines(&self) -> &[Vec<Glyph>] {
40        &self.lines
41    }
42
43    /// The longest line, in cells.
44    ///
45    /// [The width of one glyph is one cell](Glyph), however many columns a terminal draws it in, so
46    /// a wide cluster is counted once and the grid may not line up with the rows around it.
47    #[must_use]
48    pub fn width(&self) -> u16 {
49        let longest = self.lines.iter().map(Vec::len).max().unwrap_or_default();
50        u16::try_from(longest).unwrap_or(u16::MAX)
51    }
52
53    /// The number of lines.
54    #[must_use]
55    pub fn height(&self) -> u16 {
56        u16::try_from(self.lines.len()).unwrap_or(u16::MAX)
57    }
58}
59
60/// The block, its area's top-left at `at`, each line centred in `area`.
61///
62/// **The placement is the area's, and the arithmetic is a signed subtraction truncated toward
63/// zero.** A zero area is an area of zero rather than a sentinel saying "start at `at`": `0x0` puts
64/// the offsets at `-width/2` and `-height/2`, so the block sits around `at` and a box with no
65/// interior still draws its text. Nothing is clipped to the area, so a block wider than the area it
66/// was given overflows on both sides.
67///
68/// A **complete** shape in the sense of _Complete and fragment_: constructed, drawn and discarded.
69/// It writes one literal cell per glyph, an empty line writes nothing, and a line shorter than the
70/// area writes only its glyphs rather than spaces to fill it.
71#[derive(Debug)]
72pub struct TextShape {
73    /// The block's area's top-left, which is the point the offsets are measured from.
74    pub at: Pos,
75    /// The area each line is centred in.
76    pub area: Size,
77    /// The block itself.
78    pub text: Text,
79}
80
81impl Shape for TextShape {
82    fn draw(&self, surface: &mut dyn Surface) {
83        let dy = (i32::from(self.area.height) - i32::from(self.text.height())) / 2;
84
85        for (row, line) in self.text.lines().iter().enumerate() {
86            let width = i32::try_from(line.len()).unwrap_or(i32::MAX);
87            let dx = (i32::from(self.area.width) - width) / 2;
88            let Some(y) = coordinate(self.at.y, dy, row) else {
89                continue;
90            };
91
92            for (column, glyph) in line.iter().enumerate() {
93                let Some(x) = coordinate(self.at.x, dx, column) else {
94                    continue;
95                };
96                surface.stamp(Pos { x, y }, Cell::Literal(glyph.clone()));
97            }
98        }
99    }
100}
101
102/// `base` moved by `offset` and then by `step` cells, or `None` when the answer does not fit
103/// `i16` - the same silence a line's or a fill's position past the type's edge has.
104fn coordinate(base: i16, offset: i32, step: usize) -> Option<i16> {
105    let step = i32::try_from(step).ok()?;
106    i16::try_from(i32::from(base).checked_add(offset)?.checked_add(step)?).ok()
107}
108
109#[cfg(test)]
110mod tests {
111    use super::{Text, TextShape};
112    use crate::shape::counting::CountingSurface;
113    use crate::{Buffer, FigureLayer, GlyphCatalog, Pos, Shape, Size, StampMode, Window, render};
114
115    fn text(content: &str) -> Text {
116        Text::new(content).unwrap_or_else(|| panic!("{content:?} has no control character"))
117    }
118
119    fn light() -> GlyphCatalog {
120        GlyphCatalog::light()
121    }
122
123    fn origin() -> Pos {
124        Pos { x: 0, y: 0 }
125    }
126
127    fn size(width: u16, height: u16) -> Size {
128        Size { width, height }
129    }
130
131    /// Rule 1 - the constructor splits on newlines and refuses every other control character, the
132    /// whole text and not only the line that holds it.
133    #[test]
134    fn a_text_splits_on_newlines_and_refuses_other_control_characters() {
135        let text = Text::new("one\ntwo").expect("\"one\\ntwo\" is two lines");
136
137        assert_eq!(text.lines().len(), 2);
138        assert_eq!(text.lines()[0][0].as_str(), "o");
139        assert_eq!(text.lines()[1][2].as_str(), "o");
140
141        for refused in ["a\tb", "a\rb", "a\r\nb", "a\u{7}b"] {
142            assert!(
143                Text::new(refused).is_none(),
144                "{refused:?} holds a control character"
145            );
146        }
147    }
148
149    /// Rule 2 - `""` is one empty line, a trailing newline adds a line, and width is the longest
150    /// line's glyph count rather than the last line's.
151    #[test]
152    fn an_empty_text_is_one_empty_line_and_a_trailing_newline_adds_one() {
153        for (content, lines, width, height) in [
154            ("", 1, 0, 1),
155            ("one", 1, 3, 1),
156            ("one\n", 2, 3, 2),
157            ("one\n\n", 3, 3, 3),
158        ] {
159            let text = Text::new(content)
160                .unwrap_or_else(|| panic!("{content:?} has no control character"));
161
162            assert_eq!(text.lines().len(), lines, "{content:?}");
163            assert_eq!(text.width(), width, "{content:?}");
164            assert_eq!(text.height(), height, "{content:?}");
165        }
166    }
167
168    /// Rule 3 - a block draws each line centred in its area: `dx` and `dy` are the signed
169    /// differences truncated toward zero, so an odd remainder puts the extra cell on the right and
170    /// the bottom.
171    #[test]
172    fn a_text_shape_centres_each_line_in_its_area() {
173        let mut buffer = Buffer::new(Window {
174            origin: origin(),
175            size: size(8, 5),
176        });
177        TextShape {
178            at: origin(),
179            area: size(8, 5),
180            text: text("one\ntwo"),
181        }
182        .draw(&mut FigureLayer::new(&mut buffer, StampMode::Above));
183
184        assert_eq!(
185            render(&buffer, &light(), buffer.window()),
186            "        \n  one   \n  two   \n        \n        \n"
187        );
188    }
189
190    /// Rule 3 - the position is the computed one: an 8x5 area around `hi` puts it at `(3, 2)`,
191    /// which is the interior the 8x5 box in the spec centres the same line in.
192    #[test]
193    fn a_text_shape_writes_a_line_at_the_computed_position() {
194        let mut buffer = Buffer::new(Window {
195            origin: origin(),
196            size: size(8, 5),
197        });
198        TextShape {
199            at: origin(),
200            area: size(8, 5),
201            text: text("hi"),
202        }
203        .draw(&mut FigureLayer::new(&mut buffer, StampMode::Above));
204
205        assert_eq!(
206            render(&buffer, &light(), buffer.window()),
207            "        \n        \n   hi   \n        \n        \n"
208        );
209    }
210
211    /// Rule 4 - a zero area is a zero area: the block sits around `at` rather than against its
212    /// corner, and `at` is the middle cell of a three-wide line rather than its first.
213    #[test]
214    fn a_zero_area_places_the_block_around_the_position() {
215        let mut buffer = Buffer::new(Window {
216            origin: Pos { x: 8, y: 4 },
217            size: size(6, 3),
218        });
219        TextShape {
220            at: Pos { x: 10, y: 5 },
221            area: size(0, 0),
222            text: text("abc"),
223        }
224        .draw(&mut FigureLayer::new(&mut buffer, StampMode::Above));
225
226        assert_eq!(
227            render(&buffer, &light(), buffer.window()),
228            "      \n abc  \n      \n"
229        );
230    }
231
232    /// Rule 4 - the shape never clips to its area: a 1x1 area still draws all of a three-cell line,
233    /// one cell of which falls outside the area on either side.
234    #[test]
235    fn a_text_shape_does_not_clip_to_its_area() {
236        let mut buffer = Buffer::new(Window {
237            origin: origin(),
238            size: size(8, 3),
239        });
240        TextShape {
241            at: Pos { x: 5, y: 1 },
242            area: size(1, 1),
243            text: text("abc"),
244        }
245        .draw(&mut FigureLayer::new(&mut buffer, StampMode::Above));
246
247        assert_eq!(
248            render(&buffer, &light(), buffer.window()),
249            "        \n    abc \n        \n"
250        );
251    }
252
253    /// Rule 5 - an empty line writes nothing and a line shorter than the area writes only its
254    /// glyphs, not spaces to fill the area.
255    #[test]
256    fn an_empty_line_writes_nothing_and_a_short_line_writes_no_spaces() {
257        let mut buffer = Buffer::new(Window {
258            origin: origin(),
259            size: size(4, 3),
260        });
261        TextShape {
262            at: origin(),
263            area: size(4, 3),
264            text: text("a\n\nbc"),
265        }
266        .draw(&mut FigureLayer::new(&mut buffer, StampMode::Above));
267
268        assert_eq!(
269            render(&buffer, &light(), buffer.window()),
270            " a  \n    \n bc \n"
271        );
272    }
273
274    /// A text shape writes no position more than once, empty lines and a zero area included.
275    #[test]
276    fn no_text_shape_writes_any_position_more_than_once() {
277        for (area, content) in [
278            (size(8, 5), "one\ntwo"),
279            (size(0, 0), "abc"),
280            (size(1, 1), "abc"),
281            (size(4, 3), "a\n\nbc"),
282            (size(4, 3), ""),
283        ] {
284            let mut surface = CountingSurface::default();
285            TextShape {
286                at: Pos { x: 0, y: 0 },
287                area,
288                text: text(content),
289            }
290            .draw(&mut surface);
291
292            assert!(
293                surface.max_writes() <= 1,
294                "{content:?} in {area:?} wrote a position more than once"
295            );
296        }
297    }
298
299    /// Two `0x0` blocks: a three-line block spans rows 4 to 6 and columns 4 to 6, so `at` is the
300    /// middle of the cross rather than a corner, and each line is centred on its own width.
301    #[test]
302    fn a_three_line_block_at_a_zero_area_sits_around_the_point() {
303        let mut buffer = Buffer::new(Window {
304            origin: origin(),
305            size: size(10, 10),
306        });
307        TextShape {
308            at: Pos { x: 5, y: 5 },
309            area: size(0, 0),
310            text: text("ABC\nD\nEFG"),
311        }
312        .draw(&mut FigureLayer::new(&mut buffer, StampMode::Above));
313
314        assert_eq!(
315            render(&buffer, &light(), buffer.window()),
316            concat!(
317                "          \n",
318                "          \n",
319                "          \n",
320                "          \n",
321                "    ABC   \n",
322                "     D    \n",
323                "    EFG   \n",
324                "          \n",
325                "          \n",
326                "          \n",
327            )
328        );
329    }
330
331    /// Two shapes are two figures on one surface: where their cells meet, the later stamp decides.
332    /// The `G` is written and then covered by a `1`; the other order leaves `EFG23` on that row.
333    #[test]
334    fn a_second_text_shape_overwrites_the_first_where_they_meet() {
335        let first = || TextShape {
336            at: Pos { x: 5, y: 5 },
337            area: size(0, 0),
338            text: text("ABC\nD\nEFG"),
339        };
340        let second = || TextShape {
341            at: Pos { x: 5, y: 5 },
342            area: size(6, 6),
343            text: text("123\n4\n567"),
344        };
345
346        let drawn = |lower: &TextShape, upper: &TextShape| {
347            let mut buffer = Buffer::new(Window {
348                origin: origin(),
349                size: size(10, 10),
350            });
351            lower.draw(&mut FigureLayer::new(&mut buffer, StampMode::Above));
352            upper.draw(&mut FigureLayer::new(&mut buffer, StampMode::Above));
353            render(&buffer, &light(), buffer.window())
354        };
355
356        let first_then_second = drawn(&first(), &second());
357        assert_eq!(
358            first_then_second,
359            concat!(
360                "          \n",
361                "          \n",
362                "          \n",
363                "          \n",
364                "    ABC   \n",
365                "     D    \n",
366                "    EF123 \n",
367                "       4  \n",
368                "      567 \n",
369                "          \n",
370            )
371        );
372
373        let second_then_first = drawn(&second(), &first());
374        assert!(
375            second_then_first.lines().nth(6) == Some("    EFG23 "),
376            "the other order leaves `EFG23` on that row: {second_then_first:?}"
377        );
378        assert_ne!(first_then_second, second_then_first);
379    }
380}