From f7c333c37a7e50559ba927ba4a42885e48c0c191 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sun, 5 May 2024 11:09:30 +0800 Subject: [PATCH 01/11] Replace GBulb with the in-development asyncio-compatible PyGObject branch. --- gtk/pyproject.toml | 4 ++-- gtk/src/toga_gtk/app.py | 16 ++++++++-------- gtk/src/toga_gtk/libs/gtk.py | 1 + 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/gtk/pyproject.toml b/gtk/pyproject.toml index bbef568bab..71c056d2d6 100644 --- a/gtk/pyproject.toml +++ b/gtk/pyproject.toml @@ -64,9 +64,9 @@ root = ".." [tool.setuptools_dynamic_dependencies] dependencies = [ - "gbulb >= 0.5.3", "pycairo >= 1.17.0", - "pygobject >= 3.46.0", + # Use the in-development !189 benzea/gio-asyncio branch + "pygobject @ git+https://gitlab.gnome.com/GNOME/pygojbect.git@benzea/gio-asyncio", "toga-core == {version}", ] diff --git a/gtk/src/toga_gtk/app.py b/gtk/src/toga_gtk/app.py index b0889ab11f..76436e3d53 100644 --- a/gtk/src/toga_gtk/app.py +++ b/gtk/src/toga_gtk/app.py @@ -4,14 +4,12 @@ import sys from pathlib import Path -import gbulb - import toga from toga import App as toga_App from toga.command import Command, Separator from .keys import gtk_accel -from .libs import TOGA_DEFAULT_STYLES, Gdk, Gio, GLib, Gtk +from .libs import TOGA_DEFAULT_STYLES, Gdk, Gio, GLib, GLibEventLoopPolicy, Gtk from .screens import Screen as ScreenImpl from .window import Window @@ -38,8 +36,9 @@ def __init__(self, interface): self.interface = interface self.interface._impl = self - gbulb.install(gtk=True) - self.loop = asyncio.new_event_loop() + self.policy = GLibEventLoopPolicy() + asyncio.set_event_loop_policy(self.policy) + self.loop = asyncio.get_event_loop() self.create() @@ -57,10 +56,10 @@ def create(self): self.actions = None - def gtk_activate(self, data=None): + def gtk_activate(self, app): pass - def gtk_startup(self, data=None): + def gtk_startup(self, app): # Set up the default commands for the interface. self.create_app_commands() @@ -186,7 +185,8 @@ def main_loop(self): # Modify signal handlers to make sure Ctrl-C is caught and handled. signal.signal(signal.SIGINT, signal.SIG_DFL) - self.loop.run_forever(application=self.native) + # Start the app event loop + self.native.run() def set_icon(self, icon): for window in self.interface.windows: diff --git a/gtk/src/toga_gtk/libs/gtk.py b/gtk/src/toga_gtk/libs/gtk.py index 40fd0034d5..75b75f51a2 100644 --- a/gtk/src/toga_gtk/libs/gtk.py +++ b/gtk/src/toga_gtk/libs/gtk.py @@ -3,6 +3,7 @@ gi.require_version("Gdk", "3.0") gi.require_version("Gtk", "3.0") +from gi.events import GLibEventLoopPolicy # noqa: E402, F401 from gi.repository import Gdk, GdkPixbuf, Gio, GLib, GObject, Gtk # noqa: E402, F401 if Gdk.Screen.get_default() is None: # pragma: no cover From a4a2e00708c6e6f792424a137c73ca003d33bc5c Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sun, 5 May 2024 11:21:46 +0800 Subject: [PATCH 02/11] Add a httpx demo to the handlers example. --- examples/handlers/handlers/app.py | 18 ++++++++++++++++++ examples/handlers/pyproject.toml | 1 + 2 files changed, 19 insertions(+) diff --git a/examples/handlers/handlers/app.py b/examples/handlers/handlers/app.py index 617eb82b9f..e2f5bae812 100644 --- a/examples/handlers/handlers/app.py +++ b/examples/handlers/handlers/app.py @@ -1,6 +1,8 @@ import asyncio import random +import httpx + import toga from toga.constants import COLUMN from toga.style import Pack @@ -52,6 +54,16 @@ async def do_background_task(self, widget, **kwargs): self.label.text = f"Background: Iteration {self.counter}" await asyncio.sleep(1) + async def do_web_get(self, widget, **kwargs): + async with httpx.AsyncClient() as client: + response = await client.get( + f"https://jsonplaceholder.typicode.com/posts/{random.randint(0, 100)}" + ) + + payload = response.json() + + self.web_label.text = payload["title"] + def startup(self): # Set up main window self.main_window = toga.MainWindow() @@ -61,6 +73,7 @@ def startup(self): self.function_label = toga.Label("Ready.", style=Pack(padding=10)) self.generator_label = toga.Label("Ready.", style=Pack(padding=10)) self.async_label = toga.Label("Ready.", style=Pack(padding=10)) + self.web_label = toga.Label("Ready.", style=Pack(padding=10)) # Add a background task. self.counter = 0 @@ -78,6 +91,9 @@ def startup(self): "Async callback", on_press=self.do_async, style=btn_style ) btn_clear = toga.Button("Clear", on_press=self.do_clear, style=btn_style) + btn_web = toga.Button( + "Get web content", on_press=self.do_web_get, style=btn_style + ) # Outermost box box = toga.Box( @@ -89,6 +105,8 @@ def startup(self): self.generator_label, btn_async, self.async_label, + btn_web, + self.web_label, btn_clear, ], style=Pack(flex=1, direction=COLUMN, padding=10), diff --git a/examples/handlers/pyproject.toml b/examples/handlers/pyproject.toml index 182f08da64..708000d8e9 100644 --- a/examples/handlers/pyproject.toml +++ b/examples/handlers/pyproject.toml @@ -16,6 +16,7 @@ description = "A testing app" sources = ["handlers"] requires = [ "../../core", + "httpx", ] From 179032123eb4b99450a49a0dd2d9376f6edd4c51 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sun, 5 May 2024 11:26:30 +0800 Subject: [PATCH 03/11] Add changenote. --- changes/2550.feature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/2550.feature.rst diff --git a/changes/2550.feature.rst b/changes/2550.feature.rst new file mode 100644 index 0000000000..3f4b88be2d --- /dev/null +++ b/changes/2550.feature.rst @@ -0,0 +1 @@ +The GTK backend was modified to use PyGObject's native asyncio handling, instead of GBulb. From 817e06d4266bf89e9a3b5d46c72358971ade262f Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sun, 5 May 2024 11:35:18 +0800 Subject: [PATCH 04/11] Correct the repo URL. --- gtk/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gtk/pyproject.toml b/gtk/pyproject.toml index 71c056d2d6..14f263206f 100644 --- a/gtk/pyproject.toml +++ b/gtk/pyproject.toml @@ -66,7 +66,7 @@ root = ".." dependencies = [ "pycairo >= 1.17.0", # Use the in-development !189 benzea/gio-asyncio branch - "pygobject @ git+https://gitlab.gnome.com/GNOME/pygojbect.git@benzea/gio-asyncio", + "pygobject @ git+https://gitlab.gnome.org/GNOME/pygobject.git@benzea/gio-asyncio", "toga-core == {version}", ] From a643baad8f9cbc6f08d073da02d04171dc43bb8b Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 13 Sep 2024 06:28:54 +0800 Subject: [PATCH 05/11] Add on_running example, plus updated add_background_task example. --- examples/handlers/handlers/app.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/examples/handlers/handlers/app.py b/examples/handlers/handlers/app.py index e2f5bae812..d3b766f039 100644 --- a/examples/handlers/handlers/app.py +++ b/examples/handlers/handlers/app.py @@ -12,7 +12,8 @@ class HandlerApp(toga.App): # Button callback functions def do_clear(self, widget, **kwargs): self.counter = 0 - self.label.text = "Ready." + self.label_1.text = "Ready." + self.label_2.text = "Ready." self.function_label.text = "Ready." self.generator_label.text = "Ready." self.async_label.text = "Ready." @@ -46,12 +47,20 @@ async def do_async(self, widget, **kwargs): self.async_label.text = "Ready." widget.enabled = True - async def do_background_task(self, widget, **kwargs): + async def on_running(self, **kwargs): + """A task started when the app is running.""" + # This task runs in the background, without blocking the main event loop + while True: + self.counter += 1 + self.label_1.text = f"On Running: Iteration {self.counter}" + await asyncio.sleep(1) + + async def do_background_task(self, **kwargs): """A background task.""" # This task runs in the background, without blocking the main event loop while True: self.counter += 1 - self.label.text = f"Background: Iteration {self.counter}" + self.label_2.text = f"Background: Iteration {self.counter}" await asyncio.sleep(1) async def do_web_get(self, widget, **kwargs): @@ -69,7 +78,8 @@ def startup(self): self.main_window = toga.MainWindow() # Labels to show responses. - self.label = toga.Label("Ready.", style=Pack(padding=10)) + self.label_1 = toga.Label("Ready.", style=Pack(padding=10)) + self.label_2 = toga.Label("Ready.", style=Pack(padding=10)) self.function_label = toga.Label("Ready.", style=Pack(padding=10)) self.generator_label = toga.Label("Ready.", style=Pack(padding=10)) self.async_label = toga.Label("Ready.", style=Pack(padding=10)) @@ -77,7 +87,7 @@ def startup(self): # Add a background task. self.counter = 0 - self.add_background_task(self.do_background_task) + asyncio.create_task(self.do_background_task()) # Buttons btn_style = Pack(flex=1) @@ -98,7 +108,8 @@ def startup(self): # Outermost box box = toga.Box( children=[ - self.label, + self.label_1, + self.label_2, btn_function, self.function_label, btn_generator, From 467fc6b48b1a83d58b110a73f1f2a2e4d4ef9187 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 13 Sep 2024 06:29:59 +0800 Subject: [PATCH 06/11] Use better label names. --- examples/handlers/handlers/app.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/examples/handlers/handlers/app.py b/examples/handlers/handlers/app.py index d3b766f039..3afebf616e 100644 --- a/examples/handlers/handlers/app.py +++ b/examples/handlers/handlers/app.py @@ -12,8 +12,8 @@ class HandlerApp(toga.App): # Button callback functions def do_clear(self, widget, **kwargs): self.counter = 0 - self.label_1.text = "Ready." - self.label_2.text = "Ready." + self.on_running_label.text = "Ready." + self.background_label.text = "Ready." self.function_label.text = "Ready." self.generator_label.text = "Ready." self.async_label.text = "Ready." @@ -52,7 +52,7 @@ async def on_running(self, **kwargs): # This task runs in the background, without blocking the main event loop while True: self.counter += 1 - self.label_1.text = f"On Running: Iteration {self.counter}" + self.on_running_label.text = f"On Running: Iteration {self.counter}" await asyncio.sleep(1) async def do_background_task(self, **kwargs): @@ -60,7 +60,7 @@ async def do_background_task(self, **kwargs): # This task runs in the background, without blocking the main event loop while True: self.counter += 1 - self.label_2.text = f"Background: Iteration {self.counter}" + self.background_label.text = f"Background: Iteration {self.counter}" await asyncio.sleep(1) async def do_web_get(self, widget, **kwargs): @@ -78,8 +78,8 @@ def startup(self): self.main_window = toga.MainWindow() # Labels to show responses. - self.label_1 = toga.Label("Ready.", style=Pack(padding=10)) - self.label_2 = toga.Label("Ready.", style=Pack(padding=10)) + self.on_running_label = toga.Label("Ready.", style=Pack(padding=10)) + self.background_label = toga.Label("Ready.", style=Pack(padding=10)) self.function_label = toga.Label("Ready.", style=Pack(padding=10)) self.generator_label = toga.Label("Ready.", style=Pack(padding=10)) self.async_label = toga.Label("Ready.", style=Pack(padding=10)) @@ -108,8 +108,8 @@ def startup(self): # Outermost box box = toga.Box( children=[ - self.label_1, - self.label_2, + self.on_running_label, + self.background_label, btn_function, self.function_label, btn_generator, From 3b7f4fe3eb73080a0de6cf2e344a60249cfa86b9 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 13 Sep 2024 07:08:03 +0800 Subject: [PATCH 07/11] Add delay to optioncontainer tests on GTK. --- .../tests_backend/widgets/optioncontainer.py | 3 + .../tests_backend/widgets/optioncontainer.py | 3 + gtk/tests_backend/widgets/optioncontainer.py | 3 + iOS/tests_backend/widgets/optioncontainer.py | 3 + testbed/tests/widgets/test_optioncontainer.py | 70 +++++++++---------- .../tests_backend/widgets/optioncontainer.py | 3 + 6 files changed, 50 insertions(+), 35 deletions(-) diff --git a/android/tests_backend/widgets/optioncontainer.py b/android/tests_backend/widgets/optioncontainer.py index a33864a14d..0a65d5d3d6 100644 --- a/android/tests_backend/widgets/optioncontainer.py +++ b/android/tests_backend/widgets/optioncontainer.py @@ -21,6 +21,9 @@ def select_tab(self, index): item.setChecked(True) self.impl.onItemSelectedListener(item) + async def wait_for_tab(self, message): + await self.redraw(message) + def tab_enabled(self, index): return self.native_navigationview.getMenu().getItem(index).isEnabled() diff --git a/cocoa/tests_backend/widgets/optioncontainer.py b/cocoa/tests_backend/widgets/optioncontainer.py index a717acefd8..722def09ca 100644 --- a/cocoa/tests_backend/widgets/optioncontainer.py +++ b/cocoa/tests_backend/widgets/optioncontainer.py @@ -43,6 +43,9 @@ def height(self): def select_tab(self, index): self.native.selectTabViewItemAtIndex(index) + async def wait_for_tab(self, message): + await self.redraw(message) + def tab_enabled(self, index): # _isTabEnabled() is a hidden method, so the naming messes with Rubicon's # property lookup mechanism. Invoke it by passing the message directly. diff --git a/gtk/tests_backend/widgets/optioncontainer.py b/gtk/tests_backend/widgets/optioncontainer.py index 9cea43fb33..e410006057 100644 --- a/gtk/tests_backend/widgets/optioncontainer.py +++ b/gtk/tests_backend/widgets/optioncontainer.py @@ -19,6 +19,9 @@ def select_tab(self, index): if self.tab_enabled(index): self.native.set_current_page(index) + async def wait_for_tab(self, message): + await self.redraw(message, delay=0.1) + def tab_enabled(self, index): return self.impl.sub_containers[index].get_visible() diff --git a/iOS/tests_backend/widgets/optioncontainer.py b/iOS/tests_backend/widgets/optioncontainer.py index d5cdbe8a2c..b2127470e4 100644 --- a/iOS/tests_backend/widgets/optioncontainer.py +++ b/iOS/tests_backend/widgets/optioncontainer.py @@ -39,6 +39,9 @@ def select_more(self): more = self.impl.native_controller.moreNavigationController self.impl.native_controller.selectedViewController = more + async def wait_for_tab(self, message): + await self.redraw(message) + def reset_more(self): more = self.impl.native_controller.moreNavigationController more.popToRootViewControllerAnimated(False) diff --git a/testbed/tests/widgets/test_optioncontainer.py b/testbed/tests/widgets/test_optioncontainer.py index 31d210cc1e..a09b569fd0 100644 --- a/testbed/tests/widgets/test_optioncontainer.py +++ b/testbed/tests/widgets/test_optioncontainer.py @@ -81,7 +81,7 @@ async def test_select_tab( ): """Tabs of content can be selected""" # Initially selected tab has content that is the full size of the widget - await probe.redraw("Tab 1 should be selected") + await probe.wait_for_tab("Tab 1 should be selected") assert widget.current_tab.index == 0 # The content should be the same size as the container; these dimensions can @@ -96,7 +96,7 @@ async def test_select_tab( # Select item 1 programmatically widget.current_tab = "Tab 2" - await probe.redraw("Tab 2 should be selected") + await probe.wait_for_tab("Tab 2 should be selected") assert widget.current_tab.index == 1 assert content2_probe.width > probe.width * 0.8 @@ -115,7 +115,7 @@ def on_select(widget, **kwargs): # Select item 2 in the GUI probe.select_tab(2) - await probe.redraw("Tab 3 should be selected") + await probe.wait_for_tab("Tab 3 should be selected") assert widget.current_tab.index == 2 assert content3_probe.width > probe.width * 0.8 @@ -152,7 +152,7 @@ async def test_select_tab_overflow(widget, probe, on_select_handler): for i, extra in enumerate(extra_widgets, start=4): widget.content.append(f"Tab {i}", extra) - await probe.redraw("Tab 1 should be selected initially") + await probe.wait_for_tab("Tab 1 should be selected initially") assert widget.current_tab.index == 0 # Ensure mock call count is clean @@ -162,7 +162,7 @@ async def test_select_tab_overflow(widget, probe, on_select_handler): # `select_more()` doesn't exist, that feature doesn't exist on the platform. try: probe.select_more() - await probe.redraw("More option should be displayed") + await probe.wait_for_tab("More option should be displayed") # When the "more" menu is visible, the current tab is None. assert widget.current_tab.index is None except AttributeError: @@ -173,7 +173,7 @@ async def test_select_tab_overflow(widget, probe, on_select_handler): # Select the second last tab in the GUI probe.select_tab(6) - await probe.redraw("Tab 7 should be selected") + await probe.wait_for_tab("Tab 7 should be selected") assert widget.current_tab.index == 6 assert extra_probes[3].width > probe.width * 0.8 @@ -185,7 +185,7 @@ async def test_select_tab_overflow(widget, probe, on_select_handler): # Select the last tab programmatically while already on a "more" option widget.current_tab = "Tab 8" - await probe.redraw("Tab 8 should be selected") + await probe.wait_for_tab("Tab 8 should be selected") assert widget.current_tab.index == 7 assert extra_probes[4].width > probe.width * 0.8 @@ -196,7 +196,7 @@ async def test_select_tab_overflow(widget, probe, on_select_handler): # Select the first tab in the GUI probe.select_tab(0) - await probe.redraw("Tab 0 should be selected") + await probe.wait_for_tab("Tab 0 should be selected") assert widget.current_tab.index == 0 # on_select has been invoked on_select_handler.assert_called_once_with(widget) @@ -207,7 +207,7 @@ async def test_select_tab_overflow(widget, probe, on_select_handler): try: probe.select_more() if probe.more_option_is_stateful: - await probe.redraw("Previous more option should be displayed") + await probe.wait_for_tab("Previous more option should be displayed") assert widget.current_tab.index == 7 # more is stateful, so there's a been a select event for the # previously selected "more" option. @@ -215,9 +215,9 @@ async def test_select_tab_overflow(widget, probe, on_select_handler): on_select_handler.reset_mock() probe.reset_more() - await probe.redraw("More option should be reset") + await probe.wait_for_tab("More option should be reset") else: - await probe.redraw("More option should be displayed") + await probe.wait_for_tab("More option should be displayed") assert widget.current_tab.index is None except AttributeError: @@ -227,7 +227,7 @@ async def test_select_tab_overflow(widget, probe, on_select_handler): # Select the second last tab in the GUI probe.select_tab(6) - await probe.redraw("Tab 7 should be selected") + await probe.wait_for_tab("Tab 7 should be selected") assert widget.current_tab.index == 6 assert extra_probes[3].width > probe.width * 0.8 @@ -239,7 +239,7 @@ async def test_select_tab_overflow(widget, probe, on_select_handler): # Select the first tab in the GUI probe.select_tab(0) - await probe.redraw("Tab 0 should be selected") + await probe.wait_for_tab("Tab 0 should be selected") assert widget.current_tab.index == 0 # on_select has been invoked on_select_handler.assert_called_once_with(widget) @@ -247,7 +247,7 @@ async def test_select_tab_overflow(widget, probe, on_select_handler): # Select the last tab programmatically while on a non-more option widget.current_tab = "Tab 8" - await probe.redraw("Tab 8 should be selected") + await probe.wait_for_tab("Tab 8 should be selected") assert widget.current_tab.index == 7 assert extra_probes[4].width > probe.width * 0.8 @@ -263,7 +263,7 @@ async def test_select_tab_overflow(widget, probe, on_select_handler): extra_probes.pop(0) widget.content.append(f"Tab {i}", extra) - await probe.redraw("OptionContainer is at capacity") + await probe.wait_for_tab("OptionContainer is at capacity") # Ensure mock call count is clean on_select_handler.reset_mock() @@ -280,7 +280,7 @@ async def test_select_tab_overflow(widget, probe, on_select_handler): extra_probes.pop(0) widget.content.append("Tab B", extra) - await probe.redraw("Appended items were ignored") + await probe.wait_for_tab("Appended items were ignored") # Excess tab details can still be read and written widget.content[probe.max_tabs].text = "Extra Tab" @@ -300,7 +300,7 @@ async def test_select_tab_overflow(widget, probe, on_select_handler): with pytest.warns(match=r"Tab is outside selectable range"): widget.current_tab = probe.max_tabs + 1 - await probe.redraw("Item selection was ignored") + await probe.wait_for_tab("Item selection was ignored") on_select_handler.assert_not_called() # Insert a tab at the start. This will bump the last tab into the void @@ -309,7 +309,7 @@ async def test_select_tab_overflow(widget, probe, on_select_handler): extra_probes.pop(0) widget.content.insert(2, "Tab C", extra) - await probe.redraw("Inserted item bumped the last item") + await probe.wait_for_tab("Inserted item bumped the last item") # Assert the properties of the last visible item assert widget.content[probe.max_tabs - 1].text == f"Tab {probe.max_tabs - 1}" @@ -332,7 +332,7 @@ async def test_select_tab_overflow(widget, probe, on_select_handler): # the previous insertion back into view. widget.content.remove(1) - await probe.redraw("Deleting an item restores previously bumped item") + await probe.wait_for_tab("Deleting an item restores previously bumped item") assert widget.content[probe.max_tabs - 1].text == f"Tab {probe.max_tabs}" probe.assert_tab_icon(probe.max_tabs - 1, None) @@ -350,7 +350,7 @@ async def test_select_tab_overflow(widget, probe, on_select_handler): # was disabled while it wasn't visible. widget.content.remove(1) - await probe.redraw("Deleting an item creates a previously excess item") + await probe.wait_for_tab("Deleting an item creates a previously excess item") assert widget.content[probe.max_tabs - 1].text == "Extra Tab" probe.assert_tab_icon(probe.max_tabs - 1, "new-tab") @@ -376,7 +376,7 @@ async def test_enable_tab(widget, probe, on_select_handler): # Disable item 1 widget.content[1].enabled = False - await probe.redraw("Tab 2 should be disabled") + await probe.wait_for_tab("Tab 2 should be disabled") assert widget.content[0].enabled assert not widget.content[1].enabled @@ -385,7 +385,7 @@ async def test_enable_tab(widget, probe, on_select_handler): # Try to select a disabled tab probe.select_tab(1) - await probe.redraw("Try to select tab 2") + await probe.wait_for_tab("Try to select tab 2") if probe.disabled_tab_selectable: assert widget.current_tab.index == 1 @@ -401,7 +401,7 @@ async def test_enable_tab(widget, probe, on_select_handler): # Disable item 1 again, even though it's disabled widget.content[1].enabled = False - await probe.redraw("Tab 2 should still be disabled") + await probe.wait_for_tab("Tab 2 should still be disabled") assert widget.content[0].enabled assert not widget.content[1].enabled @@ -410,7 +410,7 @@ async def test_enable_tab(widget, probe, on_select_handler): # where disabling a tab means hiding the tab completely, it will be *visual* # index 1, but content index 2. Make sure the indices are all correct. widget.current_tab = 2 - await probe.redraw("Tab 3 should be selected") + await probe.wait_for_tab("Tab 3 should be selected") assert widget.current_tab.index == 2 assert widget.current_tab.text == "Tab 3" @@ -421,7 +421,7 @@ async def test_enable_tab(widget, probe, on_select_handler): # Enable item 1 widget.content[1].enabled = True - await probe.redraw("Tab 2 should be enabled") + await probe.wait_for_tab("Tab 2 should be enabled") assert widget.content[0].enabled assert widget.content[1].enabled @@ -430,7 +430,7 @@ async def test_enable_tab(widget, probe, on_select_handler): # Try to select tab 1 probe.select_tab(1) - await probe.redraw("Tab 1 should be selected") + await probe.wait_for_tab("Tab 1 should be selected") assert widget.current_tab.index == 1 assert widget.content[0].enabled @@ -442,7 +442,7 @@ async def test_enable_tab(widget, probe, on_select_handler): # Enable item 1 again, even though it's enabled widget.content[1].enabled = True - await probe.redraw("Tab 2 should still be enabled") + await probe.wait_for_tab("Tab 2 should still be enabled") assert widget.content[0].enabled assert widget.content[1].enabled @@ -465,7 +465,7 @@ async def test_change_content( new_probe = get_probe(new_box) widget.content.insert(1, "New tab", new_box, enabled=False) - await probe.redraw("New tab has been added disabled") + await probe.wait_for_tab("New tab has been added disabled") assert len(widget.content) == 4 assert widget.content[1].text == "New tab" @@ -474,7 +474,7 @@ async def test_change_content( # Enable the new content and select it widget.content[1].enabled = True widget.current_tab = "New tab" - await probe.redraw("New tab has been enabled and selected") + await probe.wait_for_tab("New tab has been enabled and selected") assert widget.current_tab.index == 1 assert widget.current_tab.text == "New tab" @@ -492,33 +492,33 @@ async def test_change_content( # Change the title of Tab 2 widget.content["Tab 2"].text = "New 2" - await probe.redraw("Tab 2 has been renamed") + await probe.wait_for_tab("Tab 2 has been renamed") assert widget.content[2].text == "New 2" # Change the icon of Tab 2 widget.content["New 2"].icon = "resources/new-tab" - await probe.redraw("Tab 2 has a new icon") + await probe.wait_for_tab("Tab 2 has a new icon") probe.assert_tab_icon(2, "new-tab") # Clear the icon of Tab 2 widget.content["New 2"].icon = None - await probe.redraw("Tab 2 has the default icon") + await probe.wait_for_tab("Tab 2 has the default icon") probe.assert_tab_icon(2, None) # Remove Tab 2 widget.content.remove("New 2") - await probe.redraw("Tab 2 has been removed") + await probe.wait_for_tab("Tab 2 has been removed") assert len(widget.content) == 3 # Add tab 2 back in at the end with a new title widget.content.append("New Tab 2", content2) - await probe.redraw("Tab 2 has been added with a new title") + await probe.wait_for_tab("Tab 2 has been added with a new title") widget.current_tab = "New Tab 2" - await probe.redraw("Revised tab 2 has been selected") + await probe.wait_for_tab("Revised tab 2 has been selected") assert widget.current_tab.index == 3 assert widget.current_tab.text == "New Tab 2" diff --git a/winforms/tests_backend/widgets/optioncontainer.py b/winforms/tests_backend/widgets/optioncontainer.py index bcd724b7d6..233ff1cc87 100644 --- a/winforms/tests_backend/widgets/optioncontainer.py +++ b/winforms/tests_backend/widgets/optioncontainer.py @@ -11,6 +11,9 @@ class OptionContainerProbe(SimpleProbe): def select_tab(self, index): self.native.SelectedIndex = index + async def wait_for_tab(self, message): + await self.redraw(message) + def tab_enabled(self, index): return self.native.TabPages[index].Enabled From 6415d71727f6e27ec556b6c34feb248915e78965 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 13 Sep 2024 08:27:41 +0800 Subject: [PATCH 08/11] Correct spacing for consistent style. Co-authored-by: Russell Martin --- gtk/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gtk/pyproject.toml b/gtk/pyproject.toml index d75b48f037..7193aae47a 100644 --- a/gtk/pyproject.toml +++ b/gtk/pyproject.toml @@ -65,7 +65,7 @@ root = ".." [tool.setuptools_dynamic_dependencies] dependencies = [ "pycairo >= 1.17.0", - "pygobject>=3.50.0", + "pygobject >= 3.50.0", "toga-core == {version}", ] From 47d1da7082e1c13a0a0c425f82fa8abdc8a81d86 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 13 Sep 2024 08:32:06 +0800 Subject: [PATCH 09/11] Replace use of deprecated get_event_loop API. --- gtk/src/toga_gtk/app.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/gtk/src/toga_gtk/app.py b/gtk/src/toga_gtk/app.py index 99032dc0b4..87dc9d0841 100644 --- a/gtk/src/toga_gtk/app.py +++ b/gtk/src/toga_gtk/app.py @@ -29,7 +29,9 @@ def __init__(self, interface): self.policy = GLibEventLoopPolicy() asyncio.set_event_loop_policy(self.policy) - self.loop = asyncio.get_event_loop() + # GTK is responsible for starting the loop; we can't query that loop + # until the application is running. self.loop is set in the `activate` + # handler. # Stimulate the build of the app self.native = Gtk.Application( @@ -49,6 +51,8 @@ def gtk_activate(self, data=None): pass def gtk_startup(self, data=None): + self.loop = asyncio.get_running_loop() + self.interface._startup() # Set any custom styles From 6a20d6aa013b4516a42726be4a5abe6dddab47a2 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 13 Sep 2024 09:09:54 +0800 Subject: [PATCH 10/11] Get the event loop for the Glib main context. --- gtk/src/toga_gtk/app.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/gtk/src/toga_gtk/app.py b/gtk/src/toga_gtk/app.py index 87dc9d0841..50d63950c5 100644 --- a/gtk/src/toga_gtk/app.py +++ b/gtk/src/toga_gtk/app.py @@ -29,9 +29,7 @@ def __init__(self, interface): self.policy = GLibEventLoopPolicy() asyncio.set_event_loop_policy(self.policy) - # GTK is responsible for starting the loop; we can't query that loop - # until the application is running. self.loop is set in the `activate` - # handler. + self.loop = self.policy.get_event_loop_for_context(GLib.MainContext.default()) # Stimulate the build of the app self.native = Gtk.Application( @@ -51,8 +49,6 @@ def gtk_activate(self, data=None): pass def gtk_startup(self, data=None): - self.loop = asyncio.get_running_loop() - self.interface._startup() # Set any custom styles From b9af094e9241c775039fa2d26526c0e7fb662d41 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 13 Sep 2024 09:20:09 +0800 Subject: [PATCH 11/11] Use a simpler invocation to get the event loop. --- gtk/src/toga_gtk/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gtk/src/toga_gtk/app.py b/gtk/src/toga_gtk/app.py index 50d63950c5..467614e6d8 100644 --- a/gtk/src/toga_gtk/app.py +++ b/gtk/src/toga_gtk/app.py @@ -29,7 +29,7 @@ def __init__(self, interface): self.policy = GLibEventLoopPolicy() asyncio.set_event_loop_policy(self.policy) - self.loop = self.policy.get_event_loop_for_context(GLib.MainContext.default()) + self.loop = self.policy.get_event_loop() # Stimulate the build of the app self.native = Gtk.Application(