Skip to main content

blockworx_editor/render/
layout.rs

1//! Pure position/alignment helpers shared by the drawing primitives and by the
2//! hit-testing in `widget`. No drawing happens here — each function maps an
3//! element's geometry to a label anchor (and its [`Align2`]).
4
5use blockworx_doc::values::LabelSide;
6use blockworx_geom::{Align, Align2, Pos2, Rect, pos2, vec2};
7
8use crate::{
9    grid::{GRID_SIZE, PORT_TEXT_SIZE, TAG_SHIM, pin_offset_y},
10    shape::{
11        ShapeLabel,
12        pin::{Pin, PinSide, slot},
13    },
14};
15
16/// Vertical gap, in world units, between a pin/port's stub line and the
17/// baseline-center of the `tag` label drawn above it.
18const PIN_TAG_RISE: f32 = PORT_TEXT_SIZE * 0.7;
19
20pub fn port_text_center(bbox: Rect, side: PinSide) -> Pos2 {
21    let y = bbox.center().y - GRID_SIZE / 2.0;
22    match side {
23        PinSide::East => pos2(bbox.center().x - GRID_SIZE / 2.0, y),
24        PinSide::West => pos2(bbox.center().x + GRID_SIZE / 2.0, y),
25    }
26}
27
28pub fn pin_text_location(bbox: Rect, pin: &Pin, side: PinSide, offset: f32) -> (Pos2, Align2) {
29    let y_coord = pin_offset_y(bbox.top(), slot(pin).offset) + offset;
30    match side {
31        PinSide::East => (
32            pos2(bbox.right() - GRID_SIZE / 2.0, y_coord),
33            Align2::RIGHT_CENTER,
34        ),
35        PinSide::West => (
36            pos2(bbox.left() + GRID_SIZE / 2.0, y_coord),
37            Align2::LEFT_CENTER,
38        ),
39    }
40}
41
42/// Position and alignment for the `tag` label, which sits above the stub line
43/// hugging the shape edge: right-justified on the West side, left-justified on
44/// the East side (opposite to how `name` is justified). `left`/`right` are the
45/// shape's edge x-coordinates (the inner end of the stub); `line_y` is the
46/// stub's y.
47pub fn pin_tag_location(left: f32, right: f32, side: PinSide, line_y: f32) -> (Pos2, Align2) {
48    let y = line_y - PIN_TAG_RISE;
49    match side {
50        PinSide::East => (pos2(right + TAG_SHIM, y), Align2::LEFT_CENTER),
51        PinSide::West => (pos2(left - TAG_SHIM, y), Align2::RIGHT_CENTER),
52    }
53}
54
55fn title_anchor(bbox: Rect, side: LabelSide, offset: f32) -> (Pos2, Align2) {
56    match side {
57        LabelSide::Bottom => (bbox.center_bottom() + vec2(offset, 0.0), Align2::CENTER_TOP),
58        LabelSide::Top => (bbox.center_top() + vec2(offset, 0.0), Align2::CENTER_BOTTOM),
59        LabelSide::Center => (bbox.center() + vec2(offset, 0.0), Align2::CENTER_CENTER),
60    }
61}
62
63pub fn block_title_position(bbox: Rect, title: &ShapeLabel<'_>) -> (Pos2, Align2) {
64    title_anchor(bbox, title.side, title.offset)
65}
66
67/// Like [`block_title_position`], but clamps the horizontal offset so a title of
68/// width `text_width` stays within the block (pinning flush when wider). Used at
69/// draw time so a later resize can't leave a stale offset drawing outside the block.
70pub fn clamped_block_title_position(
71    bbox: Rect,
72    title: &ShapeLabel<'_>,
73    text_width: f32,
74) -> (Pos2, Align2) {
75    let offset = clamp_label_offset(bbox, text_width, title.offset, Align::Center);
76    title_anchor(bbox, title.side, offset)
77}
78
79/// Clamp a block label's horizontal `offset` so its text of width `text_width`
80/// stays within the block's horizontal extent, given the label's horizontal
81/// anchor — [`Align::Center`] for the title, [`Align::Min`] (left) for the
82/// type, [`Align::Max`] (right) for the tag. When the text is wider
83/// than the block the label is pinned flush to its anchored edge.
84pub fn clamp_label_offset(bbox: Rect, text_width: f32, offset: f32, h_align: Align) -> f32 {
85    let slack = (bbox.width() - text_width).max(0.0);
86    let (lo, hi) = match h_align {
87        Align::Min => (0.0, slack),
88        Align::Center => (-slack / 2.0, slack / 2.0),
89        Align::Max => (-slack, 0.0),
90    };
91    offset.clamp(lo, hi)
92}
93
94/// The [`LabelSide`] a block label snaps to when its anchor is dragged to `y`:
95/// the top third of the block is Top, the bottom third Bottom, the middle Center.
96/// Shared by the live drag preview and the drag-commit so both agree.
97pub fn label_side_for_y(bbox: Rect, y: f32) -> LabelSide {
98    let third = bbox.height() / 3.0;
99    if y < bbox.top() + third {
100        LabelSide::Top
101    } else if y > bbox.top() + 2.0 * third {
102        LabelSide::Bottom
103    } else {
104        LabelSide::Center
105    }
106}
107
108/// Placement of a block's type label. Mirrors [`block_title_position`] but is
109/// **left-anchored** to the block's left edge, so the default (`offset == 0`,
110/// `side == Top`) sits in the upper-left corner with the text baseline on the
111/// top edge and the left edge flush with the block's.
112fn type_anchor(bbox: Rect, side: LabelSide, offset: f32) -> (Pos2, Align2) {
113    match side {
114        LabelSide::Top => (bbox.left_top() + vec2(offset, 0.0), Align2::LEFT_BOTTOM),
115        LabelSide::Center => (bbox.left_center() + vec2(offset, 0.0), Align2::LEFT_CENTER),
116        LabelSide::Bottom => (bbox.left_bottom() + vec2(offset, 0.0), Align2::LEFT_TOP),
117    }
118}
119
120pub fn block_type_position(bbox: Rect, type_label: &ShapeLabel<'_>) -> (Pos2, Align2) {
121    type_anchor(bbox, type_label.side, type_label.offset)
122}
123
124/// Like [`block_type_position`], but clamps the horizontal offset so a type label
125/// of width `text_width` stays within the block (pinning flush when wider). Used
126/// at draw time so a later resize can't leave a stale offset drawing outside.
127pub fn clamped_block_type_position(
128    bbox: Rect,
129    type_label: &ShapeLabel<'_>,
130    text_width: f32,
131) -> (Pos2, Align2) {
132    let offset = clamp_label_offset(bbox, text_width, type_label.offset, Align::Min);
133    type_anchor(bbox, type_label.side, offset)
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn clamp_label_offset_keeps_each_anchor_within_the_block_width() {
142        let bbox = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 40.0));
143        // 40-wide label, 60 of slack. Center: ±30 around center.
144        assert_eq!(clamp_label_offset(bbox, 40.0, 100.0, Align::Center), 30.0);
145        assert_eq!(clamp_label_offset(bbox, 40.0, -100.0, Align::Center), -30.0);
146        assert_eq!(clamp_label_offset(bbox, 40.0, 20.0, Align::Center), 20.0);
147        // Left-anchored (type): flush-left at 0, slides right up to the full slack.
148        assert_eq!(clamp_label_offset(bbox, 40.0, -100.0, Align::Min), 0.0);
149        assert_eq!(clamp_label_offset(bbox, 40.0, 100.0, Align::Min), 60.0);
150        // Right-anchored (tag): flush-right at 0, slides left down to -slack.
151        assert_eq!(clamp_label_offset(bbox, 40.0, 100.0, Align::Max), 0.0);
152        assert_eq!(clamp_label_offset(bbox, 40.0, -100.0, Align::Max), -60.0);
153        // A label wider than the block can only sit flush to its anchor.
154        assert_eq!(clamp_label_offset(bbox, 140.0, 50.0, Align::Center), 0.0);
155        assert_eq!(clamp_label_offset(bbox, 140.0, 50.0, Align::Min), 0.0);
156    }
157
158    #[test]
159    fn block_type_position_sits_at_the_upper_left_by_default() {
160        let bbox = Rect::from_min_max(pos2(10.0, 20.0), pos2(110.0, 60.0));
161        // The default type label: empty, top-side, no offset.
162        let (pos, align) = block_type_position(bbox, &ShapeLabel::default());
163        // Default type label is Top side, offset 0 → left-bottom anchored on the
164        // block's top-left corner (text baseline on the top edge, left-flush).
165        assert_eq!(pos, bbox.left_top());
166        assert_eq!(align, Align2::LEFT_BOTTOM);
167    }
168
169    #[test]
170    fn bottom_side_labels_hang_below_the_edge_growing_downward() {
171        // A Bottom label mirrors the Top one: anchored on the block's bottom edge
172        // with a TOP vertical align, so its text grows down and away from the rect
173        // instead of back up over the bottom edge.
174        let bbox = Rect::from_min_max(pos2(10.0, 20.0), pos2(110.0, 60.0));
175        let bottom = |side| ShapeLabel {
176            side,
177            ..Default::default()
178        };
179
180        let (pos, align) = block_title_position(bbox, &bottom(LabelSide::Bottom));
181        assert_eq!(pos, bbox.center_bottom());
182        assert_eq!(align, Align2::CENTER_TOP);
183
184        let (pos, align) = block_type_position(bbox, &bottom(LabelSide::Bottom));
185        assert_eq!(pos, bbox.left_bottom());
186        assert_eq!(align, Align2::LEFT_TOP);
187    }
188
189    fn label(side: LabelSide, offset: f32) -> ShapeLabel<'static> {
190        ShapeLabel {
191            side,
192            offset,
193            ..Default::default()
194        }
195    }
196
197    /// A centered title anchored at `x` has its text spanning `±text_width/2`.
198    fn centered_span(x: f32, text_width: f32) -> (f32, f32) {
199        (x - text_width / 2.0, x + text_width / 2.0)
200    }
201
202    /// A left-anchored type label anchored at `x` has its text spanning `[x, x+w]`.
203    fn left_span(x: f32, text_width: f32) -> (f32, f32) {
204        (x, x + text_width)
205    }
206
207    #[test]
208    fn resizing_narrower_reclamps_a_title_slid_to_the_wide_edge_back_inside() {
209        let text_width = 40.0;
210        let wide = Rect::from_min_max(pos2(0.0, 0.0), pos2(200.0, 40.0));
211        let narrow = Rect::from_min_max(pos2(0.0, 0.0), pos2(80.0, 40.0));
212
213        // Slide the title flush to the wide block's right edge, then persist that
214        // raw offset and re-clamp it against the narrow block.
215        let wide_offset = clamp_label_offset(wide, text_width, 1_000.0, Align::Center);
216        let title = label(LabelSide::Center, wide_offset);
217
218        let (raw_pos, _) = block_title_position(narrow, &title);
219        let (clamped_pos, _) = clamped_block_title_position(narrow, &title, text_width);
220        assert!(
221            clamped_pos.x < raw_pos.x,
222            "re-clamp must move the title strictly inward",
223        );
224
225        let (lo, hi) = centered_span(clamped_pos.x, text_width);
226        assert!(lo >= narrow.left() && hi <= narrow.right());
227    }
228
229    #[test]
230    fn resizing_narrower_reclamps_a_type_slid_to_the_wide_edge_back_inside() {
231        let text_width = 40.0;
232        let wide = Rect::from_min_max(pos2(0.0, 0.0), pos2(200.0, 40.0));
233        let narrow = Rect::from_min_max(pos2(0.0, 0.0), pos2(80.0, 40.0));
234
235        let wide_offset = clamp_label_offset(wide, text_width, 1_000.0, Align::Min);
236        let type_label = label(LabelSide::Top, wide_offset);
237
238        let (raw_pos, _) = block_type_position(narrow, &type_label);
239        let (clamped_pos, _) = clamped_block_type_position(narrow, &type_label, text_width);
240        assert!(
241            clamped_pos.x < raw_pos.x,
242            "re-clamp must move the type label strictly inward",
243        );
244
245        let (lo, hi) = left_span(clamped_pos.x, text_width);
246        assert!(lo >= narrow.left() && hi <= narrow.right());
247    }
248
249    #[test]
250    fn a_label_wider_than_the_block_pins_flush_for_every_side_and_anchor() {
251        let narrow = Rect::from_min_max(pos2(10.0, 0.0), pos2(60.0, 40.0));
252        let text_width = 200.0; // Wider than the 50-wide block.
253
254        for side in [LabelSide::Top, LabelSide::Center, LabelSide::Bottom] {
255            // A wide title pins flush to the horizontal center regardless of offset.
256            let title = label(side, 500.0);
257            let (pos, _) = clamped_block_title_position(narrow, &title, text_width);
258            assert_eq!(pos.x, narrow.center().x);
259
260            // A wide type label pins flush to the left edge regardless of offset.
261            let type_label = label(side, 500.0);
262            let (pos, _) = clamped_block_type_position(narrow, &type_label, text_width);
263            assert_eq!(pos.x, narrow.left());
264        }
265    }
266}