1use crate::theme::Style;
2use blockworx_geom::{Pos2, Rect, Vec2, pos2, vec2};
3use blockworx_paint::Renderer;
4
5use blockworx_doc::{block_model::Block, id::PinId};
6
7use crate::{
8 edit::{
9 lower::{accent_from_role, shape_label},
10 naming::{Authoring, InterfaceLock},
11 },
12 grid::{
13 GRID_SIZE, PIN_PITCH, PIN_TOP_MARGIN, max_pin_slot, pin_offset_y, pin_slot, px_rect,
14 round_to_pitch, snap_block_height, snap_rect,
15 },
16 path::Structure,
17 render::block_title_position,
18 shape::{
19 BaseShape, PinLocation, ShapeLabel,
20 pin::{Pin, PinSide, slot},
21 },
22 state::{RenderMode, ResizeMode},
23 theme::Role,
24};
25
26pub fn resize_rect(rect: &Rect, mode: ResizeMode, delta: Vec2) -> Rect {
27 match mode {
28 ResizeMode::LeftTop => Rect::from_two_pos(rect.left_top() + delta, rect.right_bottom()),
29 ResizeMode::RightTop => Rect::from_two_pos(rect.right_top() + delta, rect.left_bottom()),
30 ResizeMode::LeftBottom => Rect::from_two_pos(rect.left_bottom() + delta, rect.right_top()),
31 ResizeMode::RightBottom => Rect::from_two_pos(rect.right_bottom() + delta, rect.left_top()),
32 }
33}
34
35pub fn resize_corner(rect: &Rect, mode: ResizeMode) -> Pos2 {
37 match mode {
38 ResizeMode::LeftTop => rect.left_top(),
39 ResizeMode::RightTop => rect.right_top(),
40 ResizeMode::LeftBottom => rect.left_bottom(),
41 ResizeMode::RightBottom => rect.right_bottom(),
42 }
43}
44
45pub fn fixed_corner(rect: &Rect, mode: ResizeMode) -> Pos2 {
48 match mode {
49 ResizeMode::LeftTop => rect.right_bottom(),
50 ResizeMode::RightTop => rect.left_bottom(),
51 ResizeMode::LeftBottom => rect.right_top(),
52 ResizeMode::RightBottom => rect.left_top(),
53 }
54}
55
56pub const ASPECT_SNAP_RADIUS: f32 = 6.0;
59
60pub fn snap_resize_aspect(
66 bbox: Rect,
67 mode: ResizeMode,
68 resized: Rect,
69 aspect: f32,
70 radius: f32,
71) -> Option<Rect> {
72 let fixed = fixed_corner(&bbox, mode);
73 let dragged = resize_corner(&resized, mode);
74 let v = dragged - fixed;
75 if v.x == 0.0 || v.y == 0.0 {
76 return None;
77 }
78 let dir = vec2(v.x.signum() * aspect, v.y.signum()).normalized();
79 let t = v.dot(dir);
80 if t <= 0.0 {
81 return None;
82 }
83 let locked = fixed + dir * t;
84 ((dragged - locked).length() <= radius).then(|| Rect::from_two_pos(fixed, locked))
85}
86
87pub fn contain_rect(inner: Rect, bounds: Rect) -> Rect {
91 let axis = |lo: f32, hi: f32, blo: f32, bhi: f32| -> f32 {
92 if hi - lo >= bhi - blo {
93 f32::midpoint(blo, bhi) - f32::midpoint(lo, hi) } else if lo < blo {
95 blo - lo
96 } else if hi > bhi {
97 bhi - hi
98 } else {
99 0.0
100 }
101 };
102 inner.translate(vec2(
103 axis(inner.min.x, inner.max.x, bounds.min.x, bounds.max.x),
104 axis(inner.min.y, inner.max.y, bounds.min.y, bounds.max.y),
105 ))
106}
107
108pub fn icon_rect_after_resize(icon: Rect, old: Rect, new: Rect) -> Rect {
113 contain_rect(icon.translate(new.center() - old.center()), new)
114}
115
116pub fn clamp_resize_within(bbox: Rect, mode: ResizeMode, delta: Vec2, bounds: Rect) -> Vec2 {
120 let corner = resize_corner(&bbox, mode);
121 let moved = corner + delta;
122 let clamped = pos2(
123 moved.x.clamp(bounds.min.x, bounds.max.x),
124 moved.y.clamp(bounds.min.y, bounds.max.y),
125 );
126 clamped - corner
127}
128
129#[derive(Clone, Copy, PartialEq, Eq)]
139pub enum ResizeSnap {
140 BlockPitch,
141 GridOnly,
142}
143
144pub fn snap_resized_block(rect: Rect, mode: ResizeMode, snap: ResizeSnap) -> Rect {
145 let snapped = snap_rect(rect);
146 if snap == ResizeSnap::GridOnly {
147 return snapped;
148 }
149 let h = snap_block_height(snapped.height());
150 match mode {
151 ResizeMode::LeftTop | ResizeMode::RightTop => {
152 Rect::from_min_max(pos2(snapped.min.x, snapped.max.y - h), snapped.max)
153 }
154 ResizeMode::LeftBottom | ResizeMode::RightBottom => {
155 Rect::from_min_max(snapped.min, pos2(snapped.max.x, snapped.min.y + h))
156 }
157 }
158}
159
160pub fn resize_pin_shift(offsets: impl Iterator<Item = u32>, new_height: f32) -> u32 {
167 let mut bounds: Option<(u32, u32)> = None;
168 for offset in offsets {
169 bounds = Some(bounds.map_or((offset, offset), |(lo, hi)| {
170 (lo.min(offset), hi.max(offset))
171 }));
172 }
173 let Some((min_offset, max_offset)) = bounds else {
174 return 0;
175 };
176 max_offset
177 .saturating_sub(max_pin_slot(new_height))
178 .min(min_offset)
179}
180
181pub struct BlockShape<'a> {
186 pub block: &'a Block,
187 pub pins: Vec<(PinId, &'a Pin)>,
188 pub structure: Structure,
192}
193
194impl<'a> BlockShape<'a> {
195 pub fn new(block: &'a Block, pins: Vec<(PinId, &'a Pin)>, structure: Structure) -> Self {
196 Self {
197 block,
198 pins,
199 structure,
200 }
201 }
202
203 pub fn rect(&self) -> Rect {
204 px_rect(self.block.rect)
205 }
206
207 pub fn lock(&self) -> InterfaceLock {
209 (self.block.locked).into()
210 }
211
212 pub fn accent_role(&self) -> Role {
217 crate::theme::accent_role(accent_from_role(self.block.role)).unwrap_or(Role::AccentDefault)
218 }
219
220 fn offsets(&self) -> impl Iterator<Item = u32> + '_ {
221 self.pins.iter().map(|(_, pin)| slot(pin).offset)
222 }
223
224 pub fn is_pin_location_available(&self, location: PinLocation) -> bool {
225 self.is_pin_location_available_excluding(location, None)
226 }
227
228 pub fn is_pin_location_available_excluding(
231 &self,
232 location: PinLocation,
233 except: Option<PinId>,
234 ) -> bool {
235 let PinLocation { side, offset } = location;
236 let height_px = self.rect().height();
237 if offset < 0.0 || offset > height_px {
238 return false;
239 }
240 self.pins
241 .iter()
242 .filter(|(id, _)| Some(*id) != except)
243 .map(|(_, pin)| slot(pin))
244 .filter(|l| l.side == side)
245 .all(|l| (l.offset as f32 * PIN_PITCH - offset).abs() >= PIN_PITCH * 0.5)
248 }
249
250 pub fn resize_pin_shift(&self, new_height: f32) -> u32 {
253 resize_pin_shift(self.offsets(), new_height)
254 }
255
256 pub fn pin_anchor_at(&self, rect: Rect, side: PinSide, offset: u32) -> Pos2 {
264 let shift = self.resize_pin_shift(rect.height());
265 let y = pin_offset_y(rect.top(), offset.saturating_sub(shift));
266 match side {
267 PinSide::East => pos2(rect.right() + GRID_SIZE, y),
268 PinSide::West => pos2(rect.left() - GRID_SIZE, y),
269 }
270 }
271
272 fn title_label(&self) -> ShapeLabel<'a> {
273 shape_label(&self.block.title)
274 }
275
276 fn type_label_of(&self) -> ShapeLabel<'a> {
277 shape_label(&self.block.type_label)
278 }
279
280 fn committed_rect(&self, mode: RenderMode) -> Rect {
285 let bbox = self.rect();
286 match mode {
287 RenderMode::Moving { delta } => snap_rect(bbox.translate(delta)),
288 RenderMode::Resizing { mode, delta } => snap_resized_block(
289 resize_rect(&bbox, mode, delta),
290 mode,
291 ResizeSnap::BlockPitch,
292 ),
293 _ => bbox,
294 }
295 }
296
297 pub fn render_pins_ng<R: Renderer>(
302 &self,
303 accents: crate::presentation::ShapeAccents<'_>,
304 mode: RenderMode,
305 painter: &mut Style<'_, R>,
306 ) {
307 let rect = self.committed_rect(mode);
308 match mode {
309 RenderMode::PinDragged {
310 pin,
311 delta,
312 side,
313 candidate,
314 } => {
315 crate::render::render_pins_with_box(
316 self.pins
317 .iter()
318 .filter(|&&(id, _)| id != pin)
319 .map(|&(id, p)| (id, p)),
320 rect,
321 accents,
322 painter,
323 );
324 painter.line_segment(
325 [rect.center_top(), rect.center_bottom()],
326 (2.0, Role::PinDragIndicator),
327 );
328 if let Some(pin_ref) = self.pin(pin) {
329 if let Some(candidate_slot) = candidate {
332 let ghost_delta =
333 PIN_PITCH * (candidate_slot as f32 - slot(pin_ref).offset as f32);
334 let ghost_paint = crate::render::PinPaint {
335 delta_y: ghost_delta,
336 side: Some(side),
337 accent: accents.pin(pin),
338 };
339 painter.with_opacity(0.5, |ghost| {
340 crate::render::draw_pin(rect, pin_ref, ghost_paint, ghost);
341 });
342 }
343 let paint = crate::render::PinPaint {
344 delta_y: delta,
345 side: Some(side),
346 accent: accents.pin(pin),
347 };
348 crate::render::draw_pin(rect, pin_ref, paint, painter);
349 }
350 }
351 RenderMode::Selected { authoring } => {
352 crate::render::render_pins_with_box(
353 self.pins.iter().copied(),
354 rect,
355 accents,
356 painter,
357 );
358 if authoring == Authoring::Offered {
359 for (_, pin) in &self.pins {
360 crate::render::draw_pin_placeholders(rect, pin, painter);
361 }
362 }
363 }
364 RenderMode::Resizing { .. } => {
365 let dy = -(self.resize_pin_shift(rect.height()) as f32 * PIN_PITCH);
369 for &(id, pin) in &self.pins {
370 let paint = crate::render::PinPaint {
371 delta_y: dy,
372 side: None,
373 accent: accents.pin(id),
374 };
375 crate::render::draw_pin(rect, pin, paint, painter);
376 }
377 }
378 _ => crate::render::render_pins_with_box(
381 self.pins.iter().copied(),
382 rect,
383 accents,
384 painter,
385 ),
386 }
387 }
388}
389
390impl BaseShape for BlockShape<'_> {
391 fn title(&self) -> Option<ShapeLabel<'_>> {
392 Some(self.title_label())
393 }
394 fn type_label(&self) -> Option<ShapeLabel<'_>> {
395 Some(self.type_label_of())
396 }
397 fn gui_rect(&self) -> Rect {
398 self.rect()
399 }
400 fn pin(&self, id: PinId) -> Option<&Pin> {
401 self.pins
402 .iter()
403 .find(|&&(pid, _)| pid == id)
404 .map(|&(_, pin)| pin)
405 }
406 fn anchor_point_with_rect(&self, rect: Rect, id: PinId) -> Option<Pos2> {
407 let pin = self.pin(id)?;
408 let slot = slot(pin);
409 Some(self.pin_anchor_at(rect, slot.side, slot.offset))
410 }
411 fn pin_text_rect<R: Renderer>(&self, id: PinId, painter: &Style<'_, R>) -> Option<Rect> {
412 let pin = self.pin(id)?;
413 let slot = slot(pin);
414 Some(crate::render::estimate_bbox_for_pin_name(
415 self.rect(),
416 slot.side,
417 slot.offset,
418 &pin.name,
419 painter,
420 ))
421 }
422 fn pin_type_rect<R: Renderer>(&self, id: PinId, painter: &Style<'_, R>) -> Option<Rect> {
423 let pin = self.pin(id)?;
424 let slot = slot(pin);
425 Some(crate::render::estimate_bbox_for_pin_type(
426 self.rect(),
427 slot.side,
428 slot.offset,
429 &pin.type_name,
430 painter,
431 ))
432 }
433 fn tag_text_rect_for<R: Renderer>(
434 &self,
435 id: PinId,
436 text: &str,
437 painter: &Style<'_, R>,
438 ) -> Option<Rect> {
439 let pin = self.pin(id)?;
440 let slot = slot(pin);
441 let bbox = self.rect();
442 let line_y = pin_offset_y(bbox.top(), slot.offset);
443 Some(
444 crate::render::TagSlot {
445 left: bbox.left(),
446 right: bbox.right(),
447 side: slot.side,
448 line_y,
449 }
450 .bbox(text, painter),
451 )
452 }
453 fn pin_stub_rect(&self, id: PinId) -> Option<Rect> {
454 let pin = self.pin(id)?;
455 let slot = slot(pin);
456 let bbox = self.rect();
457 let line_y = pin_offset_y(bbox.top(), slot.offset);
458 Some(crate::render::estimate_bbox_for_pin_stub(
459 bbox.left(),
460 bbox.right(),
461 slot.side,
462 line_y,
463 ))
464 }
465 fn pin_drop_candidate(&self, pin_id: PinId, side: PinSide, raw_offset_px: f32) -> Option<u32> {
471 let primary = round_to_pitch(raw_offset_px).max(0.0);
472 let secondary = if raw_offset_px >= primary {
474 primary + PIN_PITCH
475 } else {
476 (primary - PIN_PITCH).max(0.0)
477 };
478 [primary, secondary].into_iter().find_map(|cand| {
479 self.is_pin_location_available_excluding(
480 PinLocation { side, offset: cand },
481 Some(pin_id),
482 )
483 .then(|| pin_slot(cand))
484 })
485 }
486 fn new_pin_locations(&self) -> Vec<PinLocation> {
487 let mut locations = Vec::new();
488 let max_slot = max_pin_slot(self.rect().height());
491 for slot in 0..=max_slot {
492 let offset = slot as f32 * PIN_PITCH;
493 if self.is_pin_location_available((PinSide::West, offset).into()) {
494 locations.push(PinLocation {
495 side: PinSide::West,
496 offset,
497 });
498 }
499 if self.is_pin_location_available((PinSide::East, offset).into()) {
500 locations.push(PinLocation {
501 side: PinSide::East,
502 offset,
503 });
504 }
505 }
506 locations
507 }
508 fn pin_position(&self, location: PinLocation) -> Option<Pos2> {
509 let inner = self.rect();
510 let left_top = inner.left_top();
511 let offset = location.offset;
512 if offset < 0.0 || offset > inner.height() {
513 return None;
514 }
515 Some(match location.side {
518 PinSide::West => left_top + vec2(0.0, offset + PIN_TOP_MARGIN),
519 PinSide::East => inner.right_top() + vec2(0.0, offset + PIN_TOP_MARGIN),
520 })
521 }
522 fn title_anchor(&self) -> Option<Pos2> {
523 let (pos, _) = block_title_position(self.rect(), &self.title_label());
524 Some(pos)
525 }
526 fn type_anchor(&self) -> Option<Pos2> {
527 let (pos, _) = crate::render::block_type_position(self.rect(), &self.type_label_of());
528 Some(pos)
529 }
530 fn resizable(&self) -> bool {
531 true
532 }
533 fn render_ng<R: Renderer>(&self, mode: RenderMode, painter: &mut Style<'_, R>) {
534 let bbox = self.rect();
535 let title = self.title_label();
536 let type_label = self.type_label_of();
537 let lock = self.lock();
538 let structure = self.structure;
539 let paint = crate::render::BlockPaint {
540 stroke: self.accent_role(),
541 lock,
542 structure,
543 };
544 let draw_type = |target: Rect, painter: &mut Style<'_, R>| {
547 crate::render::draw_block_type(target, &type_label, lock, painter);
548 };
549 match mode {
550 RenderMode::Moving { delta } => {
551 let shifted = bbox.translate(delta);
552 let predicted = snap_rect(shifted);
553 crate::render::draw_box_outline(
558 shifted,
559 Role::Transparent,
560 (1.0, Role::DragPreviewStroke),
561 painter,
562 );
563 crate::render::draw_block_frame(predicted, &title, paint, painter);
565 draw_type(predicted, painter);
566 }
567 RenderMode::Selected { authoring } => {
568 crate::render::draw_block_frame(bbox, &title, paint, painter);
569 draw_type(bbox, painter);
570 if authoring == Authoring::Offered {
571 crate::render::draw_block_type_placeholder(bbox, &type_label, painter);
572 }
573 crate::render::draw_selection_frame(bbox, None, painter);
574 if lock.is_locked() {
575 crate::render::draw_lock_hint(bbox, painter);
576 }
577 }
578 RenderMode::Resizing { mode, delta } => {
579 let resized = resize_rect(&bbox, mode, delta);
580 let predicted = snap_resized_block(resized, mode, ResizeSnap::BlockPitch);
581 crate::render::draw_box_outline(
585 resized,
586 Role::Transparent,
587 (1.0, Role::DragPreviewStroke),
588 painter,
589 );
590 crate::render::draw_block_boundary(
591 predicted,
592 Role::DragActiveFill,
593 (2.0, Role::DragActiveStroke),
594 structure,
595 painter,
596 );
597 crate::render::draw_block_title(predicted, &title, lock, painter);
598 draw_type(predicted, painter);
599 crate::render::draw_selection_frame(predicted, Some(mode), painter);
600 crate::render::draw_size_readout(predicted, painter);
601 }
602 RenderMode::TitleDragged { delta } => {
603 let (anchor_pos, _) = block_title_position(bbox, &title);
604 let shifted_title = ShapeLabel {
605 side: crate::render::label_side_for_y(bbox, anchor_pos.y + delta.y),
606 offset: title.offset + delta.x,
607 ..title
608 };
609 crate::render::draw_block_frame(bbox, &shifted_title, paint, painter);
610 draw_type(bbox, painter);
611 painter.line_segment(
612 [bbox.left_center(), bbox.right_center()],
613 (2.0, Role::PinDragIndicator),
614 );
615 }
616 RenderMode::TypeDragged { delta } => {
617 let (anchor_pos, _) = crate::render::block_type_position(bbox, &type_label);
618 let shifted_type = ShapeLabel {
619 side: crate::render::label_side_for_y(bbox, anchor_pos.y + delta.y),
620 offset: type_label.offset + delta.x,
621 ..type_label
622 };
623 crate::render::draw_block_frame(bbox, &title, paint, painter);
624 crate::render::draw_block_type(bbox, &shifted_type, lock, painter);
625 painter.line_segment(
626 [bbox.left_center(), bbox.right_center()],
627 (2.0, Role::PinDragIndicator),
628 );
629 }
630 _ => {
634 crate::render::draw_block_frame(bbox, &title, paint, painter);
635 draw_type(bbox, painter);
636 }
637 }
638 }
639}
640
641#[cfg(test)]
642pub(crate) mod tests {
643 use super::*;
644 use blockworx_doc::{
645 block_model::{Block, Icon, Label, Pin},
646 geometry::{FracVal, PinSlot},
647 id::BlockId,
648 values::{LabelSide, PinDir, Role as DocRole},
649 };
650
651 pub(crate) fn test_pin(side: PinSide, offset: u32) -> Pin {
654 Pin {
655 slot: PinSlot { side, offset },
656 dir: PinDir::InOut,
657 ..Pin::default()
658 }
659 }
660
661 pub(crate) fn test_block(rect: Rect) -> Block {
663 let label = |name: &str, side| Label {
664 name: name.into(),
665 side,
666 offset: FracVal::default(),
667 hidden: false,
668 };
669 Block {
670 parent: BlockId::default(),
671 rect: crate::grid::grid_rect(rect.min, rect.max),
672 locked: false,
673 role: DocRole::default(),
674 title: label("b", LabelSide::Bottom),
675 type_label: label("", LabelSide::Top),
676 icon: Icon::default(),
677 }
678 }
679
680 fn block_300() -> Block {
682 test_block(Rect::from_min_max(pos2(0.0, 0.0), pos2(300.0, 300.0)))
683 }
684
685 fn shape<'a>(block: &'a Block, pins: &'a [Pin]) -> BlockShape<'a> {
686 BlockShape::new(
687 block,
688 pins.iter()
689 .enumerate()
690 .map(|(i, pin)| (blockworx_doc::fixtures::pin_id(i as u32 + 1), pin))
691 .collect(),
692 Structure::Leaf,
693 )
694 }
695
696 #[test]
697 fn contain_rect_shifts_into_bounds_without_shrinking() {
698 let bounds = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0));
699 let outside = Rect::from_min_max(pos2(90.0, 90.0), pos2(130.0, 130.0));
700 let contained = contain_rect(outside, bounds);
701 assert!(bounds.contains_rect(contained));
702 assert_eq!(contained.size(), outside.size(), "shifted, not shrunk");
703 }
704
705 #[test]
706 fn clamp_resize_within_keeps_the_dragged_corner_in_bounds() {
707 let bounds = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0));
708 let bbox = Rect::from_min_max(pos2(20.0, 20.0), pos2(60.0, 60.0));
709 let d = clamp_resize_within(bbox, ResizeMode::RightBottom, vec2(1000.0, 1000.0), bounds);
711 let resized = resize_rect(&bbox, ResizeMode::RightBottom, d);
712 assert!(
713 bounds.contains_rect(resized),
714 "resized icon stays within the block"
715 );
716 assert_eq!(
717 resized.max,
718 pos2(100.0, 100.0),
719 "corner clamped to the edge"
720 );
721 }
722
723 #[test]
724 fn snap_resize_aspect_locks_a_near_ratio_box() {
725 let bbox = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
728 let perp = vec2(1.0, -2.0).normalized() * 3.0; let dragged = pos2(20.0, 10.0) + perp;
730 let resized = Rect::from_min_max(pos2(0.0, 0.0), dragged);
731 let snapped =
732 snap_resize_aspect(bbox, ResizeMode::RightBottom, resized, 2.0, 6.0).expect("snaps");
733 assert!(
734 (snapped.width() / snapped.height() - 2.0).abs() < 1e-3,
735 "w/h locks to the intrinsic aspect"
736 );
737 assert_eq!(snapped.min, pos2(0.0, 0.0), "the fixed corner stays put");
738 }
739
740 #[test]
741 fn snap_resize_aspect_ignores_a_far_box() {
742 let bbox = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
743 let resized = Rect::from_min_max(pos2(0.0, 0.0), pos2(20.0, 20.0));
745 assert!(snap_resize_aspect(bbox, ResizeMode::RightBottom, resized, 2.0, 6.0).is_none());
746 }
747
748 #[test]
749 fn snap_resize_aspect_keeps_the_fixed_corner_for_left_top() {
750 let bbox = Rect::from_min_max(pos2(0.0, 0.0), pos2(30.0, 30.0));
752 let fixed = bbox.right_bottom();
753 let perp = vec2(1.0, -2.0).normalized() * 2.0;
755 let dragged = fixed + vec2(-20.0, -10.0) + perp;
756 let resized = Rect::from_two_pos(dragged, fixed);
757 let snapped =
758 snap_resize_aspect(bbox, ResizeMode::LeftTop, resized, 2.0, 6.0).expect("snaps");
759 assert!((snapped.width() / snapped.height() - 2.0).abs() < 1e-3);
760 assert_eq!(
761 snapped.max, fixed,
762 "the fixed (bottom-right) corner stays put"
763 );
764 }
765
766 #[test]
767 fn pin_drop_candidate_returns_the_nearest_free_slot() {
768 let (block, pins) = (block_300(), [test_pin(PinSide::West, 5)]);
769 let b = shape(&block, &pins);
770 let id = b.pins[0].0;
771 assert_eq!(b.pin_drop_candidate(id, PinSide::West, 4.0), Some(0));
772 }
773
774 #[test]
775 fn pin_drop_candidate_falls_back_to_neighbor_slot() {
776 let (block, pins) = (
777 block_300(),
778 [test_pin(PinSide::West, 1), test_pin(PinSide::West, 5)],
779 );
780 let b = shape(&block, &pins);
781 let mover = b.pins[1].0;
782 assert_eq!(
784 b.pin_drop_candidate(mover, PinSide::West, PIN_PITCH),
785 Some(2)
786 );
787 }
788
789 #[test]
790 fn pin_drop_candidate_is_none_when_both_neighbors_taken() {
791 let (block, pins) = (
792 block_300(),
793 [
794 test_pin(PinSide::West, 1),
795 test_pin(PinSide::West, 2),
796 test_pin(PinSide::West, 6),
797 ],
798 );
799 let b = shape(&block, &pins);
800 let mover = b.pins[2].0;
801 assert_eq!(
803 b.pin_drop_candidate(mover, PinSide::West, 1.5 * PIN_PITCH),
804 None
805 );
806 }
807
808 #[test]
809 fn resize_pin_shift_uses_the_top_margin_to_let_a_block_shrink() {
810 let shift = |height| resize_pin_shift([5].into_iter(), height);
812 assert_eq!(shift(300.0), 0);
814 assert_eq!(shift(2.0 * PIN_TOP_MARGIN + 2.0 * PIN_PITCH), 3);
816 assert_eq!(shift(2.0 * PIN_TOP_MARGIN), 5);
818 }
819
820 #[test]
821 fn resize_pin_shift_preserves_a_multi_pin_span() {
822 assert_eq!(resize_pin_shift([2, 5].into_iter(), 4.0 * PIN_PITCH), 2);
825 }
826
827 #[test]
828 fn snap_resized_block_quantizes_height_to_a_valid_block_height() {
829 let top = 2.0 * PIN_TOP_MARGIN;
832 let r = Rect::from_min_max(pos2(0.0, top), pos2(60.0, top + 8.0 * GRID_SIZE));
833 let is_valid = |h: f32| snap_block_height(h) == h;
834
835 let q = snap_resized_block(r, ResizeMode::RightBottom, ResizeSnap::BlockPitch);
837 assert_eq!(q.min.y, top);
838 assert!(is_valid(q.height()));
839
840 let q = snap_resized_block(r, ResizeMode::LeftTop, ResizeSnap::BlockPitch);
842 assert_eq!(q.max.y, top + 8.0 * GRID_SIZE);
843 assert!(is_valid(q.height()));
844
845 let q = snap_resized_block(r, ResizeMode::RightBottom, ResizeSnap::GridOnly);
847 assert_eq!(q.height(), 8.0 * GRID_SIZE);
848 }
849
850 #[test]
851 fn a_block_reads_its_geometry_and_labels_off_its_registers() {
852 let (block, pins) = (block_300(), [test_pin(PinSide::East, 1)]);
853 let b = shape(&block, &pins);
854 assert_eq!(
855 b.gui_rect(),
856 Rect::from_min_max(pos2(0.0, 0.0), pos2(300.0, 300.0))
857 );
858 assert_eq!(b.title().expect("a block always has a title").name, "b");
859 assert!(!b.title().expect("a title").hidden);
860 let anchor = b
862 .anchor_point_with_rect(b.gui_rect(), b.pins[0].0)
863 .expect("the pin is the block's");
864 assert_eq!(anchor.x, 300.0 + GRID_SIZE);
865 assert_eq!(anchor.y, pin_offset_y(0.0, 1));
866 }
867}