Skip to content

Commit c9f23fa

Browse files
authored
Merge pull request #2746 from pythonarcade/gui/fix-memory-leak-2
Gui/fix memory leak 2
2 parents ce8db7b + 9afc0ee commit c9f23fa

File tree

17 files changed

+313
-136
lines changed

17 files changed

+313
-136
lines changed

arcade/examples/gui/6_size_hints.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,6 @@ def on_change(event: UIOnChangeEvent):
135135
content_anchor.add(UISpace(height=20))
136136

137137
self.ui.execute_layout()
138-
self.ui.debug()
139138

140139

141140
def main():

arcade/gui/experimental/focus.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,9 @@ class UIFocusMixin(UIWidget):
7171
def __init__(self, *args, **kwargs):
7272
super().__init__(*args, **kwargs)
7373

74-
bind(self, "_debug", self.trigger_full_render)
75-
bind(self, "_focused_widget", self.trigger_full_render)
76-
bind(self, "_focusable_widgets", self.trigger_full_render)
74+
bind(self, "_debug", UIFocusMixin.trigger_full_render)
75+
bind(self, "_focused_widget", UIFocusMixin.trigger_full_render)
76+
bind(self, "_focusable_widgets", UIFocusMixin.trigger_full_render)
7777

7878
def on_event(self, event: UIEvent) -> bool | None:
7979
# pass events to children first, including controller events

arcade/gui/experimental/scroll_area.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,11 @@ def __init__(self, scroll_area: UIScrollArea, vertical: bool = True):
4444
self.with_border(color=arcade.uicolor.GRAY_CONCRETE)
4545
self.vertical = vertical
4646

47-
bind(self, "_thumb_hover", self.trigger_render)
48-
bind(self, "_dragging", self.trigger_render)
49-
bind(scroll_area, "scroll_x", self.trigger_full_render)
50-
bind(scroll_area, "scroll_y", self.trigger_full_render)
51-
bind(scroll_area, "rect", self.trigger_full_render)
47+
bind(self, "_thumb_hover", UIScrollBar.trigger_render)
48+
bind(self, "_dragging", UIScrollBar.trigger_render)
49+
bind(scroll_area, "scroll_x", UIScrollBar.trigger_full_render)
50+
bind(scroll_area, "scroll_y", UIScrollBar.trigger_full_render)
51+
bind(scroll_area, "rect", UIScrollBar.trigger_full_render)
5252

5353
def on_event(self, event: UIEvent) -> bool | None:
5454
# check if we are scrollable
@@ -234,8 +234,8 @@ def __init__(
234234
size=canvas_size,
235235
)
236236

237-
bind(self, "scroll_x", self.trigger_full_render)
238-
bind(self, "scroll_y", self.trigger_full_render)
237+
bind(self, "scroll_x", UIScrollArea.trigger_full_render)
238+
bind(self, "scroll_y", UIScrollArea.trigger_full_render)
239239

240240
def add(self, child: W, **kwargs) -> W:
241241
"""Add a child to the widget."""

arcade/gui/property.py

Lines changed: 58 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import traceback
44
from collections.abc import Callable
55
from contextlib import contextmanager, suppress
6+
from enum import Enum
67
from typing import Any, Generic, TypeVar, cast
78
from weakref import WeakKeyDictionary, ref
89

@@ -18,6 +19,41 @@
1819
AnyListener = NoArgListener | InstanceListener | InstanceValueListener | InstanceNewOldListener
1920

2021

22+
class _ListenerType(Enum):
23+
"""Enum to represent the type of listener"""
24+
25+
NO_ARG = 0
26+
INSTANCE = 1
27+
INSTANCE_VALUE = 2
28+
INSTANCE_NEW_OLD = 3
29+
30+
@staticmethod
31+
def detect_callback_type(callback: AnyListener) -> "_ListenerType":
32+
"""Normalizes the callback so every callback can be invoked with the same signature."""
33+
signature = inspect.signature(callback)
34+
35+
# first detect the old *args default listener signatures
36+
with suppress(TypeError):
37+
signature.bind(..., ...)
38+
return _ListenerType.INSTANCE_VALUE
39+
40+
# check for the most common signature
41+
with suppress(TypeError):
42+
signature.bind()
43+
return _ListenerType.NO_ARG
44+
45+
# check for the other
46+
with suppress(TypeError):
47+
signature.bind(..., ..., ...)
48+
return _ListenerType.INSTANCE_NEW_OLD
49+
50+
with suppress(TypeError):
51+
signature.bind(...)
52+
return _ListenerType.INSTANCE
53+
54+
raise TypeError("Callback is not callable")
55+
56+
2157
class _Obs(Generic[P]):
2258
"""
2359
Internal holder for Property value and change listeners
@@ -29,46 +65,24 @@ def __init__(self, value: P):
2965
self.value = value
3066
# This will keep any added listener even if it is not referenced anymore
3167
# and would be garbage collected
32-
self._listeners: dict[AnyListener, InstanceNewOldListener] = dict()
68+
self._listeners: dict[AnyListener, _ListenerType] = dict()
3369

3470
def add(
3571
self,
3672
callback: AnyListener,
3773
):
3874
"""Add a callback to the list of listeners"""
39-
self._listeners[callback] = _Obs._normalize_callback(callback)
75+
self._listeners[callback] = _ListenerType.detect_callback_type(callback)
4076

4177
def remove(self, callback):
4278
"""Remove a callback from the list of listeners"""
4379
if callback in self._listeners:
4480
del self._listeners[callback]
4581

4682
@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")
83+
def listeners(self) -> list[tuple[AnyListener, _ListenerType]]:
84+
"""Returns a list of all listeners and type, both weak and strong."""
85+
return list(self._listeners.items())
7286

7387

7488
class Property(Generic[P]):
@@ -147,17 +161,24 @@ def dispatch(self, instance, value, old_value):
147161
148162
"""
149163
obs = self._get_obs(instance)
150-
for listener in obs.listeners:
164+
for listener, _listener_type in obs.listeners:
151165
try:
152-
listener(instance, value, old_value)
166+
if _listener_type == _ListenerType.NO_ARG:
167+
listener() # type: ignore[call-arg]
168+
elif _listener_type == _ListenerType.INSTANCE:
169+
listener(instance) # type: ignore[call-arg]
170+
elif _listener_type == _ListenerType.INSTANCE_VALUE:
171+
listener(instance, value) # type: ignore[call-arg]
172+
elif _listener_type == _ListenerType.INSTANCE_NEW_OLD:
173+
listener(instance, value, old_value) # type: ignore[call-arg]
153174
except Exception:
154175
print(
155176
f"Change listener for {instance}.{self.name} = {value} raised an exception!",
156177
file=sys.stderr,
157178
)
158179
traceback.print_exc()
159180

160-
def bind(self, instance, callback):
181+
def bind(self, instance: Any, callback: AnyListener):
161182
"""Binds a function to the change event of the property.
162183
163184
A reference to the function will be kept.
@@ -200,7 +221,7 @@ def __set__(self, instance, value: P):
200221
self.set(instance, value)
201222

202223

203-
def bind(instance, property: str, callback):
224+
def bind(instance, property: str, callback: AnyListener):
204225
"""Bind a function to the change event of the property.
205226
206227
A reference to the function will be kept, so that it will be still
@@ -220,6 +241,11 @@ class MyObject:
220241
my_obj.name = "Hans"
221242
# > Value of <__main__.MyObject ...> changed to Hans
222243
244+
Binding to a method of the Property owner itself can cause a memory leak, because the
245+
owner is strongly referenced. Instead, bind the class method, which will be invoked with
246+
the instance as first parameter.
247+
248+
223249
Args:
224250
instance: Instance owning the property
225251
property: Name of the property
@@ -228,6 +254,7 @@ class MyObject:
228254
Returns:
229255
None
230256
"""
257+
# TODO rename property to property_name for arcade 4.0 (just to be sure)
231258
t = type(instance)
232259
prop = getattr(t, property)
233260
if not isinstance(prop, Property):

arcade/gui/ui_manager.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -529,12 +529,10 @@ def _set_active_widget(self, widget: UIWidget | None):
529529
return
530530

531531
if self._active_widget:
532-
print(f"Deactivating widget {self._active_widget.__class__.__name__}")
533532
self._active_widget._active = False
534533

535534
self._active_widget = widget
536535
if self._active_widget:
537-
print(f"Activating widget {self._active_widget.__class__.__name__}")
538536
self._active_widget._active = True
539537

540538
def debug(self):

arcade/gui/widgets/__init__.py

Lines changed: 68 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
from __future__ import annotations
22

3+
import weakref
34
from abc import ABC
45
from collections.abc import Iterable
56
from enum import IntEnum
67
from types import EllipsisType
7-
from typing import TYPE_CHECKING, NamedTuple, TypeVar
8+
from typing import Any, Generic, TYPE_CHECKING, NamedTuple, TypeVar, overload
9+
from weakref import WeakKeyDictionary
810

911
from pyglet.event import EVENT_HANDLED, EVENT_UNHANDLED, EventDispatcher
1012
from pyglet.math import Vec2
@@ -31,6 +33,7 @@
3133
from arcade.gui.ui_manager import UIManager
3234

3335
W = TypeVar("W", bound="UIWidget")
36+
P = TypeVar("P")
3437

3538

3639
class FocusMode(IntEnum):
@@ -51,6 +54,51 @@ class _ChildEntry(NamedTuple):
5154
data: dict
5255

5356

57+
class WeakRef(Generic[P]):
58+
"""A weak reference to a UIWidget parent, which is used to prevent memory leaks."""
59+
60+
__slots__ = ("name", "obs")
61+
name: str
62+
"""Attribute name of the property"""
63+
obs: WeakKeyDictionary[Any, weakref.ref[P]]
64+
"""Weak dictionary to hold the values"""
65+
66+
def __init__(self):
67+
self.obs = WeakKeyDictionary()
68+
69+
def get(self, instance: Any) -> P | None:
70+
"""Get value for owner instance"""
71+
# If the value is not set, return None
72+
value = self.obs.get(instance)
73+
return value() if value else None
74+
75+
def set(self, instance, value: P | None):
76+
"""Set value for owner instance"""
77+
# Store a weak reference to the value
78+
if value is None:
79+
self.obs.pop(instance, None)
80+
else:
81+
self.obs[instance] = weakref.ref(value)
82+
83+
def __set_name__(self, owner, name):
84+
self.name = name
85+
86+
@overload
87+
def __get__(self, instance: None, instance_type) -> Self: ...
88+
89+
@overload
90+
def __get__(self, instance: Any, instance_type) -> P | None: ...
91+
92+
def __get__(self, instance: Any | None, instance_type) -> Self | P | None:
93+
"""Get the value for the owner instance, or None if not set."""
94+
if instance is None:
95+
return self
96+
return self.get(instance)
97+
98+
def __set__(self, instance, value: P | None):
99+
self.set(instance, value)
100+
101+
54102
@copy_dunders_unimplemented
55103
class UIWidget(EventDispatcher, ABC):
56104
"""The :class:`UIWidget` class is the base class required for creating widgets.
@@ -71,6 +119,9 @@ class UIWidget(EventDispatcher, ABC):
71119
size_hint_max: max width and height in pixel
72120
"""
73121

122+
parent: WeakRef[UIManager | UIWidget | None] = WeakRef()
123+
"""A weak reference to the parent UIManager or UIWidget,
124+
which does not prevent garbage collection of the parent."""
74125
rect = Property(LBWH(0, 0, 1, 1))
75126
visible = Property(True)
76127
focused = Property(False)
@@ -113,7 +164,6 @@ def __init__(
113164
):
114165
self._requires_render = True
115166
self.rect = LBWH(x, y, width, height)
116-
self.parent: UIManager | UIWidget | None = None
117167

118168
# Size hints are properties that can be used by layouts
119169
self.size_hint = size_hint
@@ -126,21 +176,21 @@ def __init__(
126176
for child in children:
127177
self.add(child)
128178

129-
bind(self, "rect", self.trigger_full_render)
130-
bind(self, "focused", self.trigger_full_render)
179+
bind(self, "rect", UIWidget.trigger_full_render)
180+
bind(self, "focused", UIWidget.trigger_full_render)
131181
bind(
132-
self, "visible", self.trigger_full_render
182+
self, "visible", UIWidget.trigger_full_render
133183
) # TODO maybe trigger_parent_render would be enough
134-
bind(self, "_children", self.trigger_render)
135-
bind(self, "_border_width", self.trigger_render)
136-
bind(self, "_border_color", self.trigger_render)
137-
bind(self, "_bg_color", self.trigger_render)
138-
bind(self, "_bg_tex", self.trigger_render)
139-
bind(self, "_padding_top", self.trigger_render)
140-
bind(self, "_padding_right", self.trigger_render)
141-
bind(self, "_padding_bottom", self.trigger_render)
142-
bind(self, "_padding_left", self.trigger_render)
143-
bind(self, "_strong_background", self.trigger_render)
184+
bind(self, "_children", UIWidget.trigger_render)
185+
bind(self, "_border_width", UIWidget.trigger_render)
186+
bind(self, "_border_color", UIWidget.trigger_render)
187+
bind(self, "_bg_color", UIWidget.trigger_render)
188+
bind(self, "_bg_tex", UIWidget.trigger_render)
189+
bind(self, "_padding_top", UIWidget.trigger_render)
190+
bind(self, "_padding_right", UIWidget.trigger_render)
191+
bind(self, "_padding_bottom", UIWidget.trigger_render)
192+
bind(self, "_padding_left", UIWidget.trigger_render)
193+
bind(self, "_strong_background", UIWidget.trigger_render)
144194

145195
def add(self, child: W, **kwargs) -> W:
146196
"""Add a widget as a child.
@@ -692,9 +742,9 @@ def __init__(
692742

693743
self.interaction_buttons = interaction_buttons
694744

695-
bind(self, "pressed", self.trigger_render)
696-
bind(self, "hovered", self.trigger_render)
697-
bind(self, "disabled", self.trigger_render)
745+
bind(self, "pressed", UIInteractiveWidget.trigger_render)
746+
bind(self, "hovered", UIInteractiveWidget.trigger_render)
747+
bind(self, "disabled", UIInteractiveWidget.trigger_render)
698748

699749
def on_event(self, event: UIEvent) -> bool | None:
700750
"""Handles mouse events and triggers on_click event if the widget is clicked.

arcade/gui/widgets/buttons.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ def __init__(
134134
if texture_disabled:
135135
self._textures["disabled"] = texture_disabled
136136

137-
bind(self, "_textures", self.trigger_render)
137+
bind(self, "_textures", UITextureButton.trigger_render)
138138

139139
# prepare label with default style
140140
_style = self.get_current_style()

arcade/gui/widgets/image.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,9 @@ def __init__(
5555
height=height if height else texture.height,
5656
**kwargs,
5757
)
58-
bind(self, "texture", self.trigger_render)
59-
bind(self, "alpha", self.trigger_full_render)
60-
bind(self, "angle", self.trigger_full_render)
58+
bind(self, "texture", UIImage.trigger_render)
59+
bind(self, "alpha", UIImage.trigger_full_render)
60+
bind(self, "angle", UIImage.trigger_full_render)
6161

6262
@override
6363
def do_render(self, surface: Surface):

0 commit comments

Comments
 (0)