diff --git a/README.md b/README.md
index 5ad0442..dd8a912 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,11 @@
Shimmer
-------
-![badge](https://github.com/MartinHowarth/shimmer/workflows/Test/badge.svg)
+
+
+
+
+
Hello!
diff --git a/shimmer/display/components/draggable_box.py b/shimmer/display/components/draggable_box.py
index aa6c9ff..e5e625a 100644
--- a/shimmer/display/components/draggable_box.py
+++ b/shimmer/display/components/draggable_box.py
@@ -2,11 +2,11 @@
import logging
from dataclasses import dataclass, replace
-from typing import Optional, Iterable
+from typing import Optional, Callable, Sequence
import cocos
from shimmer.display.alignment import CenterCenter
-from shimmer.display.components.box import Box
+from shimmer.display.components.box import Box, BoxDefinition
from shimmer.display.components.mouse_box import (
MouseBox,
MouseBoxDefinition,
@@ -16,6 +16,79 @@
log = logging.getLogger(__name__)
+@dataclass(frozen=True)
+class SnapBoxDefinition(BoxDefinition):
+ """
+ Definition of a SnapBox.
+
+ :param can_receive: Callback to test whether a DraggableBox should be allowed to snap
+ to this SnapBox. Return True if yes, otherwise return False if not allowed.
+ :param on_receive: Callback called when a DraggableBox snaps onto this SnapBox.
+ :param on_release: Callback called when a DraggableBox snaps off this SnapBox.
+ """
+
+ can_receive: Optional[Callable[["DraggableBox"], bool]] = None
+ on_receive: Optional[Callable[["DraggableBox"], None]] = None
+ on_release: Optional[Callable[["DraggableBox"], None]] = None
+
+
+class SnapBox(Box):
+ """
+ A Box that a DraggableBox can snap to.
+
+ This causes the DraggableBox to be centered on this SnapBox when dragged over it.
+
+ SnapBoxes are single occupancy - multiple DraggableBoxes cannot snap to the same SnapBox.
+ """
+
+ def __init__(self, definition: SnapBoxDefinition):
+ """Create a new SnapBox."""
+ super(SnapBox, self).__init__(definition)
+ self.definition: SnapBoxDefinition = self.definition
+ self.occupant: Optional[Box] = None
+
+ @property
+ def is_occupied(self) -> bool:
+ """Return True if this SnapBox is currently occupied. Otherwise False."""
+ return self.occupant is not None
+
+ def can_receive(self, other: "DraggableBox") -> bool:
+ """
+ Return True if the given DraggableBox is allowed to snap to this SnapBox.
+
+ :param other: The DraggableBox to be tested.
+ """
+ if self.is_occupied:
+ return False
+ elif self.definition.can_receive is not None:
+ return self.definition.can_receive(other)
+ return True
+
+ def receive(self, other: "DraggableBox") -> None:
+ """
+ Called when a DraggableBox is snapped to this SnapBox.
+
+ Calls the "on_receive" callback in the definition, if given.
+
+ :param other: The DraggableBox that has snapped to this SnapBox.
+ """
+ self.occupant = other
+ if self.definition.on_receive is not None:
+ self.definition.on_receive(other)
+
+ def release(self, other: "DraggableBox") -> None:
+ """
+ Called when a DraggableBox is no longer snapped to this SnapBox.
+
+ Calls the "on_release" callback in the definition, if given.
+
+ :param other: The DraggableBox that is not longer snapped to this SnapBox.
+ """
+ self.occupant = None
+ if self.definition.on_release is not None:
+ self.definition.on_release(other)
+
+
@dataclass(frozen=True)
class DraggableBoxDefinition(MouseBoxDefinition):
"""
@@ -23,11 +96,26 @@ class DraggableBoxDefinition(MouseBoxDefinition):
MouseBox actions will be overwritten when initialising the DraggableBox
- :param snap_boxes: List of Boxes which the draggable anchor will snap to.
+ :param snap_boxes: List of SnapBoxes which the draggable anchor will snap to.
Snapping means when the anchor is dragged over a snap box, their centers will be aligned.
+ :param must_be_snapped: If True then this DraggableBox must always be snapped to a SnapBox.
+ If it is dragged off a SnapBox and not onto another one then it will return to its
+ previous position.
+ If True, then "snap_boxes" must be given.
+ Note: This is not enforced on creation; only on subsequent drags.
+ Use DraggableBox.snap_to(snap_box) to set up snap behaviour on creation.
"""
- snap_boxes: Optional[Iterable[Box]] = None
+ snap_boxes: Optional[Sequence[SnapBox]] = None
+ must_be_snapped: bool = False
+
+ def __post_init__(self):
+ """Perform validation."""
+ if self.must_be_snapped:
+ if self.snap_boxes is None or len(self.snap_boxes) == 0:
+ raise ValueError(
+ f"`snap_boxes` must be given if `requires_snap_box` is True. {self.snap_boxes=}"
+ )
class DraggableBox(MouseBox):
@@ -47,7 +135,7 @@ def __init__(self, definition: DraggableBoxDefinition):
)
super(DraggableBox, self).__init__(defn)
self.definition: DraggableBoxDefinition = self.definition
- self._currently_snapped: bool = False
+ self._currently_snapped_to: Optional[SnapBox] = None
self._snap_drag_record: cocos.draw.Vector2 = cocos.draw.Vector2(0, 0)
def _should_handle_mouse_press(self, buttons: int) -> bool:
@@ -69,38 +157,97 @@ def start_dragging(
self._snap_drag_record = cocos.draw.Vector2(0, 0)
return super(DraggableBox, self).start_dragging(box, x, y, buttons, modifiers)
+ def stop_dragging(
+ self, box: "MouseBox", x: int, y: int, buttons: int, modifiers: int,
+ ) -> bool:
+ """
+ Set this box as no longer being dragged.
+
+ If this DraggableBox is defined with `must_be_snapped` then move it back to the current
+ SnapBox if needed.
+ """
+ if self.definition.must_be_snapped and self._currently_snapped_to is not None:
+ self._snap_drag_record = cocos.draw.Vector2(0, 0)
+ self._align_with_snap_box(self._currently_snapped_to)
+ return super(DraggableBox, self).stop_dragging(box, x, y, buttons, modifiers)
+
def handle_drag(
self, box: Box, x: int, y: int, dx: int, dy: int, buttons: int, modifiers: int
) -> Optional[bool]:
- """While the mouse is pressed on the area, keep updating the position."""
- self._snap_drag_record += (dx, dy)
- self.parent.position += self._snap_drag_record
+ """
+ While the mouse is pressed on the area, keep updating the position.
- if self.definition.snap_boxes is not None:
+ If snap_boxes are defined, then this DraggableBox will center its parent (and therefore
+ itself) to one of the SnapBoxes if they overlap and other conditions are met as defined
+ in the SnapBoxDefinition.
+ """
+ if self.definition.snap_boxes is None:
+ self.parent.position += cocos.draw.Vector2(dx, dy)
+ else:
+ self._snap_drag_record += (dx, dy)
+ self.parent.position += self._snap_drag_record
# Move the parent by the snap record so we can test if we still intersect a snap point
# It will get moved back it we still intersect the current snap point.
# If we don't intersect with any snap point, then we will have just moved the parent
# by the correct displacement anyway.
for snap_box in self.definition.snap_boxes:
- if self.world_rect.intersects(snap_box.world_rect):
- self._align_with_snap_box(snap_box)
+ if (
+ snap_box is self._currently_snapped_to or snap_box.can_receive(self)
+ ) and self.world_rect.intersects(snap_box.world_rect):
+ self.snap_to(snap_box)
break
else:
- # If no longer intersecting any snap boxes, then stop being snapped
+ # If no longer intersecting any valid snap boxes, then stop being snapped
self._snap_drag_record = cocos.draw.Vector2(0, 0)
- self._currently_snapped = False
+ self.unsnap_if_snapped()
return EVENT_HANDLED
- def _align_with_snap_box(self, snap_box: Box) -> None:
+ def snap_to(self, snap_box: SnapBox) -> None:
+ """
+ Call to snap this DraggableBox to the given SnapBox.
+
+ This aligns the center of this box with the snap box by moving the parent of this box.
+
+ :param snap_box: The SnapBox to snap to.
+ """
+ # Only release/receive to snap box if it has changed.
+ if self._currently_snapped_to is not snap_box:
+ if self._currently_snapped_to is not None:
+ self._currently_snapped_to.release(self)
+
+ self._currently_snapped_to = snap_box
+ snap_box.receive(self)
+
+ # Always align with the target box, regardless of whether it has changed or not.
+ self._align_with_snap_box(snap_box)
+
+ def unsnap_if_snapped(self) -> None:
+ """
+ Call to unsnap from the current SnapBox, if there is a current SnapBox.
+
+ If this DraggableBox is defined as `must_be_snapped` then this has no effect.
+
+ Does not move this DraggableBox, but notifies the current SnapBox (if there is one)
+ that this box is no longer snapped to it.
+ """
+ # Don't unsnap if this DraggableBox must always be snapped - this essentially
+ # reserves its current snap target for it to return to if the drag ends before
+ # it reaches another snap target.
+ if not self.definition.must_be_snapped:
+ if self._currently_snapped_to is not None:
+ self._currently_snapped_to.release(self)
+ self._currently_snapped_to = None
+
+ def _align_with_snap_box(self, snap_box: SnapBox) -> None:
"""Align the parent of this DraggableBox with the given snap box."""
log.debug(f"Aligning {self} with snap box {snap_box}.")
alignment_required = self.vector_between_anchors(
snap_box, CenterCenter, CenterCenter
)
self.parent.position += alignment_required
- if not self._currently_snapped:
+ if self._currently_snapped_to is not None:
# Set the drag record to be the inverse motion. This allows the user to drag on/off
# the snap point repeatedly right at the edge of the snap box.
self._snap_drag_record = -alignment_required
- self._currently_snapped = True
+ self._currently_snapped_to = snap_box
diff --git a/tests/test_display/test_components/test_draggable_anchor.py b/tests/test_display/test_components/test_draggable_anchor.py
deleted file mode 100644
index 7cd2635..0000000
--- a/tests/test_display/test_components/test_draggable_anchor.py
+++ /dev/null
@@ -1,115 +0,0 @@
-"""Test the draggable box Box."""
-
-from shimmer.display.components.box import Box, BoxDefinition
-from shimmer.display.components.draggable_box import (
- DraggableBox,
- DraggableBoxDefinition,
-)
-from shimmer.display.data_structures import Color
-
-
-def create_base_box() -> Box:
- """Create a Box that has a background color."""
- box = Box(BoxDefinition(width=100, height=100, background_color=Color(100, 20, 20)))
- return box
-
-
-def test_draggable_box(run_gui):
- """The box should be a draggable."""
- base_box = create_base_box()
- box = DraggableBox(
- DraggableBoxDefinition(width=base_box.rect.width, height=base_box.rect.height)
- )
-
- base_box.add(box)
-
- assert run_gui(test_draggable_box, base_box)
-
-
-def test_draggable_box_no_gui(subtests, mock_gui, mock_mouse):
- """Test that the draggable box changes position of parent."""
- base_box = create_base_box()
- base_box.position = 10, 10
- box = DraggableBox(
- DraggableBoxDefinition(width=base_box.rect.width, height=base_box.rect.height)
- )
- base_box.add(box)
-
- assert base_box.position == (10, 10)
- mock_mouse.click_and_drag(box, (20, 20), (140, 260))
- assert base_box.position == (130, 250)
-
-
-def test_draggable_box_snap_to_box(run_gui):
- """The box should be a draggable and should snap to the red boxes."""
- snap_points = []
- for i in range(3):
- snap_box = Box(
- BoxDefinition(width=10, height=10, background_color=Color(255, 0, 0))
- )
- snap_box.position = 150 * i, 150 * i
- snap_points.append(snap_box)
-
- base_box = create_base_box()
- box = DraggableBox(
- DraggableBoxDefinition(
- width=base_box.rect.width,
- height=base_box.rect.height,
- snap_boxes=snap_points,
- )
- )
-
- base_box.add(box)
- base_box.position = (300, 0)
-
- assert run_gui(test_draggable_box_snap_to_box, base_box, *snap_points)
-
-
-def test_draggable_box_snap_to_box_no_gui(subtests, mock_gui, mock_mouse):
- """Test that draggable boc sets position correctly when dragged over snap points."""
- snap_box = Box(BoxDefinition(width=10, height=10))
- snap_box.position = 110, 110
-
- base_box = create_base_box()
- box = DraggableBox(
- DraggableBoxDefinition(
- width=base_box.rect.width,
- height=base_box.rect.height,
- snap_boxes=[snap_box],
- )
- )
-
- base_box.add(box)
- base_box.position = 0, 0
-
- with subtests.test(
- "Dragging over the snap box causes the box to snap to the center of it."
- ):
- mock_mouse.click_and_drag(box, (20, 20), (40, 40))
- assert base_box.position == (65, 65)
-
- with subtests.test(
- "Dragging a small amount does not move the box because still snapped."
- ):
- mock_mouse.click_and_drag(box, (70, 70), (75, 75))
- assert base_box.position == (65, 65)
- mock_mouse.click_and_drag(box, (80, 80), (75, 75))
- assert base_box.position == (65, 65)
-
- with subtests.test(
- "Dragging off the snap box allows the box to move freely again."
- ):
- mock_mouse.click_and_drag(box, (70, 70), (170, 170))
- assert base_box.position == (165, 165)
-
- with subtests.test(
- "Dragging without releasing the mouse should allow fine control of snap "
- "and release near edge of snap box."
- ):
- base_box.position = (0, 0)
- mock_mouse.press(box, (95, 95))
- mock_mouse.drag(box, (95, 95), (110, 110))
- assert base_box.position == (65, 65)
- mock_mouse.drag(box, (110, 110), (97, 97))
- assert base_box.position == (2, 2)
- mock_mouse.release(box)
diff --git a/tests/test_display/test_components/test_draggable_box.py b/tests/test_display/test_components/test_draggable_box.py
new file mode 100644
index 0000000..7ebb23c
--- /dev/null
+++ b/tests/test_display/test_components/test_draggable_box.py
@@ -0,0 +1,271 @@
+"""Test the draggable Box."""
+
+from dataclasses import replace
+from typing import no_type_check
+
+from mock import MagicMock
+
+from shimmer.display.components.box import Box, BoxDefinition
+from shimmer.display.components.draggable_box import (
+ DraggableBox,
+ DraggableBoxDefinition,
+ SnapBox,
+ SnapBoxDefinition,
+)
+from shimmer.display.data_structures import Color
+
+
+def create_base_box() -> Box:
+ """Create a Box that has a background color."""
+ box = Box(BoxDefinition(width=100, height=100, background_color=Color(100, 20, 20)))
+ return box
+
+
+def test_draggable_box(run_gui):
+ """The box should be a draggable."""
+ base_box = create_base_box()
+ box = DraggableBox(
+ DraggableBoxDefinition(width=base_box.rect.width, height=base_box.rect.height)
+ )
+
+ base_box.add(box)
+
+ assert run_gui(test_draggable_box, base_box)
+
+
+def test_draggable_box_no_gui(subtests, mock_gui, mock_mouse):
+ """Test that the draggable box changes position of parent."""
+ base_box = create_base_box()
+ base_box.position = 10, 10
+ box = DraggableBox(
+ DraggableBoxDefinition(width=base_box.rect.width, height=base_box.rect.height)
+ )
+ base_box.add(box)
+
+ assert base_box.position == (10, 10)
+ mock_mouse.click_and_drag(box, (20, 20), (140, 260))
+ assert base_box.position == (130, 250)
+
+
+def test_draggable_box_snap_to_box(run_gui):
+ """The box should be a draggable and should snap to the red boxes."""
+ snap_points = []
+ for i in range(3):
+ snap_box = SnapBox(
+ SnapBoxDefinition(width=10, height=10, background_color=Color(255, 0, 0))
+ )
+ snap_box.position = 150 * i, 150 * i
+ snap_points.append(snap_box)
+
+ base_box = create_base_box()
+ box = DraggableBox(
+ DraggableBoxDefinition(
+ width=base_box.rect.width,
+ height=base_box.rect.height,
+ snap_boxes=snap_points,
+ )
+ )
+
+ base_box.add(box)
+ base_box.position = (300, 0)
+
+ assert run_gui(test_draggable_box_snap_to_box, base_box, *snap_points)
+
+
+def test_draggable_box_snap_to_box_no_gui(subtests, mock_gui, mock_mouse):
+ """Test that draggable box sets position correctly when dragged over snap points."""
+ snap_box = SnapBox(SnapBoxDefinition(width=10, height=10))
+ snap_box.position = 110, 110
+
+ base_box = create_base_box()
+ box = DraggableBox(
+ DraggableBoxDefinition(
+ width=base_box.rect.width,
+ height=base_box.rect.height,
+ snap_boxes=[snap_box],
+ )
+ )
+
+ base_box.add(box)
+ base_box.position = 0, 0
+
+ with subtests.test(
+ "Dragging over the snap box causes the box to snap to the center of it."
+ ):
+ mock_mouse.click_and_drag(box, (20, 20), (40, 40))
+ assert base_box.position == (65, 65)
+
+ with subtests.test(
+ "Dragging a small amount does not move the box because still snapped."
+ ):
+ mock_mouse.click_and_drag(box, (70, 70), (75, 75))
+ assert base_box.position == (65, 65)
+ mock_mouse.click_and_drag(box, (80, 80), (75, 75))
+ assert base_box.position == (65, 65)
+
+ with subtests.test(
+ "Dragging off the snap box allows the box to move freely again."
+ ):
+ mock_mouse.click_and_drag(box, (70, 70), (170, 170))
+ assert base_box.position == (165, 165)
+
+ with subtests.test(
+ "Dragging without releasing the mouse should allow fine control of snap "
+ "and release near edge of snap box."
+ ):
+ base_box.position = (0, 0)
+ mock_mouse.press(box, (95, 95))
+ mock_mouse.drag(box, (95, 95), (110, 110))
+ assert base_box.position == (65, 65)
+ mock_mouse.drag(box, (110, 110), (97, 97))
+ assert base_box.position == (2, 2)
+ mock_mouse.release(box)
+
+
+@no_type_check # Ignore typing because Mocks don't type nicely.
+def test_snap_box_can_receive(subtests, mock_gui, mock_mouse):
+ """Test that SnapBox can_receive callback is used correctly by DraggableBox."""
+ snap_box = SnapBox(
+ SnapBoxDefinition(width=10, height=10, can_receive=MagicMock(return_value=True))
+ )
+ snap_box.position = 110, 110
+
+ base_box = create_base_box()
+ drag_box = DraggableBox(
+ DraggableBoxDefinition(
+ width=base_box.rect.width,
+ height=base_box.rect.height,
+ snap_boxes=[snap_box],
+ )
+ )
+
+ base_box.add(drag_box)
+ base_box.position = 0, 0
+
+ with subtests.test(
+ "Dragging over the snap box causes the box to snap to it and can_receive is called."
+ ):
+ mock_mouse.click_and_drag(drag_box, (20, 20), (40, 40))
+ assert base_box.position == (65, 65)
+ assert snap_box.is_occupied is True
+ snap_box.definition.can_receive.assert_called_with(drag_box)
+
+ base_box.position = 0, 0
+ drag_box.unsnap_if_snapped()
+ snap_box.definition.can_receive.reset_mock()
+
+ snap_box.definition = replace(
+ snap_box.definition, can_receive=MagicMock(return_value=False)
+ )
+
+ with subtests.test(
+ "Dragging over the snap box does not cause a snap because can_receive returns False."
+ ):
+ mock_mouse.click_and_drag(drag_box, (20, 20), (40, 40))
+ assert base_box.position == (20, 20) # Didn't snap, but still got dragged.
+ assert snap_box.is_occupied is False
+ snap_box.definition.can_receive.assert_called_with(drag_box)
+
+
+@no_type_check # Ignore typing because Mocks don't type nicely.
+def test_snap_box_on_receive_on_release(subtests, mock_gui):
+ """Test that SnapBox calls its defined callbacks when a DraggableBox interacts with it."""
+ snap_box = SnapBox(
+ SnapBoxDefinition(
+ width=10, height=10, on_receive=MagicMock(), on_release=MagicMock(),
+ )
+ )
+
+ base_box = create_base_box()
+ drag_box = DraggableBox(
+ DraggableBoxDefinition(
+ width=base_box.rect.width,
+ height=base_box.rect.height,
+ snap_boxes=[snap_box],
+ )
+ )
+
+ base_box.add(drag_box)
+ base_box.position = 0, 0
+
+ with subtests.test(
+ "Test that on_receive is called when a drag box snaps onto a SnapBox."
+ ):
+ drag_box.snap_to(snap_box)
+ snap_box.definition.on_receive.assert_called_with(drag_box)
+ snap_box.definition.on_release.assert_not_called()
+ assert snap_box.is_occupied is True
+
+ snap_box.definition.on_receive.reset_mock()
+
+ with subtests.test(
+ "Test that on_receive is not called a second time when a drag box snaps "
+ "onto the same SnapBox."
+ ):
+ drag_box.snap_to(snap_box)
+ snap_box.definition.on_receive.assert_not_called()
+ snap_box.definition.on_release.assert_not_called()
+ assert snap_box.is_occupied is True
+
+ snap_box.definition.on_receive.reset_mock()
+
+ with subtests.test("Test that on_release is called when a draggable box unsnaps."):
+ drag_box.unsnap_if_snapped()
+ snap_box.definition.on_release.assert_called_with(drag_box)
+ snap_box.definition.on_receive.assert_not_called()
+ assert snap_box.is_occupied is False
+
+ snap_box.definition.on_release.reset_mock()
+
+ with subtests.test(
+ "Test that on_release is not called when a draggable box is not currently snapped."
+ ):
+ drag_box.unsnap_if_snapped()
+ snap_box.definition.on_release.assert_not_called()
+ snap_box.definition.on_receive.assert_not_called()
+ assert snap_box.is_occupied is False
+
+
+@no_type_check # Ignore typing because Mocks don't type nicely.
+def test_must_be_snapped(subtests, mock_gui, mock_mouse):
+ """Test that "must_be_snapped" results in a DraggableBox that is always snapped."""
+ snap_box = SnapBox(SnapBoxDefinition(width=10, height=10))
+ snap_box.position = 110, 110
+ snap_box2 = SnapBox(SnapBoxDefinition(width=10, height=10))
+ snap_box2.position = 310, 310
+
+ base_box = create_base_box()
+ drag_box = DraggableBox(
+ DraggableBoxDefinition(
+ width=base_box.rect.width,
+ height=base_box.rect.height,
+ must_be_snapped=True,
+ snap_boxes=[snap_box, snap_box2],
+ )
+ )
+
+ base_box.add(drag_box)
+ base_box.position = 0, 0
+
+ # Set the drag box as initially snapped to snap box 1.
+ drag_box.snap_to(snap_box)
+ assert base_box.position == (65, 65)
+
+ with subtests.test(
+ "Dragging the drag box off snap 1, but not over snap 2 results in the drag "
+ "box returning to be snapped over snap1."
+ ):
+ # Drag really far away to ensure we don't overlap with snap2.
+ mock_mouse.click_and_drag(drag_box, (65, 65), (1000, 1000))
+ assert base_box.position == (65, 65)
+ assert snap_box.is_occupied is True
+ assert snap_box2.is_occupied is False
+
+ with subtests.test(
+ "Dragging the drag box off snap 1 and over snap2 results in being snapped to snap2."
+ ):
+ # Drag really far away to ensure we don't overlap with snap2.
+ mock_mouse.click_and_drag(drag_box, (100, 100), (300, 300))
+ assert base_box.position == (265, 265)
+ assert snap_box.is_occupied is False
+ assert snap_box2.is_occupied is True