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::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}