From 140e5f0cfc1248d5947311138514823218c4de5c Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 3 May 2024 15:22:37 +0800 Subject: [PATCH 1/6] Replace gbulb with a direct GTK main loop integration. --- gtk/pyproject.toml | 1 - gtk/src/toga_gtk/app.py | 35 ++++++++++++++++++++++++++++------- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/gtk/pyproject.toml b/gtk/pyproject.toml index bbef568bab..a0bd45a26c 100644 --- a/gtk/pyproject.toml +++ b/gtk/pyproject.toml @@ -64,7 +64,6 @@ root = ".." [tool.setuptools_dynamic_dependencies] dependencies = [ - "gbulb >= 0.5.3", "pycairo >= 1.17.0", "pygobject >= 3.46.0", "toga-core == {version}", diff --git a/gtk/src/toga_gtk/app.py b/gtk/src/toga_gtk/app.py index b0889ab11f..d869fb89dd 100644 --- a/gtk/src/toga_gtk/app.py +++ b/gtk/src/toga_gtk/app.py @@ -4,8 +4,6 @@ import sys from pathlib import Path -import gbulb - import toga from toga import App as toga_App from toga.command import Command, Separator @@ -38,7 +36,6 @@ def __init__(self, interface): self.interface = interface self.interface._impl = self - gbulb.install(gtk=True) self.loop = asyncio.new_event_loop() self.create() @@ -53,14 +50,15 @@ def create(self): # Connect the GTK signal that will cause app startup to occur self.native.connect("startup", self.gtk_startup) + self.native.connect("shutdown", self.gtk_shutdown) self.native.connect("activate", self.gtk_activate) 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() @@ -87,6 +85,10 @@ def gtk_startup(self, data=None): Gdk.Screen.get_default(), css_provider, Gtk.STYLE_PROVIDER_PRIORITY_USER ) + def gtk_shutdown(self, app): + # Gtk Application has exited; stop the asyncio loop. + self.loop.stop() + ###################################################################### # Commands and menus ###################################################################### @@ -180,13 +182,32 @@ def create_menus(self): # We can't call this under test conditions, because it would kill the test harness def exit(self): # pragma: no cover - self.native.quit() + self.native.emit("shutdown") + + async def _gtk_app_run(self): + # An co-operative implementation of `GtkApplication.run()` + self.native.register() + self.native.activate() + + context = GLib.MainContext.default() + while True: + # If there are any events pending, process them. + if context.pending(): + handled = context.iteration(False) + else: + handled = False + + # If we handled any events, schedule another iterate again as soon + # as possible. If we didn't, it's ok to wait a little bit before + # trying to process GTK events again. + await asyncio.sleep(0.0 if handled else 0.05) 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) + self.loop.create_task(self._gtk_app_run()) + self.loop.run_forever() def set_icon(self, icon): for window in self.interface.windows: From 883ced72bddd558255ce16dc4b2a39d421de8eba Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 3 May 2024 15:55:26 +0800 Subject: [PATCH 2/6] Add changenote. --- changes/2458.feature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/2458.feature.rst diff --git a/changes/2458.feature.rst b/changes/2458.feature.rst new file mode 100644 index 0000000000..61bafbc820 --- /dev/null +++ b/changes/2458.feature.rst @@ -0,0 +1 @@ +The GTK backend was modified to integrate the GTK event loop directly into the asyncio event loop, rather than using GBulb. From 2f7d3e68748ad027e9b7f512ed198dc400735392 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sat, 4 May 2024 08:43:37 +0800 Subject: [PATCH 3/6] Incorporate asyncio-glib selector implementation. --- gtk/src/toga_gtk/app.py | 19 ++--- gtk/src/toga_gtk/libs/events.py | 138 ++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 14 deletions(-) create mode 100644 gtk/src/toga_gtk/libs/events.py diff --git a/gtk/src/toga_gtk/app.py b/gtk/src/toga_gtk/app.py index d869fb89dd..b6a351baee 100644 --- a/gtk/src/toga_gtk/app.py +++ b/gtk/src/toga_gtk/app.py @@ -10,6 +10,7 @@ from .keys import gtk_accel from .libs import TOGA_DEFAULT_STYLES, Gdk, Gio, GLib, Gtk +from .libs.events import GtkEventLoopPolicy from .screens import Screen as ScreenImpl from .window import Window @@ -36,6 +37,8 @@ def __init__(self, interface): self.interface = interface self.interface._impl = self + self.policy = GtkEventLoopPolicy() + asyncio.set_event_loop_policy(self.policy) self.loop = asyncio.new_event_loop() self.create() @@ -185,23 +188,11 @@ def exit(self): # pragma: no cover self.native.emit("shutdown") async def _gtk_app_run(self): - # An co-operative implementation of `GtkApplication.run()` + # A co-operative implementation of the startup portions of + # GtkApplication.run() self.native.register() self.native.activate() - context = GLib.MainContext.default() - while True: - # If there are any events pending, process them. - if context.pending(): - handled = context.iteration(False) - else: - handled = False - - # If we handled any events, schedule another iterate again as soon - # as possible. If we didn't, it's ok to wait a little bit before - # trying to process GTK events again. - await asyncio.sleep(0.0 if handled else 0.05) - def main_loop(self): # Modify signal handlers to make sure Ctrl-C is caught and handled. signal.signal(signal.SIGINT, signal.SIG_DFL) diff --git a/gtk/src/toga_gtk/libs/events.py b/gtk/src/toga_gtk/libs/events.py new file mode 100644 index 0000000000..5de7e470c8 --- /dev/null +++ b/gtk/src/toga_gtk/libs/events.py @@ -0,0 +1,138 @@ +########################################################################## +# This code is derived from asyncio-glib: +# +# https://github.com/jhenstridge/asyncio-glib +# +# Copyright (C) James Henstridge +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +# +########################################################################## + +import asyncio +import selectors + +from .gtk import GLib + +try: + g_main_loop_run = super(GLib.MainLoop, GLib.MainLoop).run +except AttributeError: + g_main_loop_run = GLib.MainLoop.run + + +class _SelectorSource(GLib.Source): + """A GLib source that gathers selectors.""" + + def __init__(self, main_loop): + super().__init__() + self._fd_to_tag = {} + self._fd_to_events = {} + self._main_loop = main_loop + + def prepare(self): + return False, -1 + + def check(self): + return False + + def dispatch(self, callback, args): + for fd, tag in self._fd_to_tag.items(): + condition = self.query_unix_fd(tag) + events = self._fd_to_events.setdefault(fd, 0) + if condition & GLib.IOCondition.IN: + events |= selectors.EVENT_READ + if condition & GLib.IOCondition.OUT: + events |= selectors.EVENT_WRITE + self._fd_to_events[fd] = events + self._main_loop.quit() + return GLib.SOURCE_CONTINUE + + def register(self, fd, events): + assert fd not in self._fd_to_tag + + condition = GLib.IOCondition(0) + if events & selectors.EVENT_READ: + condition |= GLib.IOCondition.IN + if events & selectors.EVENT_WRITE: + condition |= GLib.IOCondition.OUT + self._fd_to_tag[fd] = self.add_unix_fd(fd, condition) + + def unregister(self, fd): + tag = self._fd_to_tag.pop(fd) + self.remove_unix_fd(tag) + + def get_events(self, fd): + return self._fd_to_events.get(fd, 0) + + def clear(self): + self._fd_to_events.clear() + + +class GLibSelector(selectors._BaseSelectorImpl): + + def __init__(self, context): + super().__init__() + self._context = context + self._main_loop = GLib.MainLoop.new(self._context, False) + self._source = _SelectorSource(self._main_loop) + self._source.attach(self._context) + + def close(self): + self._source.destroy() + super().close() + + def register(self, fileobj, events, data=None): + key = super().register(fileobj, events, data) + self._source.register(key.fd, events) + return key + + def unregister(self, fileobj): + key = super().unregister(fileobj) + self._source.unregister(key.fd) + return key + + def select(self, timeout=None): + may_block = True + self._source.set_ready_time(-1) + if timeout is not None: + if timeout > 0: + self._source.set_ready_time( + GLib.get_monotonic_time() + int(timeout * 1000000) + ) + else: + may_block = False + + self._source.clear() + if may_block: + g_main_loop_run(self._main_loop) + else: + self._context.iteration(False) + + ready = [] + for key in self.get_map().values(): + events = self._source.get_events(key.fd) & key.events + if events != 0: + ready.append((key, events)) + return ready + + +class GtkEventLoop(asyncio.SelectorEventLoop): + def __init__(self): + selector = GLibSelector(GLib.MainContext.default()) + super().__init__(selector) + + +class GtkEventLoopPolicy(asyncio.DefaultEventLoopPolicy): + _loop_factory = GtkEventLoop From 5c99ce156970182584c0b4e658d7879229e3689b Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sat, 4 May 2024 08:44:34 +0800 Subject: [PATCH 4/6] Add async httpx to handler 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 e90b3e0a3d4b0efa88a6a2b065a454c3da814734 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sat, 4 May 2024 09:35:22 +0800 Subject: [PATCH 5/6] Do 2 GC passes to ensure loops are disposed. --- testbed/tests/widgets/test_webview.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/testbed/tests/widgets/test_webview.py b/testbed/tests/widgets/test_webview.py index 2a933bb675..1b174a9d06 100644 --- a/testbed/tests/widgets/test_webview.py +++ b/testbed/tests/widgets/test_webview.py @@ -84,6 +84,8 @@ async def widget(on_load): # This prevents a segfault at GC time likely coming from the test suite running # in a thread and Gtk WebViews sharing resources between instances. We perform # the GC run here since pytest fixtures make earlier cleanup difficult. + # Do 2 GC passes to ensure loops are resolved. + gc.collect() gc.collect() widget = toga.WebView(style=Pack(flex=1), on_webview_load=on_load) @@ -117,8 +119,10 @@ async def widget(on_load): # On Gtk, ensure that the WebView is garbage collection before the next test # case. This prevents a segfault at GC time likely coming from the test suite # running in a thread and Gtk WebViews sharing resources between instances. + # Do 2 GC passes to ensure loops are resolved. del widget gc.collect() + gc.collect() async def test_set_url(widget, probe, on_load): From f1e71493e5265f89430d114734dab5d3b6a0476c Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sat, 4 May 2024 13:22:38 +0800 Subject: [PATCH 6/6] Add some asyncio-glib patches that haven't been merged. --- gtk/src/toga_gtk/libs/events.py | 66 ++++++++++++++++----------------- 1 file changed, 31 insertions(+), 35 deletions(-) diff --git a/gtk/src/toga_gtk/libs/events.py b/gtk/src/toga_gtk/libs/events.py index 5de7e470c8..14774da6c4 100644 --- a/gtk/src/toga_gtk/libs/events.py +++ b/gtk/src/toga_gtk/libs/events.py @@ -3,22 +3,27 @@ # # https://github.com/jhenstridge/asyncio-glib # -# Copyright (C) James Henstridge +# It includes patches from pull requests against that project: +# +# * #9, contributed by Benjamin Berg +# * #6, contributed by Niels Avonds # -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public -# License as published by the Free Software Foundation; either -# version 2.1 of the License, or (at your option) any later version. +# ------------------------------------------------------------------------ +# Copyright (C) James Henstridge # -# This library is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. +# This library is free software; you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the Free +# Software Foundation; either version 2.1 of the License, or (at your option) +# any later version. # -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +# This library is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more +# details. # +# You should have received a copy of the GNU Lesser General Public License along +# with this library; if not, write to the Free Software Foundation, Inc., 51 +# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA ########################################################################## import asyncio @@ -26,23 +31,18 @@ from .gtk import GLib -try: - g_main_loop_run = super(GLib.MainLoop, GLib.MainLoop).run -except AttributeError: - g_main_loop_run = GLib.MainLoop.run - class _SelectorSource(GLib.Source): """A GLib source that gathers selectors.""" - def __init__(self, main_loop): + def __init__(self): super().__init__() self._fd_to_tag = {} self._fd_to_events = {} - self._main_loop = main_loop + self._iteration_timeout = 0 def prepare(self): - return False, -1 + return False, self._iteration_timeout def check(self): return False @@ -53,10 +53,11 @@ def dispatch(self, callback, args): events = self._fd_to_events.setdefault(fd, 0) if condition & GLib.IOCondition.IN: events |= selectors.EVENT_READ + if condition & GLib.IOCondition.HUP: + events |= selectors.EVENT_READ if condition & GLib.IOCondition.OUT: events |= selectors.EVENT_WRITE self._fd_to_events[fd] = events - self._main_loop.quit() return GLib.SOURCE_CONTINUE def register(self, fd, events): @@ -65,6 +66,7 @@ def register(self, fd, events): condition = GLib.IOCondition(0) if events & selectors.EVENT_READ: condition |= GLib.IOCondition.IN + condition |= GLib.IOCondition.HUP if events & selectors.EVENT_WRITE: condition |= GLib.IOCondition.OUT self._fd_to_tag[fd] = self.add_unix_fd(fd, condition) @@ -85,8 +87,7 @@ class GLibSelector(selectors._BaseSelectorImpl): def __init__(self, context): super().__init__() self._context = context - self._main_loop = GLib.MainLoop.new(self._context, False) - self._source = _SelectorSource(self._main_loop) + self._source = _SelectorSource() self._source.attach(self._context) def close(self): @@ -104,21 +105,15 @@ def unregister(self, fileobj): return key def select(self, timeout=None): - may_block = True - self._source.set_ready_time(-1) + # Calling .set_ready_time() always causes a mainloop iteration to finish. if timeout is not None: - if timeout > 0: - self._source.set_ready_time( - GLib.get_monotonic_time() + int(timeout * 1000000) - ) - else: - may_block = False + # Negative timeout implies immediate dispatch + self._source._iteration_timeout = int(max(0, timeout) * 1000) + else: + self._source._iteration_timeout = -1 self._source.clear() - if may_block: - g_main_loop_run(self._main_loop) - else: - self._context.iteration(False) + self._context.iteration(False) ready = [] for key in self.get_map().values(): @@ -132,6 +127,7 @@ class GtkEventLoop(asyncio.SelectorEventLoop): def __init__(self): selector = GLibSelector(GLib.MainContext.default()) super().__init__(selector) + self._clock_resolution = 1e-3 class GtkEventLoopPolicy(asyncio.DefaultEventLoopPolicy):