Skip to content

Commit

Permalink
GUI: finish public api of arcade.gui.property (#2014)
Browse files Browse the repository at this point in the history
* GUI: properties provide instance and value to listener (keep support for simple callbacks)
* GUI: fix wrong type hints

Co-authored-by: Paul <36696816+pushfoo@users.noreply.github.com>
  • Loading branch information
eruvanos and pushfoo authored Mar 13, 2024
1 parent e2d9567 commit 1e63b46
Show file tree
Hide file tree
Showing 8 changed files with 119 additions and 55 deletions.
12 changes: 6 additions & 6 deletions arcade/gui/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ class UIMouseEvent(UIEvent):
Covers all mouse event
"""

x: float
y: float
x: int
y: int

@property
def pos(self):
Expand All @@ -32,8 +32,8 @@ def pos(self):
@dataclass
class UIMouseMovementEvent(UIMouseEvent):
"""Triggered when the mouse is moved."""
dx: float
dy: float
dx: int
dy: int


@dataclass
Expand All @@ -46,8 +46,8 @@ class UIMousePressEvent(UIMouseEvent):
@dataclass
class UIMouseDragEvent(UIMouseEvent):
"""Triggered when the mouse moves while one of its buttons being pressed."""
dx: float
dy: float
dx: int
dy: int
buttons: int
modifiers: int

Expand Down
4 changes: 2 additions & 2 deletions arcade/gui/experimental/scroll_area.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ def on_event(self, event: UIEvent) -> Optional[bool]:
child_event = event
if isinstance(event, UIMouseEvent):
child_event = type(event)(**event.__dict__) # type: ignore
child_event.x = event.x - self.x + self.scroll_x
child_event.y = event.y - self.y + self.scroll_y
child_event.x = int(event.x - self.x + self.scroll_x)
child_event.y = int(event.y - self.y + self.scroll_y)

return super().on_event(child_event)
48 changes: 33 additions & 15 deletions arcade/gui/property.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,41 @@ class _Obs(Generic[P]):
def __init__(self, value: P):
self.value = value
# This will keep any added listener even if it is not referenced anymore and would be garbage collected
self.listeners: Set[Callable[[], Any]] = set()
self.listeners: Set[Callable[[Any, P], Any]] = set()


class Property(Generic[P]):
"""
An observable property which triggers observers when changed.
.. code-block:: python
def log_change(instance, value):
print("Something changed")
class MyObject:
name = Property()
my_obj = MyObject()
bind(my_obj, "name", log_change)
unbind(my_obj, "name", log_change)
my_obj.name = "Hans"
# > Something changed
:param default: Default value which is returned, if no value set before
:param default_factory: A callable which returns the default value.
Will be called with the property and the instance
"""

__slots__ = ("name", "default_factory", "obs")
name: str

def __init__(self, default: Optional[P] = None, default_factory: Optional[Callable[[Any, Any], P]] = None):
def __init__(
self,
default: Optional[P] = None,
default_factory: Optional[Callable[[Any, Any], P]] = None,
):
if default_factory is None:
default_factory = lambda prop, instance: cast(P, default)

Expand Down Expand Up @@ -60,7 +80,11 @@ def dispatch(self, instance, value):
obs = self._get_obs(instance)
for listener in obs.listeners:
try:
listener()
try:
listener(instance, value)
except TypeError:
# If the listener does not accept arguments, we call it without it
listener() # type: ignore
except Exception:
print(
f"Change listener for {instance}.{self.name} = {value} raised an exception!",
Expand Down Expand Up @@ -95,8 +119,8 @@ def bind(instance, property: str, callback):
Binds a function to the change event of the property. A reference to the function will be kept,
so that it will be still invoked, even if it would normally have been garbage collected.
def log_change():
print("Something changed")
def log_change(instance, value):
print(f"Value of {instance} changed to {value}")
class MyObject:
name = Property()
Expand All @@ -105,7 +129,7 @@ class MyObject:
bind(my_obj, "name", log_change)
my_obj.name = "Hans"
# > Something changed
# > Value of <__main__.MyObject ...> changed to Hans
:param instance: Instance owning the property
:param property: Name of the property
Expand All @@ -122,7 +146,7 @@ def unbind(instance, property: str, callback):
"""
Unbinds a function from the change event of the property.
def log_change():
def log_change(instance, value):
print("Something changed")
class MyObject:
Expand Down Expand Up @@ -150,10 +174,7 @@ class MyObject:
class _ObservableDict(dict):
"""Internal class to observe changes inside a native python dict."""

__slots__ = (
"prop",
"obj"
)
__slots__ = ("prop", "obj")

def __init__(self, prop: Property, instance, *largs):
self.prop: Property = prop
Expand Down Expand Up @@ -211,10 +232,7 @@ def set(self, instance, value: dict):
class _ObservableList(list):
"""Internal class to observe changes inside a native python list."""

__slots__ = (
"prop",
"obj"
)
__slots__ = ("prop", "obj")

def __init__(self, prop: Property, instance, *largs):
self.prop: Property = prop
Expand Down
8 changes: 4 additions & 4 deletions arcade/gui/ui_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,21 +365,21 @@ def on_event(self, event) -> Union[bool, None]:
def dispatch_ui_event(self, event):
return self.dispatch_event("on_event", event)

def on_mouse_motion(self, x: float, y: float, dx: float, dy: float):
def on_mouse_motion(self, x: int, y: int, dx: int, dy: int):
x, y = self.adjust_mouse_coordinates(x, y)
return self.dispatch_ui_event(UIMouseMovementEvent(self, x, y, dx, dy)) # type: ignore

def on_mouse_press(self, x: float, y: float, button: int, modifiers: int):
def on_mouse_press(self, x: int, y: int, button: int, modifiers: int):
x, y = self.adjust_mouse_coordinates(x, y)
return self.dispatch_ui_event(UIMousePressEvent(self, x, y, button, modifiers)) # type: ignore

def on_mouse_drag(
self, x: float, y: float, dx: float, dy: float, buttons: int, modifiers: int
self, x: int, y: int, dx: int, dy: int, buttons: int, modifiers: int
):
x, y = self.adjust_mouse_coordinates(x, y)
return self.dispatch_ui_event(UIMouseDragEvent(self, x, y, dx, dy, buttons, modifiers)) # type: ignore

def on_mouse_release(self, x: float, y: float, button: int, modifiers: int):
def on_mouse_release(self, x: int, y: int, button: int, modifiers: int):
x, y = self.adjust_mouse_coordinates(x, y)
return self.dispatch_ui_event(UIMouseReleaseEvent(self, x, y, button, modifiers)) # type: ignore

Expand Down
23 changes: 11 additions & 12 deletions arcade/gui/widgets/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations

import builtins
from abc import ABC
from random import randint
from typing import (
Expand Down Expand Up @@ -551,32 +550,32 @@ def with_border(self, *, width=2, color=(0, 0, 0)) -> Self:
def with_padding(
self,
*,
top: Union["builtins.ellipsis", int] = ...,
right: Union["builtins.ellipsis", int] = ...,
bottom: Union["builtins.ellipsis", int] = ...,
left: Union["builtins.ellipsis", int] = ...,
all: Union["builtins.ellipsis", int] = ...,
top: Optional[int] = None,
right: Optional[int] = None,
bottom: Optional[int] = None,
left: Optional[int] = None,
all: Optional[int] = None,
) -> "UIWidget":
"""
Changes the padding to the given values if set. Returns itself
:return: self
"""
if all is not ...:
if all is not None:
self.padding = all
if top is not ...:
if top is not None:
self._padding_top = top
if right is not ...:
if right is not None:
self._padding_right = right
if bottom is not ...:
if bottom is not None:
self._padding_bottom = bottom
if left is not ...:
if left is not None:
self._padding_left = left
return self

def with_background(
self,
*,
color: Union["builtins.ellipsis", Color] = ...,
color: Union[None, Color] = ..., # type: ignore
texture: Union[None, Texture, NinePatchTexture] = ..., # type: ignore
) -> "UIWidget":
"""
Expand Down
6 changes: 4 additions & 2 deletions arcade/gui/widgets/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,8 @@ def on_event(self, event: UIEvent) -> Optional[bool]:
# If active check to deactivate
if self._active and isinstance(event, UIMousePressEvent):
if self.rect.collide_with_point(event.x, event.y):
x, y = event.x - self.x - self.LAYOUT_OFFSET, event.y - self.y
x = int(event.x - self.x - self.LAYOUT_OFFSET)
y = int(event.y - self.y)
self.caret.on_mouse_press(x, y, event.button, event.modifiers)
else:
self._active = False
Expand All @@ -407,7 +408,8 @@ def on_event(self, event: UIEvent) -> Optional[bool]:
if isinstance(event, UIMouseEvent) and self.rect.collide_with_point(
event.x, event.y
):
x, y = event.x - self.x - self.LAYOUT_OFFSET, event.y - self.y
x = int(event.x - self.x - self.LAYOUT_OFFSET)
y = int(event.y - self.y)
if isinstance(event, UIMouseDragEvent):
self.caret.on_mouse_drag(
x, y, event.dx, event.dy, event.buttons, event.modifiers
Expand Down
4 changes: 2 additions & 2 deletions doc/programming_guide/gui/concept.rst
Original file line number Diff line number Diff line change
Expand Up @@ -547,5 +547,5 @@ Property
````````

:py:class:`~arcade.gui.Property` is an pure-Python implementation of Kivy
Properties. They are used to detect attribute changes of widgets and trigger
rendering. They should only be used in arcade internal code.
like Properties. They are used to detect attribute changes of widgets and trigger
rendering. They are mostly used within GUI widgets, but are globally available since 3.0.0.
69 changes: 57 additions & 12 deletions tests/unit/gui/test_property.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,37 +8,82 @@ class MyObject:


class Observer:
called = None
call_args = None
called = False

def call(self, *args, **kwargs):
self.called = (args, kwargs)
def call(self):
self.call_args = tuple()
self.called = True

def __call__(self, *args, **kwargs):
self.called = (args, kwargs)
def call_with_args(self, instance, value):
"""Match expected signature of 2 parameters"""
self.call_args = (instance, value)
self.called = True

def __call__(self, *args):
self.call_args = args
self.called = True


def test_bind_callback():
observer = Observer()

my_obj = MyObject()
bind(my_obj, "name", observer)
bind(my_obj, "name", observer.call)

assert not observer.called
assert not observer.call_args

# WHEN
my_obj.name = "New Name"

assert observer.called == (tuple(), {})
assert observer.call_args == tuple()


def test_unbind_callback():
def test_bind_callback_with_args():
"""
A bound callback can have 0 or 2 arguments.
0 arguments are used for simple callbacks, like `log_change`.
2 arguments are used for callbacks that need to know the instance and the new value.
"""
observer = Observer()

my_obj = MyObject()
bind(my_obj, "name", observer.call_with_args)

assert not observer.call_args

# WHEN
my_obj.name = "New Name"

assert observer.call_args == (my_obj, "New Name")

# Remove reference of call_args to my_obj, otherwise it will keep the object alive
observer.call_args = None


def test_bind_callback_with_star_args():
observer = Observer()

my_obj = MyObject()
bind(my_obj, "name", observer)

# WHEN
unbind(my_obj, "name", observer)
my_obj.name = "New Name"

assert observer.call_args == (my_obj, "New Name")

# Remove reference of call_args to my_obj, otherwise it will keep the object alive
observer.call_args = None


def test_unbind_callback():
observer = Observer()

my_obj = MyObject()
bind(my_obj, "name", observer.call)

# WHEN
unbind(my_obj, "name", observer.call)
my_obj.name = "New Name"

assert not observer.called
Expand Down Expand Up @@ -74,7 +119,7 @@ def test_does_not_trigger_if_value_unchanged():
observer = Observer()
my_obj = MyObject()
my_obj.name = "CONSTANT"
bind(my_obj, "name", observer)
bind(my_obj, "name", observer.call)

assert not observer.called

Expand All @@ -96,7 +141,7 @@ def test_gc_entries_are_collected():
del obj
gc.collect()

# No left overs
# No leftovers
assert len(MyObject.name.obs) == 0


Expand Down

0 comments on commit 1e63b46

Please sign in to comment.