Skip to main content

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}