Skip to main content

blockworx/tools/
resize_block.rs

1use std::ops::ControlFlow;
2
3use blockworx_geom::{Pos2, Rect, Vec2};
4
5use crate::theme::{Role, Style};
6use crate::{
7    edit::naming::Authoring,
8    grid::{GRID_SIZE, HIT_RADIUS, PIN_PITCH, PORT_RADIUS, artwork_rect},
9    shape::{
10        BlockShape, PinLocation, ShapeId, ShapeRef,
11        block::{
12            ASPECT_SNAP_RADIUS, ResizeSnap, clamp_resize_within, fixed_corner,
13            icon_rect_after_resize, resize_corner, resize_rect, snap_resize_aspect,
14            snap_resized_block,
15        },
16        pin::{PinSide, slot},
17    },
18    state::{RenderMode, ResizeMode},
19    tools::{
20        RouteTool,
21        multi_select::MultiSelect,
22        names::ToolName,
23        new_pin::{
24            HINT_ROUTE_THRESHOLD, NEW_PIN_ANIM_TIME, NEW_PIN_INACTIVE_SCALE, active_new_pin_target,
25            draw_plus, draw_route_hint, new_pin_targets, route_hint_phase, target_anim_key,
26        },
27        rename_pin::{Field, RenamePin},
28        select_pin::SelectPin,
29        tool::{Action, Deletable, Supposing, ToolTrait},
30    },
31    widget::{DrawingPasses, drawing::Drawing, hit_target::HitTarget},
32};
33use blockworx_doc::id::BlockId;
34use blockworx_paint::{Canvas, Event, Interaction};
35
36#[derive(Default)]
37pub enum ResizeBlock {
38    #[default]
39    Idle,
40    Selected {
41        shape: ShapeId,
42    },
43    ResizeRect {
44        shape: ShapeId,
45        mode: ResizeMode,
46        delta_pos: Vec2,
47        /// Smallest size (px) the shape may shrink to, computed when the resize
48        /// begins so every existing pin — and any icon — stays inside it.
49        min_size: Vec2,
50    },
51}
52
53/// Smallest width a block may be resized to: 4 grid cells. Ports, images, and
54/// icons are exempt (they may be narrower) — see [`min_resize_size`].
55const MIN_BLOCK_WIDTH: f32 = GRID_SIZE * 4.0;
56
57/// Minimum width a resize may shrink `shape` to: [`MIN_BLOCK_WIDTH`] for a child
58/// block, `0.0` for any other shape (which may be freely narrow).
59fn min_block_width(shape: ShapeId) -> f32 {
60    if shape.is_block() {
61        MIN_BLOCK_WIDTH
62    } else {
63        0.0
64    }
65}
66
67/// The size floor a resize of `shape` must respect: its own minimum (see
68/// [`min_block_width`] and [`min_block_height`]) grown to hold its icon, since a
69/// block never shrinks smaller than the artwork it carries. The icon's extent is
70/// raised to the lattice the drop snaps to, or the snap would round back through
71/// it.
72fn min_resize_size(data: &Drawing, shape: ShapeId, shape_ref: &ShapeRef<'_>) -> Vec2 {
73    let own = Vec2::new(min_block_width(shape), min_block_height(shape, shape_ref));
74    // `ShapeId::Icon` names its owning block, so ask only for a block's icon.
75    let icon = match shape {
76        ShapeId::Rect(rid) => data.icon(rid).map(|icon| artwork_rect(icon.rect)),
77        _ => None,
78    };
79    match icon {
80        Some(icon) => Vec2::new(
81            own.x.max(crate::grid::ceil_to_grid(icon.width())),
82            own.y.max(crate::grid::ceil_block_height(icon.height())),
83        ),
84        None => own,
85    }
86}
87
88/// Minimum height a resize may shrink `shape` to. For a child block this is a
89/// valid block height: at least `PIN_PITCH` (a single pin slot), and tall enough
90/// to hold its pins. The pins can slide up into the top margin as the block
91/// shrinks (the resize emitter rides them up, see
92/// [`crate::edit::geometry::resize`]), so the pin floor is set by their
93/// *span* — the slots between the highest and lowest pin — giving
94/// `PIN_PITCH * (span + 1)`. Non-blocks keep the bare pin floor (`0.0` when they
95/// have none).
96fn min_block_height(shape: ShapeId, block: &ShapeRef<'_>) -> f32 {
97    let mut bounds: Option<(u32, u32)> = None;
98    block.with_pins(|_, pin| {
99        let offset = slot(pin).offset;
100        bounds = Some(bounds.map_or((offset, offset), |(lo, hi)| {
101            (lo.min(offset), hi.max(offset))
102        }));
103    });
104    let pin_floor = match bounds {
105        Some((lo, hi)) => PIN_PITCH * ((hi - lo) as f32 + 1.0),
106        None => 0.0,
107    };
108    if shape.is_block() {
109        pin_floor.max(PIN_PITCH)
110    } else {
111        pin_floor
112    }
113}
114
115/// Clamp the y-component of a resize `delta` so the block cannot shrink below
116/// `min_height`. Which edge moves (and therefore the sign of the constraint)
117/// depends on the resize `mode`.
118fn clamp_to_min_height(mode: ResizeMode, orig: Rect, min_height: f32, mut delta: Vec2) -> Vec2 {
119    // Prevent shrinking below `min_height`, but never force a shape to grow: a
120    // fixed-height shape (a port) reports a min_height from its boundary slot
121    // that exceeds its own height, and clamping up to it would balloon the
122    // resize preview.
123    let min_height = min_height.min(orig.height());
124    match mode {
125        // Top edge moves down as delta.y grows; height = orig.height() - delta.y.
126        ResizeMode::LeftTop | ResizeMode::RightTop => {
127            delta.y = delta.y.min(orig.height() - min_height);
128        }
129        // Bottom edge moves up as delta.y shrinks; height = orig.height() + delta.y.
130        ResizeMode::LeftBottom | ResizeMode::RightBottom => {
131            delta.y = delta.y.max(min_height - orig.height());
132        }
133    }
134    delta
135}
136
137/// Clamp the x-component of a resize `delta` so the block cannot shrink below
138/// `min_width`. Which edge moves (and therefore the sign of the constraint)
139/// depends on the resize `mode`.
140fn clamp_to_min_width(mode: ResizeMode, orig: Rect, min_width: f32, mut delta: Vec2) -> Vec2 {
141    let min_width = min_width.min(orig.width());
142    match mode {
143        // Left edge moves right as delta.x grows; width = orig.width() - delta.x.
144        ResizeMode::LeftTop | ResizeMode::LeftBottom => {
145            delta.x = delta.x.min(orig.width() - min_width);
146        }
147        // Right edge moves as delta.x; width = orig.width() + delta.x.
148        ResizeMode::RightTop | ResizeMode::RightBottom => {
149            delta.x = delta.x.max(min_width - orig.width());
150        }
151    }
152    delta
153}
154
155/// The icon a resized block carries, and the offset to preview it at: exactly
156/// where the drop puts it (see [`icon_rect_after_resize`]), so the icon tracks
157/// the block through the drag instead of jumping into place on release.
158fn icon_preview(
159    data: &Drawing,
160    shape: ShapeId,
161    bbox: Rect,
162    preview: Rect,
163) -> Option<(ShapeId, Vec2)> {
164    let ShapeId::Rect(rid) = shape else {
165        return None;
166    };
167    let icon = artwork_rect(data.icon(rid)?.rect);
168    let moved = icon_rect_after_resize(icon, bbox, preview);
169    Some((ShapeId::Icon(rid), moved.min - icon.min))
170}
171
172/// The intrinsic aspect ratio (`w/h`) of an image or icon shape, or `None` for
173/// other shapes or an image the painter can't resolve. Drives the aspect-lock
174/// resize snap.
175fn intrinsic_aspect<C: Canvas>(
176    data: &Drawing,
177    shape: ShapeId,
178    painter: &Style<'_, C>,
179) -> Option<f32> {
180    let ShapeRef::Image(artwork) = data.shape(shape)? else {
181        return None;
182    };
183    let size = painter.image_intrinsic_size(artwork.asset?)?;
184    (size.y > 0.0).then(|| size.x / size.y)
185}
186
187/// The resize delta a `shape`+`mode` commits to for the accumulated `raw_delta`,
188/// computed from immutable reads only (so it can run before a mutable apply, as
189/// `DragStopped` needs). The pipeline: `constrain_resize_delta → clamp_to_min_height
190/// → clamp_to_min_width → contain-icon`, and for a free image/icon `→ aspect snap → contain → edge
191/// magnetism → contain`. Both the drag preview and the drop apply this, so they
192/// agree. An icon is re-contained after every snap, so containment always wins.
193struct Resize {
194    shape: ShapeId,
195    mode: ResizeMode,
196    /// The shape's rect at drag start.
197    bbox: Rect,
198    /// Accumulated pointer delta since drag start.
199    raw_delta: Vec2,
200    /// The floor from [`min_resize_size`], captured at drag start.
201    min_size: Vec2,
202}
203
204impl Resize {
205    fn final_delta<C: Canvas>(&self, data: &Drawing, painter: &Style<'_, C>) -> Vec2 {
206        let Self {
207            shape,
208            mode,
209            bbox,
210            raw_delta,
211            min_size,
212        } = *self;
213        let icon_bounds = match shape {
214            ShapeId::Icon(rid) => data.shape(ShapeId::Rect(rid)).map(|b| b.gui_rect()),
215            _ => None,
216        };
217        let contain = |d: Vec2| match icon_bounds {
218            Some(bounds) => clamp_resize_within(bbox, mode, d, bounds),
219            None => d,
220        };
221        let mut delta = data
222            .shape(shape)
223            .map_or(raw_delta, |s| s.constrain_resize_delta(raw_delta));
224        delta = clamp_to_min_height(mode, bbox, min_size.y, delta);
225        delta = clamp_to_min_width(mode, bbox, min_size.x, delta);
226        delta = contain(delta);
227        if shape.is_artwork() {
228            // Snap to the image's intrinsic aspect ratio when close, so the user can
229            // deliberately hit an undistorted resize.
230            if let Some(aspect) = intrinsic_aspect(data, shape, painter) {
231                let resized = resize_rect(&bbox, mode, delta);
232                if let Some(snapped) =
233                    snap_resize_aspect(bbox, mode, resized, aspect, ASPECT_SNAP_RADIUS)
234                {
235                    delta = contain(resize_corner(&snapped, mode) - resize_corner(&bbox, mode));
236                }
237            }
238            // Then snap the dragged edge to a nearby static edge, so a free image/icon
239            // lines up like a move does.
240            let corner = resize_corner(&resize_rect(&bbox, mode, delta), mode);
241            let snapped = crate::widget::alignment::snap_resize_corner(data, shape, corner);
242            if snapped != corner {
243                delta = contain(snapped - resize_corner(&bbox, mode));
244            }
245        }
246        delta
247    }
248}
249
250// ── New-pin targets ──────────────────────────────────────────────────────────
251//
252// While a block is selected, every free pin slot is offered as a small blue dot
253// one grid cell outside the block edge — where the pin's connection point will
254// land. The dot nearest the cursor (within `NEW_PIN_ACTIVATION_RANGE`) animates
255// into a blue ring that grows out to `PORT_RADIUS`. Pressing on that ring inverts
256// the marker (a filled disk) for as long as the button is held; releasing inside
257// the ring adds the pin, releasing outside cancels. The marker geometry and the
258// animation primitives live in [`crate::tools::new_pin`], shared with the route
259// tool.
260
261/// The world-space pointer: real on the live canvas, the demo's synthetic
262/// pointer in a scripted session (see
263/// [`Animator::pointer_world`](blockworx_paint::Animator::pointer_world)).
264fn pointer_world<C: Canvas>(painter: &Style<'_, C>) -> Option<Pos2> {
265    painter.pointer_world()
266}
267
268/// The new-pin marker `pos` lands on: the block's active target, within the
269/// widest grab its neighbours leave it (R34) — wider than the ring draws at,
270/// like every other target on the canvas.
271pub(crate) fn new_pin_target_at(
272    data: &Drawing,
273    rid: BlockId,
274    pos: Pos2,
275) -> Option<(PinLocation, Pos2)> {
276    let targets = new_pin_targets(&data.block_shape(rid)?);
277    let idx = active_new_pin_target(&targets, pos)?;
278    let (loc, center) = targets[idx];
279    (pos.distance(center) < crate::tools::new_pin::new_pin_grab().get()).then_some((loc, center))
280}
281
282/// Draw the new-pin markers for the selected block `rid`. The marker nearest
283/// `hover` grows a blue ring out to `PORT_RADIUS`; the marker `held` down is
284/// drawn inverted as a filled disk and locks out hover activation.
285fn draw_new_pin_targets<C: Canvas>(
286    block: &BlockShape<'_>,
287    rid: BlockId,
288    hover: Option<Pos2>,
289    held: Option<Pos2>,
290    painter: &mut Style<'_, C>,
291) {
292    let targets = new_pin_targets(block);
293    let pressed = held;
294    let active = if pressed.is_some() {
295        None
296    } else {
297        hover.and_then(|p| active_new_pin_target(&targets, p))
298    };
299    for (i, (loc, center)) in targets.iter().enumerate() {
300        if Some(*center) == pressed {
301            // Inverted "pressed" button: a filled blue disk with a light `+`.
302            painter.circle_filled(*center, PORT_RADIUS, Role::NewPinPreviewFill);
303            draw_plus(*center, PORT_RADIUS, Role::NewPinPreviewStroke, painter);
304            // While held, an animated hint teaches that dragging out starts a route.
305            let phase = route_hint_phase(painter.now());
306            draw_route_hint(*center, loc.side, phase, painter);
307            painter.request_repaint();
308            continue;
309        }
310        let goal = if active == Some(i) { 1.0 } else { 0.0 };
311        let t = painter.animate(target_anim_key(rid, *loc), goal, NEW_PIN_ANIM_TIME);
312        // The resting dot fades out as the ring grows in, so it reads as the dot
313        // expanding into the ring.
314        if t < 1.0 {
315            painter.with_opacity(1.0 - t, |p| {
316                p.circle_filled(
317                    *center,
318                    PORT_RADIUS * NEW_PIN_INACTIVE_SCALE,
319                    Role::NewPinPreviewFill,
320                );
321            });
322        }
323        if t > 0.0 {
324            // A blue ring grows from the dot's radius out to `PORT_RADIUS`.
325            let radius =
326                PORT_RADIUS * (NEW_PIN_INACTIVE_SCALE + (1.0 - NEW_PIN_INACTIVE_SCALE) * t);
327            painter.circle(
328                *center,
329                radius,
330                Role::Transparent,
331                (2.0, Role::NewPinPreviewFill),
332            );
333            painter.with_opacity(t, |p| {
334                draw_plus(*center, PORT_RADIUS, Role::NewPinPreviewFill, p);
335            });
336        }
337    }
338}
339
340/// The corner handle `pos` grabs on `shape`'s selection rect, if any. Handles
341/// only exist on resizable shapes; a text box carries none.
342fn resize_mode_at(shape: &crate::shape::ShapeRef<'_>, pos: Pos2) -> Option<ResizeMode> {
343    if !shape.resizable() {
344        return None;
345    }
346    let rect = shape.gui_rect();
347    [
348        (rect.left_top(), ResizeMode::LeftTop),
349        (rect.right_top(), ResizeMode::RightTop),
350        (rect.left_bottom(), ResizeMode::LeftBottom),
351        (rect.right_bottom(), ResizeMode::RightBottom),
352    ]
353    .into_iter()
354    .find_map(|(corner, mode)| (pos.distance(corner) < HIT_RADIUS.get()).then_some(mode))
355}
356
357/// Only blocks hold their height to the 4-cell pitch; ports, areas, images
358/// and text boxes just snap to the grid.
359fn resize_snap(shape: ShapeId) -> ResizeSnap {
360    if shape.is_block() {
361        ResizeSnap::BlockPitch
362    } else {
363        ResizeSnap::GridOnly
364    }
365}
366
367impl ToolTrait for ResizeBlock {
368    fn name(&self) -> ToolName {
369        ToolName::ResizeBlock
370    }
371
372    fn selection(&self) -> Option<Deletable> {
373        match self {
374            ResizeBlock::Selected { shape } => Some(Deletable::Shape(*shape)),
375            _ => None,
376        }
377    }
378
379    fn suppose<C: Canvas>(
380        &mut self,
381        data: &mut Drawing,
382        interaction: &Interaction,
383        painter: &mut Style<'_, C>,
384        phase: &Supposing,
385    ) {
386        let ResizeBlock::ResizeRect {
387            shape,
388            mode,
389            delta_pos,
390            min_size,
391        } = self
392        else {
393            return;
394        };
395        let Some(bbox) = data.shape(*shape).map(|s| s.gui_rect()) else {
396            return;
397        };
398        let (shape, mode, min_size) = (*shape, *mode, *min_size);
399        if let Some(Event::Dragging { delta, .. }) = interaction.event {
400            *delta_pos = Resize {
401                shape,
402                mode,
403                bbox,
404                raw_delta: *delta_pos + delta,
405                min_size,
406            }
407            .final_delta(data, painter);
408        }
409        // Suppose the previewed (grid-snapped) resized rect, which is what the
410        // drop commits.
411        let predicted = snap_resized_block(
412            resize_rect(&bbox, mode, *delta_pos),
413            mode,
414            resize_snap(shape),
415        );
416        data.suppose_resize(phase, &[(shape, predicted)]);
417    }
418
419    fn widget<C: Canvas>(
420        &mut self,
421        data: &mut Drawing,
422        interaction: &Interaction,
423        painter: &mut Style<'_, C>,
424    ) -> Option<Action> {
425        self.render(data, interaction, painter);
426        let state = std::mem::take(self);
427        match state {
428            ResizeBlock::Idle => {
429                if let Some(Event::Clicked { pos }) = interaction.event
430                    && let Some(shape) = data.shape_at_pos(pos)
431                {
432                    *self = ResizeBlock::Selected { shape };
433                    return None;
434                }
435            }
436            ResizeBlock::Selected { shape } => {
437                if interaction.delete_pressed {
438                    return Some(Action::Delete(Deletable::Shape(shape)));
439                }
440                // Hovering an existing pin grows a route-start target; pressing or
441                // dragging it hands off to the route tool, exactly as in the Select
442                // tool. Checked before the pin-stub and new-pin handling so a press
443                // on the target starts a route rather than drilling into the pin.
444                if let ControlFlow::Break(action) =
445                    crate::tools::route_start::widget(data, interaction, painter)
446                {
447                    *self = ResizeBlock::Selected { shape };
448                    return action;
449                }
450                if let Some(Event::DoubleClicked { pos }) = interaction.event {
451                    // App will switch tools in response, so leaving `*self` as the
452                    // taken-default Idle is fine here. The shared resolver opens the
453                    // editor for whatever editable label was hit — a title or tag
454                    // drawn over the block body takes precedence over the body, and
455                    // any pin / route label / text box is handled too. A bare block
456                    // body is entered via the "Expand" overlay button, not here, so
457                    // a miss falls through to the click/drag handling below.
458                    if let Some(tool) = crate::tools::select_tool::editor_at_pos(data, pos, painter)
459                    {
460                        return Some(Action::SwitchTool(tool));
461                    }
462                }
463                // Clicking a pin on the selected block drills into per-pin
464                // selection.
465                if let Some(Event::Clicked { pos }) = interaction.event
466                    && let Some(HitTarget::Pin { anchor, .. }) = data.resolve_at_pos(pos, painter)
467                    && matches!(data.pin_shape(anchor), Some(ShapeId::Rect(_)))
468                {
469                    return Some(Action::SwitchTool(SelectPin::Selected { anchor }.into()));
470                }
471                // New-pin markers, with two commit modes: a click on a marker's
472                // ring (press released in place — a click is decided at
473                // release) adds the pin and drops into editing its name; a held
474                // press pulled firmly outward adds the pin and hands the same
475                // gesture to the routing tool. A sloppy release classifies as a
476                // drag, not a click, so it commits nothing. Handled before the
477                // empty-canvas deselect below, since the markers sit off the
478                // block body.
479                if let ShapeId::Rect(rid) = shape
480                    // The markers this answers are drawn under the same
481                    // condition (see `render`), so a press near a slot the
482                    // user cannot see never adds a pin.
483                    && data.authoring_of(shape) == Authoring::Offered
484                {
485                    if let Some(Event::Clicked { pos }) = interaction.event
486                        && let Some((loc, _)) = new_pin_target_at(data, rid, pos)
487                    {
488                        if let Some(pin) = data.add_named_pin(rid, loc) {
489                            return Some(RenamePin::action(data, pin, Field::Name));
490                        }
491                        *self = ResizeBlock::Selected { shape };
492                        return None;
493                    }
494                    if let Some(press) = interaction.press
495                        && let Some((loc, center)) = new_pin_target_at(data, rid, press.origin)
496                    {
497                        // Held: the marker stays inverted (drawn by `render`) and
498                        // owns the interaction. A firm pull outward (the pin's
499                        // direction, past the hint's threshold) starts a route —
500                        // decided from actual displacement, not the host's drag
501                        // classification, so a jittery hold keeps cycling.
502                        if let Some(p) = pointer_world(painter) {
503                            let outward = match loc.side {
504                                PinSide::East => 1.0,
505                                PinSide::West => -1.0,
506                            };
507                            if (p.x - center.x) * outward > HINT_ROUTE_THRESHOLD.get()
508                                && let Some(pin) = data.add_named_pin(rid, loc)
509                            {
510                                return Some(Action::SwitchTool(
511                                    RouteTool::routing_from_new_pin(pin, p).into(),
512                                ));
513                            }
514                        }
515                        *self = ResizeBlock::Selected { shape };
516                        return None;
517                    }
518                }
519                if let Some(Event::Clicked { pos }) = interaction.event {
520                    if interaction.shift {
521                        // Shift-click toggles another shape into a multi-selection;
522                        // shift-clicking elsewhere keeps the current selection.
523                        if let Some(hit) = data.shape_at_pos(pos) {
524                            return Some(Action::SwitchTool(
525                                crate::tools::select_tool::extend_with_shape(&[shape], hit),
526                            ));
527                        }
528                        return None;
529                    }
530                    // Clicking the already-selected shape keeps it selected;
531                    // clicking any other object (shape / route / pin) selects that
532                    // directly, and empty canvas deselects.
533                    if data.shape_at_pos(pos) != Some(shape) {
534                        return Some(
535                            crate::tools::select_tool::click_to_select(data, pos, painter)
536                                .unwrap_or_default(),
537                        );
538                    }
539                }
540                // Shift-drag starts a marquee that extends the current selection.
541                if interaction.shift
542                    && let Some(Event::DragStarted { pos }) = interaction.event
543                {
544                    return Some(Action::SwitchTool(
545                        MultiSelect::Marquee {
546                            start: pos,
547                            current: pos,
548                            base: vec![shape],
549                        }
550                        .into(),
551                    ));
552                }
553                if let Some(Event::DragStarted { pos }) = interaction.event {
554                    // A grab on one of the selection's corner handles resizes;
555                    // anything else moves whatever the drag landed on. A
556                    // read-only session takes neither: `drag_to_move` answers
557                    // with a marquee.
558                    let editing = data.authoring() == Authoring::Offered;
559                    let grab = data.shape(shape).filter(|_| editing).and_then(|block| {
560                        Some((
561                            resize_mode_at(&block, pos)?,
562                            min_resize_size(data, shape, &block),
563                        ))
564                    });
565                    if let Some((mode, min_size)) = grab {
566                        *self = ResizeBlock::ResizeRect {
567                            shape,
568                            mode,
569                            delta_pos: Vec2::ZERO,
570                            min_size,
571                        };
572                        return None;
573                    }
574                    return Some(crate::tools::select_tool::drag_to_move(data, pos, painter));
575                }
576            }
577            ResizeBlock::ResizeRect {
578                shape,
579                mode,
580                delta_pos,
581                min_size,
582            } => {
583                // A shape deleted under a live drag leaves nothing to resize, so
584                // the tool drops out on the taken default.
585                if matches!(interaction.event, Some(Event::Dragging { .. }))
586                    && data.shape(shape).is_none()
587                {
588                    return None;
589                }
590                if let Some(Event::DragStopped { .. }) = interaction.event {
591                    // Compute the committed rect from immutable reads (the shared
592                    // pipeline queries the block, aspect, and other shapes), then
593                    // apply it with one mutable borrow.
594                    let free = shape.is_artwork();
595                    if let Some(bbox) = data.shape(shape).map(|s| s.gui_rect()) {
596                        let delta = Resize {
597                            shape,
598                            mode,
599                            bbox,
600                            raw_delta: delta_pos,
601                            min_size,
602                        }
603                        .final_delta(data, painter);
604                        let resized = resize_rect(&bbox, mode, delta);
605                        let predicted = if free {
606                            resized
607                        } else {
608                            snap_resized_block(resized, mode, resize_snap(shape))
609                        };
610                        data.apply_resize(shape, predicted);
611                    }
612                    *self = ResizeBlock::Selected { shape };
613                    return None;
614                }
615            }
616        }
617        *self = state;
618        None
619    }
620}
621
622impl ResizeBlock {
623    fn render<C: Canvas>(
624        &self,
625        data: &Drawing,
626        interaction: &Interaction,
627        painter: &mut Style<'_, C>,
628    ) {
629        match self {
630            ResizeBlock::Idle => {
631                crate::widget::display::widget(data, interaction, painter);
632            }
633            ResizeBlock::Selected { shape: selected_id } => {
634                let selected_id = *selected_id;
635                crate::widget::display::render_selected(data, selected_id, painter);
636                // A selected block offers its free pin slots as animated
637                // markers — unless nothing may be added to it, which a locked
638                // interface and a read-only session both mean.
639                if let ShapeId::Rect(rid) = selected_id
640                    && data.authoring_of(selected_id) == Authoring::Offered
641                    && let Some(block) = data.block_shape(rid)
642                {
643                    let hover = match interaction.event {
644                        Some(Event::HoverAt(p)) => Some(p),
645                        _ => None,
646                    };
647                    let held = interaction
648                        .press
649                        .and_then(|press| new_pin_target_at(data, rid, press.origin))
650                        .map(|(_, center)| center);
651                    draw_new_pin_targets(&block, rid, hover, held, painter);
652                }
653            }
654            ResizeBlock::ResizeRect {
655                shape: selected_id,
656                mode,
657                delta_pos,
658                ..
659            } => {
660                let selected_id = *selected_id;
661                let mode = *mode;
662                let delta_pos = *delta_pos;
663                let resizing = RenderMode::Resizing {
664                    mode,
665                    delta: delta_pos,
666                };
667                let bbox = data.shape(selected_id).map(|s| s.gui_rect());
668                // The rect the drop would commit: grid-snapped for a block, free
669                // for an image/icon.
670                let preview = bbox.map(|bbox| {
671                    let resized = resize_rect(&bbox, mode, delta_pos);
672                    if selected_id.is_block() {
673                        snap_resized_block(resized, mode, ResizeSnap::BlockPitch)
674                    } else {
675                        resized
676                    }
677                });
678                // Alignment guides for the resized shape at its previewed rect.
679                let guides = preview
680                    .map(|preview| {
681                        crate::widget::alignment::resize_guides(data, selected_id, preview)
682                    })
683                    .unwrap_or_default();
684                // A resized block's icon rides along with it.
685                let (icon_id, icon_delta) = match bbox
686                    .zip(preview)
687                    .and_then(|(bbox, preview)| icon_preview(data, selected_id, bbox, preview))
688                {
689                    Some((id, delta)) => (Some(id), delta),
690                    None => (None, Vec2::ZERO),
691                };
692                // For an image/icon snapped to its intrinsic aspect, the aspect
693                // diagonal, shown only while actually on-aspect.
694                let aspect_guide = intrinsic_aspect(data, selected_id, painter)
695                    .zip(bbox)
696                    .and_then(|(aspect, bbox)| {
697                        let resized = resize_rect(&bbox, mode, delta_pos);
698                        ((resized.width() / resized.height() - aspect).abs() < 1e-2).then_some([
699                            fixed_corner(&resized, mode),
700                            resize_corner(&resized, mode),
701                        ])
702                    });
703                DrawingPasses::new(data)
704                    .shape_mode(move |id| {
705                        if id == selected_id {
706                            resizing
707                        } else if Some(id) == icon_id {
708                            RenderMode::Moving { delta: icon_delta }
709                        } else {
710                            RenderMode::Normal
711                        }
712                    })
713                    // Guides on top so they stay visible over the diagram.
714                    .overlay(move |painter| {
715                        crate::widget::alignment::draw_guides(&guides, painter);
716                        if let Some(seg) = aspect_guide {
717                            painter.line_segment(seg, (1.0, Role::AlignmentGuide));
718                        }
719                    })
720                    .draw(painter);
721            }
722        }
723    }
724}
725
726#[cfg(test)]
727mod tests {
728    use super::*;
729    use crate::path::Scope;
730    use crate::widget::test_fixtures::{self as fx, Scene};
731    use blockworx_doc::{fixtures::block_id, id::BlockId};
732    use blockworx_geom::pos2;
733
734    /// A 20×16-cell block carrying artwork over its middle — the scene both
735    /// icon-containment claims are made against.
736    fn block_with_an_icon(n: u32) -> Scene {
737        let block = Rect::from_min_max(pos2(0.0, 0.0), pos2(20.0 * GRID_SIZE, 16.0 * GRID_SIZE));
738        let side = block.width().min(block.height()) * 0.6;
739        Scene::new(vec![
740            fx::block_in(n, Scope::Root, block),
741            fx::asset().1,
742            fx::icon(n, Rect::from_center_size(block.center(), Vec2::splat(side))),
743        ])
744    }
745
746    // A fixed-height shape (a port) reports a min_height larger than its own
747    // height. The clamp must not force the resize preview to grow to it.
748    #[test]
749    fn clamp_to_min_height_never_forces_a_fixed_height_shape_to_grow() {
750        let port = Rect::from_min_max(pos2(0.0, 0.0), pos2(60.0, 30.0));
751        let oversized_min = 210.0; // e.g. PIN_PITCH * (slot 5 + 2)
752        for mode in [
753            ResizeMode::LeftTop,
754            ResizeMode::RightTop,
755            ResizeMode::LeftBottom,
756            ResizeMode::RightBottom,
757        ] {
758            // A width-only drag (y already locked to 0) stays at 0.
759            let clamped = clamp_to_min_height(mode, port, oversized_min, Vec2::new(20.0, 0.0));
760            assert_eq!(clamped.y, 0.0, "{mode:?} ballooned the height");
761        }
762    }
763
764    #[test]
765    fn clamp_to_min_height_still_blocks_shrinking_a_block_below_its_pins() {
766        let block = Rect::from_min_max(pos2(0.0, 0.0), pos2(300.0, 300.0));
767        let min_height = 200.0;
768        // Dragging the bottom edge up far enough to cross min_height is clamped.
769        let clamped = clamp_to_min_height(
770            ResizeMode::RightBottom,
771            block,
772            min_height,
773            Vec2::new(0.0, -250.0),
774        );
775        assert_eq!(clamped.y, min_height - block.height());
776    }
777
778    #[test]
779    fn min_block_width_gates_on_rect() {
780        assert_eq!(
781            min_block_width(ShapeId::Rect(BlockId::NULL)),
782            MIN_BLOCK_WIDTH
783        );
784        assert_eq!(
785            min_block_width(ShapeId::Area(blockworx_doc::id::AreaId::NULL)),
786            0.0
787        );
788    }
789
790    #[test]
791    fn clamp_to_min_width_blocks_shrinking_a_block_below_min_width_from_the_right() {
792        let block = Rect::from_min_max(pos2(0.0, 0.0), pos2(300.0, 300.0));
793        // Dragging the right edge left far enough to cross the min width is clamped.
794        let clamped = clamp_to_min_width(
795            ResizeMode::RightBottom,
796            block,
797            MIN_BLOCK_WIDTH,
798            Vec2::new(-250.0, 0.0),
799        );
800        assert_eq!(clamped.x, MIN_BLOCK_WIDTH - block.width());
801    }
802
803    #[test]
804    fn clamp_to_min_width_blocks_shrinking_a_block_below_min_width_from_the_left() {
805        let block = Rect::from_min_max(pos2(0.0, 0.0), pos2(300.0, 300.0));
806        // Dragging the left edge right far enough to cross the min width is clamped.
807        let clamped = clamp_to_min_width(
808            ResizeMode::LeftTop,
809            block,
810            MIN_BLOCK_WIDTH,
811            Vec2::new(250.0, 0.0),
812        );
813        assert_eq!(clamped.x, block.width() - MIN_BLOCK_WIDTH);
814    }
815
816    // A shape whose min_width exceeds its own width must not be forced to grow.
817    #[test]
818    fn clamp_to_min_width_never_forces_a_fixed_width_shape_to_grow() {
819        let narrow = Rect::from_min_max(pos2(0.0, 0.0), pos2(30.0, 60.0));
820        let oversized_min = MIN_BLOCK_WIDTH; // wider than the shape itself
821        for mode in [
822            ResizeMode::LeftTop,
823            ResizeMode::RightTop,
824            ResizeMode::LeftBottom,
825            ResizeMode::RightBottom,
826        ] {
827            // A height-only drag (x already locked to 0) stays at 0.
828            let clamped = clamp_to_min_width(mode, narrow, oversized_min, Vec2::new(0.0, 20.0));
829            assert_eq!(clamped.x, 0.0, "{mode:?} ballooned the width");
830        }
831    }
832
833    /// The icon is previewed at the offset the drop commits, so it tracks the
834    /// block instead of jumping into place on release.
835    #[test]
836    fn a_resized_blocks_icon_previews_at_the_offset_the_drop_commits() {
837        let rid = block_id(1);
838        let mut scene = block_with_an_icon(1);
839        let shape = ShapeId::Rect(rid);
840        // Drag the top-left corner inward by whole cells, so the snap is exact.
841        let mode = ResizeMode::LeftTop;
842
843        let (before, preview, delta) = {
844            let data = scene.drawing();
845            let before = artwork_rect(data.icon(rid).expect("the icon").rect);
846            let bbox = data.shape(shape).expect("the block").gui_rect();
847            let preview = snap_resized_block(
848                resize_rect(&bbox, mode, Vec2::new(4.0 * GRID_SIZE, 3.0 * GRID_SIZE)),
849                mode,
850                ResizeSnap::BlockPitch,
851            );
852            let (icon_id, delta) =
853                icon_preview(&data, shape, bbox, preview).expect("the block's icon");
854            assert_eq!(icon_id, ShapeId::Icon(rid));
855            (before, preview, delta)
856        };
857        // Precondition: this resize really carries the icon, so agreement is a claim.
858        assert_ne!(delta, Vec2::ZERO);
859
860        scene.commit(|data| data.apply_resize(shape, preview));
861
862        let committed = artwork_rect(scene.drawing().icon(rid).expect("the icon").rect);
863        assert_eq!(committed.min - before.min, delta);
864    }
865
866    /// A block with an icon never shrinks smaller than the artwork it carries:
867    /// drag every corner as far inward as it goes and the committed rect still
868    /// holds the icon.
869    #[test]
870    fn a_block_never_resizes_smaller_than_its_icon() {
871        use crate::canvas::Painter;
872        use crate::theme::Theme;
873
874        let ctx = egui::Context::default();
875        ctx.run_ui(egui::RawInput::default(), |ui| {
876            let rid = block_id(1);
877            let mut scene = block_with_an_icon(1);
878            let data = scene.drawing();
879            let shape = ShapeId::Rect(rid);
880            let icon = artwork_rect(data.icon(rid).expect("the icon").rect);
881            let bbox = data.shape(shape).expect("the block").gui_rect();
882            // Precondition: the icon is smaller than the block, so a shrink has
883            // somewhere to go before it hits the icon.
884            assert!(bbox.contains_rect(icon) && icon.width() < bbox.width());
885
886            let theme = Theme::default();
887            let mut painter = Painter::headless(ui.painter().clone(), theme.palette().clone());
888            let style = Style::new(&theme, &mut painter);
889            let min_size = min_resize_size(&data, shape, &data.shape(shape).expect("the block"));
890            for mode in [
891                ResizeMode::LeftTop,
892                ResizeMode::RightTop,
893                ResizeMode::LeftBottom,
894                ResizeMode::RightBottom,
895            ] {
896                // Drag the corner clear across the block and past the far side.
897                let inward = match mode {
898                    ResizeMode::LeftTop => Vec2::new(bbox.width(), bbox.height()),
899                    ResizeMode::RightTop => Vec2::new(-bbox.width(), bbox.height()),
900                    ResizeMode::LeftBottom => Vec2::new(bbox.width(), -bbox.height()),
901                    ResizeMode::RightBottom => Vec2::new(-bbox.width(), -bbox.height()),
902                };
903                let delta = Resize {
904                    shape,
905                    mode,
906                    bbox,
907                    raw_delta: inward,
908                    min_size,
909                }
910                .final_delta(&data, &style);
911                let committed = snap_resized_block(
912                    resize_rect(&bbox, mode, delta),
913                    mode,
914                    ResizeSnap::BlockPitch,
915                );
916                assert!(
917                    committed.width() >= icon.width() && committed.height() >= icon.height(),
918                    "{mode:?} shrank {committed:?} below the icon {icon:?}"
919                );
920            }
921        })
922        .drop_without_applying_deltas();
923    }
924}