Skip to main content

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}