Skip to content

Commit

Permalink
feat: #77 improved drag and drop (#88)
Browse files Browse the repository at this point in the history
* feat: Add callbacks to snap-to-box behaviour to enable creation of a drag-n-drop system.

* fix: Add tests and fix minor bugs in drag n drop system.

* chore: Add badges to README.
  • Loading branch information
MartinHowarth authored Feb 22, 2020
1 parent 726b574 commit c2d9071
Show file tree
Hide file tree
Showing 4 changed files with 439 additions and 132 deletions.
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
Shimmer
-------

![badge](https://github.com/MartinHowarth/shimmer/workflows/Test/badge.svg)
<a href="https://github.com/MartinHowarth/shimmer/actions"><img alt="Actions Status" src="https://github.com/MartinHowarth/shimmer/workflows/Test/badge.svg"></a>
<a href="https://github.com/MartinHowarth/shimmer/blob/master/LICENSE"><img alt="License: MIT" src="https://img.shields.io/github/license/MartinHowarth/shimmer"></a>
<a href="https://pypi.org/project/shimmer/"><img alt="PyPI" src="https://img.shields.io/pypi/v/shimmer"></a>
<a href="https://pepy.tech/project/shimmer"><img alt="Downloads" src="https://pepy.tech/badge/shimmer"></a>
<a href="https://github.com/MartinHowarth/shimmer"><img alt="Code style: black" src="https://img.shields.io/badge/code%20style-black-000000.svg"></a>

Hello!

Expand Down
179 changes: 163 additions & 16 deletions shimmer/display/components/draggable_box.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -16,18 +16,106 @@
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):
"""
Definition of a draggable box.
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):
Expand All @@ -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:
Expand All @@ -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
115 changes: 0 additions & 115 deletions tests/test_display/test_components/test_draggable_anchor.py

This file was deleted.

Loading

0 comments on commit c2d9071

Please sign in to comment.