Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Replace gbulb with a direct GTK main loop integration. #2548

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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/2458.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The GTK backend was modified to integrate the GTK event loop directly into the asyncio event loop, rather than using GBulb.
18 changes: 18 additions & 0 deletions examples/handlers/handlers/app.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import asyncio
import random

import httpx

import toga
from toga.constants import COLUMN
from toga.style import Pack
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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),
Expand Down
1 change: 1 addition & 0 deletions examples/handlers/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ description = "A testing app"
sources = ["handlers"]
requires = [
"../../core",
"httpx",
]


Expand Down
1 change: 0 additions & 1 deletion gtk/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down
26 changes: 19 additions & 7 deletions gtk/src/toga_gtk/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,13 @@
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.events import GtkEventLoopPolicy
from .screens import Screen as ScreenImpl
from .window import Window

Expand All @@ -38,7 +37,8 @@ def __init__(self, interface):
self.interface = interface
self.interface._impl = self

gbulb.install(gtk=True)
self.policy = GtkEventLoopPolicy()
asyncio.set_event_loop_policy(self.policy)
self.loop = asyncio.new_event_loop()

self.create()
Expand All @@ -53,14 +53,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()

Expand All @@ -87,6 +88,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
######################################################################
Expand Down Expand Up @@ -180,13 +185,20 @@ 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):
# A co-operative implementation of the startup portions of
# GtkApplication.run()
self.native.register()
self.native.activate()

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:
Expand Down
134 changes: 134 additions & 0 deletions gtk/src/toga_gtk/libs/events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
##########################################################################
# This code is derived from asyncio-glib:
#
# https://github.com/jhenstridge/asyncio-glib
#
# It includes patches from pull requests against that project:
#
# * #9, contributed by Benjamin Berg
# * #6, contributed by Niels Avonds
#
# ------------------------------------------------------------------------
# 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


class _SelectorSource(GLib.Source):
"""A GLib source that gathers selectors."""

def __init__(self):
super().__init__()
self._fd_to_tag = {}
self._fd_to_events = {}
self._iteration_timeout = 0

def prepare(self):
return False, self._iteration_timeout

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.HUP:
events |= selectors.EVENT_READ
if condition & GLib.IOCondition.OUT:
events |= selectors.EVENT_WRITE
self._fd_to_events[fd] = events
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
condition |= GLib.IOCondition.HUP
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._source = _SelectorSource()
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):
# Calling .set_ready_time() always causes a mainloop iteration to finish.
if timeout is not None:
# Negative timeout implies immediate dispatch
self._source._iteration_timeout = int(max(0, timeout) * 1000)
else:
self._source._iteration_timeout = -1

self._source.clear()
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)
self._clock_resolution = 1e-3


class GtkEventLoopPolicy(asyncio.DefaultEventLoopPolicy):
_loop_factory = GtkEventLoop
4 changes: 4 additions & 0 deletions testbed/tests/widgets/test_webview.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down
Loading