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/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. 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/examples/handlers/handlers/app.py b/examples/handlers/handlers/app.py index cc7887a1e3..a081c81b06 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 @@ -10,7 +12,8 @@ class HandlerApp(toga.App): # Button callback functions def do_clear(self, widget, **kwargs): self.counter = 0 - self.label.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." @@ -44,23 +47,43 @@ async def do_async(self, widget, **kwargs): self.async_label.text = "Ready." widget.enabled = True + 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.on_running_label.text = f"On Running: Iteration {self.counter}" + await asyncio.sleep(1) + async def do_background_task(self): """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.background_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() # Labels to show responses. - self.label = 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)) + self.web_label = toga.Label("Ready.", style=Pack(padding=10)) # Add a background task. self.counter = 0 @@ -78,17 +101,23 @@ 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( children=[ - self.label, + self.on_running_label, + self.background_label, btn_function, self.function_label, btn_generator, 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 29de206e77..2231257076 100644 --- a/examples/handlers/pyproject.toml +++ b/examples/handlers/pyproject.toml @@ -16,6 +16,7 @@ description = "A testing app" sources = ["handlers"] requires = [ "../../core", + "httpx", ] diff --git a/gtk/pyproject.toml b/gtk/pyproject.toml index 13059a5e66..7193aae47a 100644 --- a/gtk/pyproject.toml +++ b/gtk/pyproject.toml @@ -64,12 +64,8 @@ root = ".." [tool.setuptools_dynamic_dependencies] dependencies = [ - "gbulb >= 0.5.3", "pycairo >= 1.17.0", - # New asyncio handling introduced in 3.50.0; that code is incompatible - # with gbulb, See #2550 for the code that replaces GBulb with the new - # asyncio code. - "pygobject < 3.50.0", + "pygobject >= 3.50.0", "toga-core == {version}", ] diff --git a/gtk/src/toga_gtk/app.py b/gtk/src/toga_gtk/app.py index 38531e203a..467614e6d8 100644 --- a/gtk/src/toga_gtk/app.py +++ b/gtk/src/toga_gtk/app.py @@ -1,13 +1,19 @@ import asyncio import signal -import gbulb - from toga.app import App as toga_App from toga.command import Separator from .keys import gtk_accel -from .libs import IS_WAYLAND, TOGA_DEFAULT_STYLES, Gdk, Gio, GLib, Gtk +from .libs import ( + IS_WAYLAND, + TOGA_DEFAULT_STYLES, + Gdk, + Gio, + GLib, + GLibEventLoopPolicy, + Gtk, +) from .screens import Screen as ScreenImpl @@ -21,8 +27,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 = self.policy.get_event_loop() # Stimulate the build of the app self.native = Gtk.Application( @@ -146,7 +153,8 @@ def main_loop(self): # Retain a reference to the app so that no-window apps can exist self.native.hold() - self.loop.run_forever(application=self.native) + # Start the app event loop + self.native.run() # Release the reference to the app. This can't be invoked by the testbed, # because it's after the `run_forever()` that runs the testbed. diff --git a/gtk/src/toga_gtk/libs/gtk.py b/gtk/src/toga_gtk/libs/gtk.py index f6f4f8cc01..a9efbbd970 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 ( # noqa: E402, F401 Gdk, GdkPixbuf, 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