retroglyph_ui/text.rs
1//! Single-line column-clipping, unicode-width aware.
2//!
3//! For word-wrapping multi-line text, see `retroglyph_core::layout::TextLayout`
4//! (behind the `egc` feature) rather than reimplementing wrapping here: it
5//! already handles grapheme clusters, hard newlines, and per-span styling.
6use alloc::borrow::ToOwned as _;
7use alloc::string::String;
8
9use retroglyph_core::color::Style;
10use retroglyph_core::grid::{Pos, Rect};
11use retroglyph_core::text::{split_at_width, truncate_measured};
12
13use crate::{Align, Surface};
14
15/// Truncate `s` so its display width is at most `max_cols` terminal columns.
16///
17/// Truncates on a whole-character boundary; a character that would push the
18/// total over `max_cols` is dropped along with the rest of the string. A
19/// thin wrapper over `retroglyph_core::text::split_at_width`; `max_cols` is
20/// saturated to `u16::MAX` before splitting, matching that function's own
21/// saturation.
22///
23/// Returns a borrowed slice of `s`, so this allocates nothing. See
24/// [`truncate_owned`] if you need an owned `String` (e.g. to store past the
25/// lifetime of `s`).
26///
27/// `max_cols` takes `impl Into<usize>` so a `Rect` dimension (`u16`) can be passed directly,
28/// alongside a plain `usize`.
29#[must_use]
30pub fn truncate(s: &str, max_cols: impl Into<usize>) -> &str {
31 let max_cols = max_cols.into();
32 #[allow(clippy::cast_possible_truncation)] // clamped to u16::MAX above
33 let max_cols = max_cols.min(usize::from(u16::MAX)) as u16;
34 split_at_width(s, max_cols).0
35}
36
37/// Owned variant of [`truncate`]: truncate `s` to `max_cols` display columns and copy the
38/// surviving prefix into a new `String`.
39///
40/// Prefer [`truncate`] on hot paths (it borrows instead of allocating); reach for this only when
41/// an owned `String` is actually needed.
42#[must_use]
43pub fn truncate_owned(s: &str, max_cols: impl Into<usize>) -> String {
44 truncate(s, max_cols).to_owned()
45}
46
47/// Truncate `text` to `width` columns, align it within those columns per `align`, and print it
48/// into `surface` at `at`. Returns the printed text's display width in columns.
49///
50/// This is the truncate -> align -> print sequence every single-line widget in this crate needs,
51/// collapsed to one call: [`truncate_measured`] to fit `width` and get its own display width back
52/// in the same pass (a wide character can make the truncated text narrower than `width`, never
53/// wider, so that width isn't just `width` itself), then
54/// [`Surface::print_aligned`](retroglyph_core::surface::Surface::print_aligned) to place and print it.
55/// `Align` is a plain re-export of the `HAlign` that `print_aligned` itself takes, so no
56/// conversion is needed. Delegating keeps the offset math in one place instead of a second copy
57/// here: `text` is already fitted to `width` before it reaches `print_aligned`, so its own
58/// internal width measurement agrees with `clipped_width` and produces the same offset.
59///
60/// Reach for this instead of re-deriving the sequence by hand, the same way
61/// [`fill_rect`](crate::fill_rect) is reached for instead of a hand-rolled fill loop; see
62/// [`Text`](crate::Text)'s and [`PrintLine`](crate::PrintLine)'s own `render` for the base case.
63#[must_use]
64pub fn draw_clipped(
65 surface: &mut Surface<'_>,
66 at: impl Into<Pos>,
67 width: u16,
68 text: &str,
69 align: Align,
70 style: Style,
71) -> u16 {
72 let at = at.into();
73 let (clipped, clipped_width) = truncate_measured(text, width);
74 surface.print_aligned(Rect::new(at.x, at.y, width, 1), clipped, align, style);
75 clipped_width
76}
77
78#[cfg(test)]
79mod tests {
80 use retroglyph_core::grid::{Grid, Rect};
81
82 use super::*;
83
84 #[test]
85 fn truncate_stops_at_the_column_budget() {
86 assert_eq!(truncate("hello world", 5usize), "hello");
87 assert_eq!(truncate("hi", 10usize), "hi");
88 assert_eq!(truncate("hi", 0usize), "");
89 }
90
91 #[test]
92 fn truncate_counts_wide_characters_as_two_columns() {
93 // "あ" (U+3042 HIRAGANA LETTER A) is 2 columns wide, not 1: a naive
94 // `chars().count()`-based truncation would let it through at budget
95 // 2, but the display width does not fit alongside "a".
96 assert_eq!(truncate("aあb", 2usize), "a");
97 assert_eq!(truncate("aあb", 3usize), "aあ");
98 assert_eq!(truncate("ああ", 3usize), "あ");
99 }
100
101 fn row(grid: &Grid, width: u16) -> String {
102 (0..width).map(|x| grid[Pos::new(x, 0)].glyph()).collect()
103 }
104
105 #[test]
106 fn draw_clipped_left_aligns_and_returns_the_printed_width() {
107 let mut grid = Grid::new(10, 1);
108 let printed = draw_clipped(
109 &mut Surface::new(&mut grid, Rect::new(0, 0, 10, 1), 0),
110 (0, 0),
111 10,
112 "hi",
113 Align::Left,
114 Style::new(),
115 );
116 assert_eq!(printed, 2);
117 assert_eq!(row(&grid, 10), "hi ");
118 }
119
120 #[test]
121 fn draw_clipped_centers_within_width() {
122 let mut grid = Grid::new(10, 1);
123 let _ = draw_clipped(
124 &mut Surface::new(&mut grid, Rect::new(0, 0, 10, 1), 0),
125 (0, 0),
126 10,
127 "hi",
128 Align::Center,
129 Style::new(),
130 );
131 assert_eq!(row(&grid, 10), " hi ");
132 }
133
134 #[test]
135 fn draw_clipped_truncates_wide_characters_before_measuring() {
136 // "あ" is 2 columns wide: a 3-column budget only fits one, so the printed width
137 // (and thus the centering offset) must reflect that, not the raw character count.
138 let mut grid = Grid::new(3, 1);
139 let printed = draw_clipped(
140 &mut Surface::new(&mut grid, Rect::new(0, 0, 3, 1), 0),
141 (0, 0),
142 3,
143 "ああ",
144 Align::Center,
145 Style::new(),
146 );
147 assert_eq!(printed, 2);
148 assert_eq!(row(&grid, 3), "あ ");
149 }
150}