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 min_size: Vec2,
50 },
51}
52
53const MIN_BLOCK_WIDTH: f32 = GRID_SIZE * 4.0;
56
57fn min_block_width(shape: ShapeId) -> f32 {
60 if shape.is_block() {
61 MIN_BLOCK_WIDTH
62 } else {
63 0.0
64 }
65}
66
67fn 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 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
88fn 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
115fn clamp_to_min_height(mode: ResizeMode, orig: Rect, min_height: f32, mut delta: Vec2) -> Vec2 {
119 let min_height = min_height.min(orig.height());
124 match mode {
125 ResizeMode::LeftTop | ResizeMode::RightTop => {
127 delta.y = delta.y.min(orig.height() - min_height);
128 }
129 ResizeMode::LeftBottom | ResizeMode::RightBottom => {
131 delta.y = delta.y.max(min_height - orig.height());
132 }
133 }
134 delta
135}
136
137fn 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 ResizeMode::LeftTop | ResizeMode::LeftBottom => {
145 delta.x = delta.x.min(orig.width() - min_width);
146 }
147 ResizeMode::RightTop | ResizeMode::RightBottom => {
149 delta.x = delta.x.max(min_width - orig.width());
150 }
151 }
152 delta
153}
154
155fn 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
172fn 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
187struct Resize {
194 shape: ShapeId,
195 mode: ResizeMode,
196 bbox: Rect,
198 raw_delta: Vec2,
200 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 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 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
250fn pointer_world<C: Canvas>(painter: &Style<'_, C>) -> Option<Pos2> {
265 painter.pointer_world()
266}
267
268pub(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
282fn 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 painter.circle_filled(*center, PORT_RADIUS, Role::NewPinPreviewFill);
303 draw_plus(*center, PORT_RADIUS, Role::NewPinPreviewStroke, painter);
304 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 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 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
340fn 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
357fn 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 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 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 if let Some(tool) = crate::tools::select_tool::editor_at_pos(data, pos, painter)
459 {
460 return Some(Action::SwitchTool(tool));
461 }
462 }
463 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 if let ShapeId::Rect(rid) = shape
480 && 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 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 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 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 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 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 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 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 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 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 let guides = preview
680 .map(|preview| {
681 crate::widget::alignment::resize_guides(data, selected_id, preview)
682 })
683 .unwrap_or_default();
684 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 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 .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 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 #[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; for mode in [
753 ResizeMode::LeftTop,
754 ResizeMode::RightTop,
755 ResizeMode::LeftBottom,
756 ResizeMode::RightBottom,
757 ] {
758 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 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 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 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 #[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; for mode in [
822 ResizeMode::LeftTop,
823 ResizeMode::RightTop,
824 ResizeMode::LeftBottom,
825 ResizeMode::RightBottom,
826 ] {
827 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 #[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 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 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 #[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 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 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}