Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
75bc00d
Add GTK4 TextInput support
danyeaw Mar 4, 2025
5e31db8
Expect failure for test cleanup
danyeaw Mar 6, 2025
83274b8
Refactor WeakrefCallable to be a core handler
danyeaw Mar 7, 2025
d5eb067
Fix GC unable to resolve Controller events
danyeaw Mar 7, 2025
3e00560
Fix incorrect import
danyeaw Mar 7, 2025
1ab55e3
Add tests for WeakrefCallable
danyeaw Mar 7, 2025
8b919a3
Fix container size tests for GTK4
danyeaw Mar 11, 2025
8cc2433
Add GTK4 base tests for focus
danyeaw Mar 11, 2025
ed6ef30
Add GTK4 base tests for color
danyeaw Mar 14, 2025
8df0412
Add GTK4 base tests for fonts
danyeaw Mar 14, 2025
1200c6f
Add GTK4 base tests for containers
danyeaw Mar 14, 2025
2f08e69
GTK4: disable animations during tests
danyeaw Mar 14, 2025
9345ee2
GTK4: skip keypress event tests
danyeaw Mar 14, 2025
c5693c9
Fix too many events during set_text
danyeaw Mar 21, 2025
d5466c8
GTK4: Skip ActivityIndicator tests
danyeaw Mar 21, 2025
415ef4a
GTK4: Skip layout tests
danyeaw Mar 28, 2025
e0354d8
GTK4: Skip Button tests
danyeaw Mar 28, 2025
7c906a1
Fix WeakrefCallable not found
danyeaw Mar 28, 2025
e5b4bd2
GTK: track redraw with a frame clock
danyeaw Apr 4, 2025
72f7ebc
Fix signal disconnect and widget sizing warnings
danyeaw Apr 4, 2025
6c7ae1c
GTK4: Skip Box and PasswordInput tests
danyeaw Apr 4, 2025
f3f4e40
GTK3: Fix SystemError for no handler with id
danyeaw Apr 4, 2025
b634064
GTK4: Fix TextInput width and height
danyeaw Apr 4, 2025
470f7de
Fix X11Monitor has no queue_draw
danyeaw Apr 4, 2025
8d5c1c2
Fix variable references in widget cleanup tests
danyeaw May 2, 2025
6368ae9
Fix test not compatible with multiple test runs
danyeaw May 2, 2025
86bd97d
Remove unnecessary do preferred width/height for GTK4
danyeaw May 19, 2025
bfd5ccc
Add offsets to GTK4 container allocation
danyeaw May 20, 2025
c636886
Only update container size if resized or needs a redraw
danyeaw May 20, 2025
fbee0c9
Process child widgets forwards
danyeaw May 20, 2025
6082ed7
Simplify container width/height properties
danyeaw May 20, 2025
91b8a24
Remove extra offsets since in containers coord system
danyeaw May 20, 2025
a840d9f
Ensure recompute on check for height/width
danyeaw May 20, 2025
6b87062
Use size instead of min_size for TextInput width
danyeaw May 20, 2025
4e0271f
Ensure recompute is called from width and height properties
danyeaw May 20, 2025
490e19a
Add debug statements [skip ci]
danyeaw May 20, 2025
a9570a4
activityindicator
johnzhou721 Jun 4, 2025
3ffd564
tc
johnzhou721 Jun 4, 2025
7f9cf2f
Initial work on enabling more tests.
johnzhou721 Jun 4, 2025
cc77beb
Merge branch 'gtk4ai' into gtk4fixes
johnzhou721 Jun 4, 2025
03709f2
enable testing ActivityIndiactor
johnzhou721 Jun 4, 2025
1e03f9a
revert textinput changes
johnzhou721 Jun 4, 2025
159ca87
Revert "Add debug statements [skip ci]"
johnzhou721 Jun 4, 2025
74b81be
Delete changes/3239.feature.rst
johnzhou721 Jun 4, 2025
b83dd42
reformat
johnzhou721 Jun 4, 2025
a352470
skip correctly and also make a future addition easier
johnzhou721 Jun 5, 2025
8223ce5
Update testbed/pyproject.toml
johnzhou721 Jun 5, 2025
f67533e
Update gtk/src/toga_gtk/widgets/activityindicator.py
johnzhou721 Jun 5, 2025
920c7d1
use get_width and get_height (will this fix anything?)
johnzhou721 Jun 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/3069.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The GTK backend now provides an ActivityIndicator widget when ran on GTK4.
23 changes: 23 additions & 0 deletions core/src/toga/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
129 changes: 128 additions & 1 deletion core/tests/test_handlers.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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
71 changes: 27 additions & 44 deletions gtk/src/toga_gtk/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand All @@ -103,7 +110,7 @@ class TogaContainer(Gtk.Box):
def __init__(self):
super().__init__()

# Because we dont 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)
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
14 changes: 7 additions & 7 deletions gtk/src/toga_gtk/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,26 +187,26 @@
}


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
# bindings. If we can't map it, we can't really type it either, so we
# 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}
Expand Down
17 changes: 10 additions & 7 deletions gtk/src/toga_gtk/widgets/activityindicator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Loading
Loading