Skip to main content

blockworx_editor/shape/
block.rs

1use crate::theme::Style;
2use blockworx_geom::{Pos2, Rect, Vec2, pos2, vec2};
3use blockworx_paint::Renderer;
4
5use blockworx_doc::{block_model::Block, id::PinId};
6
7use crate::{
8    edit::{
9        lower::{accent_from_role, shape_label},
10        naming::{Authoring, InterfaceLock},
11    },
12    grid::{
13        GRID_SIZE, PIN_PITCH, PIN_TOP_MARGIN, max_pin_slot, pin_offset_y, pin_slot, px_rect,
14        round_to_pitch, snap_block_height, snap_rect,
15    },
16    path::Structure,
17    render::block_title_position,
18    shape::{
19        BaseShape, PinLocation, ShapeLabel,
20        pin::{Pin, PinSide, slot},
21    },
22    state::{RenderMode, ResizeMode},
23    theme::Role,
24};
25
26pub fn resize_rect(rect: &Rect, mode: ResizeMode, delta: Vec2) -> Rect {
27    match mode {
28        ResizeMode::LeftTop => Rect::from_two_pos(rect.left_top() + delta, rect.right_bottom()),
29        ResizeMode::RightTop => Rect::from_two_pos(rect.right_top() + delta, rect.left_bottom()),
30        ResizeMode::LeftBottom => Rect::from_two_pos(rect.left_bottom() + delta, rect.right_top()),
31        ResizeMode::RightBottom => Rect::from_two_pos(rect.right_bottom() + delta, rect.left_top()),
32    }
33}
34
35/// The corner `mode` moves when resizing `rect`.
36pub fn resize_corner(rect: &Rect, mode: ResizeMode) -> Pos2 {
37    match mode {
38        ResizeMode::LeftTop => rect.left_top(),
39        ResizeMode::RightTop => rect.right_top(),
40        ResizeMode::LeftBottom => rect.left_bottom(),
41        ResizeMode::RightBottom => rect.right_bottom(),
42    }
43}
44
45/// The corner `mode` keeps fixed while resizing `rect` (opposite
46/// [`resize_corner`]).
47pub fn fixed_corner(rect: &Rect, mode: ResizeMode) -> Pos2 {
48    match mode {
49        ResizeMode::LeftTop => rect.right_bottom(),
50        ResizeMode::RightTop => rect.left_bottom(),
51        ResizeMode::LeftBottom => rect.right_top(),
52        ResizeMode::RightBottom => rect.left_top(),
53    }
54}
55
56/// Snap radius (world units) for the aspect-ratio resize lock, matching the
57/// position-alignment magnetism.
58pub const ASPECT_SNAP_RADIUS: f32 = 6.0;
59
60/// If the corner-`mode` box `resized` (grown from `bbox`) is within `radius` of
61/// the image's intrinsic `aspect` (`intrinsic_w / intrinsic_h`), return the box
62/// snapped so `w/h == aspect` exactly — the dragged corner projected onto the
63/// aspect diagonal from the fixed corner; else `None`. Every point on that
64/// diagonal has `w/h == aspect` by construction.
65pub fn snap_resize_aspect(
66    bbox: Rect,
67    mode: ResizeMode,
68    resized: Rect,
69    aspect: f32,
70    radius: f32,
71) -> Option<Rect> {
72    let fixed = fixed_corner(&bbox, mode);
73    let dragged = resize_corner(&resized, mode);
74    let v = dragged - fixed;
75    if v.x == 0.0 || v.y == 0.0 {
76        return None;
77    }
78    let dir = vec2(v.x.signum() * aspect, v.y.signum()).normalized();
79    let t = v.dot(dir);
80    if t <= 0.0 {
81        return None;
82    }
83    let locked = fixed + dir * t;
84    ((dragged - locked).length() <= radius).then(|| Rect::from_two_pos(fixed, locked))
85}
86
87/// Translate `inner` minimally so it lies within `bounds` — shifting, not
88/// shrinking. On an axis where `inner` is larger than `bounds`, it is centered.
89/// Used to keep a block's icon inside the block.
90pub fn contain_rect(inner: Rect, bounds: Rect) -> Rect {
91    let axis = |lo: f32, hi: f32, blo: f32, bhi: f32| -> f32 {
92        if hi - lo >= bhi - blo {
93            f32::midpoint(blo, bhi) - f32::midpoint(lo, hi) // center it
94        } else if lo < blo {
95            blo - lo
96        } else if hi > bhi {
97            bhi - hi
98        } else {
99            0.0
100        }
101    };
102    inner.translate(vec2(
103        axis(inner.min.x, inner.max.x, bounds.min.x, bounds.max.x),
104        axis(inner.min.y, inner.max.y, bounds.min.y, bounds.max.y),
105    ))
106}
107
108/// Where a block's `icon` lands when the block's rect goes from `old` to `new`:
109/// it rides the block's center, then is shifted back inside if the block shrank
110/// past it. Shared by the resize preview and the commit so the icon doesn't jump
111/// when the drag is released.
112pub fn icon_rect_after_resize(icon: Rect, old: Rect, new: Rect) -> Rect {
113    contain_rect(icon.translate(new.center() - old.center()), new)
114}
115
116/// Clamp a corner-resize `delta` so `resize_rect(bbox, mode, delta)` keeps the
117/// dragged corner within `bounds` (the fixed corner is assumed already inside),
118/// so a resized icon can't grow past its block.
119pub fn clamp_resize_within(bbox: Rect, mode: ResizeMode, delta: Vec2, bounds: Rect) -> Vec2 {
120    let corner = resize_corner(&bbox, mode);
121    let moved = corner + delta;
122    let clamped = pos2(
123        moved.x.clamp(bounds.min.x, bounds.max.x),
124        moved.y.clamp(bounds.min.y, bounds.max.y),
125    );
126    clamped - corner
127}
128
129/// Snap a resized block rect to the grid. When `is_block`, the *height* is
130/// further constrained to a valid block height (see [`snap_block_height`]) by
131/// moving the dragged edge, so a block resizes in lockstep with its pin-pitch
132/// lattice and never falls below the single-slot minimum. Without this the
133/// height snaps to `GRID_SIZE` (a quarter pitch), so `max_pin_slot` only steps
134/// every fourth grid row and the pins sub-step up and down against the moving edge.
135/// The fixed edge stays put, keeping it grid-aligned.
136/// Whether a resize also holds the block height to its 4-cell pitch, or just
137/// snaps to the grid (ports, areas, images and text boxes have no pitch).
138#[derive(Clone, Copy, PartialEq, Eq)]
139pub enum ResizeSnap {
140    BlockPitch,
141    GridOnly,
142}
143
144pub fn snap_resized_block(rect: Rect, mode: ResizeMode, snap: ResizeSnap) -> Rect {
145    let snapped = snap_rect(rect);
146    if snap == ResizeSnap::GridOnly {
147        return snapped;
148    }
149    let h = snap_block_height(snapped.height());
150    match mode {
151        ResizeMode::LeftTop | ResizeMode::RightTop => {
152            Rect::from_min_max(pos2(snapped.min.x, snapped.max.y - h), snapped.max)
153        }
154        ResizeMode::LeftBottom | ResizeMode::RightBottom => {
155            Rect::from_min_max(snapped.min, pos2(snapped.max.x, snapped.min.y + h))
156        }
157    }
158}
159
160/// How many slots every pin shifts up when a block holding pins at `offsets`
161/// is resized to `new_height` px, so the lowest pin keeps its bottom clearance
162/// instead of blocking the resize. Bounded by the top margin (the smallest pin
163/// offset) so the highest pin never rises above slot `0`. Zero when the pins
164/// already fit — i.e. only a height that would push the lowest pin out shifts
165/// them.
166pub fn resize_pin_shift(offsets: impl Iterator<Item = u32>, new_height: f32) -> u32 {
167    let mut bounds: Option<(u32, u32)> = None;
168    for offset in offsets {
169        bounds = Some(bounds.map_or((offset, offset), |(lo, hi)| {
170            (lo.min(offset), hi.max(offset))
171        }));
172    }
173    let Some((min_offset, max_offset)) = bounds else {
174        return 0;
175    };
176    max_offset
177        .saturating_sub(max_pin_slot(new_height))
178        .min(min_offset)
179}
180
181/// A block with the pins the caller resolved for it. In the document a pin is
182/// its own entity owned by the block, so nothing about a block's boundary can
183/// be read off the block alone; the scope that draws it hands the set in, in
184/// the order it wants them painted.
185pub struct BlockShape<'a> {
186    pub block: &'a Block,
187    pub pins: Vec<(PinId, &'a Pin)>,
188    /// Whether the block holds blocks of its own — [`crate::path::structure`],
189    /// resolved by the scope that built the shape, since a block's interior is
190    /// no more readable off the block alone than its pins are.
191    pub structure: Structure,
192}
193
194impl<'a> BlockShape<'a> {
195    pub fn new(block: &'a Block, pins: Vec<(PinId, &'a Pin)>, structure: Structure) -> Self {
196        Self {
197            block,
198            pins,
199            structure,
200        }
201    }
202
203    pub fn rect(&self) -> Rect {
204        px_rect(self.block.rect)
205    }
206
207    /// The block's [`InterfaceLock`] state, for passing to functions that branch on it.
208    pub fn lock(&self) -> InterfaceLock {
209        (self.block.locked).into()
210    }
211
212    /// The [`Role`] the block's outline strokes with, chosen by its accent
213    /// `role` register: `Accent1..=Accent8` →
214    /// [`Role::Accent0`]..[`Role::Accent7`], the plain `Accent0` →
215    /// [`Role::AccentDefault`].
216    pub fn accent_role(&self) -> Role {
217        crate::theme::accent_role(accent_from_role(self.block.role)).unwrap_or(Role::AccentDefault)
218    }
219
220    fn offsets(&self) -> impl Iterator<Item = u32> + '_ {
221        self.pins.iter().map(|(_, pin)| slot(pin).offset)
222    }
223
224    pub fn is_pin_location_available(&self, location: PinLocation) -> bool {
225        self.is_pin_location_available_excluding(location, None)
226    }
227
228    /// Like [`Self::is_pin_location_available`], but ignores the pin `except`
229    /// (so a pin being dragged doesn't block its own move).
230    pub fn is_pin_location_available_excluding(
231        &self,
232        location: PinLocation,
233        except: Option<PinId>,
234    ) -> bool {
235        let PinLocation { side, offset } = location;
236        let height_px = self.rect().height();
237        if offset < 0.0 || offset > height_px {
238            return false;
239        }
240        self.pins
241            .iter()
242            .filter(|(id, _)| Some(*id) != except)
243            .map(|(_, pin)| slot(pin))
244            .filter(|l| l.side == side)
245            // Slots are PIN_PITCH apart; reject only the exact same slot, allow
246            // adjacent slots one pitch away.
247            .all(|l| (l.offset as f32 * PIN_PITCH - offset).abs() >= PIN_PITCH * 0.5)
248    }
249
250    /// How many slots every pin shifts up when the block is resized to
251    /// `new_height` (see the free [`resize_pin_shift`]).
252    pub fn resize_pin_shift(&self, new_height: f32) -> u32 {
253        resize_pin_shift(self.offsets(), new_height)
254    }
255
256    /// Where a pin sitting at (`side`, `offset`) anchors its wire when the
257    /// block is drawn at `rect` — the slot-parameterized core of
258    /// [`BaseShape::anchor_point_with_rect`], shared with drag previews that
259    /// preview a pin at a hypothetical slot without moving it. Applies the same
260    /// upward shift the block's render preview uses, so a route re-routed
261    /// against a shrunk preview rect tracks where the pin will land; at the
262    /// block's real height the shift is zero.
263    pub fn pin_anchor_at(&self, rect: Rect, side: PinSide, offset: u32) -> Pos2 {
264        let shift = self.resize_pin_shift(rect.height());
265        let y = pin_offset_y(rect.top(), offset.saturating_sub(shift));
266        match side {
267            PinSide::East => pos2(rect.right() + GRID_SIZE, y),
268            PinSide::West => pos2(rect.left() - GRID_SIZE, y),
269        }
270    }
271
272    fn title_label(&self) -> ShapeLabel<'a> {
273        shape_label(&self.block.title)
274    }
275
276    fn type_label_of(&self) -> ShapeLabel<'a> {
277        shape_label(&self.block.type_label)
278    }
279
280    /// The grid-committed rect a block renders its frame and pins at for `mode`
281    /// (a drag/resize preview snaps to grid on commit). Shared by
282    /// [`BaseShape::render_ng`] and [`Self::render_pins_ng`] so the body and pin
283    /// passes stay aligned.
284    fn committed_rect(&self, mode: RenderMode) -> Rect {
285        let bbox = self.rect();
286        match mode {
287            RenderMode::Moving { delta } => snap_rect(bbox.translate(delta)),
288            RenderMode::Resizing { mode, delta } => snap_resized_block(
289                resize_rect(&bbox, mode, delta),
290                mode,
291                ResizeSnap::BlockPitch,
292            ),
293            _ => bbox,
294        }
295    }
296
297    /// Draw only the block's pin layer for `mode` — stubs, names, tags, the
298    /// selection placeholders, and the drag/resize previews. Split out of
299    /// [`BaseShape::render_ng`] so the scene can draw it in a later pass, on top
300    /// of a block's icon (see [`crate::widget::DrawingPasses`]).
301    pub fn render_pins_ng<R: Renderer>(
302        &self,
303        accents: crate::presentation::ShapeAccents<'_>,
304        mode: RenderMode,
305        painter: &mut Style<'_, R>,
306    ) {
307        let rect = self.committed_rect(mode);
308        match mode {
309            RenderMode::PinDragged {
310                pin,
311                delta,
312                side,
313                candidate,
314            } => {
315                crate::render::render_pins_with_box(
316                    self.pins
317                        .iter()
318                        .filter(|&&(id, _)| id != pin)
319                        .map(|&(id, p)| (id, p)),
320                    rect,
321                    accents,
322                    painter,
323                );
324                painter.line_segment(
325                    [rect.center_top(), rect.center_bottom()],
326                    (2.0, Role::PinDragIndicator),
327                );
328                if let Some(pin_ref) = self.pin(pin) {
329                    // A faded ghost at the slot the pin would snap to if released
330                    // now, behind the solid pin that follows the cursor.
331                    if let Some(candidate_slot) = candidate {
332                        let ghost_delta =
333                            PIN_PITCH * (candidate_slot as f32 - slot(pin_ref).offset as f32);
334                        let ghost_paint = crate::render::PinPaint {
335                            delta_y: ghost_delta,
336                            side: Some(side),
337                            accent: accents.pin(pin),
338                        };
339                        painter.with_opacity(0.5, |ghost| {
340                            crate::render::draw_pin(rect, pin_ref, ghost_paint, ghost);
341                        });
342                    }
343                    let paint = crate::render::PinPaint {
344                        delta_y: delta,
345                        side: Some(side),
346                        accent: accents.pin(pin),
347                    };
348                    crate::render::draw_pin(rect, pin_ref, paint, painter);
349                }
350            }
351            RenderMode::Selected { authoring } => {
352                crate::render::render_pins_with_box(
353                    self.pins.iter().copied(),
354                    rect,
355                    accents,
356                    painter,
357                );
358                if authoring == Authoring::Offered {
359                    for (_, pin) in &self.pins {
360                        crate::render::draw_pin_placeholders(rect, pin, painter);
361                    }
362                }
363            }
364            RenderMode::Resizing { .. } => {
365                // Preview the same upward pin shift the drop will commit, so a pin
366                // at the lowest slot slides into the top margin instead of spilling
367                // below the shrinking block.
368                let dy = -(self.resize_pin_shift(rect.height()) as f32 * PIN_PITCH);
369                for &(id, pin) in &self.pins {
370                    let paint = crate::render::PinPaint {
371                        delta_y: dy,
372                        side: None,
373                        accent: accents.pin(id),
374                    };
375                    crate::render::draw_pin(rect, pin, paint, painter);
376                }
377            }
378            // Normal, Hidden, Moving, TitleDragged, TypeDragged: pins at rest on
379            // the committed rect.
380            _ => crate::render::render_pins_with_box(
381                self.pins.iter().copied(),
382                rect,
383                accents,
384                painter,
385            ),
386        }
387    }
388}
389
390impl BaseShape for BlockShape<'_> {
391    fn title(&self) -> Option<ShapeLabel<'_>> {
392        Some(self.title_label())
393    }
394    fn type_label(&self) -> Option<ShapeLabel<'_>> {
395        Some(self.type_label_of())
396    }
397    fn gui_rect(&self) -> Rect {
398        self.rect()
399    }
400    fn pin(&self, id: PinId) -> Option<&Pin> {
401        self.pins
402            .iter()
403            .find(|&&(pid, _)| pid == id)
404            .map(|&(_, pin)| pin)
405    }
406    fn anchor_point_with_rect(&self, rect: Rect, id: PinId) -> Option<Pos2> {
407        let pin = self.pin(id)?;
408        let slot = slot(pin);
409        Some(self.pin_anchor_at(rect, slot.side, slot.offset))
410    }
411    fn pin_text_rect<R: Renderer>(&self, id: PinId, painter: &Style<'_, R>) -> Option<Rect> {
412        let pin = self.pin(id)?;
413        let slot = slot(pin);
414        Some(crate::render::estimate_bbox_for_pin_name(
415            self.rect(),
416            slot.side,
417            slot.offset,
418            &pin.name,
419            painter,
420        ))
421    }
422    fn pin_type_rect<R: Renderer>(&self, id: PinId, painter: &Style<'_, R>) -> Option<Rect> {
423        let pin = self.pin(id)?;
424        let slot = slot(pin);
425        Some(crate::render::estimate_bbox_for_pin_type(
426            self.rect(),
427            slot.side,
428            slot.offset,
429            &pin.type_name,
430            painter,
431        ))
432    }
433    fn tag_text_rect_for<R: Renderer>(
434        &self,
435        id: PinId,
436        text: &str,
437        painter: &Style<'_, R>,
438    ) -> Option<Rect> {
439        let pin = self.pin(id)?;
440        let slot = slot(pin);
441        let bbox = self.rect();
442        let line_y = pin_offset_y(bbox.top(), slot.offset);
443        Some(
444            crate::render::TagSlot {
445                left: bbox.left(),
446                right: bbox.right(),
447                side: slot.side,
448                line_y,
449            }
450            .bbox(text, painter),
451        )
452    }
453    fn pin_stub_rect(&self, id: PinId) -> Option<Rect> {
454        let pin = self.pin(id)?;
455        let slot = slot(pin);
456        let bbox = self.rect();
457        let line_y = pin_offset_y(bbox.top(), slot.offset);
458        Some(crate::render::estimate_bbox_for_pin_stub(
459            bbox.left(),
460            bbox.right(),
461            slot.side,
462            line_y,
463        ))
464    }
465    /// The slot a dragged pin would snap to (its grid offset), or `None` if no
466    /// free slot is available near the raw drop position. Tries the nearest slot,
467    /// then the adjacent slot on the other side of the raw drop position. The
468    /// drop's own write ([`crate::edit::geometry::move_pin`]) is handed the slot
469    /// this resolved, so the live preview lands exactly where the commit will.
470    fn pin_drop_candidate(&self, pin_id: PinId, side: PinSide, raw_offset_px: f32) -> Option<u32> {
471        let primary = round_to_pitch(raw_offset_px).max(0.0);
472        // The neighbor on the far side of the raw position from `primary`.
473        let secondary = if raw_offset_px >= primary {
474            primary + PIN_PITCH
475        } else {
476            (primary - PIN_PITCH).max(0.0)
477        };
478        [primary, secondary].into_iter().find_map(|cand| {
479            self.is_pin_location_available_excluding(
480                PinLocation { side, offset: cand },
481                Some(pin_id),
482            )
483            .then(|| pin_slot(cand))
484        })
485    }
486    fn new_pin_locations(&self) -> Vec<PinLocation> {
487        let mut locations = Vec::new();
488        // One candidate per slot from the top down to `max_pin_slot`, which
489        // already reserves two grid cells of clearance above the bottom edge.
490        let max_slot = max_pin_slot(self.rect().height());
491        for slot in 0..=max_slot {
492            let offset = slot as f32 * PIN_PITCH;
493            if self.is_pin_location_available((PinSide::West, offset).into()) {
494                locations.push(PinLocation {
495                    side: PinSide::West,
496                    offset,
497                });
498            }
499            if self.is_pin_location_available((PinSide::East, offset).into()) {
500                locations.push(PinLocation {
501                    side: PinSide::East,
502                    offset,
503                });
504            }
505        }
506        locations
507    }
508    fn pin_position(&self, location: PinLocation) -> Option<Pos2> {
509        let inner = self.rect();
510        let left_top = inner.left_top();
511        let offset = location.offset;
512        if offset < 0.0 || offset > inner.height() {
513            return None;
514        }
515        // Mirror `pin_offset_y`: a stored slot at px `offset` (= slot*PIN_PITCH)
516        // sits a `PIN_TOP_MARGIN` below the top edge, then one pitch per slot.
517        Some(match location.side {
518            PinSide::West => left_top + vec2(0.0, offset + PIN_TOP_MARGIN),
519            PinSide::East => inner.right_top() + vec2(0.0, offset + PIN_TOP_MARGIN),
520        })
521    }
522    fn title_anchor(&self) -> Option<Pos2> {
523        let (pos, _) = block_title_position(self.rect(), &self.title_label());
524        Some(pos)
525    }
526    fn type_anchor(&self) -> Option<Pos2> {
527        let (pos, _) = crate::render::block_type_position(self.rect(), &self.type_label_of());
528        Some(pos)
529    }
530    fn resizable(&self) -> bool {
531        true
532    }
533    fn render_ng<R: Renderer>(&self, mode: RenderMode, painter: &mut Style<'_, R>) {
534        let bbox = self.rect();
535        let title = self.title_label();
536        let type_label = self.type_label_of();
537        let lock = self.lock();
538        let structure = self.structure;
539        let paint = crate::render::BlockPaint {
540            stroke: self.accent_role(),
541            lock,
542            structure,
543        };
544        // The committed type label (upper-left). An empty type draws nothing
545        // (handled inside the helper).
546        let draw_type = |target: Rect, painter: &mut Style<'_, R>| {
547            crate::render::draw_block_type(target, &type_label, lock, painter);
548        };
549        match mode {
550            RenderMode::Moving { delta } => {
551                let shifted = bbox.translate(delta);
552                let predicted = snap_rect(shifted);
553                // The block commits to the grid, so draw its body at the snapped
554                // position where the route anchors land (its pins follow there in
555                // the pin pass); a ghost outline trails the raw cursor to show the
556                // un-snapped drag.
557                crate::render::draw_box_outline(
558                    shifted,
559                    Role::Transparent,
560                    (1.0, Role::DragPreviewStroke),
561                    painter,
562                );
563                // Draw the landing block with its own accent, exactly as at rest.
564                crate::render::draw_block_frame(predicted, &title, paint, painter);
565                draw_type(predicted, painter);
566            }
567            RenderMode::Selected { authoring } => {
568                crate::render::draw_block_frame(bbox, &title, paint, painter);
569                draw_type(bbox, painter);
570                if authoring == Authoring::Offered {
571                    crate::render::draw_block_type_placeholder(bbox, &type_label, painter);
572                }
573                crate::render::draw_selection_frame(bbox, None, painter);
574                if lock.is_locked() {
575                    crate::render::draw_lock_hint(bbox, painter);
576                }
577            }
578            RenderMode::Resizing { mode, delta } => {
579                let resized = resize_rect(&bbox, mode, delta);
580                let predicted = snap_resized_block(resized, mode, ResizeSnap::BlockPitch);
581                // The block commits to the grid, so draw it and its pins at the
582                // snapped position where the route anchors land; a ghost outline
583                // trails the raw cursor to show the un-snapped resize.
584                crate::render::draw_box_outline(
585                    resized,
586                    Role::Transparent,
587                    (1.0, Role::DragPreviewStroke),
588                    painter,
589                );
590                crate::render::draw_block_boundary(
591                    predicted,
592                    Role::DragActiveFill,
593                    (2.0, Role::DragActiveStroke),
594                    structure,
595                    painter,
596                );
597                crate::render::draw_block_title(predicted, &title, lock, painter);
598                draw_type(predicted, painter);
599                crate::render::draw_selection_frame(predicted, Some(mode), painter);
600                crate::render::draw_size_readout(predicted, painter);
601            }
602            RenderMode::TitleDragged { delta } => {
603                let (anchor_pos, _) = block_title_position(bbox, &title);
604                let shifted_title = ShapeLabel {
605                    side: crate::render::label_side_for_y(bbox, anchor_pos.y + delta.y),
606                    offset: title.offset + delta.x,
607                    ..title
608                };
609                crate::render::draw_block_frame(bbox, &shifted_title, paint, painter);
610                draw_type(bbox, painter);
611                painter.line_segment(
612                    [bbox.left_center(), bbox.right_center()],
613                    (2.0, Role::PinDragIndicator),
614                );
615            }
616            RenderMode::TypeDragged { delta } => {
617                let (anchor_pos, _) = crate::render::block_type_position(bbox, &type_label);
618                let shifted_type = ShapeLabel {
619                    side: crate::render::label_side_for_y(bbox, anchor_pos.y + delta.y),
620                    offset: type_label.offset + delta.x,
621                    ..type_label
622                };
623                crate::render::draw_block_frame(bbox, &title, paint, painter);
624                crate::render::draw_block_type(bbox, &shifted_type, lock, painter);
625                painter.line_segment(
626                    [bbox.left_center(), bbox.right_center()],
627                    (2.0, Role::PinDragIndicator),
628                );
629            }
630            // Normal, Hidden, PinDragged draw just the frame and type; the pin
631            // layer (including the pin-drag preview) is drawn by
632            // [`Self::render_pins_ng`] in a later pass, over any block icon.
633            _ => {
634                crate::render::draw_block_frame(bbox, &title, paint, painter);
635                draw_type(bbox, painter);
636            }
637        }
638    }
639}
640
641#[cfg(test)]
642pub(crate) mod tests {
643    use super::*;
644    use blockworx_doc::{
645        block_model::{Block, Icon, Label, Pin},
646        geometry::{FracVal, PinSlot},
647        id::BlockId,
648        values::{LabelSide, PinDir, Role as DocRole},
649    };
650
651    /// A pin at `side`/`slot`, with only the fields the geometry layer
652    /// reads set to anything but their zero.
653    pub(crate) fn test_pin(side: PinSide, offset: u32) -> Pin {
654        Pin {
655            slot: PinSlot { side, offset },
656            dir: PinDir::InOut,
657            ..Pin::default()
658        }
659    }
660
661    /// A block covering `rect` in world px, snapped to whole cells.
662    pub(crate) fn test_block(rect: Rect) -> Block {
663        let label = |name: &str, side| Label {
664            name: name.into(),
665            side,
666            offset: FracVal::default(),
667            hidden: false,
668        };
669        Block {
670            parent: BlockId::default(),
671            rect: crate::grid::grid_rect(rect.min, rect.max),
672            locked: false,
673            role: DocRole::default(),
674            title: label("b", LabelSide::Bottom),
675            type_label: label("", LabelSide::Top),
676            icon: Icon::default(),
677        }
678    }
679
680    /// A 300×300 block at the origin, holding `pins` under ids `1..`.
681    fn block_300() -> Block {
682        test_block(Rect::from_min_max(pos2(0.0, 0.0), pos2(300.0, 300.0)))
683    }
684
685    fn shape<'a>(block: &'a Block, pins: &'a [Pin]) -> BlockShape<'a> {
686        BlockShape::new(
687            block,
688            pins.iter()
689                .enumerate()
690                .map(|(i, pin)| (blockworx_doc::fixtures::pin_id(i as u32 + 1), pin))
691                .collect(),
692            Structure::Leaf,
693        )
694    }
695
696    #[test]
697    fn contain_rect_shifts_into_bounds_without_shrinking() {
698        let bounds = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0));
699        let outside = Rect::from_min_max(pos2(90.0, 90.0), pos2(130.0, 130.0));
700        let contained = contain_rect(outside, bounds);
701        assert!(bounds.contains_rect(contained));
702        assert_eq!(contained.size(), outside.size(), "shifted, not shrunk");
703    }
704
705    #[test]
706    fn clamp_resize_within_keeps_the_dragged_corner_in_bounds() {
707        let bounds = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0));
708        let bbox = Rect::from_min_max(pos2(20.0, 20.0), pos2(60.0, 60.0));
709        // Drag the bottom-right corner far past the bounds → clamped to the edge.
710        let d = clamp_resize_within(bbox, ResizeMode::RightBottom, vec2(1000.0, 1000.0), bounds);
711        let resized = resize_rect(&bbox, ResizeMode::RightBottom, d);
712        assert!(
713            bounds.contains_rect(resized),
714            "resized icon stays within the block"
715        );
716        assert_eq!(
717            resized.max,
718            pos2(100.0, 100.0),
719            "corner clamped to the edge"
720        );
721    }
722
723    #[test]
724    fn snap_resize_aspect_locks_a_near_ratio_box() {
725        // Fixed corner at the origin; the aspect ray for aspect=2 passes through
726        // (20,10). A dragged corner 3px off that ray (radius 6) snaps to it.
727        let bbox = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
728        let perp = vec2(1.0, -2.0).normalized() * 3.0; // perpendicular to (2,1)
729        let dragged = pos2(20.0, 10.0) + perp;
730        let resized = Rect::from_min_max(pos2(0.0, 0.0), dragged);
731        let snapped =
732            snap_resize_aspect(bbox, ResizeMode::RightBottom, resized, 2.0, 6.0).expect("snaps");
733        assert!(
734            (snapped.width() / snapped.height() - 2.0).abs() < 1e-3,
735            "w/h locks to the intrinsic aspect"
736        );
737        assert_eq!(snapped.min, pos2(0.0, 0.0), "the fixed corner stays put");
738    }
739
740    #[test]
741    fn snap_resize_aspect_ignores_a_far_box() {
742        let bbox = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
743        // A square box is far from the 2:1 aspect ray.
744        let resized = Rect::from_min_max(pos2(0.0, 0.0), pos2(20.0, 20.0));
745        assert!(snap_resize_aspect(bbox, ResizeMode::RightBottom, resized, 2.0, 6.0).is_none());
746    }
747
748    #[test]
749    fn snap_resize_aspect_keeps_the_fixed_corner_for_left_top() {
750        // LeftTop drags the top-left; the bottom-right stays fixed.
751        let bbox = Rect::from_min_max(pos2(0.0, 0.0), pos2(30.0, 30.0));
752        let fixed = bbox.right_bottom();
753        // Aspect 2 ray from the fixed corner toward the top-left: dir (-2,-1).
754        let perp = vec2(1.0, -2.0).normalized() * 2.0;
755        let dragged = fixed + vec2(-20.0, -10.0) + perp;
756        let resized = Rect::from_two_pos(dragged, fixed);
757        let snapped =
758            snap_resize_aspect(bbox, ResizeMode::LeftTop, resized, 2.0, 6.0).expect("snaps");
759        assert!((snapped.width() / snapped.height() - 2.0).abs() < 1e-3);
760        assert_eq!(
761            snapped.max, fixed,
762            "the fixed (bottom-right) corner stays put"
763        );
764    }
765
766    #[test]
767    fn pin_drop_candidate_returns_the_nearest_free_slot() {
768        let (block, pins) = (block_300(), [test_pin(PinSide::West, 5)]);
769        let b = shape(&block, &pins);
770        let id = b.pins[0].0;
771        assert_eq!(b.pin_drop_candidate(id, PinSide::West, 4.0), Some(0));
772    }
773
774    #[test]
775    fn pin_drop_candidate_falls_back_to_neighbor_slot() {
776        let (block, pins) = (
777            block_300(),
778            [test_pin(PinSide::West, 1), test_pin(PinSide::West, 5)],
779        );
780        let b = shape(&block, &pins);
781        let mover = b.pins[1].0;
782        // Drop exactly on slot 1 (occupied); fallback is the neighbor slot 2.
783        assert_eq!(
784            b.pin_drop_candidate(mover, PinSide::West, PIN_PITCH),
785            Some(2)
786        );
787    }
788
789    #[test]
790    fn pin_drop_candidate_is_none_when_both_neighbors_taken() {
791        let (block, pins) = (
792            block_300(),
793            [
794                test_pin(PinSide::West, 1),
795                test_pin(PinSide::West, 2),
796                test_pin(PinSide::West, 6),
797            ],
798        );
799        let b = shape(&block, &pins);
800        let mover = b.pins[2].0;
801        // Drop between slots 1 and 2: both candidates occupied.
802        assert_eq!(
803            b.pin_drop_candidate(mover, PinSide::West, 1.5 * PIN_PITCH),
804            None
805        );
806    }
807
808    #[test]
809    fn resize_pin_shift_uses_the_top_margin_to_let_a_block_shrink() {
810        // One pin at slot 5 (the max for a 300px-tall block).
811        let shift = |height| resize_pin_shift([5].into_iter(), height);
812        // No shift while the pin still fits (height unchanged, slot 5 is the max).
813        assert_eq!(shift(300.0), 0);
814        // A partial shrink slides it up just enough to keep fitting.
815        assert_eq!(shift(2.0 * PIN_TOP_MARGIN + 2.0 * PIN_PITCH), 3);
816        // Shrinking to the single-pin floor slides the pin all the way to slot 0.
817        assert_eq!(shift(2.0 * PIN_TOP_MARGIN), 5);
818    }
819
820    #[test]
821    fn resize_pin_shift_preserves_a_multi_pin_span() {
822        // span = 3; the floor PIN_PITCH*(3+1) shifts both up by the top offset (2),
823        // landing the pins at slots 0 and 3 with the span intact.
824        assert_eq!(resize_pin_shift([2, 5].into_iter(), 4.0 * PIN_PITCH), 2);
825    }
826
827    #[test]
828    fn snap_resized_block_quantizes_height_to_a_valid_block_height() {
829        // Eight grid cells tall (120px) — grid-aligned, but not a valid block
830        // height. Quantization rounds it to the nearest valid one, fixed edge held.
831        let top = 2.0 * PIN_TOP_MARGIN;
832        let r = Rect::from_min_max(pos2(0.0, top), pos2(60.0, top + 8.0 * GRID_SIZE));
833        let is_valid = |h: f32| snap_block_height(h) == h;
834
835        // Bottom-edge drag keeps the top edge put.
836        let q = snap_resized_block(r, ResizeMode::RightBottom, ResizeSnap::BlockPitch);
837        assert_eq!(q.min.y, top);
838        assert!(is_valid(q.height()));
839
840        // Top-edge drag keeps the bottom edge put.
841        let q = snap_resized_block(r, ResizeMode::LeftTop, ResizeSnap::BlockPitch);
842        assert_eq!(q.max.y, top + 8.0 * GRID_SIZE);
843        assert!(is_valid(q.height()));
844
845        // A pinless block keeps its plain grid height.
846        let q = snap_resized_block(r, ResizeMode::RightBottom, ResizeSnap::GridOnly);
847        assert_eq!(q.height(), 8.0 * GRID_SIZE);
848    }
849
850    #[test]
851    fn a_block_reads_its_geometry_and_labels_off_its_registers() {
852        let (block, pins) = (block_300(), [test_pin(PinSide::East, 1)]);
853        let b = shape(&block, &pins);
854        assert_eq!(
855            b.gui_rect(),
856            Rect::from_min_max(pos2(0.0, 0.0), pos2(300.0, 300.0))
857        );
858        assert_eq!(b.title().expect("a block always has a title").name, "b");
859        assert!(!b.title().expect("a title").hidden);
860        // The stub anchors one cell outboard of the edge its slot sits on.
861        let anchor = b
862            .anchor_point_with_rect(b.gui_rect(), b.pins[0].0)
863            .expect("the pin is the block's");
864        assert_eq!(anchor.x, 300.0 + GRID_SIZE);
865        assert_eq!(anchor.y, pin_offset_y(0.0, 1));
866    }
867}