Skip to main content

blockworx_editor/render/
text_box.rs

1//! Drawing and geometry for free-floating [`Text`] annotations. [`box_rect`]
2//! is shared between the boundary drawing here and the shape's `gui_rect`
3//! hit-test in `widget`.
4
5use std::borrow::Cow;
6use std::num::NonZeroU32;
7
8use blockworx_doc::{block_model::Text, geometry::GridSize};
9use blockworx_geom::{Align2, Pos2, Rect, Vec2, WorldPx, vec2};
10use blockworx_paint::{
11    Renderer,
12    text::{COLUMN_GLYPH, Row},
13};
14
15use crate::{
16    grid::{PORT_RADIUS, TITLE_TEXT_SIZE, grid_size_ceil, grid_u32, grid_u32_ceil, px_u},
17    theme::{Role, RoleStroke, Style},
18};
19
20/// Horizontal/vertical inset of the text from its anchor. The anchor dot sits
21/// at the top-left corner; the text starts just inside it so the two don't
22/// overlap.
23const TEXT_INSET: f32 = 4.0;
24
25/// The widest a text box's text runs before it wraps, in columns of its font.
26pub const TEXT_BOX_MAX_COLUMNS: u16 = 132;
27
28/// The most rows a text box shows. The rows past it are elided from the
29/// drawing; the document keeps them.
30pub const TEXT_BOX_MAX_ROWS: usize = 512;
31
32const ELLIPSIS: &str = "…";
33
34/// How wide a text box is.
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub enum BoxWidth {
37    /// As wide as its longest wrapped row.
38    FitsText,
39    /// As many grid cells wide as the user resized it to, but never wider
40    /// than [`BoxWidth::widest`].
41    Cells(NonZeroU32),
42}
43
44impl BoxWidth {
45    /// The width the box holding `text` is drawn at.
46    pub fn of(text: &Text) -> Self {
47        text.width.map_or(BoxWidth::FitsText, BoxWidth::Cells)
48    }
49
50    /// The width a box resized to `rect` keeps.
51    pub fn spanning(rect: Rect) -> NonZeroU32 {
52        NonZeroU32::new(grid_u32(rect.width())).unwrap_or(NonZeroU32::MIN)
53    }
54
55    /// Where a box this wide wraps its text: never past
56    /// [`TEXT_BOX_MAX_COLUMNS`] columns of the title font.
57    pub fn wrap_width(self, painter: &Style<'_, impl Renderer>) -> WorldPx {
58        let cap = column_cap(painter);
59        match self {
60            BoxWidth::FitsText => cap,
61            BoxWidth::Cells(cells) => cap.min(WorldPx::new(px_u(cells.get()) - 2.0 * TEXT_INSET)),
62        }
63    }
64
65    /// The widest a box can be: its text wrapped at the column cap, padded.
66    pub fn widest(painter: &Style<'_, impl Renderer>) -> WorldPx {
67        WorldPx::new(px_u(widest_cells(painter)))
68    }
69}
70
71/// [`TEXT_BOX_MAX_COLUMNS`] columns of the title font.
72fn column_cap(painter: &Style<'_, impl Renderer>) -> WorldPx {
73    let column = painter
74        .text_size(COLUMN_GLYPH, &painter.theme().title_font)
75        .x;
76    WorldPx::new(column * f32::from(TEXT_BOX_MAX_COLUMNS))
77}
78
79fn widest_cells(painter: &Style<'_, impl Renderer>) -> u32 {
80    grid_u32_ceil(column_cap(painter).get() + 2.0 * TEXT_INSET)
81}
82
83/// A text box's text as it is drawn: wrapped at its box's width and elided
84/// past [`TEXT_BOX_MAX_ROWS`].
85pub struct ShownText<'a> {
86    pub text: Cow<'a, str>,
87    pub wrap: WorldPx,
88    /// The laid-out size of [`Self::text`].
89    pub size: Vec2,
90}
91
92impl<'a> ShownText<'a> {
93    pub fn lay_out(painter: &Style<'_, impl Renderer>, text: &'a str, width: BoxWidth) -> Self {
94        let font = &painter.theme().title_font;
95        let wrap = width.wrap_width(painter);
96        let laid = painter.text_layout(text, font, wrap);
97        let room = wrap - WorldPx::new(painter.text_size(ELLIPSIS, font).x);
98        match elided(text, &laid.rows, room) {
99            Cow::Borrowed(text) => Self {
100                text: Cow::Borrowed(text),
101                wrap,
102                size: laid.size,
103            },
104            Cow::Owned(shown) => Self {
105                size: painter.text_size_wrapped(&shown, font, wrap),
106                text: Cow::Owned(shown),
107                wrap,
108            },
109        }
110    }
111}
112
113/// `text`, laid out in `rows`, cut to the first [`TEXT_BOX_MAX_ROWS`] of them.
114/// The last row kept ends in an ellipsis, losing the glyphs that would leave
115/// it no `room` for one. A [`Row`] holds one glyph per character, less the
116/// newline that ends it.
117fn elided<'a>(text: &'a str, rows: &[Row], room: WorldPx) -> Cow<'a, str> {
118    if rows.len() <= TEXT_BOX_MAX_ROWS {
119        return Cow::Borrowed(text);
120    }
121    let whole = &rows[..TEXT_BOX_MAX_ROWS - 1];
122    let last = &rows[TEXT_BOX_MAX_ROWS - 1];
123    let before: usize = whole
124        .iter()
125        .map(|row| row.glyphs.len() + usize::from(row.ends_with_newline))
126        .sum();
127    let fits = last
128        .glyphs
129        .iter()
130        .take_while(|glyph| glyph.pos.x + glyph.advance <= room.get())
131        .count();
132    let cut = text
133        .char_indices()
134        .nth(before + fits)
135        .map_or(text.len(), |(at, _)| at);
136    Cow::Owned(format!("{}{ELLIPSIS}", &text[..cut]))
137}
138
139/// Corner rounding of the annotation's boundary, matching `draw_box_outline`.
140const BOX_ROUNDING: WorldPx = WorldPx::new(3.0);
141
142/// Estimate a text block's on-canvas bounding box from its top-left `anchor`.
143/// `gui_rect` has no painter to lay out a galley, so we approximate from the
144/// title font's size. The estimate stands only until the box is first
145/// measured, so a slightly padded one is good enough.
146fn text_bbox(anchor: Pos2, text: &str, width: BoxWidth) -> Rect {
147    let approx_char = TITLE_TEXT_SIZE * 0.6;
148    let cap = f32::from(TEXT_BOX_MAX_COLUMNS);
149    let columns = match width {
150        BoxWidth::FitsText => cap,
151        BoxWidth::Cells(cells) => ((px_u(cells.get()) - 2.0 * TEXT_INSET) / approx_char)
152            .floor()
153            .clamp(1.0, cap),
154    };
155    let longest = text
156        .lines()
157        .map(|l| l.chars().count())
158        .max()
159        .unwrap_or(0)
160        .max(1) as f32;
161    let rows: usize = text
162        .lines()
163        .map(|l| (l.chars().count() as f32 / columns).ceil().max(1.0) as usize)
164        .sum::<usize>()
165        .clamp(1, TEXT_BOX_MAX_ROWS);
166    let wide = match width {
167        BoxWidth::FitsText => longest.min(columns) * approx_char + 2.0 * TEXT_INSET,
168        BoxWidth::Cells(cells) => px_u(cells.get()),
169    };
170    let height = rows as f32 * TITLE_TEXT_SIZE * 1.3 + 2.0 * TEXT_INSET;
171    Rect::from_min_size(anchor, vec2(wide, height))
172}
173
174/// The text box's on-canvas rect from a given `anchor`. Prefers the cached
175/// `size` (measured from a real galley when the text was last edited) and only
176/// falls back to the `text_bbox` estimate when no measurement exists yet
177/// (a freshly created box, or one saved before extents were). `anchor` is
178/// passed in rather than read from the box so callers can shift it during a
179/// drag.
180pub fn box_rect(anchor: Pos2, size: Option<GridSize>, text: &str, width: BoxWidth) -> Rect {
181    match size {
182        Some(sz) => Rect::from_min_size(anchor, vec2(px_u(sz.w), px_u(sz.h))),
183        None => text_bbox(anchor, text, width),
184    }
185}
186
187/// Measure `text` as a box `width` wide shows it and return the box extent in
188/// whole grid cells — the shown text's laid-out size plus the inset padding,
189/// rounded up so the box always encloses the text. A box with a width of its
190/// own keeps it, and only its height follows the text.
191pub fn measure_box_size(
192    painter: &Style<'_, impl Renderer>,
193    text: &str,
194    width: BoxWidth,
195) -> GridSize {
196    let shown = ShownText::lay_out(painter, text, width);
197    let fitted = grid_size_ceil(shown.size + vec2(TEXT_INSET * 2.0, TEXT_INSET * 2.0));
198    match width {
199        BoxWidth::FitsText => fitted,
200        BoxWidth::Cells(cells) => GridSize {
201            w: cells.get().min(widest_cells(painter)),
202            ..fitted
203        },
204    }
205}
206
207/// Draw the soft card framing a box's text in `rect`: a faint fill plus a thin
208/// neutral outline. Drawn before the text so the fill sits behind it.
209pub fn draw_boundary<R: Renderer>(rect: Rect, stroke_role: Role, painter: &mut Style<'_, R>) {
210    painter.rect(rect, BOX_ROUNDING, Role::TextBoxFill, (1.0, stroke_role));
211}
212
213/// Where a box anchored at `anchor` draws its text's top-left corner.
214pub fn text_origin(anchor: Pos2) -> Pos2 {
215    anchor + vec2(TEXT_INSET, TEXT_INSET)
216}
217
218/// Draw the annotation's top-left-aligned text at `anchor` as a box `width`
219/// wide shows it, matching the in-place editor's wrap.
220pub fn draw_text<R: Renderer>(
221    anchor: Pos2,
222    text: &str,
223    width: BoxWidth,
224    painter: &mut Style<'_, R>,
225) {
226    if !text.is_empty() {
227        let shown = ShownText::lay_out(painter, text, width);
228        painter.text_wrapped(
229            text_origin(anchor),
230            Align2::LEFT_TOP,
231            shown.text.as_ref(),
232            &painter.theme().title_font,
233            Role::PinText,
234            shown.wrap,
235        );
236    }
237}
238
239/// Draw the solid anchor dot shown while a text box is moved. The `Renderer`
240/// trait exposes `circle` (not `circle_filled`); a `NONE` stroke makes it a
241/// filled dot.
242pub fn draw_anchor<R: Renderer>(anchor: Pos2, painter: &mut Style<'_, R>) {
243    painter.circle(anchor, PORT_RADIUS, Role::SelectionFrame, RoleStroke::NONE);
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use blockworx_paint::text::Glyph;
250
251    const ADVANCE: f32 = 10.0;
252
253    /// What ends a row.
254    #[derive(Clone, Copy, PartialEq)]
255    enum Break {
256        Newline,
257        Wrap,
258    }
259
260    /// A row of `chars` glyphs, each `ADVANCE` wide.
261    fn row(chars: &str, ends: Break) -> Row {
262        Row {
263            pos: Vec2::ZERO,
264            height: 15.0,
265            ends_with_newline: ends == Break::Newline,
266            glyphs: chars
267                .chars()
268                .enumerate()
269                .map(|(i, chr)| Glyph {
270                    chr,
271                    id: None,
272                    pos: vec2(i as f32 * ADVANCE, 12.0),
273                    advance: ADVANCE,
274                    ascent: 12.0,
275                })
276                .collect(),
277        }
278    }
279
280    /// `count` lines of `line`, as the text and the rows it lays out in.
281    fn lines(line: &str, count: usize) -> (String, Vec<Row>) {
282        let text = vec![line; count].join("\n");
283        let rows = (0..count)
284            .map(|i| {
285                let ends = if i + 1 < count {
286                    Break::Newline
287                } else {
288                    Break::Wrap
289                };
290                row(line, ends)
291            })
292            .collect();
293        (text, rows)
294    }
295
296    #[test]
297    fn text_within_the_row_cap_is_shown_whole() {
298        let (text, rows) = lines("abc", TEXT_BOX_MAX_ROWS);
299        assert!(matches!(
300            elided(&text, &rows, WorldPx::UNBOUNDED),
301            Cow::Borrowed(shown) if shown == text
302        ));
303    }
304
305    #[test]
306    fn rows_past_the_cap_are_elided_on_the_last_row_shown() {
307        let (text, rows) = lines("abc", TEXT_BOX_MAX_ROWS + 3);
308        let shown = elided(&text, &rows, WorldPx::UNBOUNDED);
309        let shown_lines: Vec<&str> = shown.split('\n').collect();
310        assert_eq!(shown_lines.len(), TEXT_BOX_MAX_ROWS);
311        assert_eq!(shown_lines.last(), Some(&"abc…"));
312        assert!(text.starts_with(shown.trim_end_matches(ELLIPSIS)));
313    }
314
315    /// A row broken by the wrap width, not a newline, is counted by its
316    /// glyphs alone — and the glyphs that would crowd out the ellipsis go.
317    #[test]
318    fn the_last_row_gives_up_glyphs_to_make_room_for_the_ellipsis() {
319        let wrapped = "abcd".repeat(TEXT_BOX_MAX_ROWS + 1);
320        let rows: Vec<Row> = (0..=TEXT_BOX_MAX_ROWS)
321            .map(|_| row("abcd", Break::Wrap))
322            .collect();
323        let room = WorldPx::new(2.5 * ADVANCE);
324        let shown = elided(&wrapped, &rows, room);
325        let kept = "abcd".repeat(TEXT_BOX_MAX_ROWS - 1);
326        assert_eq!(shown, format!("{kept}ab…"));
327    }
328}