Skip to content

Commit 60b6847

Browse files
authored
Merge pull request #2732 from pythonarcade/gui/improvements
Gui/improvements
2 parents 4d8c653 + 6315181 commit 60b6847

File tree

6 files changed

+213
-87
lines changed

6 files changed

+213
-87
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page.
2626
- Added property setters for `center_x` and `center_y`
2727
- Added property setters for `left`, `right`, `top`, and `bottom`
2828
- Users can now set widget position and size more intuitively without needing to access the `rect` property
29+
- Property listener can now receive:
30+
- no args
31+
- instance
32+
- instance, value
33+
- instance, value, old value
34+
> Listener accepting `*args` receive `instance, value` like in previous versions.
35+
2936
- Rendering:
3037
- The `arcade.gl` package was restructured to be more modular in preparation for
3138
other backends such as WebGL and WebGPU

arcade/gui/property.py

Lines changed: 113 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import inspect
12
import sys
23
import traceback
34
from collections.abc import Callable
5+
from contextlib import contextmanager, suppress
46
from typing import Any, Generic, TypeVar, cast
57
from weakref import WeakKeyDictionary, ref
68

@@ -9,18 +11,64 @@
911
P = TypeVar("P")
1012

1113

14+
NoArgListener = Callable[[], None]
15+
InstanceListener = Callable[[Any], None]
16+
InstanceValueListener = Callable[[Any, Any], None]
17+
InstanceNewOldListener = Callable[[Any, Any, Any], None]
18+
AnyListener = NoArgListener | InstanceListener | InstanceValueListener | InstanceNewOldListener
19+
20+
1221
class _Obs(Generic[P]):
1322
"""
1423
Internal holder for Property value and change listeners
1524
"""
1625

17-
__slots__ = ("value", "listeners")
26+
__slots__ = ("value", "_listeners")
1827

1928
def __init__(self, value: P):
2029
self.value = value
2130
# This will keep any added listener even if it is not referenced anymore
2231
# and would be garbage collected
23-
self.listeners: set[Callable[[Any, P], Any] | Callable[[], Any]] = set()
32+
self._listeners: dict[AnyListener, InstanceNewOldListener] = dict()
33+
34+
def add(
35+
self,
36+
callback: AnyListener,
37+
):
38+
"""Add a callback to the list of listeners"""
39+
self._listeners[callback] = _Obs._normalize_callback(callback)
40+
41+
def remove(self, callback):
42+
"""Remove a callback from the list of listeners"""
43+
if callback in self._listeners:
44+
del self._listeners[callback]
45+
46+
@property
47+
def listeners(self) -> list[InstanceNewOldListener]:
48+
return list(self._listeners.values())
49+
50+
@staticmethod
51+
def _normalize_callback(callback) -> InstanceNewOldListener:
52+
"""Normalizes the callback so every callback can be invoked with the same signature."""
53+
signature = inspect.signature(callback)
54+
55+
with suppress(TypeError):
56+
signature.bind(1, 1)
57+
return lambda instance, new, old: callback(instance, new)
58+
59+
with suppress(TypeError):
60+
signature.bind(1, 1, 1)
61+
return lambda instance, new, old: callback(instance, new, old)
62+
63+
with suppress(TypeError):
64+
signature.bind(1)
65+
return lambda instance, new, old: callback(instance)
66+
67+
with suppress(TypeError):
68+
signature.bind()
69+
return lambda instance, new, old: callback()
70+
71+
raise TypeError("Callback is not callable")
2472

2573

2674
class Property(Generic[P]):
@@ -85,27 +133,23 @@ def set(self, instance, value):
85133
"""Set value for owner instance"""
86134
obs = self._get_obs(instance)
87135
if obs.value != value:
136+
old = obs.value
88137
obs.value = value
89-
self.dispatch(instance, value)
138+
self.dispatch(instance, value, old)
90139

91-
def dispatch(self, instance, value):
140+
def dispatch(self, instance, value, old_value):
92141
"""Notifies every listener, which subscribed to the change event.
93142
94143
Args:
95144
instance: Property instance
96-
value: new value to set
145+
value: new value set
146+
old_value: previous value
97147
98148
"""
99149
obs = self._get_obs(instance)
100150
for listener in obs.listeners:
101151
try:
102-
try:
103-
# FIXME if listener() raises an error, the invalid call will be
104-
# also shown as an exception
105-
listener(instance, value) # type: ignore
106-
except TypeError:
107-
# If the listener does not accept arguments, we call it without it
108-
listener() # type: ignore
152+
listener(instance, value, old_value)
109153
except Exception:
110154
print(
111155
f"Change listener for {instance}.{self.name} = {value} raised an exception!",
@@ -126,7 +170,7 @@ def bind(self, instance, callback):
126170
# Instance methods are bound methods, which can not be referenced by normal `ref()`
127171
# if listeners would be a WeakSet, we would have to add listeners as WeakMethod
128172
# ourselves into `WeakSet.data`.
129-
obs.listeners.add(callback)
173+
obs.add(callback)
130174

131175
def unbind(self, instance, callback):
132176
"""Unbinds a function from the change event of the property.
@@ -136,7 +180,7 @@ def unbind(self, instance, callback):
136180
callback: The callback to unbind.
137181
"""
138182
obs = self._get_obs(instance)
139-
obs.listeners.remove(callback)
183+
obs.remove(callback)
140184

141185
def __set_name__(self, owner, name):
142186
self.name = name
@@ -232,45 +276,49 @@ def __init__(self, prop: Property, instance, *args):
232276
self.obj = ref(instance)
233277
super().__init__(*args)
234278

235-
def dispatch(self):
236-
self.prop.dispatch(self.obj(), self)
279+
@contextmanager
280+
def _dispatch(self):
281+
"""This is a context manager which will dispatch the change event
282+
when the context is exited.
283+
"""
284+
old_value = self.copy()
285+
yield
286+
self.prop.dispatch(self.obj(), self, old_value)
237287

238288
@override
239289
def __setitem__(self, key, value):
240-
dict.__setitem__(self, key, value)
241-
self.dispatch()
290+
with self._dispatch():
291+
dict.__setitem__(self, key, value)
242292

243293
@override
244294
def __delitem__(self, key):
245-
dict.__delitem__(self, key)
246-
self.dispatch()
295+
with self._dispatch():
296+
dict.__delitem__(self, key)
247297

248298
@override
249299
def clear(self):
250-
dict.clear(self)
251-
self.dispatch()
300+
with self._dispatch():
301+
dict.clear(self)
252302

253303
@override
254304
def pop(self, *args):
255-
result = dict.pop(self, *args)
256-
self.dispatch()
257-
return result
305+
with self._dispatch():
306+
return dict.pop(self, *args)
258307

259308
@override
260309
def popitem(self):
261-
result = dict.popitem(self)
262-
self.dispatch()
263-
return result
310+
with self._dispatch():
311+
return dict.popitem(self)
264312

265313
@override
266314
def setdefault(self, *args):
267-
dict.setdefault(self, *args)
268-
self.dispatch()
315+
with self._dispatch():
316+
return dict.setdefault(self, *args)
269317

270318
@override
271319
def update(self, *args):
272-
dict.update(self, *args)
273-
self.dispatch()
320+
with self._dispatch():
321+
dict.update(self, *args)
274322

275323

276324
K = TypeVar("K")
@@ -309,80 +357,86 @@ def __init__(self, prop: Property, instance, *args):
309357
self.obj = ref(instance)
310358
super().__init__(*args)
311359

312-
def dispatch(self):
313-
"""Dispatches the change event."""
314-
self.prop.dispatch(self.obj(), self)
360+
@contextmanager
361+
def _dispatch(self):
362+
"""Dispatches the change event.
363+
This is a context manager which will dispatch the change event
364+
when the context is exited.
365+
"""
366+
old_value = self.copy()
367+
yield
368+
self.prop.dispatch(self.obj(), self, old_value)
315369

316370
@override
317371
def __setitem__(self, key, value):
318-
list.__setitem__(self, key, value)
319-
self.dispatch()
372+
with self._dispatch():
373+
list.__setitem__(self, key, value)
320374

321375
@override
322376
def __delitem__(self, key):
323-
list.__delitem__(self, key)
324-
self.dispatch()
377+
with self._dispatch():
378+
list.__delitem__(self, key)
325379

326380
@override
327381
def __iadd__(self, *args):
328-
list.__iadd__(self, *args)
329-
self.dispatch()
382+
with self._dispatch():
383+
list.__iadd__(self, *args)
330384
return self
331385

332386
@override
333387
def __imul__(self, *args):
334-
list.__imul__(self, *args)
335-
self.dispatch()
388+
with self._dispatch():
389+
list.__imul__(self, *args)
336390
return self
337391

338392
@override
339393
def append(self, *args):
340394
"""Proxy for list.append() which dispatches the change event."""
341-
list.append(self, *args)
342-
self.dispatch()
395+
with self._dispatch():
396+
list.append(self, *args)
343397

344398
@override
345399
def clear(self):
346400
"""Proxy for list.clear() which dispatches the change event."""
347-
list.clear(self)
348-
self.dispatch()
401+
with self._dispatch():
402+
list.clear(self)
349403

350404
@override
351405
def remove(self, *args):
352406
"""Proxy for list.remove() which dispatches the change event."""
353-
list.remove(self, *args)
354-
self.dispatch()
407+
with self._dispatch():
408+
list.remove(self, *args)
355409

356410
@override
357411
def insert(self, *args):
358412
"""Proxy for list.insert() which dispatches the change event."""
359-
list.insert(self, *args)
360-
self.dispatch()
413+
with self._dispatch():
414+
list.insert(self, *args)
361415

362416
@override
363417
def pop(self, *args):
364418
"""Proxy for list.pop() which dispatches the change"""
365-
result = list.pop(self, *args)
366-
self.dispatch()
419+
with self._dispatch():
420+
result = list.pop(self, *args)
367421
return result
368422

369423
@override
370424
def extend(self, *args):
371425
"""Proxy for list.extend() which dispatches the change event."""
372-
list.extend(self, *args)
373-
self.dispatch()
426+
with self._dispatch():
427+
list.extend(self, *args)
374428

375429
@override
376430
def sort(self, **kwargs):
377431
"""Proxy for list.sort() which dispatches the change event."""
378-
list.sort(self, **kwargs)
379-
self.dispatch()
432+
with self._dispatch():
433+
list.sort(self, **kwargs)
380434

381435
@override
382436
def reverse(self):
383437
"""Proxy for list.reverse() which dispatches the change event."""
384-
list.reverse(self)
385-
self.dispatch()
438+
with self._dispatch():
439+
list.reverse(self)
386440

387441

388442
class ListProperty(Property[list[P]], Generic[P]):

arcade/gui/widgets/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from abc import ABC
44
from collections.abc import Iterable
55
from enum import IntEnum
6+
from types import EllipsisType
67
from typing import TYPE_CHECKING, NamedTuple, TypeVar
78

89
from pyglet.event import EVENT_HANDLED, EVENT_UNHANDLED, EventDispatcher
@@ -509,8 +510,8 @@ def with_padding(
509510
def with_background(
510511
self,
511512
*,
512-
color: None | Color = ..., # type: ignore
513-
texture: None | Texture | NinePatchTexture = ..., # type: ignore
513+
color: Color | EllipsisType | None = ...,
514+
texture: Texture | NinePatchTexture | EllipsisType | None = ...,
514515
) -> Self:
515516
"""Set widgets background.
516517

arcade/gui/widgets/slider.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,6 @@ def _apply_step(self, value: float):
115115
return value
116116

117117
def _set_value(self, value: float):
118-
# TODO changing the value itself should trigger `on_change` event
119-
# current problem is, that the property does not pass the old value to listeners
120118
if value < self.min_value:
121119
value = self.min_value
122120
elif value > self.max_value:

arcade/types/rect.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -717,16 +717,16 @@ def kwargs(self) -> RectKwargs:
717717
checking. See :py:class:`typing.TypedDict` to learn more.
718718
719719
"""
720-
return {
721-
"left": self.left,
722-
"right": self.right,
723-
"bottom": self.bottom,
724-
"top": self.top,
725-
"x": self.x,
726-
"y": self.y,
727-
"width": self.width,
728-
"height": self.height,
729-
}
720+
return RectKwargs(
721+
left=self.left,
722+
right=self.right,
723+
bottom=self.bottom,
724+
top=self.top,
725+
x=self.x,
726+
y=self.y,
727+
width=self.width,
728+
height=self.height,
729+
)
730730

731731
# Since __repr__ is handled automatically by NamedTuple, we focus on
732732
# human-readable spot-check values for __str__ instead.

0 commit comments

Comments
 (0)