Skip to main content

blockworx_tools/
resize_block.rs

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