1use 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
20const TEXT_INSET: f32 = 4.0;
24
25pub const TEXT_BOX_MAX_COLUMNS: u16 = 132;
27
28pub const TEXT_BOX_MAX_ROWS: usize = 512;
31
32const ELLIPSIS: &str = "…";
33
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub enum BoxWidth {
37 FitsText,
39 Cells(NonZeroU32),
42}
43
44impl BoxWidth {
45 pub fn of(text: &Text) -> Self {
47 text.width.map_or(BoxWidth::FitsText, BoxWidth::Cells)
48 }
49
50 pub fn spanning(rect: Rect) -> NonZeroU32 {
52 NonZeroU32::new(grid_u32(rect.width())).unwrap_or(NonZeroU32::MIN)
53 }
54
55 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 pub fn widest(painter: &Style<'_, impl Renderer>) -> WorldPx {
67 WorldPx::new(px_u(widest_cells(painter)))
68 }
69}
70
71fn 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
83pub struct ShownText<'a> {
86 pub text: Cow<'a, str>,
87 pub wrap: WorldPx,
88 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
113fn 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
139const BOX_ROUNDING: WorldPx = WorldPx::new(3.0);
141
142fn 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
174pub 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
187pub 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
207pub 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
213pub fn text_origin(anchor: Pos2) -> Pos2 {
215 anchor + vec2(TEXT_INSET, TEXT_INSET)
216}
217
218pub 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
239pub 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 #[derive(Clone, Copy, PartialEq)]
255 enum Break {
256 Newline,
257 Wrap,
258 }
259
260 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 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 #[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}