diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bdadef5f6e..4d5410f513 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ on: env: min_python_version: "3.8" - max_python_version: "3.11" + max_python_version: "3.12" defaults: run: @@ -65,12 +65,12 @@ jobs: strategy: matrix: platform: [ "macos", "ubuntu", "windows" ] - python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12-dev" ] + python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12" ] include: - experimental: false - - python-version: "3.12-dev" - experimental: true + # - python-version: "3.13-dev" + # experimental: true steps: - uses: actions/checkout@v4.1.0 - name: Set up Python ${{ matrix.python-version }} diff --git a/changes/1215.bugfix.rst b/changes/1215.bugfix.rst new file mode 100644 index 0000000000..e4bc5ad084 --- /dev/null +++ b/changes/1215.bugfix.rst @@ -0,0 +1 @@ +Widgets are now removed from windows when the window is closed, preventing a memory leak on window closure. diff --git a/core/src/toga/widgets/base.py b/core/src/toga/widgets/base.py index 5ea49c2226..5d5b0e7bca 100644 --- a/core/src/toga/widgets/base.py +++ b/core/src/toga/widgets/base.py @@ -2,6 +2,7 @@ from builtins import id as identifier from typing import TYPE_CHECKING, Iterator, NoReturn +from weakref import WeakValueDictionary from travertino.node import Node @@ -13,10 +14,12 @@ from toga.window import Window -class WidgetRegistry(dict): - # WidgetRegistry is implemented as a subclass of dict, because it provides - # a mapping from ID to widget. However, it exposes a set-like API; add() - # and update() take instances to be added, and iteration is over values. +class WidgetRegistry(WeakValueDictionary): + # WidgetRegistry is implemented as a subclass of WeakValueDictionary, because it + # provides a mapping from ID to widget. However, it exposes a set-like API; add() + # and update() take instances to be added, and iteration is over values. The + # mapping is weak so the registry doesn't retain a strong reference to the widget, + # preventing memory cleanup. def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -43,6 +46,9 @@ def remove(self, id: str) -> None: def __iter__(self) -> Iterator[Widget]: return iter(self.values()) + def __repr__(self) -> str: + return "{" + ", ".join(f"{k!r}: {v!r}" for k, v in self.items()) + "}" + class Widget(Node): _MIN_WIDTH = 100 diff --git a/core/src/toga/window.py b/core/src/toga/window.py index dd236dbd43..f223ee31f5 100644 --- a/core/src/toga/window.py +++ b/core/src/toga/window.py @@ -116,6 +116,7 @@ def __init__( self._impl = None self._content = None self._is_full_screen = False + self._closed = False self._resizable = resizable self._closable = closable @@ -155,6 +156,11 @@ def app(self, app: App) -> None: self._app = app self._impl.set_app(app._impl) + @property + def closed(self) -> bool: + """Whether the window was closed.""" + return self._closed + @property def _default_title(self) -> str: return "Toga" @@ -298,9 +304,15 @@ def close(self) -> None: This *does not* invoke the ``on_close`` handler; the window will be immediately and unconditionally closed. + + Once a window has been closed, it *cannot* be reused. The behavior of any method + or property on a :class:`~toga.Window` instance after it has been closed is + undefined, except for :attr:`closed` which can be used to check if the window + was closed. """ self.app.windows -= self self._impl.close() + self._closed = True ############################################################ # Dialogs diff --git a/core/tests/test_window.py b/core/tests/test_window.py index 5b67dbebd5..09bbf28ee4 100644 --- a/core/tests/test_window.py +++ b/core/tests/test_window.py @@ -253,6 +253,7 @@ def test_close_direct(window, app): window.close() # Window has been closed, but the close handler has *not* been invoked. + assert window.closed assert window.app == app assert window not in app.windows assert_action_performed(window, "close") @@ -269,6 +270,7 @@ def test_close_no_handler(window, app): window._impl.simulate_close() # Window has been closed, and is no longer in the app's list of windows. + assert window.closed assert window.app == app assert window not in app.windows assert_action_performed(window, "close") @@ -287,6 +289,7 @@ def test_close_sucessful_handler(window, app): window._impl.simulate_close() # Window has been closed, and is no longer in the app's list of windows. + assert window.closed assert window.app == app assert window not in app.windows assert_action_performed(window, "close") @@ -306,6 +309,7 @@ def test_close_rejected_handler(window, app): window._impl.simulate_close() # Window has *not* been closed + assert not window.closed assert window.app == app assert window in app.windows assert_action_not_performed(window, "close") diff --git a/core/tests/widgets/test_base.py b/core/tests/widgets/test_base.py index 3459838236..b3f3ca810a 100644 --- a/core/tests/widgets/test_base.py +++ b/core/tests/widgets/test_base.py @@ -123,6 +123,10 @@ def test_add_child(widget): assert widget.app == app assert widget.window == window + # Widget is registered with app and window + assert widget.id in app.widgets + assert widget.id in window.widgets + # Child list is empty assert widget.children == [] @@ -181,10 +185,7 @@ def test_add_multiple_children(widget): child3 = ExampleLeafWidget(id="child3_id") # App's widget index only contains the parent - assert app.widgets["widget_id"] == widget - assert "child1_id" not in app.widgets - assert "child2_id" not in app.widgets - assert "child3_id" not in app.widgets + assert app.widgets == {"widget_id": widget} # Add the children. widget.add(child1, child2, child3) @@ -218,11 +219,20 @@ def test_add_multiple_children(widget): assert_action_performed_with(window.content, "refresh") # App's widget index has been updated - assert len(app.widgets) == 4 - assert app.widgets["widget_id"] == widget - assert app.widgets["child1_id"] == child1 - assert app.widgets["child2_id"] == child2 - assert app.widgets["child3_id"] == child3 + assert app.widgets == { + "widget_id": widget, + "child1_id": child1, + "child2_id": child2, + "child3_id": child3, + } + + # Window's widget index has been updated + assert window.widgets == { + "widget_id": widget, + "child1_id": child1, + "child2_id": child2, + "child3_id": child3, + } def test_reparent_child(widget): @@ -375,9 +385,16 @@ def test_insert_child(widget): assert_action_performed_with(window.content, "refresh") # App's widget index has been updated - assert len(app.widgets) == 2 - assert app.widgets["widget_id"] == widget - assert app.widgets["child_id"] == child + assert app.widgets == { + "widget_id": widget, + "child_id": child, + } + + # Window's widget index has been updated + assert window.widgets == { + "widget_id": widget, + "child_id": child, + } def test_insert_position(widget): @@ -402,10 +419,10 @@ def test_insert_position(widget): child3 = ExampleLeafWidget(id="child3_id") # App's widget index only contains the parent - assert app.widgets["widget_id"] == widget - assert "child1_id" not in app.widgets - assert "child2_id" not in app.widgets - assert "child3_id" not in app.widgets + assert app.widgets == {"widget_id": widget} + + # Windows's widget index only contains the parent + assert window.widgets == {"widget_id": widget} # insert the children. widget.insert(0, child1) @@ -440,11 +457,20 @@ def test_insert_position(widget): assert_action_performed_with(window.content, "refresh") # App's widget index has been updated - assert len(app.widgets) == 4 - assert app.widgets["widget_id"] == widget - assert app.widgets["child1_id"] == child1 - assert app.widgets["child2_id"] == child2 - assert app.widgets["child3_id"] == child3 + assert app.widgets == { + "widget_id": widget, + "child1_id": child1, + "child2_id": child2, + "child3_id": child3, + } + + # Window's widget index has been updated + assert window.widgets == { + "widget_id": widget, + "child1_id": child1, + "child2_id": child2, + "child3_id": child3, + } def test_insert_bad_position(widget): @@ -467,10 +493,12 @@ def test_insert_bad_position(widget): child = ExampleLeafWidget(id="child_id") # App's widget index only contains the parent - assert app.widgets["widget_id"] == widget - assert "child_id" not in app.widgets + assert app.widgets == {"widget_id": widget} + + # Window's widget index only contains the parent + assert window.widgets == {"widget_id": widget} - # Insert the child at an position greater than the length of the list. + # Insert the child at a position greater than the length of the list. # Widget will be added to the end of the list. widget.insert(37, child) @@ -492,9 +520,16 @@ def test_insert_bad_position(widget): assert_action_performed_with(window.content, "refresh") # App's widget index has been updated - assert len(app.widgets) == 2 - assert app.widgets["widget_id"] == widget - assert app.widgets["child_id"] == child + assert app.widgets == { + "widget_id": widget, + "child_id": child, + } + + # Window's widget index has been updated + assert window.widgets == { + "widget_id": widget, + "child_id": child, + } def test_insert_reparent_child(widget): @@ -618,6 +653,8 @@ def test_remove_child(widget): assert child.parent == widget assert child.app == app assert child.window == window + assert app.widgets == {"widget_id": widget, "child_id": child} + assert window.widgets == {"widget_id": widget, "child_id": child} # Remove the child widget.remove(child) @@ -630,6 +667,10 @@ def test_remove_child(widget): assert child.app is None assert child.window is None + # child widget no longer exists in the app or widgets registries. + assert app.widgets == {"widget_id": widget} + assert window.widgets == {"widget_id": widget} + # The impl's remove_child has been invoked assert_action_performed_with(widget, "remove child", child=child._impl) @@ -639,6 +680,9 @@ def test_remove_child(widget): # The window's content gets a refresh notification assert_action_performed_with(window.content, "refresh") + # App's widget index does not contain the widget + assert "child_id" not in app.widgets + def test_remove_multiple_children(widget): "Multiple children can be removed from a widget" @@ -659,6 +703,8 @@ def test_remove_multiple_children(widget): assert child.parent == widget assert child.app == app assert child.window == window + assert app.widgets[child.id] == child + assert window.widgets[child.id] == child # Remove 2 children widget.remove(child1, child3) @@ -689,6 +735,14 @@ def test_remove_multiple_children(widget): # The window's content gets a refresh notification assert_action_performed_with(window.content, "refresh") + # App's widget index does not contain the widget + assert "child1_id" not in app.widgets + assert "child3_id" not in app.widgets + + # Windows's widget index does not contain the widget + assert "child1_id" not in window.widgets + assert "child3_id" not in window.widgets + def test_clear_all_children(widget): "All children can be simultaneously removed from a widget" @@ -709,6 +763,8 @@ def test_clear_all_children(widget): assert child.parent == widget assert child.app == app assert child.window == window + assert app.widgets[child.id] == child + assert window.widgets[child.id] == child # Clear children widget.clear() @@ -740,6 +796,16 @@ def test_clear_all_children(widget): # The window's content gets a refresh notification assert_action_performed_with(window.content, "refresh") + # App's widget index does not contain the widget + assert "child1_id" not in app.widgets + assert "child2_id" not in app.widgets + assert "child3_id" not in app.widgets + + # Window's widget index does not contain the widget + assert "child1_id" not in window.widgets + assert "child2_id" not in window.widgets + assert "child3_id" not in window.widgets + def test_clear_no_children(widget): "No changes are made (no-op) if widget has no children" @@ -830,7 +896,7 @@ def test_set_app(widget): assert len(app.widgets) == 1 assert app.widgets["widget_id"] == widget - # The impl has had it's app property set. + # The impl has had its app property set. assert attribute_value(widget, "app") == app diff --git a/docs/reference/api/window.rst b/docs/reference/api/window.rst index 50e0b94f79..f622a9cd5f 100644 --- a/docs/reference/api/window.rst +++ b/docs/reference/api/window.rst @@ -46,6 +46,10 @@ If the user attempts to close the window, Toga will call the ``on_close`` handle handler must return a ``bool`` confirming whether the close is permitted. This can be used to implement protections against closing a window with unsaved changes. +Once a window has been closed (either by user action, or programmatically with +:meth:`~toga.Window.close()`), it *cannot* be reused. The behavior of any method on a +:class:`~toga.Window` instance after it has been closed is undefined. + Notes ----- diff --git a/testbed/tests/test_window.py b/testbed/tests/test_window.py index 59607fed7b..11acca0453 100644 --- a/testbed/tests/test_window.py +++ b/testbed/tests/test_window.py @@ -1,6 +1,8 @@ +import gc import io import re import traceback +import weakref from asyncio import wait_for from importlib import import_module from pathlib import Path @@ -219,6 +221,32 @@ async def test_secondary_window_with_args(app, second_window, second_window_prob assert second_window not in app.windows + async def test_secondary_window_cleanup(app_probe): + """Memory for windows is cleaned up when windows are deleted.""" + # Create and show a window with content. We can't use the second_window fixture + # because the fixture will retain a reference, preventing garbage collection. + second_window = toga.Window() + second_window.content = toga.Box() + second_window.show() + await app_probe.redraw("Secondary Window has been created") + + # Retain a weak reference to the window to check garbage collection + window_ref = weakref.ref(second_window) + impl_ref = weakref.ref(second_window._impl) + + second_window.close() + await app_probe.redraw("Secondary window has been closed") + + # Clear the local reference to the window (which should be the last reference), + # and force a garbage collection pass. This should cause deletion of both the + # interface and impl of the window. + del second_window + gc.collect() + + # Assert that the weak references are now dead. + assert window_ref() is None + assert impl_ref() is None + @pytest.mark.parametrize( "second_window_kwargs", [dict(title="Not Resizable", resizable=False, position=(200, 150))], diff --git a/testbed/tests/widgets/test_webview.py b/testbed/tests/widgets/test_webview.py index f6b28f1a03..10dcb6dd18 100644 --- a/testbed/tests/widgets/test_webview.py +++ b/testbed/tests/widgets/test_webview.py @@ -1,4 +1,5 @@ import asyncio +import gc from asyncio import wait_for from contextlib import nullcontext from time import time @@ -78,6 +79,13 @@ async def on_load(): @pytest.fixture async def widget(on_load): + if toga.platform.current_platform == "linux": + # On Gtk, ensure that the WebView from a previous test run is garbage collected. + # This prevents a segfault at GC time likely coming from the test suite running + # in a thread and Gtk WebViews sharing resources between instances. We perform + # the GC run here since pytest fixtures make earlier cleanup difficult. + gc.collect() + widget = toga.WebView(style=Pack(flex=1), on_webview_load=on_load) # We shouldn't be able to get a callback until at least one tick of the event loop # has completed. diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 7633a37611..3e1e4d9aab 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -15,6 +15,7 @@ from .keys import toga_to_winforms_key from .libs.proactor import WinformsProactorEventLoop +from .libs.wrapper import WeakrefCallable from .window import Window @@ -154,7 +155,7 @@ def create_menus(self): item = WinForms.ToolStripMenuItem(cmd.text) if cmd.action: - item.Click += cmd._impl.as_handler() + item.Click += WeakrefCallable(cmd._impl.as_handler()) item.Enabled = cmd.enabled if cmd.shortcut is not None: @@ -247,7 +248,9 @@ def run_app(self): # This catches errors in handlers, and prints them # in a usable form. - self.native.ThreadException += self.winforms_thread_exception + self.native.ThreadException += WeakrefCallable( + self.winforms_thread_exception + ) self.loop.run_forever(self) except Exception as e: diff --git a/winforms/src/toga_winforms/dialogs.py b/winforms/src/toga_winforms/dialogs.py index 2c8cfad9e0..5b82d9e5ca 100644 --- a/winforms/src/toga_winforms/dialogs.py +++ b/winforms/src/toga_winforms/dialogs.py @@ -12,6 +12,8 @@ ) from System.Windows.Forms import DialogResult, MessageBoxButtons, MessageBoxIcon +from .libs.wrapper import WeakrefCallable + class BaseDialog(ABC): def __init__(self, interface, on_result): @@ -102,7 +104,7 @@ def __init__(self, interface, title, message, content, retry, on_result): self.native.MinimizeBox = False self.native.FormBorderStyle = self.native.FormBorderStyle.FixedSingle self.native.MaximizeBox = False - self.native.FormClosing += self.winforms_FormClosing + self.native.FormClosing += WeakrefCallable(self.winforms_FormClosing) self.native.Width = 540 self.native.Height = 320 self.native.Text = title @@ -141,7 +143,7 @@ def __init__(self, interface, title, message, content, retry, on_result): retry.Top = 250 retry.Width = 100 retry.Text = "&Retry" - retry.Click += self.winforms_Click_retry + retry.Click += WeakrefCallable(self.winforms_Click_retry) self.native.Controls.Add(retry) @@ -150,7 +152,7 @@ def __init__(self, interface, title, message, content, retry, on_result): quit.Top = 250 quit.Width = 100 quit.Text = "&Quit" - quit.Click += self.winforms_Click_quit + quit.Click += WeakrefCallable(self.winforms_Click_quit) self.native.Controls.Add(quit) else: @@ -159,7 +161,7 @@ def __init__(self, interface, title, message, content, retry, on_result): accept.Top = 250 accept.Width = 100 accept.Text = "&OK" - accept.Click += self.winforms_Click_accept + accept.Click += WeakrefCallable(self.winforms_Click_accept) self.native.Controls.Add(accept) diff --git a/winforms/src/toga_winforms/libs/wrapper.py b/winforms/src/toga_winforms/libs/wrapper.py new file mode 100644 index 0000000000..958db4cc66 --- /dev/null +++ b/winforms/src/toga_winforms/libs/wrapper.py @@ -0,0 +1,22 @@ +import weakref + + +class WeakrefCallable: + """ + A wrapper for callable that holds a weak reference to it. + + This can be useful in particular when setting winforms event handlers, to avoid + cyclical reference cycles between Python and the .NET CLR that are detected neither + by the Python garbage collector nor the C# garbage collector. + """ + + def __init__(self, function): + try: + self.ref = weakref.WeakMethod(function) + except TypeError: + self.ref = weakref.ref(function) + + def __call__(self, *args, **kwargs): + function = self.ref() + if function: + return function(*args, **kwargs) diff --git a/winforms/src/toga_winforms/widgets/box.py b/winforms/src/toga_winforms/widgets/box.py index 82c00a41fc..6025c443a8 100644 --- a/winforms/src/toga_winforms/widgets/box.py +++ b/winforms/src/toga_winforms/widgets/box.py @@ -7,7 +7,6 @@ class Box(Widget): def create(self): self.native = WinForms.Panel() - self.native.interface = self.interface def rehint(self): self.interface.intrinsic.width = at_least(0) diff --git a/winforms/src/toga_winforms/widgets/button.py b/winforms/src/toga_winforms/widgets/button.py index caf699f35d..372a2c06b5 100644 --- a/winforms/src/toga_winforms/widgets/button.py +++ b/winforms/src/toga_winforms/widgets/button.py @@ -1,6 +1,7 @@ import System.Windows.Forms as WinForms from travertino.size import at_least +from ..libs.wrapper import WeakrefCallable from .base import Widget @@ -10,7 +11,7 @@ class Button(Widget): def create(self): self.native = WinForms.Button() self.native.AutoSizeMode = WinForms.AutoSizeMode.GrowAndShrink - self.native.Click += self.winforms_click + self.native.Click += WeakrefCallable(self.winforms_click) def winforms_click(self, sender, event): self.interface.on_press(None) diff --git a/winforms/src/toga_winforms/widgets/canvas.py b/winforms/src/toga_winforms/widgets/canvas.py index e762e51658..1d8d6ba8ab 100644 --- a/winforms/src/toga_winforms/widgets/canvas.py +++ b/winforms/src/toga_winforms/widgets/canvas.py @@ -24,6 +24,7 @@ from toga.widgets.canvas import Baseline, FillRule from toga_winforms.colors import native_color +from ..libs.wrapper import WeakrefCallable from .box import Box @@ -71,11 +72,11 @@ class Canvas(Box): def create(self): super().create() self.native.DoubleBuffered = True - self.native.Paint += self.winforms_paint - self.native.Resize += self.winforms_resize - self.native.MouseDown += self.winforms_mouse_down - self.native.MouseMove += self.winforms_mouse_move - self.native.MouseUp += self.winforms_mouse_up + self.native.Paint += WeakrefCallable(self.winforms_paint) + self.native.Resize += WeakrefCallable(self.winforms_resize) + self.native.MouseDown += WeakrefCallable(self.winforms_mouse_down) + self.native.MouseMove += WeakrefCallable(self.winforms_mouse_move) + self.native.MouseUp += WeakrefCallable(self.winforms_mouse_up) self.string_format = StringFormat.GenericTypographic self.dragging = False self.states = [] diff --git a/winforms/src/toga_winforms/widgets/dateinput.py b/winforms/src/toga_winforms/widgets/dateinput.py index 86f8d33c56..adb5077d00 100644 --- a/winforms/src/toga_winforms/widgets/dateinput.py +++ b/winforms/src/toga_winforms/widgets/dateinput.py @@ -4,6 +4,7 @@ from System import DateTime as WinDateTime from travertino.size import at_least +from ..libs.wrapper import WeakrefCallable from .base import Widget @@ -20,7 +21,7 @@ class DateInput(Widget): def create(self): self.native = WinForms.DateTimePicker() - self.native.ValueChanged += self.winforms_value_changed + self.native.ValueChanged += WeakrefCallable(self.winforms_value_changed) def get_value(self): return py_date(self.native.Value) diff --git a/winforms/src/toga_winforms/widgets/detailedlist.py b/winforms/src/toga_winforms/widgets/detailedlist.py index 58b96f2517..e93d8d70db 100644 --- a/winforms/src/toga_winforms/widgets/detailedlist.py +++ b/winforms/src/toga_winforms/widgets/detailedlist.py @@ -41,8 +41,9 @@ def create(self): super().create() self._table_source = TableSource(self.interface) - # DetailedList doesn't have an on_activate handler. - self.native.MouseDoubleClick -= self.winforms_double_click + def add_action_events(self): + # DetailedList doesn't have an on_activate_handler. + pass def set_primary_action_enabled(self, enabled): self.primary_action_enabled = enabled diff --git a/winforms/src/toga_winforms/widgets/imageview.py b/winforms/src/toga_winforms/widgets/imageview.py index d9e9c81a3d..4795c1daf4 100644 --- a/winforms/src/toga_winforms/widgets/imageview.py +++ b/winforms/src/toga_winforms/widgets/imageview.py @@ -9,7 +9,6 @@ class ImageView(Widget): def create(self): self.native = WinForms.PictureBox() - self.native.interface = self.interface self.native.SizeMode = WinForms.PictureBoxSizeMode.Zoom # If self.native.Image is None, Winforms renders it as a white square diff --git a/winforms/src/toga_winforms/widgets/multilinetextinput.py b/winforms/src/toga_winforms/widgets/multilinetextinput.py index 41f5c3da16..681e99a2ac 100644 --- a/winforms/src/toga_winforms/widgets/multilinetextinput.py +++ b/winforms/src/toga_winforms/widgets/multilinetextinput.py @@ -5,6 +5,7 @@ from toga_winforms.colors import native_color from toga_winforms.libs.fonts import HorizontalTextAlignment +from ..libs.wrapper import WeakrefCallable from .textinput import TextInput @@ -14,12 +15,12 @@ def create(self): # (https://stackoverflow.com/a/612234). self.native = WinForms.RichTextBox() self.native.Multiline = True - self.native.TextChanged += self.winforms_text_changed + self.native.TextChanged += WeakrefCallable(self.winforms_text_changed) # When moving focus with the tab key, the Enter/Leave event handlers see the # wrong value of ContainsFocus, so we use GotFocus/LostFocus instead. - self.native.GotFocus += self.winforms_got_focus - self.native.LostFocus += self.winforms_lost_focus + self.native.GotFocus += WeakrefCallable(self.winforms_got_focus) + self.native.LostFocus += WeakrefCallable(self.winforms_lost_focus) # Dummy values used during initialization self._placeholder = "" diff --git a/winforms/src/toga_winforms/widgets/numberinput.py b/winforms/src/toga_winforms/widgets/numberinput.py index 4639b0480a..9b677d2d60 100644 --- a/winforms/src/toga_winforms/widgets/numberinput.py +++ b/winforms/src/toga_winforms/widgets/numberinput.py @@ -8,6 +8,7 @@ from toga.widgets.numberinput import _clean_decimal from toga_winforms.libs.fonts import HorizontalTextAlignment +from ..libs.wrapper import WeakrefCallable from .base import Widget @@ -26,7 +27,7 @@ class NumberInput(Widget): def create(self): self.native = WinForms.NumericUpDown() - self.native.TextChanged += self.winforms_text_changed + self.native.TextChanged += WeakrefCallable(self.winforms_text_changed) def winforms_text_changed(self, sender, event): self.interface.on_change(None) diff --git a/winforms/src/toga_winforms/widgets/optioncontainer.py b/winforms/src/toga_winforms/widgets/optioncontainer.py index 1a5ed7ff8e..15bc0e5813 100644 --- a/winforms/src/toga_winforms/widgets/optioncontainer.py +++ b/winforms/src/toga_winforms/widgets/optioncontainer.py @@ -2,13 +2,14 @@ from travertino.size import at_least from ..container import Container +from ..libs.wrapper import WeakrefCallable from .base import Widget class OptionContainer(Widget): def create(self): self.native = TabControl() - self.native.Selected += self.winforms_selected + self.native.Selected += WeakrefCallable(self.winforms_selected) self.panels = [] def add_content(self, index, text, widget): @@ -24,7 +25,8 @@ def add_content(self, index, text, widget): # newly-selected tab's ClientSize is not updated until some time after the # Selected event fires. self.resize_content(panel) - page.ClientSizeChanged += lambda sender, event: self.resize_content(panel) + + page.ClientSizeChanged += WeakrefCallable(self.winforms_client_size_changed) def remove_content(self, index): panel = self.panels.pop(index) @@ -58,6 +60,10 @@ def set_current_tab_index(self, current_tab_index): def winforms_selected(self, sender, event): self.interface.on_select(None) + def winforms_client_size_changed(self, sender, event): + for panel in self.panels: + self.resize_content(panel) + def resize_content(self, panel): size = panel.native_parent.ClientSize panel.resize_content(size.Width, size.Height) diff --git a/winforms/src/toga_winforms/widgets/scrollcontainer.py b/winforms/src/toga_winforms/widgets/scrollcontainer.py index 2fa3b45ffa..fc7c88b61a 100644 --- a/winforms/src/toga_winforms/widgets/scrollcontainer.py +++ b/winforms/src/toga_winforms/widgets/scrollcontainer.py @@ -7,6 +7,7 @@ from toga_winforms.container import Container +from ..libs.wrapper import WeakrefCallable from .base import Widget # On Windows, scroll bars usually appear only when the content is larger than the @@ -36,8 +37,8 @@ def create(self): # The Scroll event only fires on direct interaction with the scroll bar. It # doesn't fire when using the mouse wheel, and it doesn't fire when setting # AutoScrollPosition either, despite the documentation saying otherwise. - self.native.Scroll += self.winforms_scroll - self.native.MouseWheel += self.winforms_scroll + self.native.Scroll += WeakrefCallable(self.winforms_scroll) + self.native.MouseWheel += WeakrefCallable(self.winforms_scroll) def winforms_scroll(self, sender, event): self.interface.on_scroll(None) diff --git a/winforms/src/toga_winforms/widgets/selection.py b/winforms/src/toga_winforms/widgets/selection.py index bcbfa0d73f..76453965eb 100644 --- a/winforms/src/toga_winforms/widgets/selection.py +++ b/winforms/src/toga_winforms/widgets/selection.py @@ -3,6 +3,7 @@ import System.Windows.Forms as WinForms from travertino.size import at_least +from ..libs.wrapper import WeakrefCallable from .base import Widget @@ -11,7 +12,9 @@ def __init__(self, impl): super().__init__() self.impl = impl self.DropDownStyle = WinForms.ComboBoxStyle.DropDownList - self.SelectedIndexChanged += self.winforms_selected_index_changed + self.SelectedIndexChanged += WeakrefCallable( + self.winforms_selected_index_changed + ) def winforms_selected_index_changed(self, sender, event): self.impl.on_change() diff --git a/winforms/src/toga_winforms/widgets/slider.py b/winforms/src/toga_winforms/widgets/slider.py index 2ba97d4261..0d5c0cd547 100644 --- a/winforms/src/toga_winforms/widgets/slider.py +++ b/winforms/src/toga_winforms/widgets/slider.py @@ -3,6 +3,7 @@ from toga.widgets.slider import IntSliderImpl +from ..libs.wrapper import WeakrefCallable from .base import Widget # Implementation notes @@ -25,9 +26,18 @@ def create(self): # Unlike Scroll, ValueChanged also fires when the value is changed # programmatically, such as via the testbed probe. - self.native.ValueChanged += lambda sender, event: self.on_change() - self.native.MouseDown += lambda sender, event: self.interface.on_press(None) - self.native.MouseUp += lambda sender, event: self.interface.on_release(None) + self.native.ValueChanged += WeakrefCallable(self.winforms_value_chaned) + self.native.MouseDown += WeakrefCallable(self.winforms_mouse_down) + self.native.MouseUp += WeakrefCallable(self.winforms_mouse_up) + + def winforms_value_chaned(self, sender, event): + self.on_change() + + def winforms_mouse_down(self, sender, event): + self.interface.on_press(None) + + def winforms_mouse_up(self, sender, event): + self.interface.on_release(None) def get_int_value(self): return self.native.Value diff --git a/winforms/src/toga_winforms/widgets/splitcontainer.py b/winforms/src/toga_winforms/widgets/splitcontainer.py index 0a51ae01c4..f2605fd539 100644 --- a/winforms/src/toga_winforms/widgets/splitcontainer.py +++ b/winforms/src/toga_winforms/widgets/splitcontainer.py @@ -8,13 +8,14 @@ from toga.constants import Direction from ..container import Container +from ..libs.wrapper import WeakrefCallable from .base import Widget class SplitContainer(Widget): def create(self): self.native = NativeSplitContainer() - self.native.SplitterMoved += lambda sender, event: self.resize_content() + self.native.SplitterMoved += WeakrefCallable(self.winforms_splitter_moved) # Despite what the BorderStyle documentation says, there is no border by default # (at least on Windows 10), which would make the split bar invisible. @@ -23,6 +24,9 @@ def create(self): self.panels = (Container(self.native.Panel1), Container(self.native.Panel2)) self.pending_position = None + def winforms_splitter_moved(self, sender, event): + self.resize_content() + def set_bounds(self, x, y, width, height): super().set_bounds(x, y, width, height) diff --git a/winforms/src/toga_winforms/widgets/switch.py b/winforms/src/toga_winforms/widgets/switch.py index 09412b51e5..33f6702d62 100644 --- a/winforms/src/toga_winforms/widgets/switch.py +++ b/winforms/src/toga_winforms/widgets/switch.py @@ -1,13 +1,14 @@ import System.Windows.Forms as WinForms from travertino.size import at_least +from ..libs.wrapper import WeakrefCallable from .base import Widget class Switch(Widget): def create(self): self.native = WinForms.CheckBox() - self.native.CheckedChanged += self.winforms_checked_changed + self.native.CheckedChanged += WeakrefCallable(self.winforms_checked_changed) def winforms_checked_changed(self, sender, event): self.interface.on_change(None) diff --git a/winforms/src/toga_winforms/widgets/table.py b/winforms/src/toga_winforms/widgets/table.py index 9e0a0bfe5d..5b8c1a9b43 100644 --- a/winforms/src/toga_winforms/widgets/table.py +++ b/winforms/src/toga_winforms/widgets/table.py @@ -5,6 +5,7 @@ import toga +from ..libs.wrapper import WeakrefCallable from .base import Widget @@ -54,13 +55,22 @@ def create(self): self.native.Columns.AddRange(dataColumn) self.native.SmallImageList = WinForms.ImageList() - self.native.ItemSelectionChanged += self.winforms_item_selection_changed - self.native.RetrieveVirtualItem += self.winforms_retrieve_virtual_item - self.native.CacheVirtualItems += self.winforms_cache_virtual_items - self.native.MouseDoubleClick += self.winforms_double_click - self.native.VirtualItemsSelectionRangeChanged += ( + self.native.ItemSelectionChanged += WeakrefCallable( self.winforms_item_selection_changed ) + self.native.RetrieveVirtualItem += WeakrefCallable( + self.winforms_retrieve_virtual_item + ) + self.native.CacheVirtualItems += WeakrefCallable( + self.winforms_cache_virtual_items + ) + self.native.VirtualItemsSelectionRangeChanged += WeakrefCallable( + self.winforms_item_selection_changed + ) + self.add_action_events() + + def add_action_events(self): + self.native.MouseDoubleClick += WeakrefCallable(self.winforms_double_click) def set_bounds(self, x, y, width, height): super().set_bounds(x, y, width, height) diff --git a/winforms/src/toga_winforms/widgets/textinput.py b/winforms/src/toga_winforms/widgets/textinput.py index b3650872de..08ea69b593 100644 --- a/winforms/src/toga_winforms/widgets/textinput.py +++ b/winforms/src/toga_winforms/widgets/textinput.py @@ -7,6 +7,7 @@ from toga_winforms.colors import native_color from toga_winforms.libs.fonts import HorizontalTextAlignment +from ..libs.wrapper import WeakrefCallable from .base import Widget @@ -16,10 +17,10 @@ class TextInput(Widget): def create(self): self.native = WinForms.TextBox() self.native.Multiline = False - self.native.TextChanged += self.winforms_text_changed - self.native.KeyPress += self.winforms_key_press - self.native.GotFocus += self.winforms_got_focus - self.native.LostFocus += self.winforms_lost_focus + self.native.TextChanged += WeakrefCallable(self.winforms_text_changed) + self.native.KeyPress += WeakrefCallable(self.winforms_key_press) + self.native.GotFocus += WeakrefCallable(self.winforms_got_focus) + self.native.LostFocus += WeakrefCallable(self.winforms_lost_focus) self._placeholder = "" diff --git a/winforms/src/toga_winforms/widgets/timeinput.py b/winforms/src/toga_winforms/widgets/timeinput.py index 3ee88e121d..eb28fb3d58 100644 --- a/winforms/src/toga_winforms/widgets/timeinput.py +++ b/winforms/src/toga_winforms/widgets/timeinput.py @@ -4,6 +4,7 @@ from System import DateTime as WinDateTime from travertino.size import at_least +from ..libs.wrapper import WeakrefCallable from .base import Widget @@ -22,7 +23,7 @@ class TimeInput(Widget): def create(self): self.native = WinForms.DateTimePicker() - self.native.ValueChanged += self.winforms_value_changed + self.native.ValueChanged += WeakrefCallable(self.winforms_value_changed) self.native.Format = WinForms.DateTimePickerFormat.Time self.native.ShowUpDown = True diff --git a/winforms/src/toga_winforms/widgets/webview.py b/winforms/src/toga_winforms/widgets/webview.py index 925fe72e1d..9f38f225a5 100644 --- a/winforms/src/toga_winforms/widgets/webview.py +++ b/winforms/src/toga_winforms/widgets/webview.py @@ -19,6 +19,7 @@ WebView2RuntimeNotFoundException, ) +from ..libs.wrapper import WeakrefCallable from .base import Widget @@ -35,10 +36,12 @@ def task(): class WebView(Widget): def create(self): self.native = WebView2() - self.native.CoreWebView2InitializationCompleted += ( + self.native.CoreWebView2InitializationCompleted += WeakrefCallable( self.winforms_initialization_completed ) - self.native.NavigationCompleted += self.winforms_navigation_completed + self.native.NavigationCompleted += WeakrefCallable( + self.winforms_navigation_completed + ) self.loaded_future = None props = CoreWebView2CreationProperties() diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index 0bf021cace..f477d2c924 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -4,13 +4,13 @@ from toga import GROUP_BREAK, SECTION_BREAK from .container import Container +from .libs.wrapper import WeakrefCallable from .widgets.base import Scalable class Window(Container, Scalable): def __init__(self, interface, title, position, size): self.interface = interface - self.interface._impl = self # Winforms close handling is caught on the FormClosing handler. To allow # for async close handling, we need to be able to abort this close event, @@ -20,7 +20,7 @@ def __init__(self, interface, title, position, size): self._is_closing = False self.native = WinForms.Form() - self.native.FormClosing += self.winforms_FormClosing + self.native.FormClosing += WeakrefCallable(self.winforms_FormClosing) super().__init__(self.native) self.init_scale(self.native) @@ -32,9 +32,8 @@ def __init__(self, interface, title, position, size): self.set_position(position) self.toolbar_native = None - self.toolbar_items = None - self.native.Resize += lambda sender, args: self.resize_content() + self.native.Resize += WeakrefCallable(self.winforms_Resize) self.resize_content() # Store initial size self.set_full_screen(self.interface.full_screen) @@ -122,6 +121,9 @@ def hide(self): def get_visible(self): return self.native.Visible + def winforms_Resize(self, sender, event): + self.resize_content() + def winforms_FormClosing(self, sender, event): # If the app is exiting, or a manual close has been requested, don't get # confirmation; just close.