Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

GUI: finish public api of arcade.gui.property #2014

Merged
merged 8 commits into from
Mar 13, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
eruvanos marked this conversation as resolved.
Show resolved Hide resolved

def log_change(instance, value):
eruvanos marked this conversation as resolved.
Show resolved Hide resolved
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
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
Loading