From 2eb0c0db26011702c255895bc5629f3f47786d4d Mon Sep 17 00:00:00 2001 From: MartinHowarth Date: Sat, 22 Feb 2020 19:12:21 +0000 Subject: [PATCH 1/3] feat: Add callbacks to snap-to-box behaviour to enable creation of a drag-n-drop system. --- shimmer/display/components/draggable_box.py | 177 ++++++++++++++++-- ...ggable_anchor.py => test_draggable_box.py} | 10 +- 2 files changed, 167 insertions(+), 20 deletions(-) rename tests/test_display/test_components/{test_draggable_anchor.py => test_draggable_box.py} (93%) diff --git a/shimmer/display/components/draggable_box.py b/shimmer/display/components/draggable_box.py index aa6c9ff..100e3f4 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,95 @@ 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.maybe_unsnap() 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 maybe_unsnap(self) -> None: + """ + Call to maybe unsnap from the current SnapBox. + + 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_box.py similarity index 93% rename from tests/test_display/test_components/test_draggable_anchor.py rename to tests/test_display/test_components/test_draggable_box.py index 7cd2635..9e108ec 100644 --- a/tests/test_display/test_components/test_draggable_anchor.py +++ b/tests/test_display/test_components/test_draggable_box.py @@ -1,9 +1,11 @@ -"""Test the draggable box Box.""" +"""Test the draggable Box.""" 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 @@ -44,8 +46,8 @@ 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 = SnapBox( + SnapBoxDefinition(width=10, height=10, background_color=Color(255, 0, 0)) ) snap_box.position = 150 * i, 150 * i snap_points.append(snap_box) @@ -67,7 +69,7 @@ def test_draggable_box_snap_to_box(run_gui): 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 = SnapBox(SnapBoxDefinition(width=10, height=10)) snap_box.position = 110, 110 base_box = create_base_box() From 95bbf64b5f3365804c2af286c4576d06f0dbaae7 Mon Sep 17 00:00:00 2001 From: MartinHowarth Date: Sat, 22 Feb 2020 20:05:13 +0000 Subject: [PATCH 2/3] fix: Add tests and fix minor bugs in drag n drop system. --- shimmer/display/components/draggable_box.py | 8 +- .../test_components/test_draggable_box.py | 156 +++++++++++++++++- 2 files changed, 160 insertions(+), 4 deletions(-) diff --git a/shimmer/display/components/draggable_box.py b/shimmer/display/components/draggable_box.py index 100e3f4..e5e625a 100644 --- a/shimmer/display/components/draggable_box.py +++ b/shimmer/display/components/draggable_box.py @@ -199,7 +199,7 @@ def handle_drag( else: # If no longer intersecting any valid snap boxes, then stop being snapped self._snap_drag_record = cocos.draw.Vector2(0, 0) - self.maybe_unsnap() + self.unsnap_if_snapped() return EVENT_HANDLED @@ -222,9 +222,11 @@ def snap_to(self, snap_box: SnapBox) -> None: # Always align with the target box, regardless of whether it has changed or not. self._align_with_snap_box(snap_box) - def maybe_unsnap(self) -> None: + def unsnap_if_snapped(self) -> None: """ - Call to maybe unsnap from the current SnapBox. + 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. diff --git a/tests/test_display/test_components/test_draggable_box.py b/tests/test_display/test_components/test_draggable_box.py index 9e108ec..7ebb23c 100644 --- a/tests/test_display/test_components/test_draggable_box.py +++ b/tests/test_display/test_components/test_draggable_box.py @@ -1,5 +1,10 @@ """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, @@ -68,7 +73,7 @@ def test_draggable_box_snap_to_box(run_gui): 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.""" + """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 @@ -115,3 +120,152 @@ def test_draggable_box_snap_to_box_no_gui(subtests, mock_gui, mock_mouse): 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 From 04e7e8d6fb5a72e52e973bf67f936c35490e049e Mon Sep 17 00:00:00 2001 From: MartinHowarth Date: Sat, 22 Feb 2020 20:18:28 +0000 Subject: [PATCH 3/3] chore: Add badges to README. --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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) +Actions Status +License: MIT +PyPI +Downloads +Code style: black Hello!