blockworx/render/text_box.rs
1//! Drawing and geometry for free-floating [`Text`](blockworx_doc::block_model::Text)
2//! annotations. [`box_rect`] is shared between the boundary drawing here and the
3//! shape's `gui_rect` hit-test in `widget`.
4
5use blockworx_doc::geometry::GridSize;
6use blockworx_geom::{Align2, Pos2, Rect, WorldPx, vec2};
7
8use crate::{
9 grid::{PORT_RADIUS, TITLE_TEXT_SIZE, grid_size_ceil, px, px_u},
10 theme::{Role, RoleStroke, Style},
11};
12use blockworx_paint::Renderer;
13
14/// Horizontal/vertical inset of the text from its anchor. The anchor dot sits
15/// at the top-left corner; the text starts just inside it so the two don't
16/// overlap.
17const TEXT_INSET: f32 = 4.0;
18
19/// The text box's max bounds, in whole grid cells. Text word-wraps once a line
20/// would exceed [`text_inner_width`] (so the editor and the rendered box break
21/// lines identically); the box is sized to its content up to `TEXT_BOX_WIDTH_GRID`
22/// wide and `TEXT_BOX_MAX_HEIGHT_GRID` tall. Both bounds are tunable.
23pub const TEXT_BOX_WIDTH_GRID: i32 = 20;
24pub const TEXT_BOX_MAX_HEIGHT_GRID: i32 = 16;
25
26/// The text box's maximum width in pixels (a whole number of grid cells); a line
27/// wraps rather than grow past this.
28pub fn box_width() -> f32 {
29 px(TEXT_BOX_WIDTH_GRID)
30}
31
32/// The text box's maximum height in pixels (a whole number of grid cells).
33pub fn max_box_height() -> f32 {
34 px(TEXT_BOX_MAX_HEIGHT_GRID)
35}
36
37/// The widest a line of text may be: the box width less the left/right inset.
38/// Text word-wraps at this width.
39pub fn text_inner_width() -> WorldPx {
40 WorldPx::new(box_width() - 2.0 * TEXT_INSET)
41}
42
43/// Corner rounding of the annotation's boundary, matching `draw_box_outline`.
44const BOX_ROUNDING: WorldPx = WorldPx::new(3.0);
45
46/// Estimate a text block's on-canvas bounding box from its top-left `anchor`.
47/// `gui_rect` has no painter to lay out a galley, so we approximate from the
48/// title font's size. This rect is only used as the click / marquee target —
49/// selection draws a dot at the anchor, not a frame around the text — so a
50/// slightly padded estimate is good enough.
51fn text_bbox(anchor: Pos2, text: &str) -> Rect {
52 // No painter to lay out a galley, so estimate from an average character width:
53 // each logical line takes ceil(chars / cols-per-row) rows once wrapped, and the
54 // box is as wide as its content up to the wrap width.
55 let approx_char = TITLE_TEXT_SIZE * 0.6;
56 let cols_per_row = (text_inner_width().get() / approx_char).max(1.0);
57 let longest = text
58 .lines()
59 .map(|l| l.chars().count())
60 .max()
61 .unwrap_or(0)
62 .max(1) as f32;
63 let rows: usize = text
64 .lines()
65 .map(|l| (l.chars().count() as f32 / cols_per_row).ceil().max(1.0) as usize)
66 .sum::<usize>()
67 .max(1);
68 let width = (longest.min(cols_per_row) * approx_char + 2.0 * TEXT_INSET).min(box_width());
69 let height = (rows as f32 * TITLE_TEXT_SIZE * 1.3 + 2.0 * TEXT_INSET).min(max_box_height());
70 Rect::from_min_size(anchor, vec2(width, height))
71}
72
73/// The text box's on-canvas rect from a given `anchor`. Prefers the cached
74/// `size` (measured from a real galley when the text was last edited) and only
75/// falls back to the [`text_bbox`] estimate when no measurement exists yet
76/// (legacy documents / freshly created boxes). `anchor` is passed in rather
77/// than read from the box so callers can shift it during a drag.
78pub fn box_rect(anchor: Pos2, size: Option<GridSize>, text: &str) -> Rect {
79 match size {
80 // Content-sized, clamped to the grid-aligned bounds so a legacy box
81 // measured before the limits existed still fits within them.
82 Some(sz) => {
83 let w = px_u(sz.w.min(TEXT_BOX_WIDTH_GRID as u32));
84 let h = px_u(sz.h.min(TEXT_BOX_MAX_HEIGHT_GRID as u32));
85 Rect::from_min_size(anchor, vec2(w, h))
86 }
87 None => text_bbox(anchor, text),
88 }
89}
90
91/// Measure `text` with a real galley (via the painter) and return the box
92/// extent in whole grid cells — the text's laid-out size plus the inset
93/// padding, rounded up so the box always encloses the text. The editor calls
94/// this on commit to cache an accurate hit-test / boundary rect, replacing the
95/// painter-less [`text_bbox`] estimate.
96pub fn measure_box_size(painter: &Style<'_, impl Renderer>, text: &str) -> GridSize {
97 // Measure the text wrapped to the box width, so the cached height matches what
98 // the wrapped box renders. The box is a fixed width; height grows with the
99 // wrapped text up to the max.
100 let extent = painter.text_size_wrapped(text, &painter.theme().title_font, text_inner_width())
101 + vec2(TEXT_INSET * 2.0, TEXT_INSET * 2.0);
102 let size = grid_size_ceil(extent);
103 GridSize {
104 w: size.w.min(TEXT_BOX_WIDTH_GRID as u32),
105 h: size.h.min(TEXT_BOX_MAX_HEIGHT_GRID as u32),
106 }
107}
108
109/// Draw the soft card framing the text: a faint fill plus a thin neutral
110/// outline, sized to [`box_rect`]. Drawn before the text so the fill sits
111/// behind it.
112pub fn draw_boundary<R: Renderer>(
113 anchor: Pos2,
114 size: Option<GridSize>,
115 text: &str,
116 stroke_role: Role,
117 painter: &mut Style<'_, R>,
118) {
119 let rect = box_rect(anchor, size, text);
120 painter.rect(rect, BOX_ROUNDING, Role::TextBoxFill, (1.0, stroke_role));
121}
122
123/// Draw the annotation's top-left-aligned text at `anchor`, word-wrapped to the
124/// box width. egui lays out the embedded `\n`s as separate lines and wraps each
125/// to [`text_inner_width`], matching the in-place editor.
126pub fn draw_text<R: Renderer>(anchor: Pos2, text: &str, painter: &mut Style<'_, R>) {
127 if !text.is_empty() {
128 painter.text_wrapped(
129 anchor + vec2(TEXT_INSET, TEXT_INSET),
130 Align2::LEFT_TOP,
131 text,
132 &painter.theme().title_font,
133 Role::PinText,
134 text_inner_width(),
135 );
136 }
137}
138
139/// Draw the solid anchor dot shown while a text box is selected. The `Renderer`
140/// trait exposes `circle` (not `circle_filled`); a `NONE` stroke makes it a
141/// filled dot.
142pub fn draw_anchor<R: Renderer>(anchor: Pos2, painter: &mut Style<'_, R>) {
143 painter.circle(anchor, PORT_RADIUS, Role::SelectionFrame, RoleStroke::NONE);
144}