Skip to main content

blockworx_editor/render/
block.rs

1use crate::{
2    edit::naming::InterfaceLock,
3    grid::{BLOCK_STROKE_WIDTH, GRID_SIZE, PIN_TOP_MARGIN, grid_rect},
4    path::Structure,
5    shape::ShapeLabel,
6    theme::{Role, RoleStroke, Style},
7};
8use blockworx_geom::{Align2, Pos2, Rect, WorldPx, pos2};
9use blockworx_paint::Renderer;
10
11use super::{block_type_position, clamped_block_title_position, clamped_block_type_position};
12
13/// Corner rounding of a block/port frame.
14const BOX_ROUNDING: WorldPx = WorldPx::new(GRID_SIZE / 8.0);
15
16/// How far inside its outline the second line of a scope-opening block runs.
17/// A quarter cell reads as its own line at a glance yet stays outboard of
18/// everything the block draws inside itself — the pin names start half a cell
19/// in, the size readout half a cell in from the left.
20pub const SHEET_INSET: WorldPx = WorldPx::new(GRID_SIZE / 4.0);
21
22pub fn draw_box_outline(
23    bbox: Rect,
24    fill: Role,
25    stroke: impl Into<RoleStroke>,
26    painter: &mut Style<'_, impl Renderer>,
27) {
28    let stroke = stroke.into();
29    painter.rect(bbox, BOX_ROUNDING, fill, stroke);
30}
31
32/// How far apart a conflict's hatch lines run, in world px.
33const HATCH_SPACING: f32 = GRID_SIZE / 2.0;
34
35/// 45° hatching clipped to `rect`, as segments — the painter has no fill
36/// patterns, so the lines are clipped here rather than by a clip region.
37///
38/// Each line runs down-right from a point `t` along the top edge; inside the
39/// rect it spans `u` from `max(0, -t)` to `min(h, w - t)`, which is empty
40/// for a `t` that misses the rect entirely.
41fn hatch(rect: Rect, stroke: impl Into<RoleStroke>, painter: &mut Style<'_, impl Renderer>) {
42    let stroke = stroke.into();
43    let (w, h) = (rect.width(), rect.height());
44    if w <= 0.0 || h <= 0.0 {
45        return;
46    }
47    let mut t = -h;
48    while t < w {
49        let from = (-t).max(0.0);
50        let to = h.min(w - t);
51        if to > from {
52            painter.line_segment(
53                [
54                    Pos2::new(rect.min.x + t + from, rect.min.y + from),
55                    Pos2::new(rect.min.x + t + to, rect.min.y + to),
56                ],
57                stroke,
58            );
59        }
60        t += HATCH_SPACING;
61    }
62}
63
64/// A conflict a move would *create*, hatched over the cells the two shapes
65/// would share. Only the overlaps that refuse the move are drawn: on a group
66/// move an overlap the selection already had refuses nothing, and marking it
67/// would claim a refusal that is not happening.
68///
69/// The overlap rather than either shape, because a collision is a relation —
70/// and a destination can conflict with several shapes at once, which an
71/// outline round the mover could not tell apart.
72pub fn draw_refused_conflict(overlap: Rect, painter: &mut Style<'_, impl Renderer>) {
73    hatch(overlap, (2.0, Role::MoveRefusedStroke), painter);
74    painter.rect(
75        overlap,
76        WorldPx::ZERO,
77        Role::Transparent,
78        (1.5, Role::MoveRefusedStroke),
79    );
80}
81
82/// A conflict the document already holds. Nothing refuses one — a paste has
83/// no overlap check, and the group rule exempts what a selection arrived
84/// with — but two shapes sharing cells is a routing fault the router has to
85/// work around, so it is marked until it is cleared.
86///
87/// Quieter than a refusal, and unoutlined: this is a standing fault to fix,
88/// not a gesture being turned away.
89pub fn draw_standing_conflict(overlap: Rect, painter: &mut Style<'_, impl Renderer>) {
90    hatch(overlap, (1.5, Role::StandingConflict), painter);
91}
92
93/// A block's stroked boundary: its outline, plus — for a block that opens a
94/// scope — a second line inset within it, the classical contains-a-sheet
95/// notation. Every mode that draws a block body draws it through here, so the
96/// inset rides whatever stroke and fill that mode chose.
97pub fn draw_block_boundary(
98    bbox: Rect,
99    fill: Role,
100    stroke: impl Into<RoleStroke>,
101    structure: Structure,
102    painter: &mut Style<'_, impl Renderer>,
103) {
104    let stroke = stroke.into();
105    draw_box_outline(bbox, fill, stroke, painter);
106    if structure.opens_a_scope() {
107        draw_box_outline(
108            bbox.shrink(SHEET_INSET.get()),
109            Role::Transparent,
110            stroke,
111            painter,
112        );
113    }
114}
115
116pub fn draw_block_title(
117    bbox: Rect,
118    title: &ShapeLabel<'_>,
119    lock: InterfaceLock,
120    painter: &mut Style<'_, impl Renderer>,
121) {
122    // No title toggle exists yet, but honor `hidden` for forward-compatibility
123    // (the flag lives on the `Label` namespace).
124    if title.hidden {
125        return;
126    }
127    let font = painter.theme().title_font.clone();
128    let text_width = painter.text_size(title.name, &font).x;
129    let (pos, align) = clamped_block_title_position(bbox, title, text_width);
130    let title_role = if lock.is_locked() {
131        Role::LockedShapeTitle
132    } else {
133        Role::ShapeTitle
134    };
135    painter.text(pos, align, title.name, &font, title_role);
136}
137
138/// Faint "Add type" prompt at a block's empty type slot. Like the tag
139/// placeholder, but suppressed on a locked block (whose type isn't editable) —
140/// the caller gates that.
141pub fn draw_block_type_placeholder(
142    bbox: Rect,
143    type_label: &ShapeLabel<'_>,
144    painter: &mut Style<'_, impl Renderer>,
145) {
146    if type_label.hidden || !type_label.name.is_empty() {
147        return;
148    }
149    let (pos, align) = block_type_position(bbox, type_label);
150    painter.text(
151        pos,
152        align,
153        crate::render::ADD_TYPE_PLACEHOLDER,
154        &painter.theme().type_font,
155        Role::PinLabelPlaceholder,
156    );
157}
158
159pub fn draw_block_type(
160    bbox: Rect,
161    type_label: &ShapeLabel<'_>,
162    lock: InterfaceLock,
163    painter: &mut Style<'_, impl Renderer>,
164) {
165    if type_label.hidden || type_label.name.is_empty() {
166        return;
167    }
168    let font = painter.theme().type_font.clone();
169    let text_width = painter.text_size(type_label.name, &font).x;
170    let (pos, align) = clamped_block_type_position(bbox, type_label, text_width);
171    let type_role = if lock.is_locked() {
172        Role::LockedShapeType
173    } else {
174        Role::ShapeType
175    };
176    painter.text(pos, align, type_label.name, &font, type_role);
177}
178
179/// A block rect's size in whole grid cells, the way a document records it.
180fn size_label(bbox: Rect) -> String {
181    let size = grid_rect(bbox.min, bbox.max).size;
182    format!("{} × {}", size.w, size.h)
183}
184
185/// Draw a block's size in grid cells inside its upper-left corner, where the pin
186/// gutter above the first slot leaves the one interior region no label occupies.
187/// Shown only while the block is being resized, on the rect the drop will commit.
188/// Returns the drawn extent.
189pub fn draw_size_readout(bbox: Rect, painter: &mut Style<'_, impl Renderer>) -> Rect {
190    painter.text(
191        pos2(
192            bbox.left() + GRID_SIZE / 2.0,
193            bbox.top() + PIN_TOP_MARGIN / 2.0,
194        ),
195        Align2::LEFT_CENTER,
196        size_label(bbox),
197        &painter.theme().tag_font,
198        Role::SizeReadout,
199    )
200}
201
202/// What a block's body is painted from beyond its rect: the accent it strokes
203/// with, whether its interface is frozen, and whether it opens a scope.
204#[derive(Clone, Copy)]
205pub struct BlockPaint {
206    pub stroke: Role,
207    pub lock: InterfaceLock,
208    pub structure: Structure,
209}
210
211/// Draw a block's body: the outline fill, then the title. Drawn before the
212/// block's pins and labels so its opaque fill sits under them, never over them.
213/// The type label is left to the caller (it is block-only; ports/areas that
214/// share this frame have no type).
215pub fn draw_block_frame(
216    bbox: Rect,
217    title: &ShapeLabel<'_>,
218    paint: BlockPaint,
219    painter: &mut Style<'_, impl Renderer>,
220) {
221    let BlockPaint {
222        stroke,
223        lock,
224        structure,
225    } = paint;
226    let fill = if lock.is_locked() {
227        Role::LockedShapeFill
228    } else {
229        Role::ShapeFill
230    };
231    draw_block_boundary(bbox, fill, (BLOCK_STROKE_WIDTH, stroke), structure, painter);
232    draw_block_title(bbox, title, lock, painter);
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use crate::grid::pin_offset_y;
239    use crate::theme::Theme;
240    use blockworx_geom::vec2;
241    use blockworx_paint::FontChoice;
242    use blockworx_text::measure::Measured;
243
244    #[test]
245    fn the_size_readout_reads_in_whole_grid_cells() {
246        let bbox = Rect::from_min_size(
247            pos2(3.0 * GRID_SIZE, GRID_SIZE),
248            vec2(20.0 * GRID_SIZE, 16.0 * GRID_SIZE),
249        );
250        assert_eq!(size_label(bbox), "20 × 16");
251    }
252
253    /// The readout lives inside the block's upper-left corner, in the pin gutter
254    /// above the first slot — the one interior region no label can occupy.
255    #[test]
256    fn the_size_readout_sits_in_the_upper_left_pin_gutter() {
257        let bbox = Rect::from_min_size(pos2(0.0, 0.0), vec2(20.0 * GRID_SIZE, 16.0 * GRID_SIZE));
258        let theme = Theme::default();
259        let canvas = Measured::new(FontChoice::Sketchy, theme.palette().clone());
260        let drawn = canvas.frame(|painter| {
261            let mut style = Style::new(&theme, painter);
262            draw_size_readout(bbox, &mut style)
263        });
264        assert!(bbox.contains_rect(drawn), "{drawn:?} escaped the block");
265        assert!(
266            drawn.max.y <= pin_offset_y(bbox.top(), 0),
267            "{drawn:?} reaches the first pin slot"
268        );
269        assert!(
270            drawn.center().x < bbox.center().x,
271            "{drawn:?} is not in the left half"
272        );
273    }
274}