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! 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