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#[derive(Clone, Copy, Debug)]
46pub struct ResizeBounds {
47 min: Vec2,
49 widest: WorldPx,
51}
52
53const MIN_BLOCK_WIDTH: f32 = GRID_SIZE * 4.0;
57
58fn 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
68fn 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 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
89fn 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
106fn 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
133fn clamp_to_min_height(mode: ResizeMode, orig: Rect, min_height: f32, mut delta: Vec2) -> Vec2 {
137 let min_height = min_height.min(orig.height());
142 match mode {
143 ResizeMode::LeftTop | ResizeMode::RightTop => {
145 delta.y = delta.y.min(orig.height() - min_height);
146 }
147 ResizeMode::LeftBottom | ResizeMode::RightBottom => {
149 delta.y = delta.y.max(min_height - orig.height());
150 }
151 }
152 delta
153}
154
155fn 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 ResizeMode::LeftTop | ResizeMode::LeftBottom => {
163 delta.x = delta.x.min(orig.width() - min_width);
164 }
165 ResizeMode::RightTop | ResizeMode::RightBottom => {
167 delta.x = delta.x.max(min_width - orig.width());
168 }
169 }
170 delta
171}
172
173fn 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
188fn 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
205fn 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
220struct Resize {
227 shape: ShapeId,
228 mode: ResizeMode,
229 bbox: Rect,
231 raw_delta: Vec2,
233 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 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 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
284fn 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
293fn 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 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 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 if let Some(tool) = crate::select_tool::editor_at_pos(data, pos, painter) {
395 return Some(Transition::SwitchTool(tool));
396 }
397 }
398 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 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 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 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 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 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 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 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 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 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 let guides = preview
585 .map(|preview| {
586 crate::widget::alignment::resize_guides(data, selected_id, preview)
587 })
588 .unwrap_or_default();
589 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 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 .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 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 #[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; for mode in [
658 ResizeMode::LeftTop,
659 ResizeMode::RightTop,
660 ResizeMode::LeftBottom,
661 ResizeMode::RightBottom,
662 ] {
663 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 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 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 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 #[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; for mode in [
727 ResizeMode::LeftTop,
728 ResizeMode::RightTop,
729 ResizeMode::LeftBottom,
730 ResizeMode::RightBottom,
731 ] {
732 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 #[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 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 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 #[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 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 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}