diff --git a/changes/3069.feature.rst b/changes/3069.feature.rst new file mode 100644 index 0000000000..fdb5b8bde9 --- /dev/null +++ b/changes/3069.feature.rst @@ -0,0 +1 @@ +The GTK backend now provides an ActivityIndicator widget when ran on GTK4. diff --git a/core/src/toga/handlers.py b/core/src/toga/handlers.py index 20b829a1d8..f203159d40 100644 --- a/core/src/toga/handlers.py +++ b/core/src/toga/handlers.py @@ -5,6 +5,7 @@ import sys import traceback import warnings +import weakref from abc import ABC from collections.abc import Awaitable, Callable, Generator from typing import TYPE_CHECKING, Any, NoReturn, Protocol, TypeVar, Union @@ -259,3 +260,25 @@ def __bool__(self, other: object) -> NoReturn: class PermissionResult(AsyncResult): RESULT_TYPE = "permission" + + +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. It is also used + for some of the GTK4 event controllers. + """ + + def __init__(self, function): + try: + self.ref = weakref.WeakMethod(function) + except TypeError: # pragma: no cover + self.ref = weakref.ref(function) + + def __call__(self, *args, **kwargs): + function = self.ref() + if function: # pragma: no branch + return function(*args, **kwargs) diff --git a/core/tests/test_handlers.py b/core/tests/test_handlers.py index f06dd42396..4fecfbbf7b 100644 --- a/core/tests/test_handlers.py +++ b/core/tests/test_handlers.py @@ -1,9 +1,16 @@ import asyncio +import gc from unittest.mock import Mock import pytest -from toga.handlers import AsyncResult, NativeHandler, simple_handler, wrapped_handler +from toga.handlers import ( + AsyncResult, + NativeHandler, + WeakrefCallable, + simple_handler, + wrapped_handler, +) class ExampleAsyncResult(AsyncResult): @@ -742,3 +749,123 @@ def test_async_exception_cancelled_sync(event_loop): # The callback wasn't called on_result.assert_not_called() + + +def test_weakref_function_call(): + """Test that WeakrefCallable correctly calls the wrapped function.""" + + def test_func(x, y=2): + return x + y + + wrc = WeakrefCallable(test_func) + + # Test with positional arguments + assert wrc(3) == 5 + + # Test with keyword arguments + assert wrc(3, y=3) == 6 + + # Test with mixed arguments + assert wrc(3, 4) == 7 + + +def test_weakref_method_call(): + """Test that WeakrefCallable correctly calls a method.""" + + class TestClass: + def __init__(self, value): + self.value = value + + def method(self, x): + return self.value + x + + obj = TestClass(5) + wrc = WeakrefCallable(obj.method) + + # Test method call + assert wrc(3) == 8 + + +def test_weakref_lambda_call(): + """Test that WeakrefCallable works with lambda functions.""" + # Store the lambda in a variable to prevent it from being garbage collected + lambda_func = lambda x: x * 2 # noqa: E731 + wrc = WeakrefCallable(lambda_func) + assert wrc(5) == 10 + + +def test_weakref_gc_function(): + """Test that function is garbage collected properly.""" + + def create_function_wrapper(): + def temp_func(x): + return x * 3 + + return WeakrefCallable(temp_func) + + wrc = create_function_wrapper() + + # Force garbage collection + gc.collect() + + # The function should be gone + assert wrc.ref() is None + + +def test_weakref_gc_method(): + """Test that method and its object are garbage collected properly.""" + + class TempClass: + def method(self, x): + return x * 4 + + def create_method_wrapper(): + obj = TempClass() + return WeakrefCallable(obj.method), obj + + wrc, obj_ref = create_method_wrapper() + + # Object still exists, method should work + assert wrc(2) == 8 + + # Delete the reference to the object + del obj_ref + + # Force garbage collection + gc.collect() + + # The method reference should be gone + assert wrc.ref() is None + + +def test_weakref_callable_object(): + """Test that WeakrefCallable works with callable objects.""" + + class CallableObject: + def __call__(self, x): + return x * 5 + + obj = CallableObject() + wrc = WeakrefCallable(obj) + + # Test call + assert wrc(2) == 10 + + +def test_weakref_none_result_when_function_gone(): + """Test that calling the wrapper after the target is collected doesn't error.""" + + def create_function_wrapper(): + def temp_func(x): + return x * 3 + + return WeakrefCallable(temp_func) + + wrc = create_function_wrapper() + + # Force garbage collection + gc.collect() + + # Calling the wrapper should not raise an error + result = wrc(10) + assert result is None diff --git a/gtk/src/toga_gtk/container.py b/gtk/src/toga_gtk/container.py index 8a40077e74..839b4c78ef 100644 --- a/gtk/src/toga_gtk/container.py +++ b/gtk/src/toga_gtk/container.py @@ -43,6 +43,7 @@ def do_measure(self, container, orientation, for_size): return container.min_width, container.min_width, -1, -1 elif orientation == Gtk.Orientation.VERTICAL: return container.min_height, container.min_height, -1, -1 + return None def do_allocate(self, container, width, height, baseline): """Perform the actual layout for the all widget's children. @@ -55,18 +56,24 @@ def do_allocate(self, container, width, height, baseline): # print(widget._content, f"Container layout {width}x{height} @ 0x0") if container._content: - # Re-evaluate the layout using the size as the basis for geometry - # print("REFRESH LAYOUT", width, height) - container._content.interface.style.layout(container) - - # Ensure the minimum content size from the layout is retained - container.min_width = container._content.interface.layout.min_width - container.min_height = container._content.interface.layout.min_height + current_width = container.width + current_height = container.height + resized = (width, height) != (current_width, current_height) + + if resized or container.needs_redraw: + # print("REFRESH LAYOUT", width, height) + container._content.interface.style.layout(container) + container.min_width = container._content.interface.layout.min_width + container.min_height = ( + container._content.interface.layout.min_height + ) # WARNING! This is the list of children of the *container*, not # the Toga widget. Toga maintains a tree of children; all nodes # in that tree are direct children of the container. - child_widget = container.get_last_child() + + # Process each child widget + child_widget = container.get_first_child() while child_widget is not None: if child_widget.get_visible(): # Set the allocation of the child widget to the computed @@ -89,7 +96,7 @@ def do_allocate(self, container, width, height, baseline): child_widget.interface.layout.content_height ) child_widget.size_allocate(child_widget_allocation, -1) - child_widget = child_widget.get_prev_sibling() + child_widget = child_widget.get_next_sibling() # The layout has been redrawn container.needs_redraw = False @@ -103,7 +110,7 @@ class TogaContainer(Gtk.Box): def __init__(self): super().__init__() - # Because we don’t have access to the existing layout manager, we must + # Because we don't have access to the existing layout manager, we must # create our custom layout manager class. layout_manager = TogaContainerLayoutManager() self.set_layout_manager(layout_manager) @@ -143,7 +150,11 @@ def width(self): """ if self._content is None: return 0 - return self.compute_bounds(self)[1].get_width() + + if self._dirty_widgets and self.needs_redraw: + self.recompute() + width = self.get_width() + return width @property def height(self): @@ -153,7 +164,11 @@ def height(self): """ if self._content is None: return 0 - return self.compute_bounds(self)[1].get_height() + + if self._dirty_widgets and self.needs_redraw: + self.recompute() + height = self.get_height() + return height @property def content(self): @@ -200,38 +215,6 @@ def recompute(self): self.min_width = self._content.interface.layout.min_width self.min_height = self._content.interface.layout.min_height - def do_get_preferred_width(self): - """Return (recomputing if necessary) the preferred width for the container. - - The preferred size of the container is its minimum size. This - preference will be overridden with the layout size when the layout is - applied. - - If the container does not yet have content, the minimum width is set to - 0. - """ - pass - - def do_get_preferred_height(self): - """Return (recomputing if necessary) the preferred height for the container. - - The preferred size of the container is its minimum size. This preference - will be overridden with the layout size when the layout is applied. - - If the container does not yet have content, the minimum height is set to 0. - """ - pass - - def do_size_allocate(self, allocation): - """Perform the actual layout for the widget, and all it's children. - - The container will assume whatever size it has been given by GTK - usually - the full space of the window that holds the container. The layout will then - be recomputed based on this new available size, and that new geometry will - be applied to all child widgets of the container. - """ - pass - else: # pragma: no-cover-if-gtk4 class TogaContainer(Gtk.Fixed): diff --git a/gtk/src/toga_gtk/keys.py b/gtk/src/toga_gtk/keys.py index 6a05c5283e..7f9347bbab 100644 --- a/gtk/src/toga_gtk/keys.py +++ b/gtk/src/toga_gtk/keys.py @@ -187,10 +187,10 @@ } -def toga_key(event): +def toga_key(keyval: int, state: Gdk.ModifierType): """Convert a GDK Key Event into a Toga key.""" try: - key = GDK_KEYS[event.keyval] + key = GDK_KEYS[keyval] except KeyError: # pragma: no cover # Ignore any key event code we can't map. This can happen for weird key # combination (ctrl-alt-tux), and if the X server has weird key @@ -198,15 +198,15 @@ def toga_key(event): # need to no-cover this branch. return None - modifiers = set() + modifiers: set[Key] = set() - if event.state & Gdk.ModifierType.SHIFT_MASK: + if state & Gdk.ModifierType.SHIFT_MASK: modifiers.add(Key.SHIFT) - if event.state & Gdk.ModifierType.CONTROL_MASK: + if state & Gdk.ModifierType.CONTROL_MASK: modifiers.add(Key.MOD_1) - if event.state & Gdk.ModifierType.META_MASK: + if state & Gdk.ModifierType.META_MASK: modifiers.add(Key.MOD_2) - if event.state & Gdk.ModifierType.HYPER_MASK: + if state & Gdk.ModifierType.HYPER_MASK: modifiers.add(Key.MOD_3) return {"key": key, "modifiers": modifiers} diff --git a/gtk/src/toga_gtk/widgets/activityindicator.py b/gtk/src/toga_gtk/widgets/activityindicator.py index 5020a5caae..b3cbb46b6b 100644 --- a/gtk/src/toga_gtk/widgets/activityindicator.py +++ b/gtk/src/toga_gtk/widgets/activityindicator.py @@ -7,7 +7,10 @@ def create(self): self.native = Gtk.Spinner() def is_running(self): - return self.native.get_property("active") + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + return self.native.get_property("active") + else: # pragma: no-cover-if-gtk3 + return self.native.get_property("spinning") def start(self): self.native.start() @@ -23,10 +26,10 @@ def rehint(self): # self.native.get_preferred_width(), # self.native.get_preferred_height(), # ) - width = self.native.get_preferred_width() - height = self.native.get_preferred_height() - - self.interface.intrinsic.width = width[0] - self.interface.intrinsic.height = height[0] + width = self.native.get_preferred_width()[0] + height = self.native.get_preferred_height()[0] else: # pragma: no-cover-if-gtk3 - pass + size = self.native.get_preferred_size()[0] + width, height = size.width, size.height + self.interface.intrinsic.width = width + self.interface.intrinsic.height = height diff --git a/gtk/src/toga_gtk/widgets/textinput.py b/gtk/src/toga_gtk/widgets/textinput.py index cb87cb97c9..d1f7f60e30 100644 --- a/gtk/src/toga_gtk/widgets/textinput.py +++ b/gtk/src/toga_gtk/widgets/textinput.py @@ -20,24 +20,25 @@ def create(self): pass def gtk_on_change(self, *_args): - if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 - self.interface._value_changed() - else: # pragma: no-cover-if-gtk3 - self.interface._value_changed(self.interface) + self.interface._value_changed() def gtk_focus_in_event(self, *_args): - if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 - self.interface.on_gain_focus() - else: # pragma: no-cover-if-gtk3 - self.interface.on_gain_focus(self.interface) + self.interface.on_gain_focus() def gtk_focus_out_event(self, *_args): self.interface.on_lose_focus() - def gtk_key_press_event(self, _, key_val, *_args): - key_pressed = toga_key(key_val) - if key_pressed and key_pressed["key"] in {Key.ENTER, Key.NUMPAD_ENTER}: - self.interface.on_confirm() + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + + def gtk_key_press_event(self, _entry, event): + key_pressed = toga_key(event.keyval, event.state) + if key_pressed and key_pressed["key"] in {Key.ENTER, Key.NUMPAD_ENTER}: + self.interface.on_confirm() + + else: # pragma: no-cover-if-gtk3 + + def gtk_key_pressed(self, _controller, keyval, _keycode, state): + pass def get_readonly(self): return not self.native.get_property("editable") diff --git a/gtk/tests_backend/app.py b/gtk/tests_backend/app.py index f4743b2429..4040caa069 100644 --- a/gtk/tests_backend/app.py +++ b/gtk/tests_backend/app.py @@ -237,7 +237,7 @@ def keystroke(self, combination): event.is_modifier = state != 0 event.state = state - return toga_key(event) + return toga_key(event.keyval, event.state) async def restore_standard_app(self): # No special handling needed to restore standard app. diff --git a/gtk/tests_backend/fonts.py b/gtk/tests_backend/fonts.py index d09da437f6..f1574cc04d 100644 --- a/gtk/tests_backend/fonts.py +++ b/gtk/tests_backend/fonts.py @@ -6,7 +6,7 @@ SMALL_CAPS, SYSTEM_DEFAULT_FONT_SIZE, ) -from toga_gtk.libs import Pango +from toga_gtk.libs import GTK_VERSION, Pango class FontMixin: @@ -14,34 +14,45 @@ class FontMixin: supports_custom_variable_fonts = True def assert_font_family(self, expected): - assert self.font.get_family().split(",")[0] == expected + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + assert self.font.get_family().split(",")[0] == expected + else: # pragma: no-cover-if-gtk3 + assert self.font.family == expected def assert_font_size(self, expected): - # GTK fonts aren't realized until they appear on a widget. - # The actual system default size is determined by the widget theme. - # So - if the font size reports as 0, it must be a default system - # font size that hasn't been realized yet. Once a font has been realized, - # we can't reliably determine what the system font size is, other than - # knowing that it must be non-zero. Pick some reasonable bounds instead. - # - # See also SYSTEM_DEFAULT_FONT_SIZE in toga_gtk/widgets/canvas.py. - if self.font.get_size() == 0: - assert expected == SYSTEM_DEFAULT_FONT_SIZE - elif expected == SYSTEM_DEFAULT_FONT_SIZE: - assert 8 < int(self.font.get_size() / Pango.SCALE) < 18 - else: - assert int(self.font.get_size() / Pango.SCALE) == expected + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + # GTK fonts aren't realized until they appear on a widget. + # The actual system default size is determined by the widget theme. + # So - if the font size reports as 0, it must be a default system + # font size that hasn't been realized yet. Once a font has been realized, + # we can't reliably determine what the system font size is, other than + # knowing that it must be non-zero. Pick some reasonable bounds instead. + # + # See also SYSTEM_DEFAULT_FONT_SIZE in toga_gtk/widgets/canvas.py. + if self.font.get_size() == 0: + assert expected == SYSTEM_DEFAULT_FONT_SIZE + elif expected == SYSTEM_DEFAULT_FONT_SIZE: + assert 8 < int(self.font.get_size() / Pango.SCALE) < 18 + else: + assert int(self.font.get_size() / Pango.SCALE) == expected + else: # pragma: no-cover-if-gtk3 + assert int(self.font.size) == expected def assert_font_options(self, weight=NORMAL, style=NORMAL, variant=NORMAL): - assert { - Pango.Weight.BOLD: BOLD, - }.get(self.font.get_weight(), NORMAL) == weight + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + assert { + Pango.Weight.BOLD: BOLD, + }.get(self.font.get_weight(), NORMAL) == weight - assert { - Pango.Style.ITALIC: ITALIC, - Pango.Style.OBLIQUE: OBLIQUE, - }.get(self.font.get_style(), NORMAL) == style + assert { + Pango.Style.ITALIC: ITALIC, + Pango.Style.OBLIQUE: OBLIQUE, + }.get(self.font.get_style(), NORMAL) == style - assert { - Pango.Variant.SMALL_CAPS: SMALL_CAPS, - }.get(self.font.get_variant(), NORMAL) == variant + assert { + Pango.Variant.SMALL_CAPS: SMALL_CAPS, + }.get(self.font.get_variant(), NORMAL) == variant + else: # pragma: no-cover-if-gtk3 + assert self.font.weight == weight + assert self.font.style == style + assert self.font.variant == variant diff --git a/gtk/tests_backend/probe.py b/gtk/tests_backend/probe.py index ef5b0c49a5..33e932c7e4 100644 --- a/gtk/tests_backend/probe.py +++ b/gtk/tests_backend/probe.py @@ -1,4 +1,5 @@ import asyncio +import contextlib import toga from toga_gtk.libs import GTK_VERSION, GLib, Gtk @@ -6,25 +7,51 @@ class BaseProbe: - def repaint_needed(self): - if GTK_VERSION < (4, 0, 0): - return Gtk.events_pending() - else: - return GLib.main_context_default().pending() - async def redraw(self, message=None, delay=0): """Request a redraw of the app, waiting until that redraw has completed.""" - # Force a repaint - while self.repaint_needed(): + if ( + hasattr(self, "native") + and self.native + and hasattr(self.native, "queue_draw") + ): + self.native.queue_draw() + + if frame_clock := self.native.get_frame_clock(): + handler_id = None + with contextlib.suppress(asyncio.TimeoutError): + redraw_complete = asyncio.Future() + + def on_after_paint(*args): + if not redraw_complete.done(): + redraw_complete.set_result(True) + return False + + handler_id = frame_clock.connect("after-paint", on_after_paint) + + await asyncio.wait_for(redraw_complete, 0.05) + if handler_id is not None: + with contextlib.suppress(SystemError): + frame_clock.disconnect(handler_id) + + # Process events to ensure the UI is fully updated + for _ in range(15): if GTK_VERSION < (4, 0, 0): - Gtk.main_iteration_do(blocking=False) + if Gtk.events_pending(): + Gtk.main_iteration_do(blocking=False) + else: + break else: - GLib.main_context_default().iteration(may_block=False) + context = GLib.main_context_default() + if context.pending(): + context.iteration(may_block=False) + else: + break + + # Always yield to let GTK catch up + await asyncio.sleep(0) - # If we're running slow, wait for a second if toga.App.app.run_slow: delay = max(1, delay) - if delay: print("Waiting for redraw" if message is None else message) await asyncio.sleep(delay) diff --git a/gtk/tests_backend/widgets/base.py b/gtk/tests_backend/widgets/base.py index 0988c75c84..f170d18e04 100644 --- a/gtk/tests_backend/widgets/base.py +++ b/gtk/tests_backend/widgets/base.py @@ -7,7 +7,7 @@ from ..fonts import FontMixin from ..probe import BaseProbe -from .properties import toga_color +from .properties import toga_color, toga_font class SimpleProbe(BaseProbe, FontMixin): @@ -22,20 +22,29 @@ def __init__(self, widget): # Set the target for keypress events self._keypress_target = self.native - if GTK_VERSION >= (4, 0, 0): - pytest.skip("GTK4 only has minimal container support") - # Ensure that the theme isn't using animations for the widget. - settings = Gtk.Settings.get_for_screen(self.native.get_screen()) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + settings = Gtk.Settings.get_for_screen(self.native.get_screen()) + else: # pragma: no-cover-if-gtk3 + settings = Gtk.Settings.get_for_display(self.native.get_display()) settings.set_property("gtk-enable-animations", False) def assert_container(self, container): container_native = container._impl.container - for control in container_native.get_children(): - if control == self.native: - break - else: - raise ValueError(f"cannot find {self.native} in {container_native}") + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + for control in container_native.get_children(): + if control == self.native: + break + else: + raise ValueError(f"cannot find {self.native} in {container_native}") + else: # pragma: no-cover-if-gtk3 + control = container_native.get_last_child() + while control is not None: + if control == self.native: + break + control = control.get_prev_sibling() + else: + raise ValueError(f"cannot find {self.native} in {container_native}") def assert_not_contained(self): assert self.widget._impl.container is None @@ -53,13 +62,21 @@ def enabled(self): @property def width(self): - return self.native.get_allocation().width + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + return self.native.get_allocation().width + else: # pragma: no-cover-if-gtk3 + return self.native.get_width() @property def height(self): - return self.native.get_allocation().height + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + return self.native.get_allocation().height + else: # pragma: no-cover-if-gtk3 + return self.native.get_height() def assert_layout(self, size, position): + if GTK_VERSION >= (4, 0, 0): + pytest.skip("Accurate layout is not yet supported in GTK4") # Widget is contained and in a window. assert self.widget._impl.container is not None assert self.native.get_parent() is not None @@ -93,18 +110,43 @@ def shrink_on_resize(self): @property def color(self): - sc = self.native.get_style_context() - return toga_color(sc.get_property("color", sc.get_state())) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + sc = self.native.get_style_context() + return toga_color(sc.get_property("color", sc.get_state())) + else: # pragma: no-cover-if-gtk3 + style_provider = self.impl.style_providers.get(("color", id(self.native))) + style_value = ( + style_provider.to_string().split(": ")[1].split(";")[0] + if style_provider + else None + ) + return toga_color(style_value) if style_value else None @property def background_color(self): - sc = self.native.get_style_context() - return toga_color(sc.get_property("background-color", sc.get_state())) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + sc = self.native.get_style_context() + return toga_color(sc.get_property("background-color", sc.get_state())) + else: # pragma: no-cover-if-gtk3 + style_provider = self.impl.style_providers.get( + ("background_color", id(self.native)) + ) + style_value = ( + style_provider.to_string().split(": ")[1].split(";")[0] + if style_provider + else None + ) + return toga_color(style_value) if style_value else None @property def font(self): - sc = self.native.get_style_context() - return sc.get_property("font", sc.get_state()) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + sc = self.native.get_style_context() + return sc.get_property("font", sc.get_state()) + else: # pragma: no-cover-if-gtk3 + style_provider = self.impl.style_providers.get(("font", id(self.native))) + font_value = style_provider.to_string() if style_provider else None + return toga_font(font_value) if font_value else None @property def is_hidden(self): @@ -112,9 +154,22 @@ def is_hidden(self): @property def has_focus(self): - return self.native.has_focus() + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + return self.native.has_focus() + else: # pragma: no-cover-if-gtk3 + root = self.native.get_root() + focus_widget = root.get_focus() + if focus_widget: + if focus_widget == self.native: + return self.native.has_focus() + else: + return focus_widget.is_ancestor(self.native) + else: + return False async def type_character(self, char): + if GTK_VERSION >= (4, 0, 0): + pytest.skip("GDK KeyPress is not implemented in GTK4") # Construct a GDK KeyPress event. keyval = getattr( Gdk, diff --git a/gtk/tests_backend/widgets/box.py b/gtk/tests_backend/widgets/box.py index ccb6fc8f33..47f2bafb8a 100644 --- a/gtk/tests_backend/widgets/box.py +++ b/gtk/tests_backend/widgets/box.py @@ -1,7 +1,11 @@ -from toga_gtk.libs import Gtk +import pytest + +from toga_gtk.libs import GTK_VERSION, Gtk from .base import SimpleProbe class BoxProbe(SimpleProbe): + if GTK_VERSION >= (4, 0, 0): + pytest.skip("Box is not yet supported with GTK4") native_class = Gtk.Box diff --git a/gtk/tests_backend/widgets/button.py b/gtk/tests_backend/widgets/button.py index 4d6caa1308..e6a0e42941 100644 --- a/gtk/tests_backend/widgets/button.py +++ b/gtk/tests_backend/widgets/button.py @@ -1,13 +1,15 @@ import pytest from toga.colors import TRANSPARENT -from toga_gtk.libs import Gtk +from toga_gtk.libs import GTK_VERSION, Gtk from .base import SimpleProbe class ButtonProbe(SimpleProbe): native_class = Gtk.Button + if GTK_VERSION >= (4, 0, 0): + pytest.skip("Button is not yet fully supported in GTK4") @property def text(self): diff --git a/gtk/tests_backend/widgets/passwordinput.py b/gtk/tests_backend/widgets/passwordinput.py index 4cf71c1d31..fd34f8c3f7 100644 --- a/gtk/tests_backend/widgets/passwordinput.py +++ b/gtk/tests_backend/widgets/passwordinput.py @@ -1,5 +1,11 @@ +import pytest + +from toga_gtk.libs import GTK_VERSION + from .textinput import TextInputProbe class PasswordInputProbe(TextInputProbe): + if GTK_VERSION >= (4, 0, 0): + pytest.skip("Password Input is not yet supported with GTK4") pass diff --git a/gtk/tests_backend/widgets/properties.py b/gtk/tests_backend/widgets/properties.py index 18470f02be..34fc683e15 100644 --- a/gtk/tests_backend/widgets/properties.py +++ b/gtk/tests_backend/widgets/properties.py @@ -1,18 +1,28 @@ import pytest -from toga.colors import TRANSPARENT, rgba +from toga.colors import TRANSPARENT, color as parse_color, rgba +from toga.fonts import Font from toga.style.pack import BOTTOM, CENTER, JUSTIFY, LEFT, RIGHT, TOP -from toga_gtk.libs import Gtk +from toga_gtk.libs import GTK_VERSION, Gtk def toga_color(color): if color: - c = rgba( - int(color.red * 255), - int(color.green * 255), - int(color.blue * 255), - color.alpha, - ) + if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 + c = rgba( + int(color.red * 255), + int(color.green * 255), + int(color.blue * 255), + color.alpha, + ) + else: # pragma: no-cover-if-gtk3 + color = parse_color(color) + c = rgba( + int(color.r), + int(color.g), + int(color.b), + color.a, + ) # Background color of rgba(0,0,0,0.0) is TRANSPARENT. if c.r == 0 and c.g == 0 and c.b == 0 and c.a == 0.0: @@ -54,3 +64,52 @@ def toga_text_align_from_justification(justify): Gtk.Justification.CENTER: CENTER, Gtk.Justification.FILL: JUSTIFY, }[justify] + + +def toga_font(font: str) -> Font: + """ + Convert CSS font definition to a Toga Font object. + + Args: + font (str): CSS font definition string, for example: + { + font-family: "Helvetica Neue"; + font-size: 14px; + font-style: normal; + font-variant: normal; + font-weight: 400; + } + + Returns: + Font: A Toga Font object with the parsed properties + """ + css_dict = {} + for line in font.split("\n"): + line = line.strip() + if ":" in line: + property_name, value = line.split(":", 1) + property_name = property_name.strip() + value = value.strip().rstrip(";") + css_dict[property_name] = value + + family_font_value = css_dict.get("font-family", "") + size_font_value = css_dict.get("font-size", -1) + style_font_value = css_dict.get("font-style", "normal") + variant_font_value = css_dict.get("font-variant", "normal") + weight_font_value = css_dict.get("font-weight", "normal") + + if variant_font_value == "initial": + variant_font_value = "normal" + + if weight_font_value == "400": + weight_font_value = "normal" + elif weight_font_value == "700": + weight_font_value = "bold" + + return Font( + family=family_font_value, + size=size_font_value, + style=style_font_value, + variant=variant_font_value, + weight=weight_font_value, + ) diff --git a/testbed/tests/widgets/conftest.py b/testbed/tests/widgets/conftest.py index 1c4a37626a..7900f94a92 100644 --- a/testbed/tests/widgets/conftest.py +++ b/testbed/tests/widgets/conftest.py @@ -122,27 +122,18 @@ def build_cleanup_test( xfail_platforms=(), ): async def test_cleanup(): - nonlocal args, kwargs - skip_on_platforms(*skip_platforms) xfail_on_platforms(*xfail_platforms, reason="Leaks memory") - if args is None: - args = () - - if kwargs is None: - kwargs = {} + local_args = () if args is None else args + local_kwargs = {} if kwargs is None else kwargs with safe_create(): - widget = widget_constructor(*args, **kwargs) - + widget = widget_constructor(*local_args, **local_kwargs) ref = weakref.ref(widget) - - # Args or kwargs may hold a backref to the widget itself, for example if they - # are widget content. Ensure that they are deleted before garbage collection. - del widget, args, kwargs + # Break potential reference cycles + del widget, local_args, local_kwargs gc.collect() - assert ref() is None return test_cleanup diff --git a/testbed/tests/widgets/properties.py b/testbed/tests/widgets/properties.py index 0615df2dbb..e702e6ced1 100644 --- a/testbed/tests/widgets/properties.py +++ b/testbed/tests/widgets/properties.py @@ -87,6 +87,9 @@ async def test_focus(widget, probe, other, other_probe, verify_focus_handlers): if verify_focus_handlers: on_gain_handler.assert_not_called() + # Reset the mock so it can be tested again + on_lose_handler.reset_mock() + other.focus() await probe.redraw("Focus has been lost") assert not probe.has_focus diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 6967e9f3c4..842040c749 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -11,9 +11,9 @@ from System.Windows.Threading import Dispatcher from toga.dialogs import InfoDialog +from toga.handlers import WeakrefCallable from .libs.proactor import WinformsProactorEventLoop -from .libs.wrapper import WeakrefCallable from .screens import Screen as ScreenImpl diff --git a/winforms/src/toga_winforms/command.py b/winforms/src/toga_winforms/command.py index 8b888bd521..36d6b8f630 100644 --- a/winforms/src/toga_winforms/command.py +++ b/winforms/src/toga_winforms/command.py @@ -3,8 +3,8 @@ from System.ComponentModel import InvalidEnumArgumentException from toga import Command as StandardCommand, Group, Key +from toga.handlers import WeakrefCallable from toga_winforms.keys import toga_to_winforms_key, toga_to_winforms_shortcut -from toga_winforms.libs.wrapper import WeakrefCallable class Command: diff --git a/winforms/src/toga_winforms/dialogs.py b/winforms/src/toga_winforms/dialogs.py index 6f090a0268..11ca1fd690 100644 --- a/winforms/src/toga_winforms/dialogs.py +++ b/winforms/src/toga_winforms/dialogs.py @@ -9,8 +9,9 @@ ) from System.Windows.Forms import DialogResult, MessageBoxButtons, MessageBoxIcon +from toga.handlers import WeakrefCallable + from .libs.user32 import DPI_AWARENESS_CONTEXT_UNAWARE, SetThreadDpiAwarenessContext -from .libs.wrapper import WeakrefCallable class BaseDialog: diff --git a/winforms/src/toga_winforms/libs/proactor.py b/winforms/src/toga_winforms/libs/proactor.py index 276a5f1c4b..dd2fbab44a 100644 --- a/winforms/src/toga_winforms/libs/proactor.py +++ b/winforms/src/toga_winforms/libs/proactor.py @@ -8,7 +8,7 @@ from System import Action from System.Threading.Tasks import Task -from .wrapper import WeakrefCallable +from toga.handlers import WeakrefCallable class WinformsProactorEventLoop(asyncio.ProactorEventLoop): diff --git a/winforms/src/toga_winforms/libs/wrapper.py b/winforms/src/toga_winforms/libs/wrapper.py deleted file mode 100644 index 4ad877d3a4..0000000000 --- a/winforms/src/toga_winforms/libs/wrapper.py +++ /dev/null @@ -1,22 +0,0 @@ -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: # pragma: no cover - self.ref = weakref.ref(function) - - def __call__(self, *args, **kwargs): - function = self.ref() - if function: # pragma: no branch - return function(*args, **kwargs) diff --git a/winforms/src/toga_winforms/statusicons.py b/winforms/src/toga_winforms/statusicons.py index e581ac69fd..6d360ad204 100644 --- a/winforms/src/toga_winforms/statusicons.py +++ b/winforms/src/toga_winforms/statusicons.py @@ -2,8 +2,7 @@ import toga from toga.command import Group, Separator - -from .libs.wrapper import WeakrefCallable +from toga.handlers import WeakrefCallable class StatusIcon: diff --git a/winforms/src/toga_winforms/widgets/button.py b/winforms/src/toga_winforms/widgets/button.py index ac1ba54b40..fbcc55c589 100644 --- a/winforms/src/toga_winforms/widgets/button.py +++ b/winforms/src/toga_winforms/widgets/button.py @@ -4,8 +4,8 @@ from travertino.size import at_least from toga.colors import TRANSPARENT +from toga.handlers import WeakrefCallable -from ..libs.wrapper import WeakrefCallable from .base import Widget diff --git a/winforms/src/toga_winforms/widgets/canvas.py b/winforms/src/toga_winforms/widgets/canvas.py index a98a70f53c..ffef73c3ed 100644 --- a/winforms/src/toga_winforms/widgets/canvas.py +++ b/winforms/src/toga_winforms/widgets/canvas.py @@ -24,10 +24,10 @@ from toga.colors import TRANSPARENT from toga.constants import Baseline, FillRule +from toga.handlers import WeakrefCallable from toga.widgets.canvas import arc_to_bezier, sweepangle from toga_winforms.colors import native_color -from ..libs.wrapper import WeakrefCallable from .box import Box diff --git a/winforms/src/toga_winforms/widgets/dateinput.py b/winforms/src/toga_winforms/widgets/dateinput.py index d379374bc8..617bbe343d 100644 --- a/winforms/src/toga_winforms/widgets/dateinput.py +++ b/winforms/src/toga_winforms/widgets/dateinput.py @@ -4,7 +4,8 @@ import System.Windows.Forms as WinForms from System import DateTime as WinDateTime -from ..libs.wrapper import WeakrefCallable +from toga.handlers import WeakrefCallable + from .base import Widget diff --git a/winforms/src/toga_winforms/widgets/mapview.py b/winforms/src/toga_winforms/widgets/mapview.py index a770c858fe..4e0c24b779 100644 --- a/winforms/src/toga_winforms/widgets/mapview.py +++ b/winforms/src/toga_winforms/widgets/mapview.py @@ -8,6 +8,7 @@ from System.Threading.Tasks import Task, TaskScheduler import toga +from toga.handlers import WeakrefCallable from toga.types import LatLng from toga_winforms.libs.extensions import ( CoreWebView2CreationProperties, @@ -15,7 +16,6 @@ WebView2RuntimeNotFoundException, ) -from ..libs.wrapper import WeakrefCallable from .base import Widget MAPVIEW_HTML_CONTENT = """ diff --git a/winforms/src/toga_winforms/widgets/multilinetextinput.py b/winforms/src/toga_winforms/widgets/multilinetextinput.py index db12269b6c..2a0f9068d1 100644 --- a/winforms/src/toga_winforms/widgets/multilinetextinput.py +++ b/winforms/src/toga_winforms/widgets/multilinetextinput.py @@ -2,10 +2,10 @@ from System.Drawing import SystemColors from travertino.size import at_least +from toga.handlers import WeakrefCallable from toga_winforms.colors import native_color from toga_winforms.libs.fonts import HorizontalTextAlignment -from ..libs.wrapper import WeakrefCallable from .textinput import TextInput diff --git a/winforms/src/toga_winforms/widgets/numberinput.py b/winforms/src/toga_winforms/widgets/numberinput.py index 14cf579403..bc0f685505 100644 --- a/winforms/src/toga_winforms/widgets/numberinput.py +++ b/winforms/src/toga_winforms/widgets/numberinput.py @@ -4,10 +4,10 @@ import System.Windows.Forms as WinForms from System import Convert, String +from toga.handlers import WeakrefCallable from toga.widgets.numberinput import _clean_decimal from toga_winforms.libs.fonts import HorizontalTextAlignment -from ..libs.wrapper import WeakrefCallable from .base import Widget diff --git a/winforms/src/toga_winforms/widgets/optioncontainer.py b/winforms/src/toga_winforms/widgets/optioncontainer.py index 2486b0f93b..576af47f80 100644 --- a/winforms/src/toga_winforms/widgets/optioncontainer.py +++ b/winforms/src/toga_winforms/widgets/optioncontainer.py @@ -1,7 +1,8 @@ from System.Windows.Forms import TabControl, TabPage +from toga.handlers import WeakrefCallable + from ..container import Container -from ..libs.wrapper import WeakrefCallable from .base import Widget diff --git a/winforms/src/toga_winforms/widgets/scrollcontainer.py b/winforms/src/toga_winforms/widgets/scrollcontainer.py index 415a149484..75a7edbbd1 100644 --- a/winforms/src/toga_winforms/widgets/scrollcontainer.py +++ b/winforms/src/toga_winforms/widgets/scrollcontainer.py @@ -4,9 +4,9 @@ from System.Windows.Forms import Panel, SystemInformation from travertino.node import Node +from toga.handlers import WeakrefCallable 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 diff --git a/winforms/src/toga_winforms/widgets/selection.py b/winforms/src/toga_winforms/widgets/selection.py index d7fc89d2e6..bff2b75ce0 100644 --- a/winforms/src/toga_winforms/widgets/selection.py +++ b/winforms/src/toga_winforms/widgets/selection.py @@ -3,7 +3,8 @@ import System.Windows.Forms as WinForms -from ..libs.wrapper import WeakrefCallable +from toga.handlers import WeakrefCallable + from .base import Widget diff --git a/winforms/src/toga_winforms/widgets/slider.py b/winforms/src/toga_winforms/widgets/slider.py index 5051f5860a..34e5e15da3 100644 --- a/winforms/src/toga_winforms/widgets/slider.py +++ b/winforms/src/toga_winforms/widgets/slider.py @@ -3,9 +3,9 @@ import System.Windows.Forms as WinForms from toga.colors import TRANSPARENT +from toga.handlers import WeakrefCallable from toga.widgets.slider import IntSliderImpl -from ..libs.wrapper import WeakrefCallable from .base import Widget # Implementation notes diff --git a/winforms/src/toga_winforms/widgets/splitcontainer.py b/winforms/src/toga_winforms/widgets/splitcontainer.py index 812ef3aebe..d666f4a19b 100644 --- a/winforms/src/toga_winforms/widgets/splitcontainer.py +++ b/winforms/src/toga_winforms/widgets/splitcontainer.py @@ -5,9 +5,9 @@ ) from toga.constants import Direction +from toga.handlers import WeakrefCallable from ..container import Container -from ..libs.wrapper import WeakrefCallable from .base import Widget diff --git a/winforms/src/toga_winforms/widgets/switch.py b/winforms/src/toga_winforms/widgets/switch.py index ea01598194..21a382a8e2 100644 --- a/winforms/src/toga_winforms/widgets/switch.py +++ b/winforms/src/toga_winforms/widgets/switch.py @@ -4,8 +4,8 @@ from travertino.size import at_least from toga.colors import TRANSPARENT +from toga.handlers import WeakrefCallable -from ..libs.wrapper import WeakrefCallable from .base import Widget diff --git a/winforms/src/toga_winforms/widgets/table.py b/winforms/src/toga_winforms/widgets/table.py index b6d2014cdd..726658d75d 100644 --- a/winforms/src/toga_winforms/widgets/table.py +++ b/winforms/src/toga_winforms/widgets/table.py @@ -3,8 +3,8 @@ import System.Windows.Forms as WinForms import toga +from toga.handlers import WeakrefCallable -from ..libs.wrapper import WeakrefCallable from .base import Widget diff --git a/winforms/src/toga_winforms/widgets/textinput.py b/winforms/src/toga_winforms/widgets/textinput.py index 6a48312668..351f395dc8 100644 --- a/winforms/src/toga_winforms/widgets/textinput.py +++ b/winforms/src/toga_winforms/widgets/textinput.py @@ -4,10 +4,10 @@ import System.Windows.Forms as WinForms +from toga.handlers import WeakrefCallable from toga_winforms.colors import native_color from toga_winforms.libs.fonts import HorizontalTextAlignment -from ..libs.wrapper import WeakrefCallable from .base import Widget diff --git a/winforms/src/toga_winforms/widgets/timeinput.py b/winforms/src/toga_winforms/widgets/timeinput.py index 543d988c0c..bb3a6b3509 100644 --- a/winforms/src/toga_winforms/widgets/timeinput.py +++ b/winforms/src/toga_winforms/widgets/timeinput.py @@ -4,7 +4,8 @@ import System.Windows.Forms as WinForms from System import DateTime as WinDateTime -from ..libs.wrapper import WeakrefCallable +from toga.handlers import WeakrefCallable + from .base import Widget diff --git a/winforms/src/toga_winforms/widgets/webview.py b/winforms/src/toga_winforms/widgets/webview.py index 71d71698d2..d273dee385 100644 --- a/winforms/src/toga_winforms/widgets/webview.py +++ b/winforms/src/toga_winforms/widgets/webview.py @@ -13,6 +13,7 @@ from System.Threading.Tasks import Task, TaskScheduler import toga +from toga.handlers import WeakrefCallable from toga.widgets.webview import CookiesResult, JavaScriptResult from toga_winforms.libs.extensions import ( CoreWebView2Cookie, @@ -21,7 +22,6 @@ WebView2RuntimeNotFoundException, ) -from ..libs.wrapper import WeakrefCallable from .base import Widget diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index c97a7cce78..45833d5fe3 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -10,11 +10,11 @@ from toga import App from toga.command import Separator from toga.constants import WindowState +from toga.handlers import WeakrefCallable from toga.types import Position, Size from .container import Container from .fonts import DEFAULT_FONT -from .libs.wrapper import WeakrefCallable from .screens import Screen as ScreenImpl from .widgets.base import Scalable