Skip to content

Commit 1e63b46

Browse files
eruvanospushfoo
andauthored
GUI: finish public api of arcade.gui.property (#2014)
* 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>
1 parent e2d9567 commit 1e63b46

File tree

8 files changed

+119
-55
lines changed

8 files changed

+119
-55
lines changed

arcade/gui/events.py

+6-6
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ class UIMouseEvent(UIEvent):
2121
Covers all mouse event
2222
"""
2323

24-
x: float
25-
y: float
24+
x: int
25+
y: int
2626

2727
@property
2828
def pos(self):
@@ -32,8 +32,8 @@ def pos(self):
3232
@dataclass
3333
class UIMouseMovementEvent(UIMouseEvent):
3434
"""Triggered when the mouse is moved."""
35-
dx: float
36-
dy: float
35+
dx: int
36+
dy: int
3737

3838

3939
@dataclass
@@ -46,8 +46,8 @@ class UIMousePressEvent(UIMouseEvent):
4646
@dataclass
4747
class UIMouseDragEvent(UIMouseEvent):
4848
"""Triggered when the mouse moves while one of its buttons being pressed."""
49-
dx: float
50-
dy: float
49+
dx: int
50+
dy: int
5151
buttons: int
5252
modifiers: int
5353

arcade/gui/experimental/scroll_area.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ def on_event(self, event: UIEvent) -> Optional[bool]:
113113
child_event = event
114114
if isinstance(event, UIMouseEvent):
115115
child_event = type(event)(**event.__dict__) # type: ignore
116-
child_event.x = event.x - self.x + self.scroll_x
117-
child_event.y = event.y - self.y + self.scroll_y
116+
child_event.x = int(event.x - self.x + self.scroll_x)
117+
child_event.y = int(event.y - self.y + self.scroll_y)
118118

119119
return super().on_event(child_event)

arcade/gui/property.py

+33-15
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,41 @@ class _Obs(Generic[P]):
1818
def __init__(self, value: P):
1919
self.value = value
2020
# This will keep any added listener even if it is not referenced anymore and would be garbage collected
21-
self.listeners: Set[Callable[[], Any]] = set()
21+
self.listeners: Set[Callable[[Any, P], Any]] = set()
2222

2323

2424
class Property(Generic[P]):
2525
"""
2626
An observable property which triggers observers when changed.
2727
28+
.. code-block:: python
29+
30+
def log_change(instance, value):
31+
print("Something changed")
32+
33+
class MyObject:
34+
name = Property()
35+
36+
my_obj = MyObject()
37+
bind(my_obj, "name", log_change)
38+
unbind(my_obj, "name", log_change)
39+
40+
my_obj.name = "Hans"
41+
# > Something changed
42+
2843
:param default: Default value which is returned, if no value set before
2944
:param default_factory: A callable which returns the default value.
3045
Will be called with the property and the instance
3146
"""
47+
3248
__slots__ = ("name", "default_factory", "obs")
3349
name: str
3450

35-
def __init__(self, default: Optional[P] = None, default_factory: Optional[Callable[[Any, Any], P]] = None):
51+
def __init__(
52+
self,
53+
default: Optional[P] = None,
54+
default_factory: Optional[Callable[[Any, Any], P]] = None,
55+
):
3656
if default_factory is None:
3757
default_factory = lambda prop, instance: cast(P, default)
3858

@@ -60,7 +80,11 @@ def dispatch(self, instance, value):
6080
obs = self._get_obs(instance)
6181
for listener in obs.listeners:
6282
try:
63-
listener()
83+
try:
84+
listener(instance, value)
85+
except TypeError:
86+
# If the listener does not accept arguments, we call it without it
87+
listener() # type: ignore
6488
except Exception:
6589
print(
6690
f"Change listener for {instance}.{self.name} = {value} raised an exception!",
@@ -95,8 +119,8 @@ def bind(instance, property: str, callback):
95119
Binds a function to the change event of the property. A reference to the function will be kept,
96120
so that it will be still invoked, even if it would normally have been garbage collected.
97121
98-
def log_change():
99-
print("Something changed")
122+
def log_change(instance, value):
123+
print(f"Value of {instance} changed to {value}")
100124
101125
class MyObject:
102126
name = Property()
@@ -105,7 +129,7 @@ class MyObject:
105129
bind(my_obj, "name", log_change)
106130
107131
my_obj.name = "Hans"
108-
# > Something changed
132+
# > Value of <__main__.MyObject ...> changed to Hans
109133
110134
:param instance: Instance owning the property
111135
:param property: Name of the property
@@ -122,7 +146,7 @@ def unbind(instance, property: str, callback):
122146
"""
123147
Unbinds a function from the change event of the property.
124148
125-
def log_change():
149+
def log_change(instance, value):
126150
print("Something changed")
127151
128152
class MyObject:
@@ -150,10 +174,7 @@ class MyObject:
150174
class _ObservableDict(dict):
151175
"""Internal class to observe changes inside a native python dict."""
152176

153-
__slots__ = (
154-
"prop",
155-
"obj"
156-
)
177+
__slots__ = ("prop", "obj")
157178

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

214-
__slots__ = (
215-
"prop",
216-
"obj"
217-
)
235+
__slots__ = ("prop", "obj")
218236

219237
def __init__(self, prop: Property, instance, *largs):
220238
self.prop: Property = prop

arcade/gui/ui_manager.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -365,21 +365,21 @@ def on_event(self, event) -> Union[bool, None]:
365365
def dispatch_ui_event(self, event):
366366
return self.dispatch_event("on_event", event)
367367

368-
def on_mouse_motion(self, x: float, y: float, dx: float, dy: float):
368+
def on_mouse_motion(self, x: int, y: int, dx: int, dy: int):
369369
x, y = self.adjust_mouse_coordinates(x, y)
370370
return self.dispatch_ui_event(UIMouseMovementEvent(self, x, y, dx, dy)) # type: ignore
371371

372-
def on_mouse_press(self, x: float, y: float, button: int, modifiers: int):
372+
def on_mouse_press(self, x: int, y: int, button: int, modifiers: int):
373373
x, y = self.adjust_mouse_coordinates(x, y)
374374
return self.dispatch_ui_event(UIMousePressEvent(self, x, y, button, modifiers)) # type: ignore
375375

376376
def on_mouse_drag(
377-
self, x: float, y: float, dx: float, dy: float, buttons: int, modifiers: int
377+
self, x: int, y: int, dx: int, dy: int, buttons: int, modifiers: int
378378
):
379379
x, y = self.adjust_mouse_coordinates(x, y)
380380
return self.dispatch_ui_event(UIMouseDragEvent(self, x, y, dx, dy, buttons, modifiers)) # type: ignore
381381

382-
def on_mouse_release(self, x: float, y: float, button: int, modifiers: int):
382+
def on_mouse_release(self, x: int, y: int, button: int, modifiers: int):
383383
x, y = self.adjust_mouse_coordinates(x, y)
384384
return self.dispatch_ui_event(UIMouseReleaseEvent(self, x, y, button, modifiers)) # type: ignore
385385

arcade/gui/widgets/__init__.py

+11-12
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from __future__ import annotations
22

3-
import builtins
43
from abc import ABC
54
from random import randint
65
from typing import (
@@ -551,32 +550,32 @@ def with_border(self, *, width=2, color=(0, 0, 0)) -> Self:
551550
def with_padding(
552551
self,
553552
*,
554-
top: Union["builtins.ellipsis", int] = ...,
555-
right: Union["builtins.ellipsis", int] = ...,
556-
bottom: Union["builtins.ellipsis", int] = ...,
557-
left: Union["builtins.ellipsis", int] = ...,
558-
all: Union["builtins.ellipsis", int] = ...,
553+
top: Optional[int] = None,
554+
right: Optional[int] = None,
555+
bottom: Optional[int] = None,
556+
left: Optional[int] = None,
557+
all: Optional[int] = None,
559558
) -> "UIWidget":
560559
"""
561560
Changes the padding to the given values if set. Returns itself
562561
:return: self
563562
"""
564-
if all is not ...:
563+
if all is not None:
565564
self.padding = all
566-
if top is not ...:
565+
if top is not None:
567566
self._padding_top = top
568-
if right is not ...:
567+
if right is not None:
569568
self._padding_right = right
570-
if bottom is not ...:
569+
if bottom is not None:
571570
self._padding_bottom = bottom
572-
if left is not ...:
571+
if left is not None:
573572
self._padding_left = left
574573
return self
575574

576575
def with_background(
577576
self,
578577
*,
579-
color: Union["builtins.ellipsis", Color] = ...,
578+
color: Union[None, Color] = ..., # type: ignore
580579
texture: Union[None, Texture, NinePatchTexture] = ..., # type: ignore
581580
) -> "UIWidget":
582581
"""

arcade/gui/widgets/text.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,8 @@ def on_event(self, event: UIEvent) -> Optional[bool]:
383383
# If active check to deactivate
384384
if self._active and isinstance(event, UIMousePressEvent):
385385
if self.rect.collide_with_point(event.x, event.y):
386-
x, y = event.x - self.x - self.LAYOUT_OFFSET, event.y - self.y
386+
x = int(event.x - self.x - self.LAYOUT_OFFSET)
387+
y = int(event.y - self.y)
387388
self.caret.on_mouse_press(x, y, event.button, event.modifiers)
388389
else:
389390
self._active = False
@@ -407,7 +408,8 @@ def on_event(self, event: UIEvent) -> Optional[bool]:
407408
if isinstance(event, UIMouseEvent) and self.rect.collide_with_point(
408409
event.x, event.y
409410
):
410-
x, y = event.x - self.x - self.LAYOUT_OFFSET, event.y - self.y
411+
x = int(event.x - self.x - self.LAYOUT_OFFSET)
412+
y = int(event.y - self.y)
411413
if isinstance(event, UIMouseDragEvent):
412414
self.caret.on_mouse_drag(
413415
x, y, event.dx, event.dy, event.buttons, event.modifiers

doc/programming_guide/gui/concept.rst

+2-2
Original file line numberDiff line numberDiff line change
@@ -547,5 +547,5 @@ Property
547547
````````
548548

549549
:py:class:`~arcade.gui.Property` is an pure-Python implementation of Kivy
550-
Properties. They are used to detect attribute changes of widgets and trigger
551-
rendering. They should only be used in arcade internal code.
550+
like Properties. They are used to detect attribute changes of widgets and trigger
551+
rendering. They are mostly used within GUI widgets, but are globally available since 3.0.0.

tests/unit/gui/test_property.py

+57-12
Original file line numberDiff line numberDiff line change
@@ -8,37 +8,82 @@ class MyObject:
88

99

1010
class Observer:
11-
called = None
11+
call_args = None
12+
called = False
1213

13-
def call(self, *args, **kwargs):
14-
self.called = (args, kwargs)
14+
def call(self):
15+
self.call_args = tuple()
16+
self.called = True
1517

16-
def __call__(self, *args, **kwargs):
17-
self.called = (args, kwargs)
18+
def call_with_args(self, instance, value):
19+
"""Match expected signature of 2 parameters"""
20+
self.call_args = (instance, value)
21+
self.called = True
22+
23+
def __call__(self, *args):
24+
self.call_args = args
25+
self.called = True
1826

1927

2028
def test_bind_callback():
2129
observer = Observer()
2230

2331
my_obj = MyObject()
24-
bind(my_obj, "name", observer)
32+
bind(my_obj, "name", observer.call)
2533

26-
assert not observer.called
34+
assert not observer.call_args
2735

2836
# WHEN
2937
my_obj.name = "New Name"
3038

31-
assert observer.called == (tuple(), {})
39+
assert observer.call_args == tuple()
3240

3341

34-
def test_unbind_callback():
42+
def test_bind_callback_with_args():
43+
"""
44+
A bound callback can have 0 or 2 arguments.
45+
0 arguments are used for simple callbacks, like `log_change`.
46+
2 arguments are used for callbacks that need to know the instance and the new value.
47+
"""
48+
observer = Observer()
49+
50+
my_obj = MyObject()
51+
bind(my_obj, "name", observer.call_with_args)
52+
53+
assert not observer.call_args
54+
55+
# WHEN
56+
my_obj.name = "New Name"
57+
58+
assert observer.call_args == (my_obj, "New Name")
59+
60+
# Remove reference of call_args to my_obj, otherwise it will keep the object alive
61+
observer.call_args = None
62+
63+
64+
def test_bind_callback_with_star_args():
3565
observer = Observer()
3666

3767
my_obj = MyObject()
3868
bind(my_obj, "name", observer)
3969

4070
# WHEN
41-
unbind(my_obj, "name", observer)
71+
my_obj.name = "New Name"
72+
73+
assert observer.call_args == (my_obj, "New Name")
74+
75+
# Remove reference of call_args to my_obj, otherwise it will keep the object alive
76+
observer.call_args = None
77+
78+
79+
def test_unbind_callback():
80+
observer = Observer()
81+
82+
my_obj = MyObject()
83+
bind(my_obj, "name", observer.call)
84+
85+
# WHEN
86+
unbind(my_obj, "name", observer.call)
4287
my_obj.name = "New Name"
4388

4489
assert not observer.called
@@ -74,7 +119,7 @@ def test_does_not_trigger_if_value_unchanged():
74119
observer = Observer()
75120
my_obj = MyObject()
76121
my_obj.name = "CONSTANT"
77-
bind(my_obj, "name", observer)
122+
bind(my_obj, "name", observer.call)
78123

79124
assert not observer.called
80125

@@ -96,7 +141,7 @@ def test_gc_entries_are_collected():
96141
del obj
97142
gc.collect()
98143

99-
# No left overs
144+
# No leftovers
100145
assert len(MyObject.name.obs) == 0
101146

102147

0 commit comments

Comments
 (0)