Skip to content

Commit 9ac2efc

Browse files
committed
gui: Fix issues with binding class methods on children
1 parent 809886e commit 9ac2efc

File tree

3 files changed

+106
-19
lines changed

3 files changed

+106
-19
lines changed

arcade/gui/property.py

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import inspect
22
import sys
33
import traceback
4+
import warnings
5+
import weakref
46
from collections.abc import Callable
57
from contextlib import contextmanager, suppress
68
from enum import Enum
9+
from inspect import ismethod
710
from typing import Any, Generic, TypeVar, cast
811
from weakref import WeakKeyDictionary, ref
912

@@ -221,7 +224,52 @@ def __set__(self, instance, value: P):
221224
self.set(instance, value)
222225

223226

224-
def bind(instance, property: str, callback: AnyListener):
227+
class _WeakCallback:
228+
"""Wrapper for weakly referencing a callback function.
229+
230+
Which allows to bind methods of the instance itself without
231+
causing memory leaks.
232+
233+
Also supports to be stored in a dict or set, because it implements
234+
__hash__ and __eq__ methods to match the original function.
235+
"""
236+
237+
def __init__(self, func):
238+
self._func_type = _ListenerType.detect_callback_type(func) # type: ignore[assignment]
239+
self._hash = hash(func)
240+
241+
if inspect.ismethod(func):
242+
self._func = weakref.WeakMethod(func)
243+
else:
244+
self._func = weakref.ref(func)
245+
246+
def __call__(self, instance, new_value, old_value):
247+
func = self._func()
248+
if func is None:
249+
warnings.warn("WeakCallable was called without a callable object.")
250+
251+
if self._func_type == _ListenerType.NO_ARG:
252+
return func()
253+
elif self._func_type == _ListenerType.INSTANCE:
254+
return func(instance)
255+
elif self._func_type == _ListenerType.INSTANCE_VALUE:
256+
return func(instance, new_value)
257+
elif self._func_type == _ListenerType.INSTANCE_NEW_OLD:
258+
return func(instance, new_value, old_value)
259+
260+
else:
261+
raise TypeError(f"Unsupported callback type: {self._func_type}")
262+
263+
def __hash__(self):
264+
return self._hash
265+
266+
def __eq__(self, other):
267+
if ismethod(other):
268+
return self._hash == hash(other)
269+
return False
270+
271+
272+
def bind(instance, property: str, callback: AnyListener, weak=False):
225273
"""Bind a function to the change event of the property.
226274
227275
A reference to the function will be kept, so that it will be still
@@ -243,17 +291,24 @@ class MyObject:
243291
244292
Binding to a method of the Property owner itself can cause a memory leak, because the
245293
owner is strongly referenced. Instead, bind the class method, which will be invoked with
246-
the instance as first parameter.
247-
294+
the instance as first parameter. `bind(instance, "property_name", Instance.method)`.
295+
Or use the `weak` parameter to bind the method weakly `bind(instance, "property_name", instance.method, weak=True)`
248296
249297
Args:
250298
instance: Instance owning the property
251299
property: Name of the property
252300
callback: Function to call
301+
weak: If True, the callback will be weakly referenced.
302+
This is useful for methods of the instance itself to avoid memory leaks.
253303
254304
Returns:
255305
None
256306
"""
307+
308+
if weak:
309+
# If weak is True, we use a _WeakCallable to avoid strong references
310+
callback = _WeakCallback(callback) # type: ignore[assignment]
311+
257312
# TODO rename property to property_name for arcade 4.0 (just to be sure)
258313
t = type(instance)
259314
prop = getattr(t, property)

arcade/gui/widgets/layout.py

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -288,23 +288,23 @@ def add(self, child: W, **kwargs) -> W:
288288
child: The widget to add to the layout.
289289
"""
290290
# subscribe to child's changes, which might affect the own size hint
291-
bind(child, "_children", UIBoxLayout._trigger_size_hint_update)
292-
bind(child, "rect", UIBoxLayout._trigger_size_hint_update)
293-
bind(child, "size_hint", UIBoxLayout._trigger_size_hint_update)
294-
bind(child, "size_hint_min", UIBoxLayout._trigger_size_hint_update)
295-
bind(child, "size_hint_max", UIBoxLayout._trigger_size_hint_update)
291+
bind(child, "_children", self._trigger_size_hint_update, weak=True)
292+
bind(child, "rect", self._trigger_size_hint_update, weak=True)
293+
bind(child, "size_hint", self._trigger_size_hint_update, weak=True)
294+
bind(child, "size_hint_min", self._trigger_size_hint_update, weak=True)
295+
bind(child, "size_hint_max", self._trigger_size_hint_update, weak=True)
296296

297297
return super().add(child, **kwargs)
298298

299299
@override
300300
def remove(self, child: UIWidget):
301301
"""Remove a child from the layout."""
302302
# unsubscribe from child's changes
303-
unbind(child, "_children", UIBoxLayout._trigger_size_hint_update)
304-
unbind(child, "rect", UIBoxLayout._trigger_size_hint_update)
305-
unbind(child, "size_hint", UIBoxLayout._trigger_size_hint_update)
306-
unbind(child, "size_hint_min", UIBoxLayout._trigger_size_hint_update)
307-
unbind(child, "size_hint_max", UIBoxLayout._trigger_size_hint_update)
303+
unbind(child, "_children", self._trigger_size_hint_update)
304+
unbind(child, "rect", self._trigger_size_hint_update)
305+
unbind(child, "size_hint", self._trigger_size_hint_update)
306+
unbind(child, "size_hint_min", self._trigger_size_hint_update)
307+
unbind(child, "size_hint_max", self._trigger_size_hint_update)
308308

309309
return super().remove(child)
310310

@@ -547,11 +547,11 @@ def add(
547547
row_span: Number of rows the widget will stretch for.
548548
"""
549549
# subscribe to child's changes, which might affect the own size hint
550-
bind(child, "_children", UIGridLayout._trigger_size_hint_update)
551-
bind(child, "rect", UIGridLayout._trigger_size_hint_update)
552-
bind(child, "size_hint", UIGridLayout._trigger_size_hint_update)
553-
bind(child, "size_hint_min", UIGridLayout._trigger_size_hint_update)
554-
bind(child, "size_hint_max", UIGridLayout._trigger_size_hint_update)
550+
bind(child, "_children", self._trigger_size_hint_update, weak=True)
551+
bind(child, "rect", self._trigger_size_hint_update, weak=True)
552+
bind(child, "size_hint", self._trigger_size_hint_update, weak=True)
553+
bind(child, "size_hint_min", self._trigger_size_hint_update, weak=True)
554+
bind(child, "size_hint_max", self._trigger_size_hint_update, weak=True)
555555

556556
return super().add(
557557
child,

tests/unit/gui/test_property.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,12 +144,44 @@ def test_bind_callback_with_star_args():
144144
observer.call_args = None
145145

146146

147-
def test_unbind_callback():
147+
def test_unbind_function_callback():
148+
called = False
149+
def callback(*args):
150+
nonlocal called
151+
called = True
152+
153+
my_obj = MyObject()
154+
bind(my_obj, "name", callback)
155+
156+
# WHEN
157+
unbind(my_obj, "name", callback)
158+
my_obj.name = "New Name"
159+
160+
assert not called
161+
162+
def test_unbind_method_callback():
148163
observer = Observer()
149164

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

168+
gc.collect()
169+
170+
# WHEN
171+
unbind(my_obj, "name", observer.call)
172+
my_obj.name = "New Name"
173+
174+
assert not observer.called
175+
176+
177+
def test_unbind_weak_method_callback():
178+
observer = Observer()
179+
180+
my_obj = MyObject()
181+
bind(my_obj, "name", observer.call, weak=True)
182+
183+
gc.collect()
184+
153185
# WHEN
154186
unbind(my_obj, "name", observer.call)
155187
my_obj.name = "New Name"

0 commit comments

Comments
 (0)