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