From 1ff717e99fa8cb7c54edd5fb6f534c37139896f7 Mon Sep 17 00:00:00 2001 From: "John X. Zhou" Date: Thu, 18 Sep 2025 18:06:02 -0500 Subject: [PATCH 01/37] Testbed and core changes to support Qt backend operations --- android/tests_backend/widgets/button.py | 3 +++ changes/1142.feature.rst | 1 + cocoa/src/toga_cocoa/widgets/button.py | 3 +++ core/src/toga/platform.py | 2 ++ gtk/tests_backend/widgets/button.py | 3 +++ iOS/tests_backend/widgets/button.py | 3 +++ testbed/tests/app/test_desktop.py | 4 +++- testbed/tests/conftest.py | 1 + testbed/tests/widgets/test_button.py | 7 +++---- testbed/tests/widgets/test_canvas.py | 8 +++++++- winforms/tests_backend/widgets/button.py | 3 +++ 11 files changed, 32 insertions(+), 6 deletions(-) create mode 100644 changes/1142.feature.rst diff --git a/android/tests_backend/widgets/button.py b/android/tests_backend/widgets/button.py index 9e380ac355..1a01f0eaa9 100644 --- a/android/tests_backend/widgets/button.py +++ b/android/tests_backend/widgets/button.py @@ -21,3 +21,6 @@ def assert_icon_size(self): assert (icon.getIntrinsicWidth(), icon.getIntrinsicHeight()) == scaled_size else: pytest.fail("Icon does not exist") + + def assert_taller_than(self, initial_height): + assert self.height > initial_height diff --git a/changes/1142.feature.rst b/changes/1142.feature.rst new file mode 100644 index 0000000000..8536dddae7 --- /dev/null +++ b/changes/1142.feature.rst @@ -0,0 +1 @@ +Toga now provides a Qt backend for KDE-based desktops. diff --git a/cocoa/src/toga_cocoa/widgets/button.py b/cocoa/src/toga_cocoa/widgets/button.py index eadb8e2b92..c3c642dc62 100644 --- a/cocoa/src/toga_cocoa/widgets/button.py +++ b/cocoa/src/toga_cocoa/widgets/button.py @@ -95,3 +95,6 @@ def rehint(self): content_size = self.native.intrinsicContentSize() self.interface.intrinsic.width = at_least(content_size.width) self.interface.intrinsic.height = content_size.height + + def assert_taller_than(self, initial_height): + assert self.height > initial_height diff --git a/core/src/toga/platform.py b/core/src/toga/platform.py index 45fcff2a66..4e621da248 100644 --- a/core/src/toga/platform.py +++ b/core/src/toga/platform.py @@ -29,6 +29,8 @@ def get_current_platform() -> str | None: return "android" elif sys.platform.startswith("freebsd"): return "freeBSD" + elif "kde" in os.environ.get("XDG_CURRENT_DESKTOP", "").lower() or os.environ.get("TOGA_QT","") == "1": + current_platform = "linux-qt" else: return _TOGA_PLATFORMS.get(sys.platform) diff --git a/gtk/tests_backend/widgets/button.py b/gtk/tests_backend/widgets/button.py index 4d6caa1308..81a54663d5 100644 --- a/gtk/tests_backend/widgets/button.py +++ b/gtk/tests_backend/widgets/button.py @@ -33,3 +33,6 @@ def background_color(self): async def press(self): self.native.clicked() + + def assert_taller_than(self, initial_height): + assert self.height > initial_height diff --git a/iOS/tests_backend/widgets/button.py b/iOS/tests_backend/widgets/button.py index 49bb77eed7..47332c9110 100644 --- a/iOS/tests_backend/widgets/button.py +++ b/iOS/tests_backend/widgets/button.py @@ -30,3 +30,6 @@ def color(self): @property def font(self): return self.native.titleLabel.font + + def assert_taller_than(self, initial_height): + assert self.height > initial_height diff --git a/testbed/tests/app/test_desktop.py b/testbed/tests/app/test_desktop.py index 78a65b606d..68528e3472 100644 --- a/testbed/tests/app/test_desktop.py +++ b/testbed/tests/app/test_desktop.py @@ -440,8 +440,10 @@ async def test_presentation_mode_exit_on_window_state_change( "App is in presentation mode", state=WindowState.PRESENTATION ) - assert app.in_presentation_mode assert window1_probe.instantaneous_state == WindowState.PRESENTATION + # Do this assertion after in order to give platforms that cannot support + # window states a chance to exit this test by skipping in instantaneous_state + assert app.in_presentation_mode # Changing window state of main window should make the app exit presentation mode. window1.state = new_window_state diff --git a/testbed/tests/conftest.py b/testbed/tests/conftest.py index 5509bf52ce..d129e5f051 100644 --- a/testbed/tests/conftest.py +++ b/testbed/tests/conftest.py @@ -3,6 +3,7 @@ import inspect from dataclasses import dataclass from importlib import import_module +import os from pytest import fixture, register_assert_rewrite, skip diff --git a/testbed/tests/widgets/test_button.py b/testbed/tests/widgets/test_button.py index fa3f64718a..761794784c 100644 --- a/testbed/tests/widgets/test_button.py +++ b/testbed/tests/widgets/test_button.py @@ -50,8 +50,8 @@ async def test_text(widget, probe): expected = str(text).split("\n")[0] assert widget.text == expected assert probe.text == expected - # GTK rendering can result in a very minor change in button height - assert probe.height == approx(initial_height, abs=1) + # GTK/Qt rendering can result in a very minor change in button height + assert probe.height == approx(initial_height, abs=2) async def test_icon(widget, probe): @@ -71,8 +71,7 @@ async def test_icon(widget, probe): # Icon now exists assert widget.icon is not None probe.assert_icon_size() - # Button is now taller. - assert probe.height > initial_height + probe.assert_taller_than(initial_height) # Move back to text widget.text = "Goodbye" diff --git a/testbed/tests/widgets/test_canvas.py b/testbed/tests/widgets/test_canvas.py index f9f29fa392..ad3e6f18e3 100644 --- a/testbed/tests/widgets/test_canvas.py +++ b/testbed/tests/widgets/test_canvas.py @@ -21,6 +21,7 @@ from toga.fonts import BOLD from toga.style.pack import SYSTEM, Pack +from ..conftest import skip_on_platforms from .conftest import build_cleanup_test from .properties import ( # noqa: F401 test_background_color, @@ -83,6 +84,7 @@ async def widget( on_alt_release_handler, on_alt_drag_handler, ): + skip_on_platforms("linux-kde") return toga.Canvas( on_resize=on_resize_handler, on_press=on_press_handler, @@ -109,7 +111,11 @@ def assert_pixel(image, x, y, color): assert image.getpixel((x, y)) == color -test_cleanup = build_cleanup_test(toga.Canvas, xfail_platforms=("android",)) +test_cleanup = build_cleanup_test( + toga.Canvas, + skip_platforms=("linux-kde",), + xfail_platforms=("android",), +) async def test_resize(widget, probe, on_resize_handler): diff --git a/winforms/tests_backend/widgets/button.py b/winforms/tests_backend/widgets/button.py index dcc35aded3..727f223153 100644 --- a/winforms/tests_backend/widgets/button.py +++ b/winforms/tests_backend/widgets/button.py @@ -23,3 +23,6 @@ def assert_icon_size(self): assert (icon.Size.Width, icon.Size.Height) == (32, 32) else: pytest.fail("Icon does not exist") + + def assert_taller_than(self, initial_height): + assert self.height > initial_height From 4d440ca3975444233f3c9a702fb6e5ea99856dca Mon Sep 17 00:00:00 2001 From: "John X. Zhou" Date: Thu, 18 Sep 2025 18:27:08 -0500 Subject: [PATCH 02/37] Addition of a Qt backend source (untested curretnly) --- core/src/toga/platform.py | 7 +- qt/CONTRIBUTING.md | 11 + qt/LICENSE | 27 ++ qt/README.rst | 57 +++ qt/pyproject.toml | 77 ++++ qt/src/toga_qt/__init__.py | 9 + qt/src/toga_qt/app.py | 235 +++++++++++ qt/src/toga_qt/command.py | 136 +++++++ qt/src/toga_qt/container.py | 47 +++ qt/src/toga_qt/dialogs.py | 86 ++++ qt/src/toga_qt/factory.py | 50 +++ qt/src/toga_qt/fonts.py | 11 + qt/src/toga_qt/icons.py | 41 ++ qt/src/toga_qt/images.py | 51 +++ qt/src/toga_qt/initialization.py | 16 + qt/src/toga_qt/keys.py | 150 +++++++ qt/src/toga_qt/libs/__init__.py | 3 + qt/src/toga_qt/libs/env.py | 7 + qt/src/toga_qt/libs/testing.py | 66 +++ qt/src/toga_qt/libs/utils.py | 15 + qt/src/toga_qt/paths.py | 20 + .../toga_qt/resources/activityindicator.qml | 8 + qt/src/toga_qt/resources/toga.png | Bin 0 -> 1970 bytes qt/src/toga_qt/screens.py | 50 +++ qt/src/toga_qt/statusicons.py | 34 ++ qt/src/toga_qt/togax.py | 60 +++ qt/src/toga_qt/widgets/__init__.py | 0 qt/src/toga_qt/widgets/activityindicator.py | 39 ++ qt/src/toga_qt/widgets/base.py | 121 ++++++ qt/src/toga_qt/widgets/box.py | 13 + qt/src/toga_qt/widgets/button.py | 41 ++ qt/src/toga_qt/widgets/label.py | 32 ++ qt/src/toga_qt/widgets/textinput.py | 77 ++++ qt/src/toga_qt/window.py | 378 ++++++++++++++++++ qt/tests_backend/app.py | 148 +++++++ qt/tests_backend/dialogs.py | 83 ++++ qt/tests_backend/fonts.py | 21 + qt/tests_backend/icons.py | 54 +++ qt/tests_backend/images.py | 14 + qt/tests_backend/probe.py | 68 ++++ qt/tests_backend/screens.py | 20 + qt/tests_backend/widgets/__init__.py | 0 qt/tests_backend/widgets/activityindicator.py | 10 + qt/tests_backend/widgets/base.py | 93 +++++ qt/tests_backend/widgets/box.py | 7 + qt/tests_backend/widgets/button.py | 24 ++ qt/tests_backend/widgets/label.py | 23 ++ qt/tests_backend/widgets/properties.py | 28 ++ qt/tests_backend/widgets/textinput.py | 45 +++ qt/tests_backend/window.py | 119 ++++++ testbed/tests/conftest.py | 1 - 51 files changed, 2730 insertions(+), 3 deletions(-) create mode 100644 qt/CONTRIBUTING.md create mode 100644 qt/LICENSE create mode 100644 qt/README.rst create mode 100644 qt/pyproject.toml create mode 100644 qt/src/toga_qt/__init__.py create mode 100644 qt/src/toga_qt/app.py create mode 100644 qt/src/toga_qt/command.py create mode 100644 qt/src/toga_qt/container.py create mode 100644 qt/src/toga_qt/dialogs.py create mode 100644 qt/src/toga_qt/factory.py create mode 100644 qt/src/toga_qt/fonts.py create mode 100644 qt/src/toga_qt/icons.py create mode 100644 qt/src/toga_qt/images.py create mode 100644 qt/src/toga_qt/initialization.py create mode 100644 qt/src/toga_qt/keys.py create mode 100644 qt/src/toga_qt/libs/__init__.py create mode 100644 qt/src/toga_qt/libs/env.py create mode 100644 qt/src/toga_qt/libs/testing.py create mode 100644 qt/src/toga_qt/libs/utils.py create mode 100644 qt/src/toga_qt/paths.py create mode 100644 qt/src/toga_qt/resources/activityindicator.qml create mode 100644 qt/src/toga_qt/resources/toga.png create mode 100644 qt/src/toga_qt/screens.py create mode 100644 qt/src/toga_qt/statusicons.py create mode 100644 qt/src/toga_qt/togax.py create mode 100644 qt/src/toga_qt/widgets/__init__.py create mode 100644 qt/src/toga_qt/widgets/activityindicator.py create mode 100644 qt/src/toga_qt/widgets/base.py create mode 100644 qt/src/toga_qt/widgets/box.py create mode 100644 qt/src/toga_qt/widgets/button.py create mode 100644 qt/src/toga_qt/widgets/label.py create mode 100644 qt/src/toga_qt/widgets/textinput.py create mode 100644 qt/src/toga_qt/window.py create mode 100644 qt/tests_backend/app.py create mode 100644 qt/tests_backend/dialogs.py create mode 100644 qt/tests_backend/fonts.py create mode 100644 qt/tests_backend/icons.py create mode 100644 qt/tests_backend/images.py create mode 100644 qt/tests_backend/probe.py create mode 100644 qt/tests_backend/screens.py create mode 100644 qt/tests_backend/widgets/__init__.py create mode 100644 qt/tests_backend/widgets/activityindicator.py create mode 100644 qt/tests_backend/widgets/base.py create mode 100644 qt/tests_backend/widgets/box.py create mode 100644 qt/tests_backend/widgets/button.py create mode 100644 qt/tests_backend/widgets/label.py create mode 100644 qt/tests_backend/widgets/properties.py create mode 100644 qt/tests_backend/widgets/textinput.py create mode 100644 qt/tests_backend/window.py diff --git a/core/src/toga/platform.py b/core/src/toga/platform.py index 4e621da248..13a49814a9 100644 --- a/core/src/toga/platform.py +++ b/core/src/toga/platform.py @@ -29,8 +29,11 @@ def get_current_platform() -> str | None: return "android" elif sys.platform.startswith("freebsd"): return "freeBSD" - elif "kde" in os.environ.get("XDG_CURRENT_DESKTOP", "").lower() or os.environ.get("TOGA_QT","") == "1": - current_platform = "linux-qt" + elif ( + "kde" in os.environ.get("XDG_CURRENT_DESKTOP", "").lower() + or os.environ.get("TOGA_QT", "") == "1" + ): + return "linux-qt" else: return _TOGA_PLATFORMS.get(sys.platform) diff --git a/qt/CONTRIBUTING.md b/qt/CONTRIBUTING.md new file mode 100644 index 0000000000..98a227d621 --- /dev/null +++ b/qt/CONTRIBUTING.md @@ -0,0 +1,11 @@ +# Contributing + +BeeWare <3's contributions! + +Please be aware that BeeWare operates under a [Code of +Conduct](https://beeware.org/community/behavior/code-of-conduct/). + +If you'd like to contribute to Toga development, our [contribution +guide](https://toga.readthedocs.io/en/latest/how-to/contribute/index.html) details how +to set up a development environment, and other requirements we have as part of our +contribution process. diff --git a/qt/LICENSE b/qt/LICENSE new file mode 100644 index 0000000000..98911767f9 --- /dev/null +++ b/qt/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2025 Russell Keith-Magee. +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + 3. Neither the name of Toga nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/qt/README.rst b/qt/README.rst new file mode 100644 index 0000000000..e8a9089315 --- /dev/null +++ b/qt/README.rst @@ -0,0 +1,57 @@ +.. |pyversions| image:: https://img.shields.io/pypi/pyversions/toga-qt.svg + :target: https://pypi.python.org/pypi/toga-qt + :alt: Python Versions + +.. |license| image:: https://img.shields.io/pypi/l/toga-qt.svg + :target: https://github.com/beeware/toga-qt/blob/main/LICENSE + :alt: BSD-3-Clause License + +.. |maturity| image:: https://img.shields.io/pypi/status/toga-qt.svg + :target: https://pypi.python.org/pypi/toga-qt + :alt: Project status + +toga-qt +======== + +|pyversions| |license| |maturity| + +A Qt backend for the `Toga widget toolkit`_. + +This package isn't much use by itself; it needs to be combined with `the core Toga library`_. + +For platform requirements, see the Qt platform documentation (TODO). + +For more details, see the `Toga project on GitHub`_. + +.. _Toga widget toolkit: https://beeware.org/toga +.. _the core Toga library: https://pypi.python.org/pypi/toga-core +.. _Toga project on GitHub: https://github.com/beeware/toga + +Community +--------- + +Toga is part of the `BeeWare suite`_. You can talk to the community through: + +* `@beeware@fosstodon.org on Mastodon`_ +* `Discord`_ +* The Toga `GitHub Discussions forum`_ + +We foster a welcoming and respectful community as described in our +`BeeWare Community Code of Conduct`_. + +.. _BeeWare suite: https://beeware.org +.. _@beeware@fosstodon.org on Mastodon: https://fosstodon.org/@beeware +.. _Discord: https://beeware.org/bee/chat/ +.. _GitHub Discussions forum: https://github.com/beeware/toga/discussions +.. _BeeWare Community Code of Conduct: https://beeware.org/community/behavior/ + +Contributing +------------ + +If you experience problems with Toga, `log them on GitHub +`__. + +If you'd like to contribute to Toga development, our `contribution guide +`__ +details how to set up a development environment, and other requirements we have +as part of our contribution process. diff --git a/qt/pyproject.toml b/qt/pyproject.toml new file mode 100644 index 0000000000..596006c8e4 --- /dev/null +++ b/qt/pyproject.toml @@ -0,0 +1,77 @@ +[build-system] +requires = [ + "setuptools==80.9.0", + "setuptools_scm==9.2.0", + "setuptools_dynamic_dependencies==1.0.0", +] +build-backend = "setuptools.build_meta" + +[project] +dynamic = ["version"] +name = "toga-qt" +description = "An Qt (KDE) backend for the Toga widget toolkit." +readme = "README.rst" +requires-python = ">= 3.10" +license = "BSD-3-Clause" +license-files = [ + "LICENSE" +] +authors = [ + {name="John", email="johnzhou721@gmail.com"}, + {name="Russell Keith-Magee", email="russell@keith-magee.com"}, +] +maintainers = [ + {name="BeeWare Team", email="team@beeware.org"}, +] +keywords = [ + "gui", + "widget", + "cross-platform", + "toga", + "desktop", + "qt", +] +classifiers = [ + "Development Status :: 1 - Planning", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Software Development", + "Topic :: Software Development :: User Interfaces", + "Topic :: Software Development :: Widget Sets", +] + + +[project.entry-points."toga.backends"] +linux-qt = "toga_qt" + +[tool.setuptools_scm] +root = "." + +[tool.setuptools_dynamic_dependencies] +dependencies = [ + "qasync", + "toga-core == {version}", +] + +[tool.coverage.run] +parallel = true +branch = true +relative_files = true + +# See notes in the root pyproject.toml file. +source = ["src"] +source_pkgs = ["toga_qt"] + +[tool.coverage.paths] +source = [ + "src/toga_qt", + "**/toga_qt", +] diff --git a/qt/src/toga_qt/__init__.py b/qt/src/toga_qt/__init__.py new file mode 100644 index 0000000000..e889ffaa5c --- /dev/null +++ b/qt/src/toga_qt/__init__.py @@ -0,0 +1,9 @@ +# Examples of valid version strings +# __version__ = '1.2.3.dev1' # Development release 1 +# __version__ = '1.2.3a1' # Alpha Release 1 +# __version__ = '1.2.3b1' # Beta Release 1 +# __version__ = '1.2.3rc1' # RC Release 1 +# __version__ = '1.2.3' # Final Release +# __version__ = '1.2.3.post1' # Post Release 1 + +__version__ = "0.0.0" diff --git a/qt/src/toga_qt/app.py b/qt/src/toga_qt/app.py new file mode 100644 index 0000000000..aa859b123e --- /dev/null +++ b/qt/src/toga_qt/app.py @@ -0,0 +1,235 @@ +import asyncio + +from PySide6.QtCore import QObject, QSize, Qt, QTimer, Signal +from PySide6.QtGui import QCursor, QGuiApplication, QIcon +from PySide6.QtWidgets import QApplication, QMessageBox +from qasync import QEventLoop + +import toga +from toga.command import Command, Group + +from .screens import Screen as ScreenImpl +from .togax import NativeIcon + + +def operate_on_focus(method_name, interface, needwrite=False): + fw = QApplication.focusWidget() + if not fw: + return + if needwrite: + fnwrite = getattr(fw, "isReadOnly", None) + if callable(fnwrite) and fnwrite(): + return + fn = getattr(fw, method_name, None) + if callable(fn): + fn() + + +def _create_about_dialog(app): + message = ( + f'

' + f"{app.interface.formal_name}

" + ) + versionauthor = [] + if app.interface.version: + versionauthor.append(f"Version {app.interface.version}") + if app.interface.author: + versionauthor.append(f"Copyright \u00a9 {app.interface.author}") + if versionauthor != []: + message += f"

{'
'.join(versionauthor)}

" + if app.interface.home_page: + message += ( + f"

{app.interface.home_page}

" + ) + dialog = QMessageBox( + QMessageBox.Information, + app.interface.formal_name, + message, + QMessageBox.NoButton, + app.get_current_window(), + ) + icon = dialog.windowIcon() + dialog.setIconPixmap(icon.pixmap(icon.actualSize(QSize(64, 64)))) + dialog.setModal(False) + return dialog + + +class AppSignalsListener(QObject): + appStarting = Signal() + + def __init__(self, impl): + super().__init__() + self.impl = impl + self.interface = impl.interface + self.appStarting.connect(self.on_app_starting) + QTimer.singleShot(0, self.appStarting.emit) + + def on_app_starting(self): + self.interface._startup() + + +appsingle = QApplication() + + +class App: + # GTK apps exit when the last window is closed + CLOSE_ON_LAST_WINDOW = True + # GTK apps use default command line handling + HANDLES_COMMAND_LINE = False + + def __init__(self, interface): + self.interface = interface + self.interface._impl = self + + self.native = appsingle + self.loop = QEventLoop(self.native) + asyncio.set_event_loop(self.loop) + self.app_close_event = asyncio.Event() + self.native.aboutToQuit.connect(self.app_close_event.set) + + # no idea what to name this... or should i put this into the main class + self.signalslistener = AppSignalsListener(self) + + self.cursorhidden = False + + ###################################################################### + # Commands and menus + # Impl incomplete. See GitHub thread. + ###################################################################### + + def create_standard_commands(self): + # This is sorta weird. On KDE, default bundled apps have these stuff + # and they automatically enable / disable based on if this functionality + # is available... there's not a satisfying way to implement that in Qt + # though... see https://stackoverflow.com/questions/2047456, so we omit + # the enabled detection for now. Most people just use Ctrl + Z etc. + # anyways... + self.interface.commands.add( + Command( + lambda interface: operate_on_focus("undo", interface), + "Undo", + shortcut=toga.Key.MOD_1 + "z", + group=Group.EDIT, + order=10, + icon=NativeIcon(QIcon.fromTheme("edit-undo")), + ), + Command( + lambda interface: operate_on_focus("redo", interface), + "Redo", + shortcut=toga.Key.SHIFT + toga.Key.MOD_1 + "z", + group=Group.EDIT, + order=20, + icon=NativeIcon(QIcon.fromTheme("edit-redo")), + ), + Command( + lambda interface: operate_on_focus("cut", interface, True), + "Cut", + shortcut=toga.Key.MOD_1 + "x", + group=Group.EDIT, + section=10, + order=10, + icon=NativeIcon(QIcon.fromTheme("edit-cut")), + ), + Command( + lambda interface: operate_on_focus("copy", interface), + "Copy", + shortcut=toga.Key.MOD_1 + "c", + group=Group.EDIT, + section=10, + order=20, + icon=NativeIcon(QIcon.fromTheme("edit-copy")), + ), + Command( + lambda interface: operate_on_focus("paste", interface, True), + "Paste", + shortcut=toga.Key.MOD_1 + "v", + group=Group.EDIT, + section=10, + order=30, + icon=NativeIcon(QIcon.fromTheme("edit-paste")), + ), + ) + + def create_menus(self): + for window in self.interface.windows: + if hasattr(window._impl, "create_menus"): + window._impl.create_menus() + + ###################################################################### + # App lifecycle + ###################################################################### + + # We can't call this under test conditions, because it would kill the test harness + def exit(self): # pragma: no cover + self.native.quit() + + def main_loop(self): + self.loop.run_until_complete(self.app_close_event.wait()) + + def set_icon(self, icon): + for window in QApplication.topLevelWidgets(): + window.setWindowIcon(icon._impl.native) + self.interface.commands[Command.ABOUT].icon = icon + self.interface.commands[Command.PREFERENCES].icon = icon + + # Not implemented yet + def set_main_window(self, window): + self.interface.factory.not_implemented("App.set_main_window()") + + ###################################################################### + # App resources + ###################################################################### + + # ScreenImpl not impl'd yet + def get_screens(self): + screens = QGuiApplication.screens() + primary = QGuiApplication.primaryScreen() + screens = [primary] + [ + s for s in screens if s != primary + ] # Ensure first is primary + + return [ScreenImpl(native=monitor) for monitor in QGuiApplication.screens()] + + ###################################################################### + # App state + ###################################################################### + + def get_dark_mode_state(self): + return QGuiApplication.styleHints().colorScheme() == Qt.ColorScheme.Dark + + ###################################################################### + # App capabilities + ###################################################################### + + def beep(self): + QApplication.beep() + + def show_about_dialog(self): + # Storing property to facilitate testing + # Not creating at start to ensure correct parent + self._about_dialog = _create_about_dialog(self) + self._about_dialog.show() + + ###################################################################### + # Cursor control + ###################################################################### + + def hide_cursor(self): + if not self.cursorhidden: + self.cursorhidden = True + self.native.setOverrideCursor(QCursor(Qt.BlankCursor)) + + def show_cursor(self): + if self.cursorhidden: + self.cursorhidden = False + self.native.restoreOverrideCursor() + + ###################################################################### + # Window control + ###################################################################### + + def get_current_window(self): + return self.native.activeWindow() + + def set_current_window(self, window): + window._impl.native.activateWindow() diff --git a/qt/src/toga_qt/command.py b/qt/src/toga_qt/command.py new file mode 100644 index 0000000000..82b2184147 --- /dev/null +++ b/qt/src/toga_qt/command.py @@ -0,0 +1,136 @@ +import sys + +from PySide6.QtGui import QAction, QIcon + +from toga import Command as StandardCommand, Group, Key + +from .keys import toga_to_qt_key +from .togax import NativeIcon # also patches to add Group.SETTINGS. adds icon changing + + +class Command: + """ + Command `native` property is a list of native widgets associated with the command. + + Native widgets is of type QAction + """ + + def __init__(self, interface): + self.interface = interface + self.native = [] + + @classmethod + def standard(self, app, id): + # ---- File menu ---------- + if id == StandardCommand.PREFERENCES: + return { + "text": "Configure " + app.formal_name, + "shortcut": Key.MOD_1 + Key.SHIFT + ",", + "group": Group.SETTINGS, + "section": sys.maxsize - 1, + "icon": app.icon, + } + elif id == StandardCommand.EXIT: + # File > Quit?? + return { + "text": "Quit", + "shortcut": Key.MOD_1 + "q", + "group": Group.FILE, + "section": sys.maxsize, + "icon": NativeIcon(QIcon.fromTheme("application-exit")), + } + + # ---- File menu ----------------------------------- + elif id == StandardCommand.NEW: + return { + "text": "New", + "shortcut": Key.MOD_1 + "n", + "group": Group.FILE, + "section": 0, + "order": 0, + } + elif id == StandardCommand.OPEN: + return { + "text": "Open...", + "shortcut": Key.MOD_1 + "o", + "group": Group.FILE, + "section": 0, + "order": 10, + } + + elif id == StandardCommand.SAVE: + return { + "text": "Save", + "shortcut": Key.MOD_1 + "s", + "group": Group.FILE, + "section": 0, + "order": 20, + } + elif id == StandardCommand.SAVE_AS: + return { + "text": "Save As...", + "shortcut": Key.MOD_1 + "S", + "group": Group.FILE, + "section": 0, + "order": 21, + } + elif id == StandardCommand.SAVE_ALL: + return { + "text": "Save All", + "shortcut": Key.MOD_1 + Key.MOD_2 + "s", + "group": Group.FILE, + "section": 0, + "order": 21, + } + # ---- Help menu ----------------------------------- + elif id == StandardCommand.VISIT_HOMEPAGE: + return None # Code this info into the About menu. + # return { + # "text": "Visit homepage", + # "enabled": app.home_page is not None, + # "group": Group.HELP, + # } + elif id == StandardCommand.ABOUT: + return { + "text": f"About {app.formal_name}", + "group": Group.HELP, + "section": sys.maxsize, + "icon": app.icon, + } + + raise ValueError(f"Unknown standard command {id!r}") + + def set_enabled(self, value): + enabled = self.interface.enabled + for widget in self.native: + widget.setEnabled(enabled) + + def qt_click(self): + self.interface.action() # interface call + + def create_menu_item(self): + item = QAction(self.interface.text) + + if self.interface.icon: + item.setIcon(self.interface.icon._impl.native) + + item.triggered.connect(self.qt_click) + + if self.interface.shortcut is not None: + # try: + item.setShortcut(toga_to_qt_key(self.interface.shortcut)) + # except (???) as e: # pragma: no cover + # # Make this a non-fatal warning, because different backends may + # # accept different shortcuts. + # print(f"WARNING: invalid shortcut {self.interface.shortcut!r}:" + # f"{e}") + + item.setEnabled(self.interface.enabled) + + self.native.append(item) + + return item + + def set_icon(self): + for item in self.native: + item.setIcon(self.interface.icon._impl.native) diff --git a/qt/src/toga_qt/container.py b/qt/src/toga_qt/container.py new file mode 100644 index 0000000000..b07a605b77 --- /dev/null +++ b/qt/src/toga_qt/container.py @@ -0,0 +1,47 @@ +from PySide6.QtWidgets import QWidget + + +class Container: + def __init__(self, content=None, layout_native=None, on_refresh=None): + self.native = QWidget() + self.native.hide() + self.layout_native = self.native if layout_native is None else layout_native + self._content = None + self.on_refresh = on_refresh + + self.content = content # Set initial content + + def __del__(self): + self.native = None + + @property + def width(self): + return self.layout_native.width() + + @property + def height(self): + return self.layout_native.height() - self.top_offset + + @property + def top_offset(self): + return 0 ## Stub (?) + + @property + def content(self): + return self._content + + @content.setter + def content(self, widget): + if self._content: + self._content.container = None + self._content.native.setParent(None) + + self._content = widget + + if widget: + widget.container = self + widget.native.setParent(self.native) + + def refreshed(self): + if self.on_refresh is not None: + self.on_refresh(self) diff --git a/qt/src/toga_qt/dialogs.py b/qt/src/toga_qt/dialogs.py new file mode 100644 index 0000000000..af1f395f55 --- /dev/null +++ b/qt/src/toga_qt/dialogs.py @@ -0,0 +1,86 @@ +from PySide6.QtWidgets import QMessageBox + +import toga + + +class MessageDialog: + def __init__(self, title, message, icon, buttons=QMessageBox.Ok): + self.title, self.message, self.icon, self.buttons = ( + title, + message, + icon, + buttons, + ) + # Mote that AFAICT you have to pass the parent in at creation time. + # So I guess we'd have to do this.' + self.native = None + + def show(self, parent, future): + self.future = future + if parent is not None: + self.native = QMessageBox(parent._impl.native) + else: + self.native = QMessageBox() + self.native.setIcon(self.icon) + self.native.setWindowTitle(self.title) + self.native.setText(self.message) + self.native.setStandardButtons(self.buttons) + self.native.setModal(True) + self.native.finished.connect(self.qt_response) + self.native.show() + + def qt_response(self): + self.future.set_result(None) + + +class InfoDialog(MessageDialog): + def __init__(self, title, message): + super().__init__(title, message, icon=QMessageBox.Icon.Information) + + +class AlertDialog: + def __init__(self, *args, **kwargs): + toga.App.app.factory.not_implemented("dialogs.AlertDialog()") + self.native = None + + +class QuestionDialog: + def __init__(self, *args, **kwargs): + toga.App.app.factory.not_implemented("dialogs.QuestionDialog()") + self.native = None + + +class ConfirmDialog: + def __init__(self, *args, **kwargs): + toga.App.app.factory.not_implemented("dialogs.ConfirmDialog()") + self.native = None + + +class ErrorDialog: + def __init__(self, *args, **kwargs): + toga.App.app.factory.not_implemented("dialogs.ErrorDialog()") + self.native = None + + +class StackTraceDialog: + def __init__(self, *args, **kwargs): + toga.App.app.factory.not_implemented("dialogs.StackTraceDialog()") + self.native = None + + +class SaveFileDialog: + def __init__(self, *args, **kwargs): + toga.App.app.factory.not_implemented("dialogs.SaveFileDialog()") + self.native = None + + +class OpenFileDialog: + def __init__(self, *args, **kwargs): + toga.App.app.factory.not_implemented("dialogs.OpenFileDialog()") + self.native = None + + +class SelectFolderDialog: + def __init__(self, *args, **kwargs): + toga.App.app.factory.not_implemented("dialogs.SelectFolderDialog()") + self.native = None diff --git a/qt/src/toga_qt/factory.py b/qt/src/toga_qt/factory.py new file mode 100644 index 0000000000..411617163e --- /dev/null +++ b/qt/src/toga_qt/factory.py @@ -0,0 +1,50 @@ +from toga import NotImplementedWarning + +from . import ( + dialogs, # noqa + initialization, # noqa: F401 +) +from .app import App +from .command import Command +from .container import Container +from .fonts import Font +from .icons import Icon +from .images import Image +from .paths import Paths +from .statusicons import MenuStatusIcon, SimpleStatusIcon, StatusIconSet +from .widgets.activityindicator import ActivityIndicator +from .widgets.box import Box +from .widgets.button import Button +from .widgets.label import Label +from .widgets.textinput import TextInput +from .window import MainWindow, Window + +__all__ = [ + "not_implemented", + "ActivityIndicator", + "App", + "Paths", + "Icon", + "Image", + "MenuStatusIcon", + "SimpleStatusIcon", + "StatusIconSet", + "Window", + "MainWindow", + "Command", + "Button", + "Font", + "Container", + "Box", + "Label", + "TextInput", + "dialogs", +] + + +def not_implemented(feature): + NotImplementedWarning.warn("Qt", feature) + + +def __getattr__(name): # pragma: no cover + raise NotImplementedError(f"Toga's Qt backend doesn't implement {name}") diff --git a/qt/src/toga_qt/fonts.py b/qt/src/toga_qt/fonts.py new file mode 100644 index 0000000000..274ebbd559 --- /dev/null +++ b/qt/src/toga_qt/fonts.py @@ -0,0 +1,11 @@ +# Not yet implemented + + +class Font: + def __init__(self, interface): ... + + def load_predefined_system_font(self): ... + + def load_user_registered_font(self): ... + + def load_arbitrary_system_font(self): ... diff --git a/qt/src/toga_qt/icons.py b/qt/src/toga_qt/icons.py new file mode 100644 index 0000000000..270e13e55c --- /dev/null +++ b/qt/src/toga_qt/icons.py @@ -0,0 +1,41 @@ +import sys +from pathlib import Path + +from PySide6.QtGui import QIcon + +import toga + +IMPL_DICT = {} + + +class Icon: + EXTENSIONS = [".png", ".jpeg", ".jpg", ".gif", ".bmp", ".ico"] + SIZES = None + + def __init__(self, interface, path): + self.interface = interface + + if path is None: + SIZES = [512, 256, 128, 72, 64, 32, 16] # same as GTK for now. + # Use the executable location to find the share folder; look for icons + # matching the app bundle in that location. + hicolor = Path(sys.executable).parent.parent / "share/icons/hicolor" + sizes = { + size: hicolor / f"{size}x{size}/apps/{toga.App.app.app_id}.png" + for size in SIZES + if (hicolor / f"{size}x{size}/apps/{toga.App.app.app_id}.png").is_file() + } + + if not sizes: + raise FileNotFoundError("No icon variants found") + + path = sizes[max(sizes)] + + self.native = QIcon(str(path)) + + if self.native.isNull(): + raise ValueError(f"Unable to load icon from {path}") + + IMPL_DICT[self.native] = self + + self.path = path diff --git a/qt/src/toga_qt/images.py b/qt/src/toga_qt/images.py new file mode 100644 index 0000000000..d04d255569 --- /dev/null +++ b/qt/src/toga_qt/images.py @@ -0,0 +1,51 @@ +from pathlib import Path + +from PySide6.QtCore import QBuffer, QIODevice +from PySide6.QtGui import QImage + + +class Image: + RAW_TYPE = QImage + + def __init__(self, interface, path=None, data=None, raw=None): + self.interface = interface + + if path: + self.native = QImage(str(path)) + if self.native.isNull(): + raise ValueError(f"Unable to load image from {path}") + elif data: + image = QImage() + if not image.loadFromData(data): + raise ValueError("Unable to load image from data") + self.native = image + else: + self.native = raw + + def get_width(self): + return self.native.width() + + def get_height(self): + return self.native.height() + + def get_data(self): + buffer = QBuffer() + buffer.open(QIODevice.WriteOnly) + if not self.native.save(buffer, "PNG"): + raise ValueError("Unable to get PNG data for image") + return buffer.data().data() + + def save(self, path): + path = Path(path) + filetype = { + ".jpg": "JPEG", + ".jpeg": "JPEG", + ".png": "PNG", + ".bmp": "BMP", + }.get(path.suffix.lower()) + + if not filetype: + raise ValueError(f"Don't know how to save image of type {path.suffix!r}") + + if not self.native.save(str(path), filetype): + raise ValueError(f"Failed to save image to {path}") diff --git a/qt/src/toga_qt/initialization.py b/qt/src/toga_qt/initialization.py new file mode 100644 index 0000000000..9f36994a6a --- /dev/null +++ b/qt/src/toga_qt/initialization.py @@ -0,0 +1,16 @@ +import site +import sys + + +def import_pyside6(): + """Temporarily break isolation to import system PySide6.""" + system_site = site.getsitepackages() + print(system_site) + old_path = sys.path.copy() + sys.path.extend(system_site) + import PySide6 # noqa + + sys.path = old_path + + +import_pyside6() diff --git a/qt/src/toga_qt/keys.py b/qt/src/toga_qt/keys.py new file mode 100644 index 0000000000..b56fb34235 --- /dev/null +++ b/qt/src/toga_qt/keys.py @@ -0,0 +1,150 @@ +import re +from string import ascii_lowercase + +from PySide6.QtCore import Qt +from PySide6.QtGui import QKeySequence + +from toga.keys import Key + +QT_MODIFIERS = { + Key.MOD_1: Qt.ControlModifier, + Key.MOD_2: Qt.AltModifier, + Key.MOD_3: Qt.MetaModifier, + Key.SHIFT: Qt.ShiftModifier, +} + +# [sweating intensifies] +# TODO: Refs https://forum.qt.io/topic/162828/how-do-i-check-for-shift-tab +QT_KEYS = { + Key.ESCAPE.value: Qt.Key_Escape, + Key.BACK_QUOTE.value: Qt.Key_QuoteLeft, # Why quoteleft, are the Qt devs up to TeX? + Key.MINUS.value: Qt.Key_Minus, + Key.EQUAL.value: Qt.Key_Equal, + Key.CAPSLOCK.value: Qt.Key_CapsLock, + Key.TAB.value: Qt.Key_Tab, + Key.OPEN_BRACKET.value: Qt.Key_BracketLeft, + Key.CLOSE_BRACKET.value: Qt.Key_BracketRight, + Key.BACKSLASH.value: Qt.Key_Backslash, + Key.SEMICOLON.value: Qt.Key_Semicolon, + Key.QUOTE.value: Qt.Key_QuoteDbl, # why shifted form? + Key.COMMA.value: Qt.Key_Comma, + Key.FULL_STOP.value: Qt.Key_Period, + Key.SLASH.value: Qt.Key_Slash, + Key.SPACE.value: Qt.Key_Space, + Key.PAGE_UP.value: Qt.Key_PageUp, + Key.PAGE_DOWN.value: Qt.Key_PageDown, + Key.INSERT.value: Qt.Key_Insert, + Key.DELETE.value: Qt.Key_Delete, + Key.HOME.value: Qt.Key_Home, + Key.END.value: Qt.Key_End, + Key.UP.value: Qt.Key_Up, + Key.DOWN.value: Qt.Key_Down, + Key.LEFT.value: Qt.Key_Left, + Key.RIGHT.value: Qt.Key_Right, + Key.NUMLOCK.value: Qt.Key_NumLock, + Key.SCROLLLOCK.value: Qt.Key_ScrollLock, + Key.MENU.value: Qt.Key_Menu, +} + + +QT_KEYS.update({str(digit): getattr(Qt, f"Key_{digit}") for digit in range(10)}) + +QT_KEYS.update( + {getattr(Key, f"F{num}").value: getattr(Qt, f"Key_F{num}") for num in range(1, 20)} +) + +QT_KEYS.update( + { + getattr(Key, letter).value: getattr(Qt, f"Key_{letter}") + for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + } +) + +NUMPAD_KEYS = { + Key.NUMPAD_DECIMAL_POINT.value: Qt.Key_Period, +} + +NUMPAD_KEYS.update( + { + getattr(Key, f"NUMPAD_{digit}").value: getattr(Key, f"_{digit}").value + for digit in range(10) + } +) + +NUMPAD_KEYS_REV = {v: k for k, v in NUMPAD_KEYS.items()} + +SHIFTED_KEYS = dict(zip("!@#$%^&*()", "1234567890")) +SHIFTED_KEYS.update( + { + "~": "`", + "_": "-", + "+": "=", + "{": "[", + "}": "]", + "|": "\\", + ":": ";", + '"': "'", + "<": ",", + ">": ".", + "?": "/", + } +) +TEST_SHIFTED_KEYS = {v: k for k, v in SHIFTED_KEYS.items()} + +SHIFTED_KEYS.update({lower.upper(): lower for lower in ascii_lowercase}) + + +def toga_to_qt_key(key): + # Convert a Key object into QKeySequence form. + try: + key = key.value + except AttributeError: + pass + + codes = Qt.NoModifier + for modifier, modifier_code in QT_MODIFIERS.items(): + if modifier.value in key: + codes |= modifier_code + key = key.replace(modifier.value, "") + + if regular := NUMPAD_KEYS.get(key): + key = regular + codes |= Qt.KeypadModifier + + if lower := SHIFTED_KEYS.get(key): + key = lower + codes |= Qt.ShiftModifier + + try: + codes |= QT_KEYS[key] + except KeyError: + if match := re.fullmatch(r"<(.+)>", key): + key = match[1] + try: + codes |= getattr(Qt, key.title()) + except AttributeError: # pragma: no cover + raise ValueError(f"unknown key: {key!r}") from None + + return QKeySequence(codes) + + +def qt_to_toga_key(code): + modifiers = set() + native_mods = code[0].keyboardModifiers() + for mod_key, qt_mod in QT_MODIFIERS.items(): + if native_mods & qt_mod: + modifiers.add(mod_key) + + qt_key_code = code[0].key() + qt_to_toga = {v: k for k, v in QT_KEYS.items()} + toga_value = qt_to_toga.get(qt_key_code) + + # Qt decomposes shifted characters + if Key.SHIFT in modifiers and toga_value in TEST_SHIFTED_KEYS: + modifiers.remove(Key.SHIFT) + toga_value = TEST_SHIFTED_KEYS[toga_value] + # Qt uses a separate modifier for numpad + if native_mods & Qt.KeypadModifier: + toga_value = NUMPAD_KEYS_REV[toga_value] + + return {"key": Key(toga_value), "modifiers": modifiers} diff --git a/qt/src/toga_qt/libs/__init__.py b/qt/src/toga_qt/libs/__init__.py new file mode 100644 index 0000000000..33c00e7c1a --- /dev/null +++ b/qt/src/toga_qt/libs/__init__.py @@ -0,0 +1,3 @@ +from .env import * # noqa +from .testing import * # noqa +from .utils import * # noqa diff --git a/qt/src/toga_qt/libs/env.py b/qt/src/toga_qt/libs/env.py new file mode 100644 index 0000000000..2349087f81 --- /dev/null +++ b/qt/src/toga_qt/libs/env.py @@ -0,0 +1,7 @@ +from PySide6.QtGui import QGuiApplication + + +# Must be defined as a function, else the expression +# gets evaluated too early and returns xcb on Wayland. +def get_is_wayland(): + return "wayland" == QGuiApplication.platformName() diff --git a/qt/src/toga_qt/libs/testing.py b/qt/src/toga_qt/libs/testing.py new file mode 100644 index 0000000000..75f49a4be6 --- /dev/null +++ b/qt/src/toga_qt/libs/testing.py @@ -0,0 +1,66 @@ +import os + + +class AnyWithin: + def __init__(self, low, high): + self.low = low + self.high = high + + def __eq__(self, other): + if isinstance(other, AnyWithin): + return max(self.low, other.low) <= min(self.high, other.high) + try: + return self.low <= other <= self.high + except TypeError: + return False + + def __lt__(self, other): + if isinstance(other, AnyWithin): + return self.high < other.low + return self.high < other + + def __le__(self, other): + if isinstance(other, AnyWithin): + return self.high <= other.low + return self.high <= other + + def __gt__(self, other): + if isinstance(other, AnyWithin): + return self.low > other.high + return self.low > other + + def __ge__(self, other): + if isinstance(other, AnyWithin): + return self.low >= other.high + return self.low >= other + + def __add__(self, other): + if isinstance(other, AnyWithin): + return AnyWithin(self.low + other.low, self.high + other.high) + elif isinstance(other, (int, float)): + return AnyWithin(self.low + other, self.high + other) + return NotImplemented + + def __radd__(self, other): + return self.__add__(other) + + def __sub__(self, other): + if isinstance(other, AnyWithin): + return AnyWithin(self.low - other.high, self.high - other.low) + elif isinstance(other, (int, float)): + return AnyWithin(self.low - other, self.high - other) + return NotImplemented + + def __rsub__(self, other): + if isinstance(other, AnyWithin): + return AnyWithin(other.low - self.high, other.high - self.low) + elif isinstance(other, (int, float)): + return AnyWithin(other - self.high, other - self.low) + return NotImplemented + + def __repr__(self): + return f"AnyWithin({self.low}, {self.high})" + + +def get_testing(): + return bool(os.environ.get("PYTEST_VERSION")) diff --git a/qt/src/toga_qt/libs/utils.py b/qt/src/toga_qt/libs/utils.py new file mode 100644 index 0000000000..0e9630970a --- /dev/null +++ b/qt/src/toga_qt/libs/utils.py @@ -0,0 +1,15 @@ +from PySide6.QtCore import Qt +from travertino.constants import BOTTOM, CENTER, JUSTIFY, LEFT, RIGHT, TOP + + +def qt_text_align(valuex, valuey): + return { + LEFT: Qt.AlignLeft, + CENTER: Qt.AlignHCenter, + RIGHT: Qt.AlignRight, + JUSTIFY: Qt.AlignJustify, + }[valuex] | { + TOP: Qt.AlignTop, + CENTER: Qt.AlignVCenter, + BOTTOM: Qt.AlignBottom, + }[valuey] diff --git a/qt/src/toga_qt/paths.py b/qt/src/toga_qt/paths.py new file mode 100644 index 0000000000..1c5c2b9a3a --- /dev/null +++ b/qt/src/toga_qt/paths.py @@ -0,0 +1,20 @@ +from pathlib import Path + +from toga import App + + +class Paths: + def __init__(self, interface): + self.interface = interface + + def get_config_path(self): + return Path.home() / f".config/{App.app.app_name}" + + def get_data_path(self): + return Path.home() / f".local/share/{App.app.app_name}" + + def get_cache_path(self): + return Path.home() / f".cache/{App.app.app_name}" + + def get_logs_path(self): + return Path.home() / f".local/state/{App.app.app_name}/log" diff --git a/qt/src/toga_qt/resources/activityindicator.qml b/qt/src/toga_qt/resources/activityindicator.qml new file mode 100644 index 0000000000..0aca6d20a1 --- /dev/null +++ b/qt/src/toga_qt/resources/activityindicator.qml @@ -0,0 +1,8 @@ +import QtQuick +import QtQuick.Controls + +BusyIndicator { + width: 32 + height: 32 + running: true +} diff --git a/qt/src/toga_qt/resources/toga.png b/qt/src/toga_qt/resources/toga.png new file mode 100644 index 0000000000000000000000000000000000000000..eae2a2e7531a0dee47552aa7e3e528ad189e9b1f GIT binary patch literal 1970 zcmV;j2Tk~iP)004R>004l5008;`004mK004C`008P>0026e000+ooVrmw00006 zVoOIv0RI600RN!9r;`8x010qNS#tmY3ljhU3ljkVnw%H_00y*4L_t(o!|j&aZ&cS6 zhre^5Ii9iSVw;(<4K^`0!H_~JnASy!qF_=rgoIX#swkD9Q5C5Xt1o$z~3lvwFvmST?XqW5j>z}Tx zt9#<`;lry|)-(;%G+U$5=sr!;)~q5m2)qV7E$`T|V|`aw*W*Wz9*s)UimNCAW~EXI zHa9narMtVk$uNwVVHhjT?*q;OqiQr74eZ{%yJ2W(=tqNtgRcP@KvxxcTa{PqP!xG_ zRe>m!D-*WurkmDO@2d@Xcg1INp*?%{tZQ#?_Z&a|r>Dy0@;DF&3J-JOGj$Jw&r5sv z?!9#6$dSjcT)ASu{`%|hoIZW#H_@=?$SsXTPII2@cwaI2r)B}%nnYca_45!g|j8g9#}F|UKaKm8YRo#yrs47#N0xu zPf4fKb|4UVyt}*G?Ck8^Fq52mzO7z#g@e^q`HG5ZNCbR3iHrlXrfz8Pdq$?q?0@l9 z_CI%kp`js8_q<70M_6uK7xq8eT-&m-x%SJgjiCe4THn`wrv9Ykh)BLvNk~u^3V6TS zwl?$&Lsx@iQ<r;9&-ar|AE9idVk> zbGGmPD(BAkqlrA*zqE(Z(NRvm@msbusrafq2mz`hSTj?L3#vMl2uGtl<|<5OvFd~}Mf%?1I#7a_!5JzoCQRGHdCw?!kE2yqA_~K)Y@<==_QkjvIB*g$&Ho(Zp2*7zmSH?F)^jQCU zzX#2aAu_BpWd=St2}nf!7PSErLs#+WDvtZf;&+;ehJCD$1-m6F_Ddjjmw_e9`J$Eb z=qlSc7&wlLu7!{!iM6(k5TI#Zrf0MGygDnB{x_;BQ{y-4fSP3k@|^+S+P}?8rVIV) zYzak{u_`XtZ>70mpnepuhJFfy69LYpSpvmw?~%(B9vR=$MI-<3H+SZ0~SGjvhl9)&=xD&~RcUzl6_VWA#x6#(t zHrdzLcQKdCb!eIvNhXu>*)#uO;_5%ywW$tORc`lXzS z8IPfwOInqC*OpgeU1EhYKW)idH+(JNVtl`T!Fa-D*yIs+Pz}R)J{pbow6wHbjKyL-hG9H+?AWozKA9%6 zEVl)N!CyBtG`tguME>mcdS6;)wO51(2rZ2dLIRBch45$mSNRt-4%y|Gz4nv<001R) zMObuXVRU6WV{&C-bY%cCFflYOFgGnRIaD$*Iy5ypG%zbLI65#ez+2d70000bbVXQn zWMOn=I&E)cX=Zr Position: + return Position( + self.native.geometry().topLeft().x(), self.native.geometry().topLeft().y() + ) + + def get_size(self) -> Size: + geometry = self.native.geometry() + return Size(geometry.width(), geometry.height()) + + def get_image_data(self): + if not get_is_wayland(): + grabbed = self.native.grabWindow(0) + byte_array = QByteArray() + buffer = QBuffer(byte_array) + buffer.open(QIODevice.WriteOnly) + grabbed.save(buffer, "PNG") + return byte_array.data() + else: + self.interface.factory.not_implemented("Screen.get_image_data() on Wayland") diff --git a/qt/src/toga_qt/statusicons.py b/qt/src/toga_qt/statusicons.py new file mode 100644 index 0000000000..50ad00ea30 --- /dev/null +++ b/qt/src/toga_qt/statusicons.py @@ -0,0 +1,34 @@ +import toga + +# STUB IMPL + + +class StatusIcon: + def __init__(self, interface): + self.interface = interface + self.native = None + + def set_icon(self, icon): + pass + + def create(self): + toga.NotImplementedWarning.warn("Web", "Status Icons") + + def remove(self): + pass + + +class SimpleStatusIcon(StatusIcon): + pass + + +class MenuStatusIcon(StatusIcon): + pass + + +class StatusIconSet: + def __init__(self, interface): + self.interface = interface + + def create(self): + pass diff --git a/qt/src/toga_qt/togax.py b/qt/src/toga_qt/togax.py new file mode 100644 index 0000000000..d04d41ce8a --- /dev/null +++ b/qt/src/toga_qt/togax.py @@ -0,0 +1,60 @@ +""" +This is a module that stores some random stuff +that Toga doesn't yet conceptualize, but is needed +by this unofficial backend. +""" + +from toga import Command, Group, Icon + +########################################### +# Add support for native icon wrapper. +########################################### + +# hack hack hack... the icon thing is used +# nowhere else, and our backend directly +# accesses the native method when using +# commands... so we just do it like this. + + +class NativeIcon: + def __init__(self, native): + self._impl = NativeIconImpl(native) + + +class NativeIconImpl: + def __init__(self, native): + self.native = native + + +# Make setter of command accept the hack, and we also call +# impl to change the icon + + +def icon_setter(self, icon_or_name) -> None: + # added second condition. + if ( + isinstance(icon_or_name, Icon) + or isinstance(icon_or_name, NativeIcon) + or icon_or_name is None + ): + self._icon = icon_or_name + else: + self._icon = Icon(icon_or_name) + + try: + # Call impl to change icon + self._impl.set_icon() + except AttributeError: + # This is the first time where we init and setting + # icon is handled by impl. Pass + pass + + +Command.icon = Command.icon.setter(icon_setter) + + +########################################### +# Add support for Settings group. +########################################### + +Group.SETTINGS = Group("Settings", order=80) diff --git a/qt/src/toga_qt/widgets/__init__.py b/qt/src/toga_qt/widgets/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/qt/src/toga_qt/widgets/activityindicator.py b/qt/src/toga_qt/widgets/activityindicator.py new file mode 100644 index 0000000000..d3bdfc8c75 --- /dev/null +++ b/qt/src/toga_qt/widgets/activityindicator.py @@ -0,0 +1,39 @@ +from pathlib import Path + +from PySide6.QtCore import Qt +from PySide6.QtQuickWidgets import QQuickWidget + +from .base import Widget + + +class ActivityIndicator(Widget): + def create(self): + self.native = QQuickWidget() + self.native.setSource( + str(Path(__file__).parent.parent / "resources/activityindicator.qml") + ) + self.native.setResizeMode(QQuickWidget.SizeRootObjectToView) + self.running = False + self.native.setAttribute(Qt.WA_AlwaysStackOnTop) + self.native.setAttribute(Qt.WA_TranslucentBackground) + self.native.setClearColor(Qt.transparent) + + def _apply_hidden(self, hidden): + print(hidden) + self.native.setVisible(self.running and not hidden) + self.ai_hidden = hidden + + def is_running(self): + return self.running + + def start(self): + self.running = True + self.native.setVisible(not self.ai_hidden) + + def stop(self): + self.native.setVisible(False) + self.running = False + + def rehint(self): + self.interface.intrinsic.width = 32 + self.interface.intrinsic.height = 32 diff --git a/qt/src/toga_qt/widgets/base.py b/qt/src/toga_qt/widgets/base.py new file mode 100644 index 0000000000..98b1fca45d --- /dev/null +++ b/qt/src/toga_qt/widgets/base.py @@ -0,0 +1,121 @@ +from abc import abstractmethod + +from PySide6.QtCore import Qt +from travertino.size import at_least + + +class Widget: + def __init__(self, interface): + self.interface = interface + self._container = None + self.native = None + self.create() + self.native.hide() + self._hidden = True + + @property + def container(self): + return self._container + + @container.setter + def container(self, container): + if self.container: + assert container is None, f"{self} already has a container" + + # Existing container should be removed + self.native.setParent(None) + self._container = None + self.native.hide() + elif container: + # setting container + self._container = container + self.native.setParent(container.native) + self.set_hidden(self._hidden) + + for child in self.interface.children: + child._impl.container = container + + self.rehint() + + @abstractmethod + def create(self): ... + + def set_app(self, app): + pass + + def set_window(self, window): + pass + + def get_enabled(self): + return self.native.isEnabled() + + def set_enabled(self, value): + self.native.setEnabled(value) + + @property + def has_focus(self): + return self.native.hasFocus() + + def focus(self): + if not self.has_focus: + self.native.setFocus(Qt.OtherFocusReason) + + def get_tab_index(self): + self.interface.factory.not_implemented("Widget.get_tab_index()") + + def set_tab_index(self, tab_index): + self.interface.factory.not_implemented("Widget.set_tab_index()") + + ###################################################################### + # APPLICATOR + ###################################################################### + + def set_bounds(self, x, y, width, height): + self.native.setGeometry(x, y, width, height) + + def set_text_align(self, alignment): + # Not implemented yet + pass + + def set_hidden(self, hidden): + if self.container is not None: + self._apply_hidden(hidden) + self._hidden = hidden + + def _apply_hidden(self, hidden): + self.native.setHidden(hidden) + + def set_color(self, color): + # Not implemented yet + pass + + def set_background_color(self, color): + # Not implemented yet + pass + + def set_font(self, font): + # Not implemented yet + pass + + ###################################################################### + # INTERFACE + ###################################################################### + + def add_child(self, child): + child.container = self.container + + def insert_child(self, index, child): + self.add_child(child) + + def remove_child(self, child): + child.container = None + + def refresh(self): + self.rehint() + + def rehint(self): + width = self.native.sizeHint().width() + height = self.native.sizeHint().height() + + self.interface.intrinsic.width = at_least(width) + self.interface.intrinsic.height = at_least(height) diff --git a/qt/src/toga_qt/widgets/box.py b/qt/src/toga_qt/widgets/box.py new file mode 100644 index 0000000000..d5452104c3 --- /dev/null +++ b/qt/src/toga_qt/widgets/box.py @@ -0,0 +1,13 @@ +from PySide6.QtWidgets import QWidget +from travertino.size import at_least + +from .base import Widget + + +class Box(Widget): + def create(self): + self.native = QWidget() + + def rehint(self): + self.interface.intrinsic.width = at_least(0) + self.interface.intrinsic.height = at_least(0) diff --git a/qt/src/toga_qt/widgets/button.py b/qt/src/toga_qt/widgets/button.py new file mode 100644 index 0000000000..a2e170878e --- /dev/null +++ b/qt/src/toga_qt/widgets/button.py @@ -0,0 +1,41 @@ +from PySide6.QtGui import QIcon +from PySide6.QtWidgets import QPushButton +from travertino.size import at_least + +from .base import Widget + + +class Button(Widget): + def create(self): + self.native = QPushButton() + + self.native.clicked.connect(self.clicked) + + self._icon = None + + def clicked(self): + self.interface.on_press() + + def get_text(self): + return str(self.native.text()) + + def set_text(self, text): + self.native.setText(text) + + def get_icon(self): + return self._icon + + def set_icon(self, icon): + if icon is not None: + self.native.setIcon(icon._impl.native) + else: + self.native.setIcon(QIcon()) + # Somehow Qt copies into different memory address when I pass icon + self._icon = icon + + def rehint(self): + width = self.native.sizeHint().width() + height = self.native.sizeHint().height() + + self.interface.intrinsic.width = at_least(width) + self.interface.intrinsic.height = height # height of a button is known diff --git a/qt/src/toga_qt/widgets/label.py b/qt/src/toga_qt/widgets/label.py new file mode 100644 index 0000000000..ff9c3197b3 --- /dev/null +++ b/qt/src/toga_qt/widgets/label.py @@ -0,0 +1,32 @@ +from PySide6.QtWidgets import QLabel +from travertino.constants import TOP +from travertino.size import at_least + +from ..libs import qt_text_align +from .base import Widget + + +class Label(Widget): + def create(self): + self.native = QLabel() + + def set_color(self, value): + pass + + def set_font(self, font): + pass + + def get_text(self): + return self.native.text() + + def set_text(self, value): + self.native.setText(value) + self.refresh() + + def rehint(self): + content_size = self.native.sizeHint() + self.interface.intrinsic.width = at_least(content_size.width()) + self.interface.intrinsic.height = content_size.height() + + def set_text_align(self, value): + self.native.setAlignment(qt_text_align(value, TOP)) diff --git a/qt/src/toga_qt/widgets/textinput.py b/qt/src/toga_qt/widgets/textinput.py new file mode 100644 index 0000000000..5a0fb57c35 --- /dev/null +++ b/qt/src/toga_qt/widgets/textinput.py @@ -0,0 +1,77 @@ +from PySide6.QtWidgets import QApplication, QLineEdit, QStyle +from travertino.constants import CENTER +from travertino.size import at_least + +from ..libs import qt_text_align +from .base import Widget + + +class TogaLineEdit(QLineEdit): + def __init__(self, impl, *args, **kwargs): + super().__init__(*args, **kwargs) + self.impl = impl + self.interface = impl.interface + self.textChanged.connect(self.qt_on_change) + self.returnPressed.connect(self.qt_on_confirm) + + def qt_on_change(self): + self.interface._value_changed() + + def qt_on_confirm(self): + self.interface.on_confirm() + + def focusInEvent(self, event): + super().focusInEvent(event) + self.interface.on_gain_focus() + + def focusOutEvent(self, event): + super().focusOutEvent(event) + self.interface.on_lose_focus() + + +class TextInput(Widget): + def create(self): + self.native = TogaLineEdit(self) + warning_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxWarning) + self.icon_action = self.native.addAction( + warning_icon, QLineEdit.TrailingPosition + ) + self.icon_action.setVisible(False) + + def get_readonly(self): + return self.native.isReadOnly() + + def set_readonly(self, value): + self.native.setReadOnly(value) + + def get_placeholder(self): + return self.native.placeholderText() + + def set_placeholder(self, value): + self.native.setPlaceholderText(value) + + def set_text_align(self, value): + self.native.setAlignment(qt_text_align(value, CENTER)) + + def get_value(self): + return self.native.text() + + def set_value(self, value): + self.native.setText(value) + + def rehint(self): + size = self.native.sizeHint() + self.interface.intrinsic.width = at_least( + max(self.interface._MIN_WIDTH, size.width()) + ) + self.interface.intrinsic.height = size.height() + + def set_error(self, error_message): + self.icon_action.setToolTip(error_message) + self.icon_action.setVisible(True) + + def clear_error(self): + self.icon_action.setVisible(False) + + def is_valid(self): + return not self.icon_action.isVisible() diff --git a/qt/src/toga_qt/window.py b/qt/src/toga_qt/window.py new file mode 100644 index 0000000000..d5e11a205d --- /dev/null +++ b/qt/src/toga_qt/window.py @@ -0,0 +1,378 @@ +from functools import partial + +from PySide6.QtCore import QEvent, Qt, QTimer +from PySide6.QtGui import QWindowStateChangeEvent +from PySide6.QtWidgets import QApplication, QMainWindow, QMenu, QVBoxLayout, QWidget + +from toga.command import Separator +from toga.constants import WindowState +from toga.types import Position, Size + +from .container import Container +from .libs import ( + AnyWithin, # tests hackery... + get_is_wayland, + get_testing, +) +from .screens import Screen as ScreenImpl + + +def _handle_statechange(impl, changeid): + current_state = impl.get_window_state() + if changeid != impl._changeventid: # not the latest state change + pass # handle it after the next state change event is ready to process + if impl._pending_state_transition: + if impl._pending_state_transition != current_state: + impl._apply_state(impl._pending_state_transition) + else: + impl._pending_state_transition = None + + +def process_change(native, event): + if event.type() == QEvent.WindowStateChange: + old = event.oldState() + new = native.windowState() + if not old & Qt.WindowMinimized and new & Qt.WindowMinimized: + native.interface.on_hide() + elif old & Qt.WindowMinimized and not new & Qt.WindowMinimized: + native.interface.on_show() + impl = native.impl + # Handle this later as the states etc may not have been fully realized. + # I have no idea why 100ms is needed here. + impl._changeventid += 1 + if get_is_wayland(): + QTimer.singleShot( + 100, partial(_handle_statechange, impl, impl._changeventid) + ) + elif event.type() == QEvent.ActivationChange: + if native.isActiveWindow(): + native.interface.on_gain_focus() + else: + native.interface.on_lose_focus() + + +class TogaTLWidget(QWidget): + def __init__(self, impl, *args, **kwargs): + super().__init__(*args, **kwargs) + self.interface = impl.interface + self.impl = impl + + def changeEvent(self, event): + process_change(self, event) + super().changeEvent(event) + + +class TogaMainWindow(QMainWindow): + def __init__(self, impl, *args, **kwargs): + super().__init__(*args, **kwargs) + self.interface = impl.interface + self.impl = impl + + def changeEvent(self, event): + process_change(self, event) + super().changeEvent(event) + + +def wrap_container(widget, impl): + wrapper = TogaTLWidget(impl) + layout = QVBoxLayout(wrapper) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(widget) + return wrapper + + +class Window: + def __init__(self, interface, title, position, size): + self.interface = interface + self.interface._impl = self + self.container = Container(on_refresh=self.container_refreshed) + self.container.native.show() + self._changeventid = 0 + + self.create() + + self._hidden_window_state = None + self._pending_state_transition = None + + self.native.interface = interface + self.native.impl = self + self.native.closeEvent = self.qt_close_event + self.prog_close = False + + self._in_presentation_mode = False + + self.native.setWindowTitle(title) + self.native.resize(size[0], size[1]) + if not self.interface.resizable: + self.native.setFixedSize(size[0], size[1]) + if position is not None: + self.native.move(position[0], position[1]) + + # This does not actually work on KDE! + # self._set_minimizable(self.interface.minimizable) + + self.native.resizeEvent = self.resizeEvent + + def qt_close_event(self, event): + if not self.prog_close: + event.ignore() + if self.interface.closable: + # Subtlety: If on_close approves the closing + # this handler doesn't get called again + self.interface.on_close() + + def create(self): + self.native = wrap_container(self.container.native, self) + + # def _set_minimizable(self, enabled): + # flags = self.native.windowFlags() + # if enabled: + # flags |= Qt.WindowMinimizeButtonHint + # else: + # flags &= ~Qt.WindowMinimizeButtonHint + # self.native.setWindowFlags(flags) + + def hide(self): + # https://forum.qt.io/topic/163064/delayed-window-state-read-after-hide-gives-wrong-results-even-in-x11/ + # Sorta unreliable window state when hidden here, pull our own logic. + if self._hidden_window_state is None: + self._hidden_window_state = self.get_window_state(in_progress_state=True) + self._pending_state_transition = None + + self.native.hide() + # Ideally we'd love to be able to use showEvent but AFAICT + # it also gets triggered on deminimization and sometimes even + # TWICE so it's unreliable. Hack this around, no way to hide + # window through system in KDE AFAICT anyways. + self.interface.on_hide() + + def show(self): + # Do this bee-fore we show as the docs indicate it'd be applied on show + # and also to avoid brief flashing / failure to apply + if self._hidden_window_state is not None: + self.set_window_state(self._hidden_window_state) + self._hidden_window_state = None + self.native.show() + self.interface.on_show() + + def close(self): + # OK, this is a bit of a stretch, since + # this could've been a user-induced close + # on_closed as well, however this flag + # is only used for qt_close_event and you + # can check out the subtlety there. + self.prog_close = True + self.native.close() + + def get_title(self): + return self.native.windowTitle() + + def set_title(self, title): + self.native.setWindowTitle(title) + + def get_size(self): + if get_testing(): + # Upstream glitch. Try making a window, set its size, read it after a sec, + # it changes by 1 or 2. Reproducible with 300x200 as the size + # Ideally we should use pytest.approx; however that doesn't support compar- + # sions. + return Size( + AnyWithin( + self.native.size().width() - 2, self.native.size().width() + 2 + ), + AnyWithin( + self.native.size().height() - 2, self.native.size().height() + 2 + ), + ) + else: + return Size( + self.native.size().width(), + self.native.size().height(), + ) + + def set_size(self, size): + if not self.interface.resizable: + self.native.setFixedSize(size[0], size[1]) + self.native.resize(size[0], size[1]) + + def resizeEvent(self, event): + if self.interface.content: + self.interface.content.refresh() + + def _extra_height(self): + return self.native.size().height() - self.container.native.size().height() + + def container_refreshed(self, container): + min_width = self.interface.content.layout.min_width + min_height = self.interface.content.layout.min_height + size = self.container.native.size() + # Calling self.set_size here to trigger logic about fixed size windows. + if size.width() < min_width and size.height() < min_height: + self.set_size((min_width, min_height + self._extra_height())) + elif size.width() < min_width: + self.set_size((min_width, size.height() + self._extra_height())) + elif size.height() < min_height: + self.set_size((size.width(), size.height() + self._extra_height())) + self.container.native.setMinimumSize(min_width, min_height) + + def get_current_screen(self): + return ScreenImpl(self.native.screen()) + + def get_position(self) -> Position: + return Position(self.native.pos().x(), self.native.pos().y()) + + def set_position(self, position): + self.native.move(position[0], position[1]) + + def set_app(self, app): + # All windows instantiated belongs to your only QApplication + # but we need to set the icon + self.native.setWindowIcon(app.interface.icon._impl.native) + + def get_visible(self): + return self.native.isVisible() + + # =============== WINDOW STATES ================ + # non-minimizable is not implemented as the minimize button + # still exists at least when using Breeze theme even if it + # is hinted away. + def get_window_state(self, in_progress_state=False): + # NOTE - MINIMIZED does not round-trip on Wayland + if self._hidden_window_state: + return self._hidden_window_state + if in_progress_state and self._pending_state_transition: + return self._pending_state_transition + window_state = self.native.windowState() + + if window_state & Qt.WindowFullScreen: + if self._in_presentation_mode: + return WindowState.PRESENTATION + else: + return WindowState.FULLSCREEN + elif window_state & Qt.WindowMaximized: + return WindowState.MAXIMIZED + elif window_state & Qt.WindowMinimized: + return WindowState.MINIMIZED + else: + return WindowState.NORMAL + + def set_window_state(self, state): + if ( + self._hidden_window_state + ): # skip all the logic and simply do this on next show if currently hidden + self._hidden_window_state = state + return + + if self._pending_state_transition: + self._pending_state_transition = state + return + + # print("SET WINDOW STATE") + + # Exit app presentation mode if another window is in it + if any( + window.state == WindowState.PRESENTATION and window != self.interface + for window in self.interface.app.windows + ): + self.interface.app.exit_presentation_mode() + + if get_is_wayland(): + self._pending_state_transition = state + self._apply_state(state) + + def _apply_state(self, state): + if state is None: + return + + current_state = self.get_window_state() + current_native_state = self.native.windowState() + if current_state == WindowState.MINIMIZED and not get_is_wayland(): + self.native.showNormal() + if current_state == state: + self._pending_state_transition = None + return + + if current_state == WindowState.PRESENTATION: + self.interface.screen = self._before_presentation_mode_screen + if hasattr(self.native, "menuBar"): + self.native.menuBar().show() + del self._before_presentation_mode_screen + self._in_presentation_mode = False + + if state == WindowState.MAXIMIZED: + self.native.showMaximized() + + elif state == WindowState.MINIMIZED: + print("SHOW MIN") + if not get_is_wayland(): + self.native.showNormal() + self.native.showMinimized() + + elif state == WindowState.FULLSCREEN: + self.native.showFullScreen() + if current_state == WindowState.PRESENTATION: + QApplication.sendEvent( + self.native, QWindowStateChangeEvent(current_native_state) + ) + + elif state == WindowState.PRESENTATION: + self._before_presentation_mode_screen = self.interface.screen + if hasattr(self.native, "menuBar"): + self.native.menuBar().hide() + # Do this bee-fore showFullScreen bee-cause + # showFullScreen might immediately trigger the event + # and the window state read there might read a non- + # presentation mode + self._in_presentation_mode = True + self.native.showFullScreen() + if current_state == WindowState.FULLSCREEN: + QApplication.sendEvent( + self.native, QWindowStateChangeEvent(current_native_state) + ) + + else: + self.native.showNormal() + + QApplication.processEvents() + + # ============== STUB ============= + + def get_image_data(self): + pass + + def set_content(self, widget): + self.container.content = widget + + +class MainWindow(Window): + def create(self): + self.native = TogaMainWindow(self) + self.native.setCentralWidget(self.container.native) + + def _submenu(self, group, group_cache): + try: + return group_cache[group] + except KeyError: + parent_menu = self._submenu(group.parent, group_cache) + submenu = QMenu(group.text) + parent_menu.addMenu(submenu) + + group_cache[group] = submenu + return submenu + + def create_menus(self): + menubar = self.native.menuBar() + menubar.clear() + + group_cache = {None: menubar} + submenu = None + for cmd in self.interface.app.commands: + submenu = self._submenu(cmd.group, group_cache) + if isinstance(cmd, Separator): + submenu.addSeparator() + else: + submenu.addAction(cmd._impl.create_menu_item()) + + def create_toolbar(self): + pass diff --git a/qt/tests_backend/app.py b/qt/tests_backend/app.py new file mode 100644 index 0000000000..fdb596560c --- /dev/null +++ b/qt/tests_backend/app.py @@ -0,0 +1,148 @@ +""" +Written with haste. Expect hundreds of errors. +""" + +from pathlib import Path + +import pytest +from PySide6.QtCore import Qt +from PySide6.QtGui import QCursor +from PySide6.QtWidgets import QApplication, QDialog +from toga_qt.keys import qt_to_toga_key, toga_to_qt_key +from toga_qt.libs import get_is_wayland + +from .probe import BaseProbe + + +class AppProbe(BaseProbe): + supports_key = True + supports_key_mod3 = True + supports_current_window_assignment = True + supports_dark_mode = True + + def __init__(self, app): + super().__init__() + self.app = app + self.main_window = app.main_window + self.native = self.app._impl.native + self.impl = self.app._impl + assert isinstance(QApplication.instance(), QApplication) + # and the clouds are moving on with every autumn... + assert self.native.style().objectName() == "breeze" + # KWin supports this but not mutter which is used in CI. + if get_is_wayland(): + self.supports_current_window_assignment = False + + @property + def config_path(self): + return Path.home() / ".config/testbed" + + @property + def data_path(self): + return Path.home() / ".local/share/testbed" + + @property + def cache_path(self): + return Path.home() / ".cache/testbed" + + @property + def logs_path(self): + return Path.home() / ".local/state/testbed/log" + + @property + def is_cursor_visible(self): + return self.native.overrideCursor() != QCursor(Qt.BlankCursor) + + def unhide(self): + self.main_window._impl.native.show() + + def assert_app_icon(self, icon): + raise pytest.skip("Not implemented in probe yet") + + def _menu_item(self, path): + menu_bar = self.main_window._impl.native.menuBar() + current_menu = menu_bar + for label in path: + for action in current_menu.actions(): + if action.text() == label: + if action.menu(): + current_menu = action.menu() + else: + return action + break + else: + raise AssertionError(f"Menu path {path} not found") + return current_menu + + def _activate_menu_item(self, path): + item = self._menu_item(path) + item.trigger() + + def activate_menu_hide(self): + pytest.xfail("No hide in menu for KDE apps") + + def activate_menu_exit(self): + self._activate_menu_item(["File", "Quit"]) + + def activate_menu_about(self): + self._activate_menu_item(["Help", "About Toga Testbed"]) + + async def close_about_dialog(self): + self.impl._about_dialog.done(QDialog.DialogCode.Accepted) + + def activate_menu_visit_homepage(self): + raise pytest.xfail("Qt apps do not have Visit Homepage") + + def assert_dialog_in_focus(self, dialog): + active_window = QApplication.activeWindow() + assert active_window.windowTitle() == dialog._impl.native.windowTitle() + + def assert_menu_item(self, path, *, enabled=True): + item = self._menu_item(path) + assert item.isEnabled() == enabled + + def assert_menu_order(self, path, expected): + menu = self._menu_item(path) + actual_titles = [ + action.text() if action.isSeparator() is False else "---" + for action in menu.actions() + ] + assert actual_titles == expected + + def assert_system_menus(self): + # Incomplete + self.assert_menu_item(["File", "Quit"]) + self.assert_menu_item(["Help", "About Toga Testbed"]) + + def activate_menu_close_window(self): + pytest.xfail("Menu close is not typical of Qt") + + def activate_menu_close_all_windows(self): + pytest.xfail("Menu close all windows is not typical of Qt") + + def activate_menu_minimize(self): + pytest.xfail("Menu Minimize is not typical of Qt") + + def keystroke(self, combination): + return qt_to_toga_key(toga_to_qt_key(combination)) + + async def restore_standard_app(self): + pytest.skip("not impld") + + async def open_initial_document(self, monkeypatch, document_path): + pytest.skip("not impld") + + def open_document_by_drag(self, document_path): + pytest.skip("Not impld") + + def has_status_icon(self, status_icon): + pytest.skip("Not impld") + + def status_menu_items(self, status_icon): + pytest.skip("Not impld") + + def activate_status_icon_button(self, item_id): + pytest.skip("Not impld") + + def activate_status_menu_item(self, item_id, title): + pytest.skip("Not impld") diff --git a/qt/tests_backend/dialogs.py b/qt/tests_backend/dialogs.py new file mode 100644 index 0000000000..ff6c5ff7f0 --- /dev/null +++ b/qt/tests_backend/dialogs.py @@ -0,0 +1,83 @@ +import asyncio + +import pytest +from PySide6.QtWidgets import QDialog + + +class DialogsMixin: + supports_multiple_select_folder = True + + def _default_close_handler(self, dialog, qt_result): + dialog._impl.native.done(qt_result) + # Note: somehow at this point if I do QApplication.processEvents() + # it'll hang forever however the signal we use is emitted immediately + # anyways. + + def _setup_dialog_result( + self, dialog, qt_result, close_handler=None, pre_close_test_method=None + ): + orig_exec = dialog._impl.show + + def automated_exec(host_window, future): + orig_exec(host_window, future) + + async def _close_dialog(): + try: + if pre_close_test_method: + pre_close_test_method(dialog) + finally: + try: + if close_handler: + close_handler(dialog, qt_result) + else: + # This is nessacary because without it the dialog would + # not display for some reason. + # Won't be an issue with public API if the dialog hasn't + # been shown successfully yet, + # no user could dismiss it and the call can't complete. + await self.redraw( + "Dialog Internal: Just before close", delay=0.1 + ) + self._default_close_handler(dialog, qt_result) + except Exception as e: + future.set_exception(e) + + asyncio.create_task(_close_dialog()) + + dialog._impl.show = automated_exec + + def setup_info_dialog_result(self, dialog, pre_close_test_method=None): + self._setup_dialog_result( + dialog, + QDialog.DialogCode.Accepted, + pre_close_test_method=pre_close_test_method, + ) + + def setup_question_dialog_result(self, dialog, result): + pytest.skip("no impl") + + def setup_confirm_dialog_result(self, dialog, result): + pytest.skip("no impl") + + def setup_error_dialog_result(self, dialog): + pytest.skip("no impl") + + def setup_stack_trace_dialog_result(self, dialog, result): + pytest.skip("no impl") + + def setup_save_file_dialog_result(self, dialog, result): + pytest.skip("no impl") + + def setup_open_file_dialog_result(self, dialog, result, multiple_select): + pytest.skip("no impl") + + def setup_select_folder_dialog_result(self, dialog, result, multiple_select): + pytest.skip("no impl") + + def is_modal_dialog(self, dialog): + if dialog._impl.native is not None: + return dialog._impl.native.isModal() + else: + # Can't really get this tested... + # we need to create native when our parent window is known + return True diff --git a/qt/tests_backend/fonts.py b/qt/tests_backend/fonts.py new file mode 100644 index 0000000000..d3b1c23849 --- /dev/null +++ b/qt/tests_backend/fonts.py @@ -0,0 +1,21 @@ +import pytest + +from toga.fonts import NORMAL + + +class FontMixin: + # Explicitly indicate that no font features are supported + supports_custom_fonts = False + supports_custom_variable_fonts = False + + def preinstalled_font(self): + pytest.skip("Qt backend no impl fonts") + + def assert_font_family(self, expected): + pytest.skip("Qt backend no impl fonts") + + def assert_font_size(self, expected): + pytest.skip("Qt backend no impl fonts") + + def assert_font_options(self, weight=NORMAL, style=NORMAL, variant=NORMAL): + pytest.skip("Qt backend no impl fonts") diff --git a/qt/tests_backend/icons.py b/qt/tests_backend/icons.py new file mode 100644 index 0000000000..59b47d5c19 --- /dev/null +++ b/qt/tests_backend/icons.py @@ -0,0 +1,54 @@ +import sys +from pathlib import Path + +import pytest +import toga_qt +from PySide6.QtGui import QIcon + +import toga + +from .probe import BaseProbe + + +class IconProbe(BaseProbe): + alternate_resource = "resources/icons/orange" + + def __init__(self, app, icon): + self.icon = icon + self.app = app + assert isinstance(self.icon._impl.native, QIcon) + + def assert_icon_content(self, path): + if path == "resources/icons/green": + assert ( + self.icon._impl.path == self.app.paths.app / "resources/icons/green.png" + ) + elif path == "resources/icons/blue": + assert ( + self.icon._impl.path == self.app.paths.app / "resources/icons/blue.png" + ) + elif path == "resources/icons/orange": + assert ( + self.icon._impl.path + == self.app.paths.app / "resources/icons/orange.ico" + ) + else: + pytest.fail("Unknown icon resource") + + def assert_default_icon_content(self): + assert ( + self.icon._impl.path == Path(toga_qt.__file__).parent / "resources/toga.png" + ) + + def assert_platform_icon_content(self): + pytest.xfail("Qt does not use sized icons") + + def assert_app_icon_content(self): + if Path(sys.executable).stem.startswith("python"): + assert self.icon._impl == toga.Icon.DEFAULT_ICON._impl + else: + assert ( + self.icon._impl.path + == Path(sys.executable).parent.parent + / "share/icons/hicolor/512x512/apps/org.beeware.toga.testbed.png" + ) diff --git a/qt/tests_backend/images.py b/qt/tests_backend/images.py new file mode 100644 index 0000000000..d66ef9bfc3 --- /dev/null +++ b/qt/tests_backend/images.py @@ -0,0 +1,14 @@ +from PySide6.QtGui import QImage + +from .probe import BaseProbe + + +class ImageProbe(BaseProbe): + def __init__(self, app, image): + super().__init__() + self.app = app + self.image = image + assert isinstance(self.image._impl.native, QImage) + + def supports_extension(self, extension): + return extension.lower() in {".jpg", ".jpeg", ".png", ".bmp"} diff --git a/qt/tests_backend/probe.py b/qt/tests_backend/probe.py new file mode 100644 index 0000000000..ef915468ed --- /dev/null +++ b/qt/tests_backend/probe.py @@ -0,0 +1,68 @@ +import asyncio + +from PySide6.QtCore import QEvent, Qt +from PySide6.QtGui import QKeyEvent +from PySide6.QtWidgets import QApplication +from pytest import approx + +import toga + +from .dialogs import DialogsMixin + +SPECIAL_KEY_MAP = { + " ": Qt.Key_Space, + "-": Qt.Key_Minus, + ".": Qt.Key_Period, + "\n": Qt.Key_Return, + "": Qt.Key_Escape, + "'": Qt.Key_Apostrophe, + '"': Qt.Key_QuoteDbl, +} + +MODIFIER_MAP = { + "shift": Qt.ShiftModifier, + "ctrl": Qt.ControlModifier, + "alt": Qt.AltModifier, +} + + +class BaseProbe(DialogsMixin): + async def redraw(self, message=None, delay=0): + for widget in QApplication.allWidgets(): + widget.repaint() # this is immediate and will block + + # Wait a second... (pun intended) + if toga.App.app.run_slow: + delay = max(1, delay) + + if delay: + print("Waiting for redraw" if message is None else message) + await asyncio.sleep(delay) + + def assert_image_size(self, image_size, size, screen): + assert ( + approx(image_size, abs=1) == size * screen._impl.native.devicePixelRatio() + ) + + async def type_character(self, char, *, shift=False, ctrl=False, alt=False): + widget = QApplication.focusWidget() + if widget is None: + raise RuntimeError("No widget has focus to receive key events.") + + key = SPECIAL_KEY_MAP.get(char) + if key is None: + if len(char) == 1: + key = Qt.Key(ord(char.upper())) + else: + raise ValueError(f"Unsupported character: {char!r}") + modifiers = Qt.NoModifier + if shift: + modifiers |= Qt.ShiftModifier + if ctrl: + modifiers |= Qt.ControlModifier + if alt: + modifiers |= Qt.AltModifier + press = QKeyEvent(QEvent.KeyPress, key, modifiers, char) + release = QKeyEvent(QEvent.KeyRelease, key, modifiers, char) + QApplication.sendEvent(widget, press) + QApplication.sendEvent(widget, release) diff --git a/qt/tests_backend/screens.py b/qt/tests_backend/screens.py new file mode 100644 index 0000000000..67c7b57e05 --- /dev/null +++ b/qt/tests_backend/screens.py @@ -0,0 +1,20 @@ +import pytest +from toga_qt.libs import get_is_wayland + +from toga.images import Image as TogaImage + +from .probe import BaseProbe + + +class ScreenProbe(BaseProbe): + def __init__(self, screen): + super().__init__() + self.screen = screen + self._impl = screen._impl + self.native = screen._impl.native + + def get_screenshot(self, format=TogaImage): + if get_is_wayland(): + pytest.xfail("Cannot get image in Qt using conventional APIs of screen") + else: + return self.screen.as_image(format=format) diff --git a/qt/tests_backend/widgets/__init__.py b/qt/tests_backend/widgets/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/qt/tests_backend/widgets/activityindicator.py b/qt/tests_backend/widgets/activityindicator.py new file mode 100644 index 0000000000..093aab8343 --- /dev/null +++ b/qt/tests_backend/widgets/activityindicator.py @@ -0,0 +1,10 @@ +from PySide6.QtQuickWidgets import QQuickWidget + +from .base import SimpleProbe + + +class ActivityIndicatorProbe(SimpleProbe): + native_class = QQuickWidget + + def assert_spinner_is_hidden(self, value): + assert (not self.native.isVisible()) == value diff --git a/qt/tests_backend/widgets/base.py b/qt/tests_backend/widgets/base.py new file mode 100644 index 0000000000..8fc3be56d3 --- /dev/null +++ b/qt/tests_backend/widgets/base.py @@ -0,0 +1,93 @@ +import pytest + +from ..fonts import FontMixin +from ..probe import BaseProbe + + +class SimpleProbe(BaseProbe, FontMixin): + def __init__(self, widget): + super().__init__() + self.app = widget.app + self.window = widget.window + self.widget = widget + self.impl = widget._impl + self.native = widget._impl.native + assert isinstance(self.native, self.native_class) + + def assert_container(self, container): + assert container._impl.container == self.impl.container + container_native = container._impl.container.native + for obj in container_native.children(): + if obj == self.native: + break + else: + raise ValueError(f"cannot find {self.native} in {container_native}") + + def assert_not_contained(self): + assert self.widget._impl.container is None + assert self.native.parentWidget() is None + + def assert_text_align(self, expected): + pytest.xfail("fonts not impld on qt") + + @property + def enabled(self): + return self.native.isEnabled() + + @property + def color(self): + pytest.skip("Colors not implemented on Qt") + + @property + def background_color(self): + pytest.skip("Colors not implemented on Qt") + + @property + def hidden(self): + return not self.native.isVisible() + + @property + def shrink_on_resize(self): + return True + + def assert_layout(self, size, position): + # Widget is contained and in a window. + assert self.widget._impl.container is not None + assert self.native.parentWidget() is not None + + assert (self.native.width(), self.native.height()) == size + assert (self.native.pos().x(), self.native.pos().y()) == position + + async def press(self): + self.native.click() + + def mouse_event(self, x=0, y=0, **kwargs): + pytest.fail("Please implement the mouse event probe") + + @property + def is_hidden(self): + return not self.native.isVisible() + + @property + def has_focus(self): + return self.native.hasFocus() + + @property + def width(self): + return self.native.width() + + def assert_width(self, min_width, max_width): + assert min_width <= self.width <= max_width + + def assert_height(self, min_height, max_height): + assert min_height <= self.height <= max_height + + @property + def height(self): + return self.native.height() + + async def undo(self): + pytest.skip("Undo not supported by default on widgets") + + async def redo(self): + pytest.skip("Redo not supported by default on widgets") diff --git a/qt/tests_backend/widgets/box.py b/qt/tests_backend/widgets/box.py new file mode 100644 index 0000000000..a48f58d23e --- /dev/null +++ b/qt/tests_backend/widgets/box.py @@ -0,0 +1,7 @@ +from PySide6.QtWidgets import QWidget + +from .base import SimpleProbe + + +class BoxProbe(SimpleProbe): + native_class = QWidget diff --git a/qt/tests_backend/widgets/button.py b/qt/tests_backend/widgets/button.py new file mode 100644 index 0000000000..0f63126395 --- /dev/null +++ b/qt/tests_backend/widgets/button.py @@ -0,0 +1,24 @@ +from PySide6.QtWidgets import QPushButton + +from .base import SimpleProbe + + +class ButtonProbe(SimpleProbe): + native_class = QPushButton + + @property + def text(self): + # Normalize the zero width space to the empty string. + if self.native.text() == "\u200b": + return "" + return self.native.text() + + def assert_no_icon(self): + assert self.native.icon().isNull() + + def assert_icon_size(self): + # No assertion needed here. It is handled by theme. + # [better not write anything more here just in case of + # anything about size accidentally being an inappropriate + # joke] + pass diff --git a/qt/tests_backend/widgets/label.py b/qt/tests_backend/widgets/label.py new file mode 100644 index 0000000000..d59544ae38 --- /dev/null +++ b/qt/tests_backend/widgets/label.py @@ -0,0 +1,23 @@ +from PySide6.QtWidgets import QLabel + +from .base import SimpleProbe +from .properties import toga_x_text_align, toga_y_text_align + + +class LabelProbe(SimpleProbe): + native_class = QLabel + + @property + def text(self): + return self.native.text() + + @property + def text_align(self): + return toga_x_text_align(self.native.alignment()) + + @property + def vertical_text_align(self): + return + + def assert_vertical_text_align(self, expected): + assert toga_y_text_align(self.native.alignment()) == expected diff --git a/qt/tests_backend/widgets/properties.py b/qt/tests_backend/widgets/properties.py new file mode 100644 index 0000000000..74eff75dcf --- /dev/null +++ b/qt/tests_backend/widgets/properties.py @@ -0,0 +1,28 @@ +import pytest +from PySide6.QtCore import Qt + +from toga.style.pack import BOTTOM, CENTER, JUSTIFY, LEFT, RIGHT, TOP + + +def toga_x_text_align(alignment): + if alignment & Qt.AlignLeft: + return LEFT + elif alignment & Qt.AlignHCenter: + return CENTER + elif alignment & Qt.AlignRight: + return RIGHT + elif alignment & Qt.AlignJustify: + return JUSTIFY + else: + pytest.fail(f"Qt alignment {alignment} cannot be interpreted as horizontal") + + +def toga_y_text_align(alignment): + if alignment & Qt.AlignTop: + return TOP + elif alignment & Qt.AlignVCenter: + return CENTER + elif alignment & Qt.AlignBottom: + return BOTTOM + else: + pytest.fail(f"Qt alignment {alignment} cannot be interpreted as vertical") diff --git a/qt/tests_backend/widgets/textinput.py b/qt/tests_backend/widgets/textinput.py new file mode 100644 index 0000000000..7b7c821d51 --- /dev/null +++ b/qt/tests_backend/widgets/textinput.py @@ -0,0 +1,45 @@ +from PySide6.QtWidgets import QLineEdit + +from .base import SimpleProbe +from .properties import toga_x_text_align, toga_y_text_align + + +class TextInputProbe(SimpleProbe): + native_class = QLineEdit + + @property + def value(self): + return ( + self.native.placeholderText() + if self.placeholder_visible + else self.native.text() + ) + + @property + def placeholder_visible(self): + return not self.native.text() + + @property + def value_hidden(self): + return self.native.echoMode() == QLineEdit.Password + + @property + def placeholder_hides_on_focus(self): + return False + + @property + def readonly(self): + return self.native.isReadOnly() + + @property + def text_align(self): + return toga_x_text_align(self.native.alignment()) + + def assert_text_align(self, expected): + assert self.text_align == expected + + def assert_vertical_text_align(self, expected): + assert toga_y_text_align(self.native.alignment()) == expected + + def set_cursor_at_end(self): + self.native.setCursorPosition(len(self.native.text())) diff --git a/qt/tests_backend/window.py b/qt/tests_backend/window.py new file mode 100644 index 0000000000..1e4fdbc617 --- /dev/null +++ b/qt/tests_backend/window.py @@ -0,0 +1,119 @@ +import asyncio + +import pytest +from PySide6.QtCore import Qt +from toga_qt.libs import AnyWithin, get_is_wayland + +from toga.constants import WindowState + +from .probe import BaseProbe + + +class WindowProbe(BaseProbe): + # There *is* a close button hint but it doesn't seem to work + # under KDE so we take similar handling as winforms here. + supports_closable = False + supports_as_image = False # not impld yet + supports_focus = True + supports_minimizable = ( + False # cannot be impld on Qt, at least you hint it but it still show on KDE + ) + supports_move_while_hidden = False + supports_unminimize = True + supports_minimize = True + supports_placement = True + + def __init__(self, app, window): + self.app = app + self.window = window + self.native = window._impl.native + self.container = window._impl.container + assert self.native.isWindow() + if get_is_wayland(): + self.supports_placement = ( + False # returns all sorts of messy values in CI in mutter + ) + self.supports_focus = ( + False # Qt activiateWindow doesn't work with mutter used in CI + ) + # Qt upstream bug + self.supports_unminimize = False + self.supports_minimize = False + + async def wait_for_window(self, message, state=None): + # Wait for composite transitions to finish the delay bee-fore repaint + await asyncio.sleep(0.1) + await self.redraw(message, delay=0.3) + if state == WindowState.MINIMIZED and get_is_wayland(): + state = WindowState.NORMAL + + if state: + timeout = 5 + polling_interval = 0.1 + exception = None + loop = asyncio.get_running_loop() + start_time = loop.time() + while (loop.time() - start_time) < timeout: + try: + assert self.instantaneous_state == state + return + except AssertionError as e: + exception = e + await asyncio.sleep(polling_interval) + raise exception + + async def cleanup(self): + self.window.close() + await self.redraw("Closing window", delay=0.5) + + def close(self): + if self.is_closable: + self.native.close() + + @property + def content_size(self): + size = self.container.native.size() + return AnyWithin(size.width() - 2, size.width() + 2), AnyWithin( + size.height() - 2, size.height() + 2 + ) + + @property + def is_resizable(self): + min_size = self.native.minimumSize() + max_size = self.native.maximumSize() + return not (min_size == max_size) + + @property + def is_closable(self): + flags = self.native.windowFlags() + return bool(flags & Qt.WindowCloseButtonHint) + + @property + def is_minimized(self): + return self.native.isMinimized() + + def minimize(self): + self.native.showMinimized() + + def unminimize(self): + self.native.showNormal() + + # @property + # def is_minimizable(self): + # return bool(self.native.windowFlags() & Qt.WindowMinimizeButtonHint) + + @property + def instantaneous_state(self): + return self.window._impl.get_window_state(in_progress_state=False) + + def has_toolbar(self): + raise pytest.skip("toolbar no impl") + + def assert_is_toolbar_separator(self, index, section=False): + raise pytest.skip("toolbar no impl") + + def assert_toolbar_item(self, index, label, tooltip, has_icon, enabled): + raise pytest.skip("toolbar no impl") + + def press_toolbar_button(self, index): + raise pytest.skip("toolbar no impl") diff --git a/testbed/tests/conftest.py b/testbed/tests/conftest.py index d129e5f051..5509bf52ce 100644 --- a/testbed/tests/conftest.py +++ b/testbed/tests/conftest.py @@ -3,7 +3,6 @@ import inspect from dataclasses import dataclass from importlib import import_module -import os from pytest import fixture, register_assert_rewrite, skip From 2b968100f82953dcc7a945047383ca93349a39f9 Mon Sep 17 00:00:00 2001 From: "John X. Zhou" Date: Thu, 18 Sep 2025 18:39:02 -0500 Subject: [PATCH 03/37] get rid of unofficial hacks and integrate into core --- core/src/toga/__init__.pyi | 1 + core/src/toga/command.py | 10 +++++-- core/src/toga/icons.py | 12 +++++++- qt/pyproject.toml | 3 +- qt/src/toga_qt/app.py | 2 +- qt/src/toga_qt/command.py | 3 +- qt/src/toga_qt/icons.py | 5 ++++ qt/src/toga_qt/togax.py | 60 -------------------------------------- 8 files changed, 28 insertions(+), 68 deletions(-) delete mode 100644 qt/src/toga_qt/togax.py diff --git a/core/src/toga/__init__.pyi b/core/src/toga/__init__.pyi index 6da8a38637..83dcc3e422 100644 --- a/core/src/toga/__init__.pyi +++ b/core/src/toga/__init__.pyi @@ -22,6 +22,7 @@ from toga.documents import Document as Document from toga.documents import DocumentWindow as DocumentWindow from toga.fonts import Font as Font from toga.icons import Icon as Icon +from toga.icons import NativeIcon as NativeIcon from toga.images import Image as Image from toga.keys import Key as Key from toga.statusicons import MenuStatusIcon as MenuStatusIcon diff --git a/core/src/toga/command.py b/core/src/toga/command.py index caa8643262..5efb486387 100644 --- a/core/src/toga/command.py +++ b/core/src/toga/command.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Protocol from toga.handlers import simple_handler, wrapped_handler -from toga.icons import Icon +from toga.icons import Icon, NativeIcon from toga.keys import Key from toga.platform import get_platform_factory @@ -153,6 +153,7 @@ def key(self) -> tuple[tuple[int, int, str], ...]: COMMANDS: Group #: Default group for user-provided commands WINDOW: Group #: Window management commands HELP: Group #: Help commands + SETTINGS: Group #: Preferences menu group for KDE-based apps Group.APP = Group("*", order=-100) @@ -162,6 +163,7 @@ def key(self) -> tuple[tuple[int, int, str], ...]: Group.COMMANDS = Group("Commands", order=30) Group.WINDOW = Group("Window", order=90) Group.HELP = Group("Help", order=100) +Group.SETTINGS = Group("Settings", order=80) class ActionHandler(Protocol): @@ -341,7 +343,11 @@ def icon(self) -> Icon | None: @icon.setter def icon(self, icon_or_name: IconContentT | None) -> None: - if isinstance(icon_or_name, Icon) or icon_or_name is None: + if ( + isinstance(icon_or_name, Icon) + or isinstance(icon_or_name, NativeIcon) + or icon_or_name is None + ): self._icon = icon_or_name else: self._icon = Icon(icon_or_name) diff --git a/core/src/toga/icons.py b/core/src/toga/icons.py index 3a9bf5434d..65744c051d 100644 --- a/core/src/toga/icons.py +++ b/core/src/toga/icons.py @@ -10,7 +10,7 @@ if TYPE_CHECKING: from typing import TypeAlias - IconContentT: TypeAlias = str | Path | toga.Icon + IconContentT: TypeAlias = str | Path | toga.Icon | toga.NativeIcon class cachedicon: @@ -32,6 +32,16 @@ def __get__(self, obj: object, owner: type[Icon]) -> Icon: return value +class NativeIcon: + """ + For internal use only + """ + + def __init__(self, native): + self.factory = get_platform_factory() + self._impl = self.factory.NativeIcon(native) + + # A sentinel value that is type compatible with the `path` argument, # but can be used to uniquely identify a request for an application icon _APP_ICON = "" diff --git a/qt/pyproject.toml b/qt/pyproject.toml index 596006c8e4..549f458b80 100644 --- a/qt/pyproject.toml +++ b/qt/pyproject.toml @@ -34,7 +34,6 @@ keywords = [ classifiers = [ "Development Status :: 1 - Planning", "Intended Audience :: Developers", - "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", @@ -53,7 +52,7 @@ classifiers = [ linux-qt = "toga_qt" [tool.setuptools_scm] -root = "." +root = ".." [tool.setuptools_dynamic_dependencies] dependencies = [ diff --git a/qt/src/toga_qt/app.py b/qt/src/toga_qt/app.py index aa859b123e..09cff3a642 100644 --- a/qt/src/toga_qt/app.py +++ b/qt/src/toga_qt/app.py @@ -6,10 +6,10 @@ from qasync import QEventLoop import toga +from toga import NativeIcon from toga.command import Command, Group from .screens import Screen as ScreenImpl -from .togax import NativeIcon def operate_on_focus(method_name, interface, needwrite=False): diff --git a/qt/src/toga_qt/command.py b/qt/src/toga_qt/command.py index 82b2184147..e02dc67ff9 100644 --- a/qt/src/toga_qt/command.py +++ b/qt/src/toga_qt/command.py @@ -2,10 +2,9 @@ from PySide6.QtGui import QAction, QIcon -from toga import Command as StandardCommand, Group, Key +from toga import Command as StandardCommand, Group, Key, NativeIcon from .keys import toga_to_qt_key -from .togax import NativeIcon # also patches to add Group.SETTINGS. adds icon changing class Command: diff --git a/qt/src/toga_qt/icons.py b/qt/src/toga_qt/icons.py index 270e13e55c..2c64ffd8d5 100644 --- a/qt/src/toga_qt/icons.py +++ b/qt/src/toga_qt/icons.py @@ -39,3 +39,8 @@ def __init__(self, interface, path): IMPL_DICT[self.native] = self self.path = path + + +class NativeIcon: + def __init__(self, native): + self.native = native diff --git a/qt/src/toga_qt/togax.py b/qt/src/toga_qt/togax.py deleted file mode 100644 index d04d41ce8a..0000000000 --- a/qt/src/toga_qt/togax.py +++ /dev/null @@ -1,60 +0,0 @@ -""" -This is a module that stores some random stuff -that Toga doesn't yet conceptualize, but is needed -by this unofficial backend. -""" - -from toga import Command, Group, Icon - -########################################### -# Add support for native icon wrapper. -########################################### - -# hack hack hack... the icon thing is used -# nowhere else, and our backend directly -# accesses the native method when using -# commands... so we just do it like this. - - -class NativeIcon: - def __init__(self, native): - self._impl = NativeIconImpl(native) - - -class NativeIconImpl: - def __init__(self, native): - self.native = native - - -# Make setter of command accept the hack, and we also call -# impl to change the icon - - -def icon_setter(self, icon_or_name) -> None: - # added second condition. - if ( - isinstance(icon_or_name, Icon) - or isinstance(icon_or_name, NativeIcon) - or icon_or_name is None - ): - self._icon = icon_or_name - else: - self._icon = Icon(icon_or_name) - - try: - # Call impl to change icon - self._impl.set_icon() - except AttributeError: - # This is the first time where we init and setting - # icon is handled by impl. Pass - pass - - -Command.icon = Command.icon.setter(icon_setter) - - -########################################### -# Add support for Settings group. -########################################### - -Group.SETTINGS = Group("Settings", order=80) From b7504bc8e512a4534752adcb872ef30a44751328 Mon Sep 17 00:00:00 2001 From: "John X. Zhou" Date: Thu, 18 Sep 2025 18:41:01 -0500 Subject: [PATCH 04/37] a missed bug --- qt/src/toga_qt/factory.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/qt/src/toga_qt/factory.py b/qt/src/toga_qt/factory.py index 411617163e..ad8d3814e0 100644 --- a/qt/src/toga_qt/factory.py +++ b/qt/src/toga_qt/factory.py @@ -8,7 +8,7 @@ from .command import Command from .container import Container from .fonts import Font -from .icons import Icon +from .icons import Icon, NativeIcon from .images import Image from .paths import Paths from .statusicons import MenuStatusIcon, SimpleStatusIcon, StatusIconSet @@ -25,6 +25,7 @@ "App", "Paths", "Icon", + "NativeIcon", "Image", "MenuStatusIcon", "SimpleStatusIcon", From 49312d097ecc788a1abbdd14a83d0504c9933821 Mon Sep 17 00:00:00 2001 From: "John X. Zhou" Date: Thu, 18 Sep 2025 18:50:16 -0500 Subject: [PATCH 05/37] no-cover trivial parts --- core/src/toga/icons.py | 4 ++-- core/src/toga/platform.py | 4 +++- testbed/src/testbed/.app.py.kate-swp | Bin 0 -> 78 bytes testbed/src/testbed/app.py | 15 +++++++++++++++ 4 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 testbed/src/testbed/.app.py.kate-swp diff --git a/core/src/toga/icons.py b/core/src/toga/icons.py index 65744c051d..b8b7e78b3e 100644 --- a/core/src/toga/icons.py +++ b/core/src/toga/icons.py @@ -34,10 +34,10 @@ def __get__(self, obj: object, owner: type[Icon]) -> Icon: class NativeIcon: """ - For internal use only + For internal use for Qt backend only """ - def __init__(self, native): + def __init__(self, native): # pragma: no cover self.factory = get_platform_factory() self._impl = self.factory.NativeIcon(native) diff --git a/core/src/toga/platform.py b/core/src/toga/platform.py index 13a49814a9..c21688d9a9 100644 --- a/core/src/toga/platform.py +++ b/core/src/toga/platform.py @@ -29,10 +29,12 @@ def get_current_platform() -> str | None: return "android" elif sys.platform.startswith("freebsd"): return "freeBSD" + # No-covering since it's hard to fake environment variables in core + # tests elif ( "kde" in os.environ.get("XDG_CURRENT_DESKTOP", "").lower() or os.environ.get("TOGA_QT", "") == "1" - ): + ): # pragma: no cover return "linux-qt" else: return _TOGA_PLATFORMS.get(sys.platform) diff --git a/testbed/src/testbed/.app.py.kate-swp b/testbed/src/testbed/.app.py.kate-swp new file mode 100644 index 0000000000000000000000000000000000000000..b0a9cd6987a71ecbd4b40432e7515f016298eb5c GIT binary patch literal 78 zcmZQzU=Z?7EJ;-eE>A2_aLdd|RWQ;sU|?VnNej9$W#z|@jpr^b`@TSvt7j9lB4cnk VP{t034LpH_JrIKchXNG10sw%;6GZ?3 literal 0 HcmV?d00001 diff --git a/testbed/src/testbed/app.py b/testbed/src/testbed/app.py index a7bcd4dcde..57854efcdd 100644 --- a/testbed/src/testbed/app.py +++ b/testbed/src/testbed/app.py @@ -1,3 +1,6 @@ +import importlib +import importlib.util +import sys from unittest.mock import Mock import toga @@ -59,6 +62,18 @@ def task_factory(loop, coro, **kwargs): self.loop.set_task_factory(task_factory) + # Toga's Qt backend is packaged the same as GTK Linux; substitute + # the Qt backend tests if running on Qt. + qt_module_name = "tests_backend_qt" + alias_module_name = "tests_backend" + spec = importlib.util.find_spec(qt_module_name) + if spec is None: + raise FileNotFoundError("Could not find Qt backend tests") + qt_module = importlib.import_module(qt_module_name) + + sys.modules[alias_module_name] = qt_module + return True + # Set a default return code for the app, so that a value is # available if the app exits for a reason other than the test # suite exiting/crashing. From 5f64d700bd19fe8d994ca3d7b9ecac8433fc58e0 Mon Sep 17 00:00:00 2001 From: "John X. Zhou" Date: Thu, 18 Sep 2025 18:50:49 -0500 Subject: [PATCH 06/37] remove swapfile --- testbed/src/testbed/.app.py.kate-swp | Bin 78 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 testbed/src/testbed/.app.py.kate-swp diff --git a/testbed/src/testbed/.app.py.kate-swp b/testbed/src/testbed/.app.py.kate-swp deleted file mode 100644 index b0a9cd6987a71ecbd4b40432e7515f016298eb5c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 78 zcmZQzU=Z?7EJ;-eE>A2_aLdd|RWQ;sU|?VnNej9$W#z|@jpr^b`@TSvt7j9lB4cnk VP{t034LpH_JrIKchXNG10sw%;6GZ?3 From 86e5731830e3c84996fb0440000a7686a9f62f3b Mon Sep 17 00:00:00 2001 From: "John X. Zhou" Date: Thu, 18 Sep 2025 18:58:33 -0500 Subject: [PATCH 07/37] workaround Briefcase's lack of packaging --- .github/workflows/ci.yml | 1 + qt/{tests_backend => tests_backend_qt}/app.py | 0 .../dialogs.py | 0 qt/{tests_backend => tests_backend_qt}/fonts.py | 0 qt/{tests_backend => tests_backend_qt}/icons.py | 0 .../images.py | 0 qt/{tests_backend => tests_backend_qt}/probe.py | 0 .../screens.py | 0 .../widgets/__init__.py | 0 .../widgets/activityindicator.py | 0 .../widgets/base.py | 0 .../widgets/box.py | 0 .../widgets/button.py | 0 .../widgets/label.py | 0 .../widgets/properties.py | 0 .../widgets/textinput.py | 0 .../window.py | 0 testbed/pyproject.toml | 2 ++ testbed/src/testbed/app.py | 17 ++++++++--------- 19 files changed, 11 insertions(+), 9 deletions(-) rename qt/{tests_backend => tests_backend_qt}/app.py (100%) rename qt/{tests_backend => tests_backend_qt}/dialogs.py (100%) rename qt/{tests_backend => tests_backend_qt}/fonts.py (100%) rename qt/{tests_backend => tests_backend_qt}/icons.py (100%) rename qt/{tests_backend => tests_backend_qt}/images.py (100%) rename qt/{tests_backend => tests_backend_qt}/probe.py (100%) rename qt/{tests_backend => tests_backend_qt}/screens.py (100%) rename qt/{tests_backend => tests_backend_qt}/widgets/__init__.py (100%) rename qt/{tests_backend => tests_backend_qt}/widgets/activityindicator.py (100%) rename qt/{tests_backend => tests_backend_qt}/widgets/base.py (100%) rename qt/{tests_backend => tests_backend_qt}/widgets/box.py (100%) rename qt/{tests_backend => tests_backend_qt}/widgets/button.py (100%) rename qt/{tests_backend => tests_backend_qt}/widgets/label.py (100%) rename qt/{tests_backend => tests_backend_qt}/widgets/properties.py (100%) rename qt/{tests_backend => tests_backend_qt}/widgets/textinput.py (100%) rename qt/{tests_backend => tests_backend_qt}/window.py (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2ba14d85d..34336ba994 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,7 @@ jobs: - "demo" - "dummy" - "gtk" + - "qt" - "iOS" - "toga" - "positron" diff --git a/qt/tests_backend/app.py b/qt/tests_backend_qt/app.py similarity index 100% rename from qt/tests_backend/app.py rename to qt/tests_backend_qt/app.py diff --git a/qt/tests_backend/dialogs.py b/qt/tests_backend_qt/dialogs.py similarity index 100% rename from qt/tests_backend/dialogs.py rename to qt/tests_backend_qt/dialogs.py diff --git a/qt/tests_backend/fonts.py b/qt/tests_backend_qt/fonts.py similarity index 100% rename from qt/tests_backend/fonts.py rename to qt/tests_backend_qt/fonts.py diff --git a/qt/tests_backend/icons.py b/qt/tests_backend_qt/icons.py similarity index 100% rename from qt/tests_backend/icons.py rename to qt/tests_backend_qt/icons.py diff --git a/qt/tests_backend/images.py b/qt/tests_backend_qt/images.py similarity index 100% rename from qt/tests_backend/images.py rename to qt/tests_backend_qt/images.py diff --git a/qt/tests_backend/probe.py b/qt/tests_backend_qt/probe.py similarity index 100% rename from qt/tests_backend/probe.py rename to qt/tests_backend_qt/probe.py diff --git a/qt/tests_backend/screens.py b/qt/tests_backend_qt/screens.py similarity index 100% rename from qt/tests_backend/screens.py rename to qt/tests_backend_qt/screens.py diff --git a/qt/tests_backend/widgets/__init__.py b/qt/tests_backend_qt/widgets/__init__.py similarity index 100% rename from qt/tests_backend/widgets/__init__.py rename to qt/tests_backend_qt/widgets/__init__.py diff --git a/qt/tests_backend/widgets/activityindicator.py b/qt/tests_backend_qt/widgets/activityindicator.py similarity index 100% rename from qt/tests_backend/widgets/activityindicator.py rename to qt/tests_backend_qt/widgets/activityindicator.py diff --git a/qt/tests_backend/widgets/base.py b/qt/tests_backend_qt/widgets/base.py similarity index 100% rename from qt/tests_backend/widgets/base.py rename to qt/tests_backend_qt/widgets/base.py diff --git a/qt/tests_backend/widgets/box.py b/qt/tests_backend_qt/widgets/box.py similarity index 100% rename from qt/tests_backend/widgets/box.py rename to qt/tests_backend_qt/widgets/box.py diff --git a/qt/tests_backend/widgets/button.py b/qt/tests_backend_qt/widgets/button.py similarity index 100% rename from qt/tests_backend/widgets/button.py rename to qt/tests_backend_qt/widgets/button.py diff --git a/qt/tests_backend/widgets/label.py b/qt/tests_backend_qt/widgets/label.py similarity index 100% rename from qt/tests_backend/widgets/label.py rename to qt/tests_backend_qt/widgets/label.py diff --git a/qt/tests_backend/widgets/properties.py b/qt/tests_backend_qt/widgets/properties.py similarity index 100% rename from qt/tests_backend/widgets/properties.py rename to qt/tests_backend_qt/widgets/properties.py diff --git a/qt/tests_backend/widgets/textinput.py b/qt/tests_backend_qt/widgets/textinput.py similarity index 100% rename from qt/tests_backend/widgets/textinput.py rename to qt/tests_backend_qt/widgets/textinput.py diff --git a/qt/tests_backend/window.py b/qt/tests_backend_qt/window.py similarity index 100% rename from qt/tests_backend/window.py rename to qt/tests_backend_qt/window.py diff --git a/testbed/pyproject.toml b/testbed/pyproject.toml index 86d5fc0069..4340fae34a 100644 --- a/testbed/pyproject.toml +++ b/testbed/pyproject.toml @@ -68,9 +68,11 @@ test_sources = [ [tool.briefcase.app.testbed.linux] test_sources = [ "../gtk/tests_backend", + "../qt/tests_backend_qt", # Will be substituted in Python tests ] requires = [ "../gtk", + "../qt", # Toga will choose right backend automatically ] [tool.briefcase.app.testbed.windows] diff --git a/testbed/src/testbed/app.py b/testbed/src/testbed/app.py index 57854efcdd..39bc4688ed 100644 --- a/testbed/src/testbed/app.py +++ b/testbed/src/testbed/app.py @@ -4,6 +4,7 @@ from unittest.mock import Mock import toga +from toga.platform import current_platform class ExampleDoc(toga.Document): @@ -64,15 +65,13 @@ def task_factory(loop, coro, **kwargs): # Toga's Qt backend is packaged the same as GTK Linux; substitute # the Qt backend tests if running on Qt. - qt_module_name = "tests_backend_qt" - alias_module_name = "tests_backend" - spec = importlib.util.find_spec(qt_module_name) - if spec is None: - raise FileNotFoundError("Could not find Qt backend tests") - qt_module = importlib.import_module(qt_module_name) - - sys.modules[alias_module_name] = qt_module - return True + if current_platform == "linux-qt": + spec = importlib.util.find_spec("tests_backend_qt") + if spec is None: + raise FileNotFoundError("Could not find Qt backend tests") + qt_module = importlib.import_module("tests_backend_qt") + + sys.modules["tests_backend"] = qt_module # Set a default return code for the app, so that a value is # available if the app exits for a reason other than the test From 69a660ddfd73289cca2f141369f9cc372fa021c1 Mon Sep 17 00:00:00 2001 From: "John X. Zhou" Date: Thu, 18 Sep 2025 19:11:49 -0500 Subject: [PATCH 08/37] fixup pyside6 imoprt hack --- qt/src/toga_qt/factory.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/qt/src/toga_qt/factory.py b/qt/src/toga_qt/factory.py index ad8d3814e0..32eeaad306 100644 --- a/qt/src/toga_qt/factory.py +++ b/qt/src/toga_qt/factory.py @@ -1,9 +1,25 @@ +# ruff: noqa: E402 + +import site +import sys + + +def import_pyside6(): + """Temporarily break isolation to import system PySide6.""" + system_site = site.getsitepackages() + print(system_site) + old_path = sys.path.copy() + sys.path.extend(system_site) + import PySide6 # noqa + + sys.path = old_path + + +import_pyside6() + from toga import NotImplementedWarning -from . import ( - dialogs, # noqa - initialization, # noqa: F401 -) +from . import dialogs from .app import App from .command import Command from .container import Container From 3028865577040e9d3c95f5af036cab857aee8bd2 Mon Sep 17 00:00:00 2001 From: "John X. Zhou" Date: Thu, 18 Sep 2025 19:15:04 -0500 Subject: [PATCH 09/37] fixup to get ready for testbed --- qt/pyproject.toml | 2 +- testbed/src/testbed/app.py | 14 -------------- testbed/tests/conftest.py | 14 ++++++++++++++ 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/qt/pyproject.toml b/qt/pyproject.toml index 549f458b80..4de826a909 100644 --- a/qt/pyproject.toml +++ b/qt/pyproject.toml @@ -7,7 +7,7 @@ requires = [ build-backend = "setuptools.build_meta" [project] -dynamic = ["version"] +dynamic = ["version", "dependencies"] name = "toga-qt" description = "An Qt (KDE) backend for the Toga widget toolkit." readme = "README.rst" diff --git a/testbed/src/testbed/app.py b/testbed/src/testbed/app.py index 39bc4688ed..a7bcd4dcde 100644 --- a/testbed/src/testbed/app.py +++ b/testbed/src/testbed/app.py @@ -1,10 +1,6 @@ -import importlib -import importlib.util -import sys from unittest.mock import Mock import toga -from toga.platform import current_platform class ExampleDoc(toga.Document): @@ -63,16 +59,6 @@ def task_factory(loop, coro, **kwargs): self.loop.set_task_factory(task_factory) - # Toga's Qt backend is packaged the same as GTK Linux; substitute - # the Qt backend tests if running on Qt. - if current_platform == "linux-qt": - spec = importlib.util.find_spec("tests_backend_qt") - if spec is None: - raise FileNotFoundError("Could not find Qt backend tests") - qt_module = importlib.import_module("tests_backend_qt") - - sys.modules["tests_backend"] = qt_module - # Set a default return code for the app, so that a value is # available if the app exits for a reason other than the test # suite exiting/crashing. diff --git a/testbed/tests/conftest.py b/testbed/tests/conftest.py index 5509bf52ce..85e32e4913 100644 --- a/testbed/tests/conftest.py +++ b/testbed/tests/conftest.py @@ -1,6 +1,9 @@ import asyncio import gc +import importlib +import importlib.util import inspect +import sys from dataclasses import dataclass from importlib import import_module @@ -9,6 +12,7 @@ import toga from toga.colors import GOLDENROD from toga.constants import WindowState +from toga.platform import current_platform from toga.style import Pack # Ideally, we'd register rewrites for "tests" and get all the submodules @@ -17,6 +21,16 @@ register_assert_rewrite("tests.widgets") register_assert_rewrite("tests_backend") +# Toga's Qt backend is packaged the same as GTK Linux; substitute +# the Qt backend tests if running on Qt. +if current_platform == "linux-qt": + spec = importlib.util.find_spec("tests_backend_qt") + if spec is None: + raise FileNotFoundError("Could not find Qt backend tests") + qt_module = importlib.import_module("tests_backend_qt") + + sys.modules["tests_backend"] = qt_module + # Use this for widgets or tests which are not supported on some platforms, # but could be supported in the future. From 39349ecc6a5178c7e35b526713df0d63b23318c1 Mon Sep 17 00:00:00 2001 From: John Date: Thu, 18 Sep 2025 19:37:10 -0500 Subject: [PATCH 10/37] Update button.py --- cocoa/src/toga_cocoa/widgets/button.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/cocoa/src/toga_cocoa/widgets/button.py b/cocoa/src/toga_cocoa/widgets/button.py index c3c642dc62..eadb8e2b92 100644 --- a/cocoa/src/toga_cocoa/widgets/button.py +++ b/cocoa/src/toga_cocoa/widgets/button.py @@ -95,6 +95,3 @@ def rehint(self): content_size = self.native.intrinsicContentSize() self.interface.intrinsic.width = at_least(content_size.width) self.interface.intrinsic.height = content_size.height - - def assert_taller_than(self, initial_height): - assert self.height > initial_height From d2d088663268dfa2ef0613abc624f2c1e9865199 Mon Sep 17 00:00:00 2001 From: John Date: Thu, 18 Sep 2025 19:38:33 -0500 Subject: [PATCH 11/37] Update button.py --- cocoa/tests_backend/widgets/button.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cocoa/tests_backend/widgets/button.py b/cocoa/tests_backend/widgets/button.py index e6d627ef39..1783824cda 100644 --- a/cocoa/tests_backend/widgets/button.py +++ b/cocoa/tests_backend/widgets/button.py @@ -56,3 +56,6 @@ def height(self): assert self.native.bezelStyle == NSBezelStyle.Rounded return super().height + + def assert_taller_than(self, initial_height): + assert self.height > initial_height From 9236d50f646c05507b4b0528aca7a67bbf449d93 Mon Sep 17 00:00:00 2001 From: John Date: Thu, 18 Sep 2025 19:55:27 -0500 Subject: [PATCH 12/37] rerun ios ci From 92552809bac75d31e67c6e7e69c231c15d6c9de5 Mon Sep 17 00:00:00 2001 From: John Date: Tue, 23 Sep 2025 17:36:28 -0500 Subject: [PATCH 13/37] Update core/src/toga/__init__.pyi --- core/src/toga/__init__.pyi | 1 - 1 file changed, 1 deletion(-) diff --git a/core/src/toga/__init__.pyi b/core/src/toga/__init__.pyi index 83dcc3e422..6da8a38637 100644 --- a/core/src/toga/__init__.pyi +++ b/core/src/toga/__init__.pyi @@ -22,7 +22,6 @@ from toga.documents import Document as Document from toga.documents import DocumentWindow as DocumentWindow from toga.fonts import Font as Font from toga.icons import Icon as Icon -from toga.icons import NativeIcon as NativeIcon from toga.images import Image as Image from toga.keys import Key as Key from toga.statusicons import MenuStatusIcon as MenuStatusIcon From d712512de17853b7af8bb71aae8b09bbddfb2e1b Mon Sep 17 00:00:00 2001 From: John Date: Fri, 26 Sep 2025 09:13:14 -0500 Subject: [PATCH 14/37] Cosmetic cleanups --- qt/src/toga_qt/app.py | 35 ++++++++---- qt/src/toga_qt/command.py | 16 +----- qt/src/toga_qt/dialogs.py | 4 +- qt/src/toga_qt/factory.py | 2 +- qt/src/toga_qt/icons.py | 8 +-- qt/src/toga_qt/keys.py | 8 +-- qt/src/toga_qt/libs/__init__.py | 6 +-- qt/src/toga_qt/libs/testing.py | 6 +++ qt/src/toga_qt/screens.py | 2 + qt/src/toga_qt/statusicons.py | 4 +- qt/src/toga_qt/widgets/activityindicator.py | 9 ++++ qt/src/toga_qt/widgets/base.py | 4 -- qt/src/toga_qt/widgets/button.py | 5 +- qt/src/toga_qt/widgets/label.py | 6 --- qt/src/toga_qt/window.py | 59 ++++++++------------- qt/tests_backend_qt/app.py | 23 ++++---- qt/tests_backend_qt/dialogs.py | 32 +++++------ qt/tests_backend_qt/fonts.py | 9 ++-- qt/tests_backend_qt/probe.py | 1 - qt/tests_backend_qt/screens.py | 2 +- qt/tests_backend_qt/widgets/base.py | 4 +- qt/tests_backend_qt/widgets/button.py | 6 +-- qt/tests_backend_qt/window.py | 17 +++--- 23 files changed, 129 insertions(+), 139 deletions(-) diff --git a/qt/src/toga_qt/app.py b/qt/src/toga_qt/app.py index 09cff3a642..627c0ac421 100644 --- a/qt/src/toga_qt/app.py +++ b/qt/src/toga_qt/app.py @@ -13,6 +13,15 @@ def operate_on_focus(method_name, interface, needwrite=False): + """ + Perform a menu item property onto the focused widget, similar to + SEL in Objective-C. This is used to implement the Edit, Copy, etc. + actions. + + :param: needwrite: Whether write access is required for the focus + widget. + """ + fw = QApplication.focusWidget() if not fw: return @@ -26,6 +35,12 @@ def operate_on_focus(method_name, interface, needwrite=False): def _create_about_dialog(app): + """ + Qt has an API, namely QMessageBox.about etc, to produce these + dialogs. However, these static APIs are blocking and modal, which + is unlike native apps on KDE where the About dialogs are non-modal. + """ + message = ( f'

' f"{app.interface.formal_name}

" @@ -94,16 +109,15 @@ def __init__(self, interface): ###################################################################### # Commands and menus - # Impl incomplete. See GitHub thread. ###################################################################### def create_standard_commands(self): - # This is sorta weird. On KDE, default bundled apps have these stuff - # and they automatically enable / disable based on if this functionality - # is available... there's not a satisfying way to implement that in Qt - # though... see https://stackoverflow.com/questions/2047456, so we omit - # the enabled detection for now. Most people just use Ctrl + Z etc. - # anyways... + # On KDE, default bundled apps have the following extra commands, + # and they automatically enable / disable based on if the associated + # functionality is available for the current focused widget. + # There's not a satisfying way to implement that in Qt though... + # I've referenced https://stackoverflow.com/questions/2047456, so + # we omit the enabled detection for now. self.interface.commands.add( Command( lambda interface: operate_on_focus("undo", interface), @@ -172,7 +186,6 @@ def set_icon(self, icon): self.interface.commands[Command.ABOUT].icon = icon self.interface.commands[Command.PREFERENCES].icon = icon - # Not implemented yet def set_main_window(self, window): self.interface.factory.not_implemented("App.set_main_window()") @@ -180,7 +193,6 @@ def set_main_window(self, window): # App resources ###################################################################### - # ScreenImpl not impl'd yet def get_screens(self): screens = QGuiApplication.screens() primary = QGuiApplication.primaryScreen() @@ -205,8 +217,9 @@ def beep(self): QApplication.beep() def show_about_dialog(self): - # Storing property to facilitate testing - # Not creating at start to ensure correct parent + # A reference to the about dialog is stored for facilitate testing. + # A new instance is created each time to ensure correct window + # membership. self._about_dialog = _create_about_dialog(self) self._about_dialog.show() diff --git a/qt/src/toga_qt/command.py b/qt/src/toga_qt/command.py index e02dc67ff9..33bc0aeffb 100644 --- a/qt/src/toga_qt/command.py +++ b/qt/src/toga_qt/command.py @@ -30,7 +30,6 @@ def standard(self, app, id): "icon": app.icon, } elif id == StandardCommand.EXIT: - # File > Quit?? return { "text": "Quit", "shortcut": Key.MOD_1 + "q", @@ -83,12 +82,7 @@ def standard(self, app, id): } # ---- Help menu ----------------------------------- elif id == StandardCommand.VISIT_HOMEPAGE: - return None # Code this info into the About menu. - # return { - # "text": "Visit homepage", - # "enabled": app.home_page is not None, - # "group": Group.HELP, - # } + return None # KDE apps have homepage link in about dialog elif id == StandardCommand.ABOUT: return { "text": f"About {app.formal_name}", @@ -105,7 +99,7 @@ def set_enabled(self, value): widget.setEnabled(enabled) def qt_click(self): - self.interface.action() # interface call + self.interface.action() def create_menu_item(self): item = QAction(self.interface.text) @@ -116,13 +110,7 @@ def create_menu_item(self): item.triggered.connect(self.qt_click) if self.interface.shortcut is not None: - # try: item.setShortcut(toga_to_qt_key(self.interface.shortcut)) - # except (???) as e: # pragma: no cover - # # Make this a non-fatal warning, because different backends may - # # accept different shortcuts. - # print(f"WARNING: invalid shortcut {self.interface.shortcut!r}:" - # f"{e}") item.setEnabled(self.interface.enabled) diff --git a/qt/src/toga_qt/dialogs.py b/qt/src/toga_qt/dialogs.py index af1f395f55..d5daa60157 100644 --- a/qt/src/toga_qt/dialogs.py +++ b/qt/src/toga_qt/dialogs.py @@ -11,8 +11,8 @@ def __init__(self, title, message, icon, buttons=QMessageBox.Ok): icon, buttons, ) - # Mote that AFAICT you have to pass the parent in at creation time. - # So I guess we'd have to do this.' + # Note: The parent of the dialog must be passed in at creation time. + # Therefore, the native must be initially None. self.native = None def show(self, parent, future): diff --git a/qt/src/toga_qt/factory.py b/qt/src/toga_qt/factory.py index 32eeaad306..f938fa3d79 100644 --- a/qt/src/toga_qt/factory.py +++ b/qt/src/toga_qt/factory.py @@ -63,5 +63,5 @@ def not_implemented(feature): NotImplementedWarning.warn("Qt", feature) -def __getattr__(name): # pragma: no cover +def __getattr__(name): raise NotImplementedError(f"Toga's Qt backend doesn't implement {name}") diff --git a/qt/src/toga_qt/icons.py b/qt/src/toga_qt/icons.py index 2c64ffd8d5..b438c0f035 100644 --- a/qt/src/toga_qt/icons.py +++ b/qt/src/toga_qt/icons.py @@ -16,9 +16,10 @@ def __init__(self, interface, path): self.interface = interface if path is None: - SIZES = [512, 256, 128, 72, 64, 32, 16] # same as GTK for now. - # Use the executable location to find the share folder; look for icons - # matching the app bundle in that location. + # Briefcase's Linux application packaging still yields sized icons; + # look for the highest size, since Qt icon sizing is handled by the + # theme, and from Toga's perspective, they're unsized. + SIZES = [512, 256, 128, 72, 64, 32, 16] hicolor = Path(sys.executable).parent.parent / "share/icons/hicolor" sizes = { size: hicolor / f"{size}x{size}/apps/{toga.App.app.app_id}.png" @@ -33,6 +34,7 @@ def __init__(self, interface, path): self.native = QIcon(str(path)) + # A lot of Qt's APIs simply results in null when anything is wrong. if self.native.isNull(): raise ValueError(f"Unable to load icon from {path}") diff --git a/qt/src/toga_qt/keys.py b/qt/src/toga_qt/keys.py index b56fb34235..44ed99b865 100644 --- a/qt/src/toga_qt/keys.py +++ b/qt/src/toga_qt/keys.py @@ -13,8 +13,10 @@ Key.SHIFT: Qt.ShiftModifier, } -# [sweating intensifies] -# TODO: Refs https://forum.qt.io/topic/162828/how-do-i-check-for-shift-tab +# Note: In Qt's key combos there's certain ones like Key_Copy or Key_BackTab. +# Empirical evidence shows that they exists to abstracts the shortcuts in a +# cross-platform way; they are not needed since the Qt backend is for Linux +# only. QT_KEYS = { Key.ESCAPE.value: Qt.Key_Escape, Key.BACK_QUOTE.value: Qt.Key_QuoteLeft, # Why quoteleft, are the Qt devs up to TeX? @@ -26,7 +28,7 @@ Key.CLOSE_BRACKET.value: Qt.Key_BracketRight, Key.BACKSLASH.value: Qt.Key_Backslash, Key.SEMICOLON.value: Qt.Key_Semicolon, - Key.QUOTE.value: Qt.Key_QuoteDbl, # why shifted form? + Key.QUOTE.value: Qt.Key_QuoteDbl, Key.COMMA.value: Qt.Key_Comma, Key.FULL_STOP.value: Qt.Key_Period, Key.SLASH.value: Qt.Key_Slash, diff --git a/qt/src/toga_qt/libs/__init__.py b/qt/src/toga_qt/libs/__init__.py index 33c00e7c1a..e6981f6263 100644 --- a/qt/src/toga_qt/libs/__init__.py +++ b/qt/src/toga_qt/libs/__init__.py @@ -1,3 +1,3 @@ -from .env import * # noqa -from .testing import * # noqa -from .utils import * # noqa +from .env import * # noqa: F401, F403 +from .testing import * # noqa: F401, F403 +from .utils import * # noqa: F401, F403 diff --git a/qt/src/toga_qt/libs/testing.py b/qt/src/toga_qt/libs/testing.py index 75f49a4be6..b5acd42b58 100644 --- a/qt/src/toga_qt/libs/testing.py +++ b/qt/src/toga_qt/libs/testing.py @@ -2,6 +2,12 @@ class AnyWithin: + """ + An alternative to pytest.approx to use in tests that supports + comparisons; used to work around the fact that Qt window + size does not round-trip exactly. + """ + def __init__(self, low, high): self.low = low self.high = high diff --git a/qt/src/toga_qt/screens.py b/qt/src/toga_qt/screens.py index baf852166b..b57a205d26 100644 --- a/qt/src/toga_qt/screens.py +++ b/qt/src/toga_qt/screens.py @@ -20,6 +20,8 @@ def __new__(cls, native): return instance def get_name(self): + # FIXME: What combinations of values are guaranteed to be + # unique? return "|".join( [ self.native.name(), diff --git a/qt/src/toga_qt/statusicons.py b/qt/src/toga_qt/statusicons.py index 50ad00ea30..d13b93fe94 100644 --- a/qt/src/toga_qt/statusicons.py +++ b/qt/src/toga_qt/statusicons.py @@ -1,6 +1,6 @@ import toga -# STUB IMPL +# Not implemented on Qt yet. class StatusIcon: @@ -12,7 +12,7 @@ def set_icon(self, icon): pass def create(self): - toga.NotImplementedWarning.warn("Web", "Status Icons") + toga.NotImplementedWarning.warn("Qt", "Status Icons") def remove(self): pass diff --git a/qt/src/toga_qt/widgets/activityindicator.py b/qt/src/toga_qt/widgets/activityindicator.py index d3bdfc8c75..4a27e9d461 100644 --- a/qt/src/toga_qt/widgets/activityindicator.py +++ b/qt/src/toga_qt/widgets/activityindicator.py @@ -6,6 +6,15 @@ from .base import Widget +####################################################################################### +# Implementation note: +# +# Qt does not provide a Widget for an Activity Indicator; however, it does +# provide a QML type; set up a pre-initialized QML file that can be embedded +# into a Qt Widgets Application to represent the spinner. +####################################################################################### + + class ActivityIndicator(Widget): def create(self): self.native = QQuickWidget() diff --git a/qt/src/toga_qt/widgets/base.py b/qt/src/toga_qt/widgets/base.py index 98b1fca45d..54d14aa2d8 100644 --- a/qt/src/toga_qt/widgets/base.py +++ b/qt/src/toga_qt/widgets/base.py @@ -73,10 +73,6 @@ def set_tab_index(self, tab_index): def set_bounds(self, x, y, width, height): self.native.setGeometry(x, y, width, height) - def set_text_align(self, alignment): - # Not implemented yet - pass - def set_hidden(self, hidden): if self.container is not None: self._apply_hidden(hidden) diff --git a/qt/src/toga_qt/widgets/button.py b/qt/src/toga_qt/widgets/button.py index a2e170878e..eaf209b1b2 100644 --- a/qt/src/toga_qt/widgets/button.py +++ b/qt/src/toga_qt/widgets/button.py @@ -30,7 +30,7 @@ def set_icon(self, icon): self.native.setIcon(icon._impl.native) else: self.native.setIcon(QIcon()) - # Somehow Qt copies into different memory address when I pass icon + # Qt does not round-trip the same instance of the icon back. self._icon = icon def rehint(self): @@ -38,4 +38,5 @@ def rehint(self): height = self.native.sizeHint().height() self.interface.intrinsic.width = at_least(width) - self.interface.intrinsic.height = height # height of a button is known + # Height of a button is known. + self.interface.intrinsic.height = height diff --git a/qt/src/toga_qt/widgets/label.py b/qt/src/toga_qt/widgets/label.py index ff9c3197b3..8c3e57ce54 100644 --- a/qt/src/toga_qt/widgets/label.py +++ b/qt/src/toga_qt/widgets/label.py @@ -10,12 +10,6 @@ class Label(Widget): def create(self): self.native = QLabel() - def set_color(self, value): - pass - - def set_font(self, font): - pass - def get_text(self): return self.native.text() diff --git a/qt/src/toga_qt/window.py b/qt/src/toga_qt/window.py index d5e11a205d..a3ed3f2e32 100644 --- a/qt/src/toga_qt/window.py +++ b/qt/src/toga_qt/window.py @@ -10,7 +10,7 @@ from .container import Container from .libs import ( - AnyWithin, # tests hackery... + AnyWithin, get_is_wayland, get_testing, ) @@ -38,7 +38,8 @@ def process_change(native, event): native.interface.on_show() impl = native.impl # Handle this later as the states etc may not have been fully realized. - # I have no idea why 100ms is needed here. + # Starting the next transition now will cause extra window events to be + # generated, and sometimes the window ends up in an incorrect state. impl._changeventid += 1 if get_is_wayland(): QTimer.singleShot( @@ -109,47 +110,44 @@ def __init__(self, interface, title, position, size): if position is not None: self.native.move(position[0], position[1]) - # This does not actually work on KDE! - # self._set_minimizable(self.interface.minimizable) + # Note: KDE's default theme does not respond to minimize button + # window hints, so minimizable cannot be implemented. self.native.resizeEvent = self.resizeEvent def qt_close_event(self, event): if not self.prog_close: + # Subtlety: If on_close approves the closing + # this handler doesn't get called again. Therefore + # the event is always rejected. event.ignore() if self.interface.closable: - # Subtlety: If on_close approves the closing - # this handler doesn't get called again self.interface.on_close() def create(self): self.native = wrap_container(self.container.native, self) - # def _set_minimizable(self, enabled): - # flags = self.native.windowFlags() - # if enabled: - # flags |= Qt.WindowMinimizeButtonHint - # else: - # flags &= ~Qt.WindowMinimizeButtonHint - # self.native.setWindowFlags(flags) - def hide(self): # https://forum.qt.io/topic/163064/delayed-window-state-read-after-hide-gives-wrong-results-even-in-x11/ - # Sorta unreliable window state when hidden here, pull our own logic. + # The window state when a window is hidden is unreliable; pull our own logic to cache. if self._hidden_window_state is None: self._hidden_window_state = self.get_window_state(in_progress_state=True) self._pending_state_transition = None self.native.hide() - # Ideally we'd love to be able to use showEvent but AFAICT - # it also gets triggered on deminimization and sometimes even - # TWICE so it's unreliable. Hack this around, no way to hide - # window through system in KDE AFAICT anyways. + # Ideally, showEvent and hideEvent should be used on Qt; however, + # due to some unknown subtleties to me, these events are unreliable + # and sometimes emits multiple times during window state changes; + # therefore, emit on_hide here, on_show when programmatically showing, + # and use the window state change events to handle show/hide from + # window states, since there isn't a way to hide windows by the user + # as far as I know of on KDE. self.interface.on_hide() def show(self): - # Do this bee-fore we show as the docs indicate it'd be applied on show - # and also to avoid brief flashing / failure to apply + # Restore cached state before we show as the docs indicate that window states + # set when a window is hidden will be applied on show, to avoid any brief flashing + # or failure to apply. if self._hidden_window_state is not None: self.set_window_state(self._hidden_window_state) self._hidden_window_state = None @@ -157,11 +155,6 @@ def show(self): self.interface.on_show() def close(self): - # OK, this is a bit of a stretch, since - # this could've been a user-induced close - # on_closed as well, however this flag - # is only used for qt_close_event and you - # can check out the subtlety there. self.prog_close = True self.native.close() @@ -227,16 +220,14 @@ def set_position(self, position): def set_app(self, app): # All windows instantiated belongs to your only QApplication - # but we need to set the icon + # and no need to explicitly set app, but the app icon needs to be + # applied onto the window. self.native.setWindowIcon(app.interface.icon._impl.native) def get_visible(self): return self.native.isVisible() # =============== WINDOW STATES ================ - # non-minimizable is not implemented as the minimize button - # still exists at least when using Breeze theme even if it - # is hinted away. def get_window_state(self, in_progress_state=False): # NOTE - MINIMIZED does not round-trip on Wayland if self._hidden_window_state: @@ -268,8 +259,6 @@ def set_window_state(self, state): self._pending_state_transition = state return - # print("SET WINDOW STATE") - # Exit app presentation mode if another window is in it if any( window.state == WindowState.PRESENTATION and window != self.interface @@ -304,7 +293,6 @@ def _apply_state(self, state): self.native.showMaximized() elif state == WindowState.MINIMIZED: - print("SHOW MIN") if not get_is_wayland(): self.native.showNormal() self.native.showMinimized() @@ -336,10 +324,8 @@ def _apply_state(self, state): QApplication.processEvents() - # ============== STUB ============= - def get_image_data(self): - pass + self.interface.factory.not_implemented("Window.get_image_data") def set_content(self, widget): self.container.content = widget @@ -375,4 +361,5 @@ def create_menus(self): submenu.addAction(cmd._impl.create_menu_item()) def create_toolbar(self): + # Not implemented pass diff --git a/qt/tests_backend_qt/app.py b/qt/tests_backend_qt/app.py index fdb596560c..0afe998680 100644 --- a/qt/tests_backend_qt/app.py +++ b/qt/tests_backend_qt/app.py @@ -1,7 +1,3 @@ -""" -Written with haste. Expect hundreds of errors. -""" - from pathlib import Path import pytest @@ -27,7 +23,6 @@ def __init__(self, app): self.native = self.app._impl.native self.impl = self.app._impl assert isinstance(QApplication.instance(), QApplication) - # and the clouds are moving on with every autumn... assert self.native.style().objectName() == "breeze" # KWin supports this but not mutter which is used in CI. if get_is_wayland(): @@ -79,7 +74,7 @@ def _activate_menu_item(self, path): item.trigger() def activate_menu_hide(self): - pytest.xfail("No hide in menu for KDE apps") + pytest.xfail("KDE apps do not include a Hide in the menu bar") def activate_menu_exit(self): self._activate_menu_item(["File", "Quit"]) @@ -91,7 +86,7 @@ async def close_about_dialog(self): self.impl._about_dialog.done(QDialog.DialogCode.Accepted) def activate_menu_visit_homepage(self): - raise pytest.xfail("Qt apps do not have Visit Homepage") + raise pytest.xfail("Qt apps do not have a Visit Homepage menu action") def assert_dialog_in_focus(self, dialog): active_window = QApplication.activeWindow() @@ -115,13 +110,13 @@ def assert_system_menus(self): self.assert_menu_item(["Help", "About Toga Testbed"]) def activate_menu_close_window(self): - pytest.xfail("Menu close is not typical of Qt") + pytest.xfail("KDE apps do not include a Close in the menu bar") def activate_menu_close_all_windows(self): - pytest.xfail("Menu close all windows is not typical of Qt") + pytest.xfail("KDE apps do not include a Close All in the menu bar") def activate_menu_minimize(self): - pytest.xfail("Menu Minimize is not typical of Qt") + pytest.xfail("KDE apps do not include a Minimize in the menu bar") def keystroke(self, combination): return qt_to_toga_key(toga_to_qt_key(combination)) @@ -136,13 +131,13 @@ def open_document_by_drag(self, document_path): pytest.skip("Not impld") def has_status_icon(self, status_icon): - pytest.skip("Not impld") + pytest.skip("Status Icons not yet implemented on Qt") def status_menu_items(self, status_icon): - pytest.skip("Not impld") + pytest.skip("Status Icons not yet implemented on Qt") def activate_status_icon_button(self, item_id): - pytest.skip("Not impld") + pytest.skip("Status Icons not yet implemented on Qt") def activate_status_menu_item(self, item_id, title): - pytest.skip("Not impld") + pytest.skip("Status Icons not yet implemented on Qt") diff --git a/qt/tests_backend_qt/dialogs.py b/qt/tests_backend_qt/dialogs.py index ff6c5ff7f0..f5b7806110 100644 --- a/qt/tests_backend_qt/dialogs.py +++ b/qt/tests_backend_qt/dialogs.py @@ -30,13 +30,13 @@ async def _close_dialog(): if close_handler: close_handler(dialog, qt_result) else: - # This is nessacary because without it the dialog would - # not display for some reason. - # Won't be an issue with public API if the dialog hasn't - # been shown successfully yet, - # no user could dismiss it and the call can't complete. + # On Qt, if a dialog is dismissed before it is fully + # realized, nothing will show or even flash. Add + # an explicit redraw with a delay to have Qt realize + # the dialog before closing it, so the appearance + # of the dialog may be verified. await self.redraw( - "Dialog Internal: Just before close", delay=0.1 + "Qt: Dialog display", delay=0.1 ) self._default_close_handler(dialog, qt_result) except Exception as e: @@ -54,30 +54,32 @@ def setup_info_dialog_result(self, dialog, pre_close_test_method=None): ) def setup_question_dialog_result(self, dialog, result): - pytest.skip("no impl") + pytest.skip("Qt backend only implements info dialog so far") def setup_confirm_dialog_result(self, dialog, result): - pytest.skip("no impl") + pytest.skip("Qt backend only implements info dialog so far") def setup_error_dialog_result(self, dialog): - pytest.skip("no impl") + pytest.skip("Qt backend only implements info dialog so far") def setup_stack_trace_dialog_result(self, dialog, result): - pytest.skip("no impl") + pytest.skip("Qt backend only implements info dialog so far") def setup_save_file_dialog_result(self, dialog, result): - pytest.skip("no impl") + pytest.skip("Qt backend only implements info dialog so far") def setup_open_file_dialog_result(self, dialog, result, multiple_select): - pytest.skip("no impl") + pytest.skip("Qt backend only implements info dialog so far") def setup_select_folder_dialog_result(self, dialog, result, multiple_select): - pytest.skip("no impl") + pytest.skip("Qt backend only implements info dialog so far") def is_modal_dialog(self, dialog): if dialog._impl.native is not None: return dialog._impl.native.isModal() else: - # Can't really get this tested... - # we need to create native when our parent window is known + # The native dialog is created at execution time + # to ensure a correct parent, so it is not feasible + # to test the modality of the dialog before first + # execution. return True diff --git a/qt/tests_backend_qt/fonts.py b/qt/tests_backend_qt/fonts.py index d3b1c23849..b8ac00378e 100644 --- a/qt/tests_backend_qt/fonts.py +++ b/qt/tests_backend_qt/fonts.py @@ -4,18 +4,17 @@ class FontMixin: - # Explicitly indicate that no font features are supported supports_custom_fonts = False supports_custom_variable_fonts = False def preinstalled_font(self): - pytest.skip("Qt backend no impl fonts") + pytest.skip("Qt backend doesn't implement fonts") def assert_font_family(self, expected): - pytest.skip("Qt backend no impl fonts") + pytest.skip("Qt backend doesn't implement fonts") def assert_font_size(self, expected): - pytest.skip("Qt backend no impl fonts") + pytest.skip("Qt backend doesn't implement fonts") def assert_font_options(self, weight=NORMAL, style=NORMAL, variant=NORMAL): - pytest.skip("Qt backend no impl fonts") + pytest.skip("Qt backend doesn't implement fonts") diff --git a/qt/tests_backend_qt/probe.py b/qt/tests_backend_qt/probe.py index ef915468ed..b78ad4a244 100644 --- a/qt/tests_backend_qt/probe.py +++ b/qt/tests_backend_qt/probe.py @@ -31,7 +31,6 @@ async def redraw(self, message=None, delay=0): for widget in QApplication.allWidgets(): widget.repaint() # this is immediate and will block - # Wait a second... (pun intended) if toga.App.app.run_slow: delay = max(1, delay) diff --git a/qt/tests_backend_qt/screens.py b/qt/tests_backend_qt/screens.py index 67c7b57e05..931b3018f5 100644 --- a/qt/tests_backend_qt/screens.py +++ b/qt/tests_backend_qt/screens.py @@ -15,6 +15,6 @@ def __init__(self, screen): def get_screenshot(self, format=TogaImage): if get_is_wayland(): - pytest.xfail("Cannot get image in Qt using conventional APIs of screen") + pytest.xfail("Cannot get image in Qt using APIs of screen in Wayland") else: return self.screen.as_image(format=format) diff --git a/qt/tests_backend_qt/widgets/base.py b/qt/tests_backend_qt/widgets/base.py index 8fc3be56d3..3e4379a1b3 100644 --- a/qt/tests_backend_qt/widgets/base.py +++ b/qt/tests_backend_qt/widgets/base.py @@ -28,7 +28,7 @@ def assert_not_contained(self): assert self.native.parentWidget() is None def assert_text_align(self, expected): - pytest.xfail("fonts not impld on qt") + pytest.xfail("Font not implemented on qt") @property def enabled(self): @@ -62,7 +62,7 @@ async def press(self): self.native.click() def mouse_event(self, x=0, y=0, **kwargs): - pytest.fail("Please implement the mouse event probe") + pytest.skip("Mouse event probe not yet implemented on Qt") @property def is_hidden(self): diff --git a/qt/tests_backend_qt/widgets/button.py b/qt/tests_backend_qt/widgets/button.py index 0f63126395..ec68a1dfcf 100644 --- a/qt/tests_backend_qt/widgets/button.py +++ b/qt/tests_backend_qt/widgets/button.py @@ -17,8 +17,6 @@ def assert_no_icon(self): assert self.native.icon().isNull() def assert_icon_size(self): - # No assertion needed here. It is handled by theme. - # [better not write anything more here just in case of - # anything about size accidentally being an inappropriate - # joke] + # Icons sizes in Qt are handled by the system theme; + # no assertion is needed here. pass diff --git a/qt/tests_backend_qt/window.py b/qt/tests_backend_qt/window.py index 1e4fdbc617..4b56d4c027 100644 --- a/qt/tests_backend_qt/window.py +++ b/qt/tests_backend_qt/window.py @@ -11,12 +11,13 @@ class WindowProbe(BaseProbe): # There *is* a close button hint but it doesn't seem to work - # under KDE so we take similar handling as winforms here. + # under KDE so we take similar handling as winforms here: disable + # the action of the close button. supports_closable = False supports_as_image = False # not impld yet supports_focus = True supports_minimizable = ( - False # cannot be impld on Qt, at least you hint it but it still show on KDE + False # cannot be impl'd on Qt, the minimize button will show even if hinted away ) supports_move_while_hidden = False supports_unminimize = True @@ -98,22 +99,18 @@ def minimize(self): def unminimize(self): self.native.showNormal() - # @property - # def is_minimizable(self): - # return bool(self.native.windowFlags() & Qt.WindowMinimizeButtonHint) - @property def instantaneous_state(self): return self.window._impl.get_window_state(in_progress_state=False) def has_toolbar(self): - raise pytest.skip("toolbar no impl") + raise pytest.skip("Toolbar is not implemented on Qt yet") def assert_is_toolbar_separator(self, index, section=False): - raise pytest.skip("toolbar no impl") + raise pytest.skip("Toolbar is not implemented on Qt yet") def assert_toolbar_item(self, index, label, tooltip, has_icon, enabled): - raise pytest.skip("toolbar no impl") + raise pytest.skip("Toolbar is not implemented on Qt yet") def press_toolbar_button(self, index): - raise pytest.skip("toolbar no impl") + raise pytest.skip("Toolbar is not implemented on Qt yet") From 7bbecbf589030d958935a5dbdcd448b3b0f39dc3 Mon Sep 17 00:00:00 2001 From: John Date: Fri, 26 Sep 2025 09:13:52 -0500 Subject: [PATCH 15/37] Update qt/src/toga_qt/libs/testing.py --- qt/src/toga_qt/libs/testing.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/qt/src/toga_qt/libs/testing.py b/qt/src/toga_qt/libs/testing.py index b5acd42b58..ba32c2f988 100644 --- a/qt/src/toga_qt/libs/testing.py +++ b/qt/src/toga_qt/libs/testing.py @@ -8,6 +8,12 @@ class AnyWithin: size does not round-trip exactly. """ + """ + An alternative to pytest.approx to use in tests that supports + comparisons; used to work around the fact that Qt window + size does not round-trip exactly. + """ + def __init__(self, low, high): self.low = low self.high = high From 027b404c90fe9974a6d430f300a2086e29d675a6 Mon Sep 17 00:00:00 2001 From: John Date: Fri, 26 Sep 2025 09:14:01 -0500 Subject: [PATCH 16/37] Update qt/src/toga_qt/widgets/activityindicator.py --- qt/src/toga_qt/widgets/activityindicator.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/qt/src/toga_qt/widgets/activityindicator.py b/qt/src/toga_qt/widgets/activityindicator.py index 4a27e9d461..71e18e7f31 100644 --- a/qt/src/toga_qt/widgets/activityindicator.py +++ b/qt/src/toga_qt/widgets/activityindicator.py @@ -15,6 +15,15 @@ ####################################################################################### +####################################################################################### +# Implementation note: +# +# Qt does not provide a Widget for an Activity Indicator; however, it does +# provide a QML type; set up a pre-initialized QML file that can be embedded +# into a Qt Widgets Application to represent the spinner. +####################################################################################### + + class ActivityIndicator(Widget): def create(self): self.native = QQuickWidget() From 4a0695b632e57390003a2543dcc57a95a52c78f4 Mon Sep 17 00:00:00 2001 From: John Date: Fri, 26 Sep 2025 09:14:49 -0500 Subject: [PATCH 17/37] Update qt/src/toga_qt/libs/testing.py --- qt/src/toga_qt/libs/testing.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/qt/src/toga_qt/libs/testing.py b/qt/src/toga_qt/libs/testing.py index ba32c2f988..b5acd42b58 100644 --- a/qt/src/toga_qt/libs/testing.py +++ b/qt/src/toga_qt/libs/testing.py @@ -8,12 +8,6 @@ class AnyWithin: size does not round-trip exactly. """ - """ - An alternative to pytest.approx to use in tests that supports - comparisons; used to work around the fact that Qt window - size does not round-trip exactly. - """ - def __init__(self, low, high): self.low = low self.high = high From f7098187437ec1646f3636f28ac56b21978229f8 Mon Sep 17 00:00:00 2001 From: John Date: Fri, 26 Sep 2025 09:24:38 -0500 Subject: [PATCH 18/37] Update qt/src/toga_qt/widgets/activityindicator.py --- qt/src/toga_qt/widgets/activityindicator.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/qt/src/toga_qt/widgets/activityindicator.py b/qt/src/toga_qt/widgets/activityindicator.py index 71e18e7f31..4a27e9d461 100644 --- a/qt/src/toga_qt/widgets/activityindicator.py +++ b/qt/src/toga_qt/widgets/activityindicator.py @@ -15,15 +15,6 @@ ####################################################################################### -####################################################################################### -# Implementation note: -# -# Qt does not provide a Widget for an Activity Indicator; however, it does -# provide a QML type; set up a pre-initialized QML file that can be embedded -# into a Qt Widgets Application to represent the spinner. -####################################################################################### - - class ActivityIndicator(Widget): def create(self): self.native = QQuickWidget() From 03a21f70615db549da3f457f4e415432a5baff6d Mon Sep 17 00:00:00 2001 From: John Date: Fri, 26 Sep 2025 09:26:41 -0500 Subject: [PATCH 19/37] Update qt/src/toga_qt/screens.py --- qt/src/toga_qt/screens.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/qt/src/toga_qt/screens.py b/qt/src/toga_qt/screens.py index b57a205d26..887d6b6f1d 100644 --- a/qt/src/toga_qt/screens.py +++ b/qt/src/toga_qt/screens.py @@ -20,6 +20,8 @@ def __new__(cls, native): return instance def get_name(self): + # FIXME: What combinations of values are guaranteed to be + # unique? # FIXME: What combinations of values are guaranteed to be # unique? return "|".join( From e8f15a38856c4803d09188a3b796eef6b80cf7c9 Mon Sep 17 00:00:00 2001 From: John Zhou Date: Fri, 26 Sep 2025 11:29:15 -0500 Subject: [PATCH 20/37] Formatting fixes --- qt/src/toga_qt/app.py | 2 +- qt/src/toga_qt/widgets/activityindicator.py | 1 - qt/src/toga_qt/window.py | 8 +++++--- qt/tests_backend_qt/dialogs.py | 4 +--- qt/tests_backend_qt/window.py | 5 ++--- 5 files changed, 9 insertions(+), 11 deletions(-) diff --git a/qt/src/toga_qt/app.py b/qt/src/toga_qt/app.py index 627c0ac421..c07aeee143 100644 --- a/qt/src/toga_qt/app.py +++ b/qt/src/toga_qt/app.py @@ -17,7 +17,7 @@ def operate_on_focus(method_name, interface, needwrite=False): Perform a menu item property onto the focused widget, similar to SEL in Objective-C. This is used to implement the Edit, Copy, etc. actions. - + :param: needwrite: Whether write access is required for the focus widget. """ diff --git a/qt/src/toga_qt/widgets/activityindicator.py b/qt/src/toga_qt/widgets/activityindicator.py index 4a27e9d461..bcd9f4d457 100644 --- a/qt/src/toga_qt/widgets/activityindicator.py +++ b/qt/src/toga_qt/widgets/activityindicator.py @@ -5,7 +5,6 @@ from .base import Widget - ####################################################################################### # Implementation note: # diff --git a/qt/src/toga_qt/window.py b/qt/src/toga_qt/window.py index a3ed3f2e32..f739c10e41 100644 --- a/qt/src/toga_qt/window.py +++ b/qt/src/toga_qt/window.py @@ -129,7 +129,9 @@ def create(self): def hide(self): # https://forum.qt.io/topic/163064/delayed-window-state-read-after-hide-gives-wrong-results-even-in-x11/ - # The window state when a window is hidden is unreliable; pull our own logic to cache. + # The window state when a window is hidden is unreliable in + # regards to preserving normal vs. maximized state, so caching + # is done for window states when the window is hidden. if self._hidden_window_state is None: self._hidden_window_state = self.get_window_state(in_progress_state=True) self._pending_state_transition = None @@ -146,8 +148,8 @@ def hide(self): def show(self): # Restore cached state before we show as the docs indicate that window states - # set when a window is hidden will be applied on show, to avoid any brief flashing - # or failure to apply. + # set when a window is hidden will be applied on show, to avoid any brief + # flashing or failure to apply. if self._hidden_window_state is not None: self.set_window_state(self._hidden_window_state) self._hidden_window_state = None diff --git a/qt/tests_backend_qt/dialogs.py b/qt/tests_backend_qt/dialogs.py index f5b7806110..cc1fc3ccfd 100644 --- a/qt/tests_backend_qt/dialogs.py +++ b/qt/tests_backend_qt/dialogs.py @@ -35,9 +35,7 @@ async def _close_dialog(): # an explicit redraw with a delay to have Qt realize # the dialog before closing it, so the appearance # of the dialog may be verified. - await self.redraw( - "Qt: Dialog display", delay=0.1 - ) + await self.redraw("Qt: Dialog display", delay=0.1) self._default_close_handler(dialog, qt_result) except Exception as e: future.set_exception(e) diff --git a/qt/tests_backend_qt/window.py b/qt/tests_backend_qt/window.py index 4b56d4c027..eeafc576df 100644 --- a/qt/tests_backend_qt/window.py +++ b/qt/tests_backend_qt/window.py @@ -16,9 +16,8 @@ class WindowProbe(BaseProbe): supports_closable = False supports_as_image = False # not impld yet supports_focus = True - supports_minimizable = ( - False # cannot be impl'd on Qt, the minimize button will show even if hinted away - ) + # Cannot be implemented on Qt, the minimize button will show even if hinted away + supports_minimizable = False supports_move_while_hidden = False supports_unminimize = True supports_minimize = True From 81a964ed7ca09637a10b07846b6ad99e18aab725 Mon Sep 17 00:00:00 2001 From: "John X. Zhou" Date: Fri, 26 Sep 2025 11:54:38 -0500 Subject: [PATCH 21/37] Remove nativeicon, hack action instead --- core/src/toga/command.py | 8 ++---- core/src/toga/icons.py | 12 +------- qt/src/toga_qt/app.py | 51 +++++++++++++++++++--------------- qt/src/toga_qt/command.py | 13 +++++---- qt/src/toga_qt/widgets/base.py | 3 ++ 5 files changed, 42 insertions(+), 45 deletions(-) diff --git a/core/src/toga/command.py b/core/src/toga/command.py index 5efb486387..dd4638d30a 100644 --- a/core/src/toga/command.py +++ b/core/src/toga/command.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Protocol from toga.handlers import simple_handler, wrapped_handler -from toga.icons import Icon, NativeIcon +from toga.icons import Icon from toga.keys import Key from toga.platform import get_platform_factory @@ -343,11 +343,7 @@ def icon(self) -> Icon | None: @icon.setter def icon(self, icon_or_name: IconContentT | None) -> None: - if ( - isinstance(icon_or_name, Icon) - or isinstance(icon_or_name, NativeIcon) - or icon_or_name is None - ): + if isinstance(icon_or_name, Icon) or icon_or_name is None: self._icon = icon_or_name else: self._icon = Icon(icon_or_name) diff --git a/core/src/toga/icons.py b/core/src/toga/icons.py index b8b7e78b3e..3a9bf5434d 100644 --- a/core/src/toga/icons.py +++ b/core/src/toga/icons.py @@ -10,7 +10,7 @@ if TYPE_CHECKING: from typing import TypeAlias - IconContentT: TypeAlias = str | Path | toga.Icon | toga.NativeIcon + IconContentT: TypeAlias = str | Path | toga.Icon class cachedicon: @@ -32,16 +32,6 @@ def __get__(self, obj: object, owner: type[Icon]) -> Icon: return value -class NativeIcon: - """ - For internal use for Qt backend only - """ - - def __init__(self, native): # pragma: no cover - self.factory = get_platform_factory() - self._impl = self.factory.NativeIcon(native) - - # A sentinel value that is type compatible with the `path` argument, # but can be used to uniquely identify a request for an application icon _APP_ICON = "" diff --git a/qt/src/toga_qt/app.py b/qt/src/toga_qt/app.py index c07aeee143..47037ed4bf 100644 --- a/qt/src/toga_qt/app.py +++ b/qt/src/toga_qt/app.py @@ -1,18 +1,18 @@ import asyncio from PySide6.QtCore import QObject, QSize, Qt, QTimer, Signal -from PySide6.QtGui import QCursor, QGuiApplication, QIcon +from PySide6.QtGui import QCursor, QGuiApplication from PySide6.QtWidgets import QApplication, QMessageBox from qasync import QEventLoop import toga -from toga import NativeIcon from toga.command import Command, Group +from toga.handlers import NativeHandler from .screens import Screen as ScreenImpl -def operate_on_focus(method_name, interface, needwrite=False): +class EditOperation: """ Perform a menu item property onto the focused widget, similar to SEL in Objective-C. This is used to implement the Edit, Copy, etc. @@ -22,16 +22,25 @@ def operate_on_focus(method_name, interface, needwrite=False): widget. """ - fw = QApplication.focusWidget() - if not fw: - return - if needwrite: - fnwrite = getattr(fw, "isReadOnly", None) - if callable(fnwrite) and fnwrite(): + def __init__(self, method_name, needwrite=False): + self.method_name = method_name + self.needwrite = needwrite + + def __call__(self, interface): + fw = QApplication.focusWidget() + if not fw: return - fn = getattr(fw, method_name, None) - if callable(fn): - fn() + if self.needwrite: + fnwrite = getattr(fw, "isReadOnly", None) + if callable(fnwrite) and fnwrite(): + return + fn = getattr(fw, self.method_name, None) + if callable(fn): + fn() + + @property + def icon_name(self): + return "edit-" + self.method_name def _create_about_dialog(app): @@ -118,49 +127,47 @@ def create_standard_commands(self): # There's not a satisfying way to implement that in Qt though... # I've referenced https://stackoverflow.com/questions/2047456, so # we omit the enabled detection for now. + + # NativeHandler is (ab)used here to ensure that the function stays + # of type EditOperation, to provide the appropriate icon. self.interface.commands.add( Command( - lambda interface: operate_on_focus("undo", interface), + NativeHandler(EditOperation("undo")), "Undo", shortcut=toga.Key.MOD_1 + "z", group=Group.EDIT, order=10, - icon=NativeIcon(QIcon.fromTheme("edit-undo")), ), Command( - lambda interface: operate_on_focus("redo", interface), + NativeHandler(EditOperation("redo")), "Redo", shortcut=toga.Key.SHIFT + toga.Key.MOD_1 + "z", group=Group.EDIT, order=20, - icon=NativeIcon(QIcon.fromTheme("edit-redo")), ), Command( - lambda interface: operate_on_focus("cut", interface, True), + NativeHandler(EditOperation("cut", True)), "Cut", shortcut=toga.Key.MOD_1 + "x", group=Group.EDIT, section=10, order=10, - icon=NativeIcon(QIcon.fromTheme("edit-cut")), ), Command( - lambda interface: operate_on_focus("copy", interface), + NativeHandler(EditOperation("copy")), "Copy", shortcut=toga.Key.MOD_1 + "c", group=Group.EDIT, section=10, order=20, - icon=NativeIcon(QIcon.fromTheme("edit-copy")), ), Command( - lambda interface: operate_on_focus("paste", interface, True), + NativeHandler(EditOperation("paste", True)), "Paste", shortcut=toga.Key.MOD_1 + "v", group=Group.EDIT, section=10, order=30, - icon=NativeIcon(QIcon.fromTheme("edit-paste")), ), ) diff --git a/qt/src/toga_qt/command.py b/qt/src/toga_qt/command.py index 33bc0aeffb..cbf55454b3 100644 --- a/qt/src/toga_qt/command.py +++ b/qt/src/toga_qt/command.py @@ -2,7 +2,7 @@ from PySide6.QtGui import QAction, QIcon -from toga import Command as StandardCommand, Group, Key, NativeIcon +from toga import Command as StandardCommand, Group, Key from .keys import toga_to_qt_key @@ -35,7 +35,6 @@ def standard(self, app, id): "shortcut": Key.MOD_1 + "q", "group": Group.FILE, "section": sys.maxsize, - "icon": NativeIcon(QIcon.fromTheme("application-exit")), } # ---- File menu ----------------------------------- @@ -104,6 +103,12 @@ def qt_click(self): def create_menu_item(self): item = QAction(self.interface.text) + if hasattr(self.interface.action, "icon_name"): + item.setIcon(QIcon.fromTheme(self.interface.action.icon_name)) + + if self.interface.text == "Quit": # Apply the quit icon for application exit. + item.setIcon(QIcon.fromTheme("application-exit")) + if self.interface.icon: item.setIcon(self.interface.icon._impl.native) @@ -117,7 +122,3 @@ def create_menu_item(self): self.native.append(item) return item - - def set_icon(self): - for item in self.native: - item.setIcon(self.interface.icon._impl.native) diff --git a/qt/src/toga_qt/widgets/base.py b/qt/src/toga_qt/widgets/base.py index 54d14aa2d8..940fe9a447 100644 --- a/qt/src/toga_qt/widgets/base.py +++ b/qt/src/toga_qt/widgets/base.py @@ -81,6 +81,9 @@ def set_hidden(self, hidden): def _apply_hidden(self, hidden): self.native.setHidden(hidden) + def set_text_align(self, alignment): + pass # If appropriate, a widget subclass will implement this. + def set_color(self, color): # Not implemented yet pass From bece50bfa7a585be8561880bc345ac4f0510df88 Mon Sep 17 00:00:00 2001 From: "John X. Zhou" Date: Fri, 26 Sep 2025 12:48:36 -0500 Subject: [PATCH 22/37] Platform changes --- .github/workflows/ci.yml | 4 ++-- core/src/toga/platform.py | 7 ------- testbed/tests/conftest.py | 5 +++-- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34336ba994..a7fafaf346 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -287,7 +287,7 @@ jobs: echo "Start window manager..." DISPLAY=:99 blackbox & sleep 1 - briefcase-run-prefix: 'DISPLAY=:99' + briefcase-run-prefix: 'DISPLAY=:99 TOGA_BACKEND=toga_gtk' setup-python: false # Use the system Python packages app-user-data-path: "$HOME/.local/share/testbed" @@ -314,7 +314,7 @@ jobs: DISPLAY=:99 MUTTER_DEBUG_DUMMY_MODE_SPECS=2048x1536 \ mutter --nested --wayland --no-x11 --wayland-display toga & sleep 1 - briefcase-run-prefix: "WAYLAND_DISPLAY=toga" + briefcase-run-prefix: "WAYLAND_DISPLAY=toga TOGA_BACKEND=toga_gtk" setup-python: false # Use the system Python packages app-user-data-path: "$HOME/.local/share/testbed" diff --git a/core/src/toga/platform.py b/core/src/toga/platform.py index c21688d9a9..45fcff2a66 100644 --- a/core/src/toga/platform.py +++ b/core/src/toga/platform.py @@ -29,13 +29,6 @@ def get_current_platform() -> str | None: return "android" elif sys.platform.startswith("freebsd"): return "freeBSD" - # No-covering since it's hard to fake environment variables in core - # tests - elif ( - "kde" in os.environ.get("XDG_CURRENT_DESKTOP", "").lower() - or os.environ.get("TOGA_QT", "") == "1" - ): # pragma: no cover - return "linux-qt" else: return _TOGA_PLATFORMS.get(sys.platform) diff --git a/testbed/tests/conftest.py b/testbed/tests/conftest.py index 85e32e4913..1fc5d7488b 100644 --- a/testbed/tests/conftest.py +++ b/testbed/tests/conftest.py @@ -12,7 +12,7 @@ import toga from toga.colors import GOLDENROD from toga.constants import WindowState -from toga.platform import current_platform +from toga.platform import get_platform_factory from toga.style import Pack # Ideally, we'd register rewrites for "tests" and get all the submodules @@ -20,10 +20,11 @@ register_assert_rewrite("tests.assertions") register_assert_rewrite("tests.widgets") register_assert_rewrite("tests_backend") +register_assert_rewrite("tests_backend_qt") # Toga's Qt backend is packaged the same as GTK Linux; substitute # the Qt backend tests if running on Qt. -if current_platform == "linux-qt": +if get_platform_factory().__name__ == "toga_qt.factory": spec = importlib.util.find_spec("tests_backend_qt") if spec is None: raise FileNotFoundError("Could not find Qt backend tests") From 5760f1f549ea39b23e6fd4285c8aa8f44bc3e267 Mon Sep 17 00:00:00 2001 From: "John X. Zhou" Date: Sun, 28 Sep 2025 11:30:16 -0500 Subject: [PATCH 23/37] implement colors, a small window fixup --- qt/src/toga_qt/colors.py | 14 ++++++++++++++ qt/src/toga_qt/widgets/base.py | 22 ++++++++++++++++++---- qt/src/toga_qt/widgets/button.py | 10 ++++++++++ qt/src/toga_qt/window.py | 17 +++++++++++------ qt/tests_backend_qt/widgets/base.py | 5 +++-- qt/tests_backend_qt/widgets/button.py | 6 ++++++ 6 files changed, 62 insertions(+), 12 deletions(-) create mode 100644 qt/src/toga_qt/colors.py diff --git a/qt/src/toga_qt/colors.py b/qt/src/toga_qt/colors.py new file mode 100644 index 0000000000..fba34041dd --- /dev/null +++ b/qt/src/toga_qt/colors.py @@ -0,0 +1,14 @@ +from PySide6.QtGui import QColor +from travertino.colors import rgb + + +def native_color(c): + if c == "transparent": + return QColor(0, 0, 0, 0) + return QColor(c.rgba.r, c.rgba.g, c.rgba.b, c.rgba.a * 255) + + +def toga_color(c): + if c.alpha() == 0 and c.red() == 0 and c.green() == 0 and c.blue() == 0: + return "transparent" + return rgb(c.red(), c.green(), c.blue(), c.alpha() / 255) diff --git a/qt/src/toga_qt/widgets/base.py b/qt/src/toga_qt/widgets/base.py index 940fe9a447..ee2171af18 100644 --- a/qt/src/toga_qt/widgets/base.py +++ b/qt/src/toga_qt/widgets/base.py @@ -3,6 +3,8 @@ from PySide6.QtCore import Qt from travertino.size import at_least +from ..colors import native_color, toga_color + class Widget: def __init__(self, interface): @@ -12,6 +14,12 @@ def __init__(self, interface): self.create() self.native.hide() self._hidden = True + self._default_background_color = toga_color( + self.native.palette().color(self.native.backgroundRole()) + ) + self._default_foreground_color = toga_color( + self.native.palette().color(self.native.foregroundRole()) + ) @property def container(self): @@ -85,12 +93,18 @@ def set_text_align(self, alignment): pass # If appropriate, a widget subclass will implement this. def set_color(self, color): - # Not implemented yet - pass + if color is None: + color = self._default_foreground_color + palette = self.native.palette() + palette.setColor(self.native.foregroundRole(), native_color(color)) + self.native.setPalette(palette) def set_background_color(self, color): - # Not implemented yet - pass + if color is None: + color = self._default_background_color + palette = self.native.palette() + palette.setColor(self.native.backgroundRole(), native_color(color)) + self.native.setPalette(palette) def set_font(self, font): # Not implemented yet diff --git a/qt/src/toga_qt/widgets/button.py b/qt/src/toga_qt/widgets/button.py index eaf209b1b2..ceead7aff8 100644 --- a/qt/src/toga_qt/widgets/button.py +++ b/qt/src/toga_qt/widgets/button.py @@ -40,3 +40,13 @@ def rehint(self): self.interface.intrinsic.width = at_least(width) # Height of a button is known. self.interface.intrinsic.height = height + + def set_color(self, color): + if color == "transparent": + color = None + super().set_color(color) + + def set_background_color(self, color): + if color == "transparent": + color = None + super().set_background_color(color) diff --git a/qt/src/toga_qt/window.py b/qt/src/toga_qt/window.py index f739c10e41..242a3925ad 100644 --- a/qt/src/toga_qt/window.py +++ b/qt/src/toga_qt/window.py @@ -36,12 +36,12 @@ def process_change(native, event): native.interface.on_hide() elif old & Qt.WindowMinimized and not new & Qt.WindowMinimized: native.interface.on_show() - impl = native.impl - # Handle this later as the states etc may not have been fully realized. - # Starting the next transition now will cause extra window events to be - # generated, and sometimes the window ends up in an incorrect state. - impl._changeventid += 1 if get_is_wayland(): + impl = native.impl + impl._changeventid += 1 + # Handle this later as the states etc may not have been fully realized. + # Starting the next transition now will cause extra window events to be + # generated, and sometimes the window ends up in an incorrect state. QTimer.singleShot( 100, partial(_handle_statechange, impl, impl._changeventid) ) @@ -231,7 +231,7 @@ def get_visible(self): # =============== WINDOW STATES ================ def get_window_state(self, in_progress_state=False): - # NOTE - MINIMIZED does not round-trip on Wayland + # print("GET STATE") if self._hidden_window_state: return self._hidden_window_state if in_progress_state and self._pending_state_transition: @@ -251,6 +251,11 @@ def get_window_state(self, in_progress_state=False): return WindowState.NORMAL def set_window_state(self, state): + # NOTE - MINIMIZED does not round-trip on Wayland + # and will cause infinite recursion. Don't support it + if get_is_wayland() and state == WindowState.MINIMIZED: + return + if ( self._hidden_window_state ): # skip all the logic and simply do this on next show if currently hidden diff --git a/qt/tests_backend_qt/widgets/base.py b/qt/tests_backend_qt/widgets/base.py index 3e4379a1b3..2716c74836 100644 --- a/qt/tests_backend_qt/widgets/base.py +++ b/qt/tests_backend_qt/widgets/base.py @@ -1,4 +1,5 @@ import pytest +from toga_qt.colors import toga_color from ..fonts import FontMixin from ..probe import BaseProbe @@ -36,11 +37,11 @@ def enabled(self): @property def color(self): - pytest.skip("Colors not implemented on Qt") + return toga_color(self.native.palette().color(self.native.foregroundRole())) @property def background_color(self): - pytest.skip("Colors not implemented on Qt") + return toga_color(self.native.palette().color(self.native.backgroundRole())) @property def hidden(self): diff --git a/qt/tests_backend_qt/widgets/button.py b/qt/tests_backend_qt/widgets/button.py index ec68a1dfcf..675d811edd 100644 --- a/qt/tests_backend_qt/widgets/button.py +++ b/qt/tests_backend_qt/widgets/button.py @@ -20,3 +20,9 @@ def assert_icon_size(self): # Icons sizes in Qt are handled by the system theme; # no assertion is needed here. pass + + def assert_taller_than(self, initial_height): + # Icons sizes in Qt are handled by the system theme; + # no assertion is needed here, as whether the icon is + # smaller or larger than text height does not matter. + pass From 7b11df2e67593a1447c4721357357da93f2dc403 Mon Sep 17 00:00:00 2001 From: "John X. Zhou" Date: Sun, 28 Sep 2025 12:16:25 -0500 Subject: [PATCH 24/37] multi-app structure setup --- qt/pyproject.toml | 2 +- qt/{tests_backend_qt => tests_backend}/app.py | 0 .../dialogs.py | 0 .../fonts.py | 0 .../icons.py | 0 .../images.py | 0 .../probe.py | 0 .../screens.py | 0 .../widgets/__init__.py | 0 .../widgets/activityindicator.py | 0 .../widgets/base.py | 0 .../widgets/box.py | 0 .../widgets/button.py | 0 .../widgets/label.py | 0 .../widgets/properties.py | 0 .../widgets/textinput.py | 0 .../window.py | 0 testbed/pyproject.toml | 40 ++++++++++++++++++- testbed/src/testbed/__main__.py | 6 ++- testbed/src/testbed/app.py | 1 - testbed/src/testbed_qt | 1 + testbed/tests/conftest.py | 15 ------- 22 files changed, 45 insertions(+), 20 deletions(-) rename qt/{tests_backend_qt => tests_backend}/app.py (100%) rename qt/{tests_backend_qt => tests_backend}/dialogs.py (100%) rename qt/{tests_backend_qt => tests_backend}/fonts.py (100%) rename qt/{tests_backend_qt => tests_backend}/icons.py (100%) rename qt/{tests_backend_qt => tests_backend}/images.py (100%) rename qt/{tests_backend_qt => tests_backend}/probe.py (100%) rename qt/{tests_backend_qt => tests_backend}/screens.py (100%) rename qt/{tests_backend_qt => tests_backend}/widgets/__init__.py (100%) rename qt/{tests_backend_qt => tests_backend}/widgets/activityindicator.py (100%) rename qt/{tests_backend_qt => tests_backend}/widgets/base.py (100%) rename qt/{tests_backend_qt => tests_backend}/widgets/box.py (100%) rename qt/{tests_backend_qt => tests_backend}/widgets/button.py (100%) rename qt/{tests_backend_qt => tests_backend}/widgets/label.py (100%) rename qt/{tests_backend_qt => tests_backend}/widgets/properties.py (100%) rename qt/{tests_backend_qt => tests_backend}/widgets/textinput.py (100%) rename qt/{tests_backend_qt => tests_backend}/window.py (100%) create mode 120000 testbed/src/testbed_qt diff --git a/qt/pyproject.toml b/qt/pyproject.toml index 4de826a909..9badef2d59 100644 --- a/qt/pyproject.toml +++ b/qt/pyproject.toml @@ -49,7 +49,7 @@ classifiers = [ [project.entry-points."toga.backends"] -linux-qt = "toga_qt" +linux = "toga_qt" [tool.setuptools_scm] root = ".." diff --git a/qt/tests_backend_qt/app.py b/qt/tests_backend/app.py similarity index 100% rename from qt/tests_backend_qt/app.py rename to qt/tests_backend/app.py diff --git a/qt/tests_backend_qt/dialogs.py b/qt/tests_backend/dialogs.py similarity index 100% rename from qt/tests_backend_qt/dialogs.py rename to qt/tests_backend/dialogs.py diff --git a/qt/tests_backend_qt/fonts.py b/qt/tests_backend/fonts.py similarity index 100% rename from qt/tests_backend_qt/fonts.py rename to qt/tests_backend/fonts.py diff --git a/qt/tests_backend_qt/icons.py b/qt/tests_backend/icons.py similarity index 100% rename from qt/tests_backend_qt/icons.py rename to qt/tests_backend/icons.py diff --git a/qt/tests_backend_qt/images.py b/qt/tests_backend/images.py similarity index 100% rename from qt/tests_backend_qt/images.py rename to qt/tests_backend/images.py diff --git a/qt/tests_backend_qt/probe.py b/qt/tests_backend/probe.py similarity index 100% rename from qt/tests_backend_qt/probe.py rename to qt/tests_backend/probe.py diff --git a/qt/tests_backend_qt/screens.py b/qt/tests_backend/screens.py similarity index 100% rename from qt/tests_backend_qt/screens.py rename to qt/tests_backend/screens.py diff --git a/qt/tests_backend_qt/widgets/__init__.py b/qt/tests_backend/widgets/__init__.py similarity index 100% rename from qt/tests_backend_qt/widgets/__init__.py rename to qt/tests_backend/widgets/__init__.py diff --git a/qt/tests_backend_qt/widgets/activityindicator.py b/qt/tests_backend/widgets/activityindicator.py similarity index 100% rename from qt/tests_backend_qt/widgets/activityindicator.py rename to qt/tests_backend/widgets/activityindicator.py diff --git a/qt/tests_backend_qt/widgets/base.py b/qt/tests_backend/widgets/base.py similarity index 100% rename from qt/tests_backend_qt/widgets/base.py rename to qt/tests_backend/widgets/base.py diff --git a/qt/tests_backend_qt/widgets/box.py b/qt/tests_backend/widgets/box.py similarity index 100% rename from qt/tests_backend_qt/widgets/box.py rename to qt/tests_backend/widgets/box.py diff --git a/qt/tests_backend_qt/widgets/button.py b/qt/tests_backend/widgets/button.py similarity index 100% rename from qt/tests_backend_qt/widgets/button.py rename to qt/tests_backend/widgets/button.py diff --git a/qt/tests_backend_qt/widgets/label.py b/qt/tests_backend/widgets/label.py similarity index 100% rename from qt/tests_backend_qt/widgets/label.py rename to qt/tests_backend/widgets/label.py diff --git a/qt/tests_backend_qt/widgets/properties.py b/qt/tests_backend/widgets/properties.py similarity index 100% rename from qt/tests_backend_qt/widgets/properties.py rename to qt/tests_backend/widgets/properties.py diff --git a/qt/tests_backend_qt/widgets/textinput.py b/qt/tests_backend/widgets/textinput.py similarity index 100% rename from qt/tests_backend_qt/widgets/textinput.py rename to qt/tests_backend/widgets/textinput.py diff --git a/qt/tests_backend_qt/window.py b/qt/tests_backend/window.py similarity index 100% rename from qt/tests_backend_qt/window.py rename to qt/tests_backend/window.py diff --git a/testbed/pyproject.toml b/testbed/pyproject.toml index 4340fae34a..f8a9c0b58a 100644 --- a/testbed/pyproject.toml +++ b/testbed/pyproject.toml @@ -68,11 +68,9 @@ test_sources = [ [tool.briefcase.app.testbed.linux] test_sources = [ "../gtk/tests_backend", - "../qt/tests_backend_qt", # Will be substituted in Python tests ] requires = [ "../gtk", - "../qt", # Toga will choose right backend automatically ] [tool.briefcase.app.testbed.windows] @@ -123,3 +121,41 @@ android.defaultConfig.python { requires = [ "../web" ] + +[tool.briefcase.app.testbed-qt] +formal_name = "Toga Testbed" +description = "A testbed for Toga visual tests" +icon = "icons/testbed" +sources = [ + "src/testbed_qt", +] +test_sources = [ + "tests", +] +requires = [ + "../travertino", + "../core", +] + +# Some CI configurations (e.g., Textual) manually override `requires` to specify +# installation via the wheels built as part of the CI run. Adding `--find-links` allows +# those wheels to be found. However, in most CI builds, these wheels will be .devX +# wheels, so we need to add `--pre` to ensure they are found as solutions to pip's +# solver. +requirement_installer_args=[ + "--pre", + "--find-links", "../dist", +] + +permission.camera = "The testbed needs to exercise Camera APIs" +permission.fine_location = "The testbed needs to exercise fine-grained geolocation services." +permission.coarse_location = "The testbed needs to exercise coarse-grained geolocation services." +permission.background_location = "The testbed needs to exercise capturing your location while in the background" + +[tool.briefcase.app.testbed-qt.linux] +test_sources = [ + "../qt/tests_backend", +] +requires = [ + "../qt", +] diff --git a/testbed/src/testbed/__main__.py b/testbed/src/testbed/__main__.py index 7c4c4d4546..4675cb5ccd 100644 --- a/testbed/src/testbed/__main__.py +++ b/testbed/src/testbed/__main__.py @@ -1,4 +1,8 @@ -from testbed.app import main +if __package__ == "testbed": + from testbed.app import main +elif __package__ == "testbed_qt": + from testbed_qt.app import main + if __name__ == "__main__": main().main_loop() diff --git a/testbed/src/testbed/app.py b/testbed/src/testbed/app.py index a7bcd4dcde..11464271d3 100644 --- a/testbed/src/testbed/app.py +++ b/testbed/src/testbed/app.py @@ -223,6 +223,5 @@ def task_factory(loop, coro, **kwargs): def main(): return Testbed( - app_name="testbed", document_types=[ExampleDoc, ReadonlyDoc], ) diff --git a/testbed/src/testbed_qt b/testbed/src/testbed_qt new file mode 120000 index 0000000000..d648967ea7 --- /dev/null +++ b/testbed/src/testbed_qt @@ -0,0 +1 @@ +testbed \ No newline at end of file diff --git a/testbed/tests/conftest.py b/testbed/tests/conftest.py index 1fc5d7488b..5509bf52ce 100644 --- a/testbed/tests/conftest.py +++ b/testbed/tests/conftest.py @@ -1,9 +1,6 @@ import asyncio import gc -import importlib -import importlib.util import inspect -import sys from dataclasses import dataclass from importlib import import_module @@ -12,7 +9,6 @@ import toga from toga.colors import GOLDENROD from toga.constants import WindowState -from toga.platform import get_platform_factory from toga.style import Pack # Ideally, we'd register rewrites for "tests" and get all the submodules @@ -20,17 +16,6 @@ register_assert_rewrite("tests.assertions") register_assert_rewrite("tests.widgets") register_assert_rewrite("tests_backend") -register_assert_rewrite("tests_backend_qt") - -# Toga's Qt backend is packaged the same as GTK Linux; substitute -# the Qt backend tests if running on Qt. -if get_platform_factory().__name__ == "toga_qt.factory": - spec = importlib.util.find_spec("tests_backend_qt") - if spec is None: - raise FileNotFoundError("Could not find Qt backend tests") - qt_module = importlib.import_module("tests_backend_qt") - - sys.modules["tests_backend"] = qt_module # Use this for widgets or tests which are not supported on some platforms, From f1cf4b3a502fd3f3a05a9f1c9e5dbfe8a17769c7 Mon Sep 17 00:00:00 2001 From: "John X. Zhou" Date: Sun, 28 Sep 2025 12:32:01 -0500 Subject: [PATCH 25/37] correct the config --- testbed/src/testbed/__main__.py | 8 ++------ testbed/src/testbed/app.py | 3 ++- testbed/tests/app/test_document_app.py | 6 +++++- testbed/tests/testbed.py | 12 ++++++++---- testbed/tests/testbed_qt.py | 6 ++++++ 5 files changed, 23 insertions(+), 12 deletions(-) create mode 100644 testbed/tests/testbed_qt.py diff --git a/testbed/src/testbed/__main__.py b/testbed/src/testbed/__main__.py index 4675cb5ccd..0a3adf21a4 100644 --- a/testbed/src/testbed/__main__.py +++ b/testbed/src/testbed/__main__.py @@ -1,8 +1,4 @@ -if __package__ == "testbed": - from testbed.app import main -elif __package__ == "testbed_qt": - from testbed_qt.app import main - +from .app import main if __name__ == "__main__": - main().main_loop() + main(__package__).main_loop() diff --git a/testbed/src/testbed/app.py b/testbed/src/testbed/app.py index 11464271d3..1998c30a4c 100644 --- a/testbed/src/testbed/app.py +++ b/testbed/src/testbed/app.py @@ -221,7 +221,8 @@ def task_factory(loop, coro, **kwargs): self.main_window.show() -def main(): +def main(appname): return Testbed( + app_name=appname, document_types=[ExampleDoc, ReadonlyDoc], ) diff --git a/testbed/tests/app/test_document_app.py b/testbed/tests/app/test_document_app.py index 7992c1748b..662f6a3466 100644 --- a/testbed/tests/app/test_document_app.py +++ b/testbed/tests/app/test_document_app.py @@ -1,9 +1,13 @@ +import importlib from pathlib import Path import pytest import toga -from testbed.app import ExampleDoc + +app_module = importlib.import_module(toga.App.app.__module__) + +ExampleDoc = app_module.ExampleDoc #################################################################################### # Document API tests diff --git a/testbed/tests/testbed.py b/testbed/tests/testbed.py index 400f58345d..0e0fd3b8ad 100644 --- a/testbed/tests/testbed.py +++ b/testbed/tests/testbed.py @@ -10,8 +10,6 @@ import coverage import pytest -import testbed.app - def run_tests(app, cov, args, report_coverage, run_slow, running_in_ci): try: @@ -113,7 +111,7 @@ def run_tests(app, cov, args, report_coverage, run_slow, running_in_ci): app.loop.call_soon_threadsafe(app.exit) -if __name__ == "__main__": +def main(application): # Determine the toga backend. This replicates the behavior in toga/platform.py; # we can't use that module directly because we need to capture all the import # side effects as part of the coverage data. @@ -185,7 +183,7 @@ def run_tests(app, cov, args, report_coverage, run_slow, running_in_ci): report_coverage = True # Create the test app, starting the test suite as a background task - app = testbed.app.main() + app = application.main(application.__package__) thread = Thread( target=partial( @@ -209,3 +207,9 @@ def run_tests(app, cov, args, report_coverage, run_slow, running_in_ci): # Start the test app app.main_loop() + + +if __name__ == "__main__": + import testbed.app + + main(testbed.app) diff --git a/testbed/tests/testbed_qt.py b/testbed/tests/testbed_qt.py new file mode 100644 index 0000000000..5b7cc9cd6d --- /dev/null +++ b/testbed/tests/testbed_qt.py @@ -0,0 +1,6 @@ +from .testbed import main + +if __name__ == "__main__": + import testbed_qt.app + + main(testbed_qt.app) From 5ee1ce6b4b62d1b98224ccc3c84b72d3df1bca75 Mon Sep 17 00:00:00 2001 From: "John X. Zhou" Date: Sun, 28 Sep 2025 12:58:23 -0500 Subject: [PATCH 26/37] qt testbed working --- qt/src/toga_qt/factory.py | 5 +++++ qt/tests_backend/app.py | 8 ++++---- qt/tests_backend/hardware/__init__.py | 3 +++ qt/tests_backend/icons.py | 2 +- 4 files changed, 13 insertions(+), 5 deletions(-) create mode 100644 qt/tests_backend/hardware/__init__.py diff --git a/qt/src/toga_qt/factory.py b/qt/src/toga_qt/factory.py index f938fa3d79..d64757d596 100644 --- a/qt/src/toga_qt/factory.py +++ b/qt/src/toga_qt/factory.py @@ -26,6 +26,7 @@ def import_pyside6(): from .fonts import Font from .icons import Icon, NativeIcon from .images import Image +from .libs import get_testing from .paths import Paths from .statusicons import MenuStatusIcon, SimpleStatusIcon, StatusIconSet from .widgets.activityindicator import ActivityIndicator @@ -64,4 +65,8 @@ def not_implemented(feature): def __getattr__(name): + if get_testing(): + import pytest + + pytest.skip("Widget not implemented on qt", allow_module_level=True) raise NotImplementedError(f"Toga's Qt backend doesn't implement {name}") diff --git a/qt/tests_backend/app.py b/qt/tests_backend/app.py index 0afe998680..b0d040d789 100644 --- a/qt/tests_backend/app.py +++ b/qt/tests_backend/app.py @@ -30,19 +30,19 @@ def __init__(self, app): @property def config_path(self): - return Path.home() / ".config/testbed" + return Path.home() / ".config/testbed_qt" @property def data_path(self): - return Path.home() / ".local/share/testbed" + return Path.home() / ".local/share/testbed_qt" @property def cache_path(self): - return Path.home() / ".cache/testbed" + return Path.home() / ".cache/testbed_qt" @property def logs_path(self): - return Path.home() / ".local/state/testbed/log" + return Path.home() / ".local/state/testbed_qt/log" @property def is_cursor_visible(self): diff --git a/qt/tests_backend/hardware/__init__.py b/qt/tests_backend/hardware/__init__.py new file mode 100644 index 0000000000..280ac11c13 --- /dev/null +++ b/qt/tests_backend/hardware/__init__.py @@ -0,0 +1,3 @@ +import pytest + +pytest.skip("No hardware on qt so far", allow_module_level=True) diff --git a/qt/tests_backend/icons.py b/qt/tests_backend/icons.py index 59b47d5c19..7bb1f00da2 100644 --- a/qt/tests_backend/icons.py +++ b/qt/tests_backend/icons.py @@ -50,5 +50,5 @@ def assert_app_icon_content(self): assert ( self.icon._impl.path == Path(sys.executable).parent.parent - / "share/icons/hicolor/512x512/apps/org.beeware.toga.testbed.png" + / "share/icons/hicolor/512x512/apps/org.beeware.toga.testbed-qt.png" ) From 12a9859b73f1536d1c4e39ce314288002e59f051 Mon Sep 17 00:00:00 2001 From: "John X. Zhou" Date: Sun, 28 Sep 2025 13:26:06 -0500 Subject: [PATCH 27/37] fixup --- qt/src/toga_qt/widgets/box.py | 1 + qt/src/toga_qt/widgets/label.py | 1 + testbed/tests/testbed.py | 31 +++++++++++++++++-------------- testbed/tests/testbed_qt.py | 2 +- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/qt/src/toga_qt/widgets/box.py b/qt/src/toga_qt/widgets/box.py index d5452104c3..74069ae5b4 100644 --- a/qt/src/toga_qt/widgets/box.py +++ b/qt/src/toga_qt/widgets/box.py @@ -7,6 +7,7 @@ class Box(Widget): def create(self): self.native = QWidget() + self.native.setAutoFillBackground(True) def rehint(self): self.interface.intrinsic.width = at_least(0) diff --git a/qt/src/toga_qt/widgets/label.py b/qt/src/toga_qt/widgets/label.py index 8c3e57ce54..b9050b8d10 100644 --- a/qt/src/toga_qt/widgets/label.py +++ b/qt/src/toga_qt/widgets/label.py @@ -9,6 +9,7 @@ class Label(Widget): def create(self): self.native = QLabel() + self.native.setAutoFillBackground(True) def get_text(self): return self.native.text() diff --git a/testbed/tests/testbed.py b/testbed/tests/testbed.py index 0e0fd3b8ad..08cc67468c 100644 --- a/testbed/tests/testbed.py +++ b/testbed/tests/testbed.py @@ -111,23 +111,26 @@ def run_tests(app, cov, args, report_coverage, run_slow, running_in_ci): app.loop.call_soon_threadsafe(app.exit) -def main(application): +def main(application, backend_override=None): # Determine the toga backend. This replicates the behavior in toga/platform.py; # we can't use that module directly because we need to capture all the import # side effects as part of the coverage data. - try: - toga_backend = os.environ["TOGA_BACKEND"] - except KeyError: - if hasattr(sys, "getandroidapilevel"): - toga_backend = "toga_android" - else: - toga_backend = { - "darwin": "toga_cocoa", - "ios": "toga_iOS", - "linux": "toga_gtk", - "emscripten": "toga_web", - "win32": "toga_winforms", - }.get(sys.platform) + if backend_override is not None: + toga_backend = backend_override + else: + try: + toga_backend = os.environ["TOGA_BACKEND"] + except KeyError: + if hasattr(sys, "getandroidapilevel"): + toga_backend = "toga_android" + else: + toga_backend = { + "darwin": "toga_cocoa", + "ios": "toga_iOS", + "linux": "toga_gtk", + "emscripten": "toga_web", + "win32": "toga_winforms", + }.get(sys.platform) # Start coverage tracking. # This needs to happen in the main thread, before the app has been created diff --git a/testbed/tests/testbed_qt.py b/testbed/tests/testbed_qt.py index 5b7cc9cd6d..fdf35a0997 100644 --- a/testbed/tests/testbed_qt.py +++ b/testbed/tests/testbed_qt.py @@ -3,4 +3,4 @@ if __name__ == "__main__": import testbed_qt.app - main(testbed_qt.app) + main(testbed_qt.app, backend_override="toga_qt") From 220a78610fb757a56eba14d3da27f788e66cf169 Mon Sep 17 00:00:00 2001 From: "John X. Zhou" Date: Sun, 28 Sep 2025 13:52:02 -0500 Subject: [PATCH 28/37] increase timeouts --- testbed/tests/app/test_document_app.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/testbed/tests/app/test_document_app.py b/testbed/tests/app/test_document_app.py index 662f6a3466..f947a5208f 100644 --- a/testbed/tests/app/test_document_app.py +++ b/testbed/tests/app/test_document_app.py @@ -39,7 +39,7 @@ async def test_open_document(app, app_probe): document_path = Path(__file__).parent / "docs/example.testbed" app.documents.open(document_path) - await app_probe.redraw("Document has been opened", delay=0.1) + await app_probe.redraw("Document has been opened", delay=0.2) assert len(app.documents) == 1 assert len(app.windows) == 2 @@ -113,7 +113,7 @@ async def test_save_document(app, app_probe): document_path = Path(__file__).parent / "docs/example.testbed" app.documents.open(document_path) - await app_probe.redraw("Document has been opened", delay=0.1) + await app_probe.redraw("Document has been opened", delay=0.2) assert len(app.documents) == 1 assert len(app.windows) == 2 @@ -142,7 +142,7 @@ async def mock_save_as_dialog(dialog): monkeypatch.setattr(document.main_window, "dialog", mock_save_as_dialog) - await app_probe.redraw("Document has been opened", delay=0.1) + await app_probe.redraw("Document has been opened", delay=0.2) assert len(app.documents) == 1 assert len(app.windows) == 2 @@ -166,7 +166,7 @@ async def test_save_all_documents(app, app_probe): document_path = Path(__file__).parent / "docs/example.testbed" app.documents.open(document_path) - await app_probe.redraw("Document has been opened", delay=0.1) + await app_probe.redraw("Document has been opened", delay=0.2) assert len(app.documents) == 1 assert len(app.windows) == 2 From 84eb389fffd4c10792b1d46188036a01d7966183 Mon Sep 17 00:00:00 2001 From: "John X. Zhou" Date: Sun, 28 Sep 2025 13:53:38 -0500 Subject: [PATCH 29/37] restore gtk test --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7fafaf346..d62dec15dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -248,6 +248,7 @@ jobs: briefcase-run-prefix: "" briefcase-run-args: "" setup-python: true + testbed-app: testbed - backend: "macOS-x86_64" platform: "macOS" @@ -445,7 +446,8 @@ jobs: timeout-minutes: 15 run: | ${{ matrix.briefcase-run-prefix }} \ - briefcase run ${{ matrix.platform }} --log --test ${{ matrix.briefcase-run-args }} -- --ci + briefcase run ${{ matrix.platform }} --log --test \ + ${{ matrix.briefcase-run-args }} --app ${{ matrix.testbed-app }} -- --ci - name: Upload Logs uses: actions/upload-artifact@v4.6.2 From 8387ade355b8ca025243e676cb941388adf84d8f Mon Sep 17 00:00:00 2001 From: "John X. Zhou" Date: Thu, 2 Oct 2025 18:19:43 -0500 Subject: [PATCH 30/37] continued progress. --- qt/tests_backend/window.py | 6 +-- testbed/pyproject.toml | 60 +++++++++----------------- testbed/src/testbed_qt | 1 - testbed/src/testbed_qt/__init__.py | 0 testbed/src/testbed_qt/__main__.py | 4 ++ testbed/tests/app/test_document_app.py | 6 +-- testbed/tests/testbed.py | 10 ++--- testbed/tests/testbed_qt.py | 4 +- testbed/tests/widgets/test_canvas.py | 3 -- 9 files changed, 34 insertions(+), 60 deletions(-) delete mode 120000 testbed/src/testbed_qt create mode 100644 testbed/src/testbed_qt/__init__.py create mode 100644 testbed/src/testbed_qt/__main__.py diff --git a/qt/tests_backend/window.py b/qt/tests_backend/window.py index eeafc576df..3fef1f1d9d 100644 --- a/qt/tests_backend/window.py +++ b/qt/tests_backend/window.py @@ -41,9 +41,9 @@ def __init__(self, app, window): self.supports_minimize = False async def wait_for_window(self, message, state=None): - # Wait for composite transitions to finish the delay bee-fore repaint - await asyncio.sleep(0.1) - await self.redraw(message, delay=0.3) + # 0.1 seconds to allow window size tests to ensure + # the correct size. + await self.redraw(message, 0.1) if state == WindowState.MINIMIZED and get_is_wayland(): state = WindowState.NORMAL diff --git a/testbed/pyproject.toml b/testbed/pyproject.toml index f8a9c0b58a..1a7d66414e 100644 --- a/testbed/pyproject.toml +++ b/testbed/pyproject.toml @@ -26,21 +26,6 @@ license-files = [ author = "Tiberius Yak" author_email = "tiberius@beeware.org" -[tool.briefcase.app.testbed] -formal_name = "Toga Testbed" -description = "A testbed for Toga visual tests" -icon = "icons/testbed" -sources = [ - "src/testbed", -] -test_sources = [ - "tests", -] -requires = [ - "../travertino", - "../core", -] - # Some CI configurations (e.g., Textual) manually override `requires` to specify # installation via the wheels built as part of the CI run. Adding `--find-links` allows # those wheels to be found. However, in most CI builds, these wheels will be .devX @@ -56,6 +41,25 @@ permission.fine_location = "The testbed needs to exercise fine-grained geolocati permission.coarse_location = "The testbed needs to exercise coarse-grained geolocation services." permission.background_location = "The testbed needs to exercise capturing your location while in the background" + +formal_name = "Toga Testbed" +description = "A testbed for Toga visual tests" +icon = "icons/testbed" + +test_sources = [ + "tests", +] +requires = [ + "../travertino", + "../core", +] + +[tool.briefcase.app.testbed] +sources = [ + "src/testbed", +] + + [tool.briefcase.app.testbed.macOS] requires = [ "../cocoa", @@ -123,34 +127,10 @@ requires = [ ] [tool.briefcase.app.testbed-qt] -formal_name = "Toga Testbed" -description = "A testbed for Toga visual tests" -icon = "icons/testbed" sources = [ "src/testbed_qt", + "src/testbed", ] -test_sources = [ - "tests", -] -requires = [ - "../travertino", - "../core", -] - -# Some CI configurations (e.g., Textual) manually override `requires` to specify -# installation via the wheels built as part of the CI run. Adding `--find-links` allows -# those wheels to be found. However, in most CI builds, these wheels will be .devX -# wheels, so we need to add `--pre` to ensure they are found as solutions to pip's -# solver. -requirement_installer_args=[ - "--pre", - "--find-links", "../dist", -] - -permission.camera = "The testbed needs to exercise Camera APIs" -permission.fine_location = "The testbed needs to exercise fine-grained geolocation services." -permission.coarse_location = "The testbed needs to exercise coarse-grained geolocation services." -permission.background_location = "The testbed needs to exercise capturing your location while in the background" [tool.briefcase.app.testbed-qt.linux] test_sources = [ diff --git a/testbed/src/testbed_qt b/testbed/src/testbed_qt deleted file mode 120000 index d648967ea7..0000000000 --- a/testbed/src/testbed_qt +++ /dev/null @@ -1 +0,0 @@ -testbed \ No newline at end of file diff --git a/testbed/src/testbed_qt/__init__.py b/testbed/src/testbed_qt/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testbed/src/testbed_qt/__main__.py b/testbed/src/testbed_qt/__main__.py new file mode 100644 index 0000000000..8c0bfa67ae --- /dev/null +++ b/testbed/src/testbed_qt/__main__.py @@ -0,0 +1,4 @@ +from testbed.app import main + +if __name__ == "__main__": + main(__package__).main_loop() diff --git a/testbed/tests/app/test_document_app.py b/testbed/tests/app/test_document_app.py index f947a5208f..c9fe444b09 100644 --- a/testbed/tests/app/test_document_app.py +++ b/testbed/tests/app/test_document_app.py @@ -1,13 +1,9 @@ -import importlib from pathlib import Path import pytest import toga - -app_module = importlib.import_module(toga.App.app.__module__) - -ExampleDoc = app_module.ExampleDoc +from testbed.app import ExampleDoc #################################################################################### # Document API tests diff --git a/testbed/tests/testbed.py b/testbed/tests/testbed.py index 08cc67468c..dcac62f502 100644 --- a/testbed/tests/testbed.py +++ b/testbed/tests/testbed.py @@ -10,6 +10,8 @@ import coverage import pytest +import testbed.app + def run_tests(app, cov, args, report_coverage, run_slow, running_in_ci): try: @@ -111,7 +113,7 @@ def run_tests(app, cov, args, report_coverage, run_slow, running_in_ci): app.loop.call_soon_threadsafe(app.exit) -def main(application, backend_override=None): +def main(main_package_name, backend_override=None): # Determine the toga backend. This replicates the behavior in toga/platform.py; # we can't use that module directly because we need to capture all the import # side effects as part of the coverage data. @@ -186,7 +188,7 @@ def main(application, backend_override=None): report_coverage = True # Create the test app, starting the test suite as a background task - app = application.main(application.__package__) + app = testbed.app.main(main_package_name) thread = Thread( target=partial( @@ -213,6 +215,4 @@ def main(application, backend_override=None): if __name__ == "__main__": - import testbed.app - - main(testbed.app) + main("testbed") diff --git a/testbed/tests/testbed_qt.py b/testbed/tests/testbed_qt.py index fdf35a0997..5240eee2ce 100644 --- a/testbed/tests/testbed_qt.py +++ b/testbed/tests/testbed_qt.py @@ -1,6 +1,4 @@ from .testbed import main if __name__ == "__main__": - import testbed_qt.app - - main(testbed_qt.app, backend_override="toga_qt") + main("testbed_qt", backend_override="toga_qt") diff --git a/testbed/tests/widgets/test_canvas.py b/testbed/tests/widgets/test_canvas.py index ad3e6f18e3..6f2c5433db 100644 --- a/testbed/tests/widgets/test_canvas.py +++ b/testbed/tests/widgets/test_canvas.py @@ -21,7 +21,6 @@ from toga.fonts import BOLD from toga.style.pack import SYSTEM, Pack -from ..conftest import skip_on_platforms from .conftest import build_cleanup_test from .properties import ( # noqa: F401 test_background_color, @@ -84,7 +83,6 @@ async def widget( on_alt_release_handler, on_alt_drag_handler, ): - skip_on_platforms("linux-kde") return toga.Canvas( on_resize=on_resize_handler, on_press=on_press_handler, @@ -113,7 +111,6 @@ def assert_pixel(image, x, y, color): test_cleanup = build_cleanup_test( toga.Canvas, - skip_platforms=("linux-kde",), xfail_platforms=("android",), ) From f274bce014c61f2c522f1702aa759ec0df72da1c Mon Sep 17 00:00:00 2001 From: "John X. Zhou" Date: Sat, 4 Oct 2025 19:04:13 -0500 Subject: [PATCH 31/37] a bunch of fixups --- cocoa/tests_backend/app.py | 1 + gtk/tests_backend/app.py | 1 + qt/src/toga_qt/app.py | 32 ++------ qt/src/toga_qt/container.py | 9 +-- qt/src/toga_qt/factory.py | 9 +++ qt/src/toga_qt/initialization.py | 16 ---- qt/src/toga_qt/window.py | 103 ++++++++++-------------- qt/tests_backend/app.py | 23 +----- qt/tests_backend/probe.py | 23 ++++++ qt/tests_backend/widgets/base.py | 6 +- qt/tests_backend/widgets/textinput.py | 6 ++ qt/tests_backend/window.py | 6 +- testbed/tests/app/test_desktop.py | 24 +++++- testbed/tests/widgets/test_textinput.py | 34 ++++++++ winforms/tests_backend/app.py | 1 + 15 files changed, 159 insertions(+), 135 deletions(-) delete mode 100644 qt/src/toga_qt/initialization.py diff --git a/cocoa/tests_backend/app.py b/cocoa/tests_backend/app.py index 74b3d9c311..0b4f14fbf3 100644 --- a/cocoa/tests_backend/app.py +++ b/cocoa/tests_backend/app.py @@ -25,6 +25,7 @@ class AppProbe(BaseProbe, DialogsMixin): supports_key_mod3 = True supports_current_window_assignment = True supports_dark_mode = True + edit_menu_noop_enabled = False def __init__(self, app): super().__init__() diff --git a/gtk/tests_backend/app.py b/gtk/tests_backend/app.py index a3bc35c8b6..f1d9dcab71 100644 --- a/gtk/tests_backend/app.py +++ b/gtk/tests_backend/app.py @@ -18,6 +18,7 @@ class AppProbe(BaseProbe, DialogsMixin): # Gtk 3.24.41 ships with Ubuntu 24.04 where present() works on Wayland supports_current_window_assignment = not (IS_WAYLAND and GTK_VERSION < (3, 24, 41)) supports_dark_mode = True + edit_menu_noop_enabled = False def __init__(self, app): super().__init__() diff --git a/qt/src/toga_qt/app.py b/qt/src/toga_qt/app.py index 47037ed4bf..a31817d870 100644 --- a/qt/src/toga_qt/app.py +++ b/qt/src/toga_qt/app.py @@ -1,6 +1,6 @@ import asyncio -from PySide6.QtCore import QObject, QSize, Qt, QTimer, Signal +from PySide6.QtCore import QSize, Qt, QTimer from PySide6.QtGui import QCursor, QGuiApplication from PySide6.QtWidgets import QApplication, QMessageBox from qasync import QEventLoop @@ -26,7 +26,7 @@ def __init__(self, method_name, needwrite=False): self.method_name = method_name self.needwrite = needwrite - def __call__(self, interface): + def __call__(self): fw = QApplication.focusWidget() if not fw: return @@ -78,23 +78,6 @@ def _create_about_dialog(app): return dialog -class AppSignalsListener(QObject): - appStarting = Signal() - - def __init__(self, impl): - super().__init__() - self.impl = impl - self.interface = impl.interface - self.appStarting.connect(self.on_app_starting) - QTimer.singleShot(0, self.appStarting.emit) - - def on_app_starting(self): - self.interface._startup() - - -appsingle = QApplication() - - class App: # GTK apps exit when the last window is closed CLOSE_ON_LAST_WINDOW = True @@ -105,14 +88,15 @@ def __init__(self, interface): self.interface = interface self.interface._impl = self - self.native = appsingle + self.native = QApplication.instance() self.loop = QEventLoop(self.native) asyncio.set_event_loop(self.loop) self.app_close_event = asyncio.Event() self.native.aboutToQuit.connect(self.app_close_event.set) - - # no idea what to name this... or should i put this into the main class - self.signalslistener = AppSignalsListener(self) + # By this point our app is already up and running. Everything is set up, + # so we run this manually without need to use native mechanisms. + # Also, somehow if we don't QTimer.singleShot we end up with dangling Tasks. + QTimer.singleShot(0, self.interface._startup) self.cursorhidden = False @@ -207,7 +191,7 @@ def get_screens(self): s for s in screens if s != primary ] # Ensure first is primary - return [ScreenImpl(native=monitor) for monitor in QGuiApplication.screens()] + return [ScreenImpl(native=monitor) for monitor in screens] ###################################################################### # App state diff --git a/qt/src/toga_qt/container.py b/qt/src/toga_qt/container.py index b07a605b77..506edd2a97 100644 --- a/qt/src/toga_qt/container.py +++ b/qt/src/toga_qt/container.py @@ -11,20 +11,13 @@ def __init__(self, content=None, layout_native=None, on_refresh=None): self.content = content # Set initial content - def __del__(self): - self.native = None - @property def width(self): return self.layout_native.width() @property def height(self): - return self.layout_native.height() - self.top_offset - - @property - def top_offset(self): - return 0 ## Stub (?) + return self.layout_native.height() @property def content(self): diff --git a/qt/src/toga_qt/factory.py b/qt/src/toga_qt/factory.py index d64757d596..b657672659 100644 --- a/qt/src/toga_qt/factory.py +++ b/qt/src/toga_qt/factory.py @@ -1,5 +1,7 @@ # ruff: noqa: E402 +# noqa for initializing some Qt stuff and setting up QApplication + import site import sys @@ -17,6 +19,13 @@ def import_pyside6(): import_pyside6() +from PySide6.QtWidgets import QApplication + +# In Qt, most operations, even manipulating icons, must be done +# after QApplication has been initialized. Therefore, initialize +# it and also the event loop as early as possible. +QApplication() + from toga import NotImplementedWarning from . import dialogs diff --git a/qt/src/toga_qt/initialization.py b/qt/src/toga_qt/initialization.py deleted file mode 100644 index 9f36994a6a..0000000000 --- a/qt/src/toga_qt/initialization.py +++ /dev/null @@ -1,16 +0,0 @@ -import site -import sys - - -def import_pyside6(): - """Temporarily break isolation to import system PySide6.""" - system_site = site.getsitepackages() - print(system_site) - old_path = sys.path.copy() - sys.path.extend(system_site) - import PySide6 # noqa - - sys.path = old_path - - -import_pyside6() diff --git a/qt/src/toga_qt/window.py b/qt/src/toga_qt/window.py index 242a3925ad..1a93290667 100644 --- a/qt/src/toga_qt/window.py +++ b/qt/src/toga_qt/window.py @@ -2,7 +2,7 @@ from PySide6.QtCore import QEvent, Qt, QTimer from PySide6.QtGui import QWindowStateChangeEvent -from PySide6.QtWidgets import QApplication, QMainWindow, QMenu, QVBoxLayout, QWidget +from PySide6.QtWidgets import QApplication, QMainWindow, QMenu from toga.command import Separator from toga.constants import WindowState @@ -28,41 +28,6 @@ def _handle_statechange(impl, changeid): impl._pending_state_transition = None -def process_change(native, event): - if event.type() == QEvent.WindowStateChange: - old = event.oldState() - new = native.windowState() - if not old & Qt.WindowMinimized and new & Qt.WindowMinimized: - native.interface.on_hide() - elif old & Qt.WindowMinimized and not new & Qt.WindowMinimized: - native.interface.on_show() - if get_is_wayland(): - impl = native.impl - impl._changeventid += 1 - # Handle this later as the states etc may not have been fully realized. - # Starting the next transition now will cause extra window events to be - # generated, and sometimes the window ends up in an incorrect state. - QTimer.singleShot( - 100, partial(_handle_statechange, impl, impl._changeventid) - ) - elif event.type() == QEvent.ActivationChange: - if native.isActiveWindow(): - native.interface.on_gain_focus() - else: - native.interface.on_lose_focus() - - -class TogaTLWidget(QWidget): - def __init__(self, impl, *args, **kwargs): - super().__init__(*args, **kwargs) - self.interface = impl.interface - self.impl = impl - - def changeEvent(self, event): - process_change(self, event) - super().changeEvent(event) - - class TogaMainWindow(QMainWindow): def __init__(self, impl, *args, **kwargs): super().__init__(*args, **kwargs) @@ -70,19 +35,30 @@ def __init__(self, impl, *args, **kwargs): self.impl = impl def changeEvent(self, event): - process_change(self, event) + if event.type() == QEvent.WindowStateChange: + old = event.oldState() + new = self.windowState() + if not old & Qt.WindowMinimized and new & Qt.WindowMinimized: + self.interface.on_hide() + elif old & Qt.WindowMinimized and not new & Qt.WindowMinimized: + self.interface.on_show() + if get_is_wayland(): # pragma: no-cover-if-linux-x + self.impl._changeventid += 1 + # Handle this later as the states etc may not have been fully realized. + # Starting the next transition now will cause extra window events to be + # generated, and sometimes the window ends up in an incorrect state. + QTimer.singleShot( + 100, + partial(_handle_statechange, self.impl, self.impl._changeventid), + ) + elif event.type() == QEvent.ActivationChange: + if self.isActiveWindow(): + self.interface.on_gain_focus() + else: + self.interface.on_lose_focus() super().changeEvent(event) -def wrap_container(widget, impl): - wrapper = TogaTLWidget(impl) - layout = QVBoxLayout(wrapper) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - layout.addWidget(widget) - return wrapper - - class Window: def __init__(self, interface, title, position, size): self.interface = interface @@ -125,7 +101,13 @@ def qt_close_event(self, event): self.interface.on_close() def create(self): - self.native = wrap_container(self.container.native, self) + # QMainWindow is used in order to save duplication for + # subclassing; QMainWindow does not *require* a menubar, + # and if there are no items, it is not displayed. + # This also allows us to simplify menubar hiding logic + # by not requiring us to check hasattr. + self.native = TogaMainWindow(self) + self.native.setCentralWidget(self.container.native) def hide(self): # https://forum.qt.io/topic/163064/delayed-window-state-read-after-hide-gives-wrong-results-even-in-x11/ @@ -253,7 +235,9 @@ def get_window_state(self, in_progress_state=False): def set_window_state(self, state): # NOTE - MINIMIZED does not round-trip on Wayland # and will cause infinite recursion. Don't support it - if get_is_wayland() and state == WindowState.MINIMIZED: + if ( + get_is_wayland() and state == WindowState.MINIMIZED + ): # pragma: no-cover-if-linux-x return if ( @@ -273,17 +257,22 @@ def set_window_state(self, state): ): self.interface.app.exit_presentation_mode() - if get_is_wayland(): + if get_is_wayland(): # pragma: no-cover-if-linux-x self._pending_state_transition = state self._apply_state(state) def _apply_state(self, state): - if state is None: + # Stop the chain immediately if hidden. + if not self.get_visible(): + self._hidden_window_state = self._pending_state_transition + self._pending_state_transition = None return current_state = self.get_window_state() current_native_state = self.native.windowState() - if current_state == WindowState.MINIMIZED and not get_is_wayland(): + if ( + current_state == WindowState.MINIMIZED and not get_is_wayland() + ): # pragma: no-cover-if-linux-wayland self.native.showNormal() if current_state == state: self._pending_state_transition = None @@ -291,8 +280,7 @@ def _apply_state(self, state): if current_state == WindowState.PRESENTATION: self.interface.screen = self._before_presentation_mode_screen - if hasattr(self.native, "menuBar"): - self.native.menuBar().show() + self.native.menuBar().show() del self._before_presentation_mode_screen self._in_presentation_mode = False @@ -300,7 +288,7 @@ def _apply_state(self, state): self.native.showMaximized() elif state == WindowState.MINIMIZED: - if not get_is_wayland(): + if not get_is_wayland(): # pragma: no-cover-if-linux-wayland self.native.showNormal() self.native.showMinimized() @@ -313,8 +301,7 @@ def _apply_state(self, state): elif state == WindowState.PRESENTATION: self._before_presentation_mode_screen = self.interface.screen - if hasattr(self.native, "menuBar"): - self.native.menuBar().hide() + self.native.menuBar().hide() # Do this bee-fore showFullScreen bee-cause # showFullScreen might immediately trigger the event # and the window state read there might read a non- @@ -339,10 +326,6 @@ def set_content(self, widget): class MainWindow(Window): - def create(self): - self.native = TogaMainWindow(self) - self.native.setCentralWidget(self.container.native) - def _submenu(self, group, group_cache): try: return group_cache[group] diff --git a/qt/tests_backend/app.py b/qt/tests_backend/app.py index b0d040d789..2a7c5fba22 100644 --- a/qt/tests_backend/app.py +++ b/qt/tests_backend/app.py @@ -15,6 +15,7 @@ class AppProbe(BaseProbe): supports_key_mod3 = True supports_current_window_assignment = True supports_dark_mode = True + edit_menu_noop_enabled = True def __init__(self, app): super().__init__() @@ -54,25 +55,6 @@ def unhide(self): def assert_app_icon(self, icon): raise pytest.skip("Not implemented in probe yet") - def _menu_item(self, path): - menu_bar = self.main_window._impl.native.menuBar() - current_menu = menu_bar - for label in path: - for action in current_menu.actions(): - if action.text() == label: - if action.menu(): - current_menu = action.menu() - else: - return action - break - else: - raise AssertionError(f"Menu path {path} not found") - return current_menu - - def _activate_menu_item(self, path): - item = self._menu_item(path) - item.trigger() - def activate_menu_hide(self): pytest.xfail("KDE apps do not include a Hide in the menu bar") @@ -141,3 +123,6 @@ def activate_status_icon_button(self, item_id): def activate_status_menu_item(self, item_id, title): pytest.skip("Status Icons not yet implemented on Qt") + + def perform_edit_action(self, action): + self._activate_menu_item(["Edit", action]) diff --git a/qt/tests_backend/probe.py b/qt/tests_backend/probe.py index b78ad4a244..febdbea8f5 100644 --- a/qt/tests_backend/probe.py +++ b/qt/tests_backend/probe.py @@ -65,3 +65,26 @@ async def type_character(self, char, *, shift=False, ctrl=False, alt=False): release = QKeyEvent(QEvent.KeyRelease, key, modifiers, char) QApplication.sendEvent(widget, press) QApplication.sendEvent(widget, release) + + def _menu_item(self, path): + # Do not let the test fail if there is no focussed window, + # though we'd prefer that because users do it. + menu_bar = ( + toga.App.app.current_window or toga.App.app.main_window + )._impl.native.menuBar() + current_menu = menu_bar + for label in path: + for action in current_menu.actions(): + if action.text() == label: + if action.menu(): + current_menu = action.menu() + else: + return action + break + else: + raise AssertionError(f"Menu path {path} not found") + return current_menu + + def _activate_menu_item(self, path): + item = self._menu_item(path) + item.trigger() diff --git a/qt/tests_backend/widgets/base.py b/qt/tests_backend/widgets/base.py index 2716c74836..01a49ff19a 100644 --- a/qt/tests_backend/widgets/base.py +++ b/qt/tests_backend/widgets/base.py @@ -88,7 +88,9 @@ def height(self): return self.native.height() async def undo(self): - pytest.skip("Undo not supported by default on widgets") + # Few literate computer users do this; however, + # this is the most complex case that shall be tested. + self._activate_menu_item(["Edit", "Undo"]) async def redo(self): - pytest.skip("Redo not supported by default on widgets") + self._activate_menu_item(["Edit", "Redo"]) diff --git a/qt/tests_backend/widgets/textinput.py b/qt/tests_backend/widgets/textinput.py index 7b7c821d51..ee0c4bcffb 100644 --- a/qt/tests_backend/widgets/textinput.py +++ b/qt/tests_backend/widgets/textinput.py @@ -43,3 +43,9 @@ def assert_vertical_text_align(self, expected): def set_cursor_at_end(self): self.native.setCursorPosition(len(self.native.text())) + + def select_range(self, start, length): # Start after the start-th character + self.native.setSelection(start, length) + + def end_undo_block(self): + self.native.editingFinished.emit() diff --git a/qt/tests_backend/window.py b/qt/tests_backend/window.py index 3fef1f1d9d..ae422ed949 100644 --- a/qt/tests_backend/window.py +++ b/qt/tests_backend/window.py @@ -41,9 +41,9 @@ def __init__(self, app, window): self.supports_minimize = False async def wait_for_window(self, message, state=None): - # 0.1 seconds to allow window size tests to ensure - # the correct size. - await self.redraw(message, 0.1) + # 0.2 seconds to allow window size tests to ensure + # the correct size amd retain correct focus. + await self.redraw(message, 0.2) if state == WindowState.MINIMIZED and get_is_wayland(): state = WindowState.NORMAL diff --git a/testbed/tests/app/test_desktop.py b/testbed/tests/app/test_desktop.py index 68528e3472..e03618458c 100644 --- a/testbed/tests/app/test_desktop.py +++ b/testbed/tests/app/test_desktop.py @@ -440,10 +440,8 @@ async def test_presentation_mode_exit_on_window_state_change( "App is in presentation mode", state=WindowState.PRESENTATION ) - assert window1_probe.instantaneous_state == WindowState.PRESENTATION - # Do this assertion after in order to give platforms that cannot support - # window states a chance to exit this test by skipping in instantaneous_state assert app.in_presentation_mode + assert window1_probe.instantaneous_state == WindowState.PRESENTATION # Changing window state of main window should make the app exit presentation mode. window1.state = new_window_state @@ -941,3 +939,23 @@ async def test_background_app( finally: app.main_window = main_window await app_probe.restore_standard_app() + + +@pytest.mark.parametrize( + "action", + [ + "Undo", + "Redo", + "Cut", + "Copy", + "Paste", + ], +) +async def test_edit_no_focus_noop(app_probe, action): + """Attempting to invoke edit actions with no focused widget should not error""" + # This test is for edit menus that enable even when they're no-op, + # because doing edit menus properly disabling is hard on some platforms. + if not app_probe.edit_menu_noop_enabled: + pytest.xfail("Platform does not have Edit menu that enables but no-ops") + app_probe.perform_edit_action(action) + # No exceptions diff --git a/testbed/tests/widgets/test_textinput.py b/testbed/tests/widgets/test_textinput.py index ffdc1edacb..18ef3c6c61 100644 --- a/testbed/tests/widgets/test_textinput.py +++ b/testbed/tests/widgets/test_textinput.py @@ -309,3 +309,37 @@ async def test_no_event_on_style_change(widget, probe, on_change): await probe.redraw("Text color has been changed") on_change.assert_not_called() on_change.reset_mock() + + +@pytest.mark.parametrize( + "action, select, undo", + [ + ("Undo", False, False), + ("Redo", False, True), + ("Cut", True, False), + ("Paste", False, False), + ], +) +async def test_edit_readonly_noop(widget, probe, app_probe, action, select, undo): + if not app_probe.edit_menu_noop_enabled: + pytest.xfail("Platform does not have Edit menu that enables but no-ops") + """Attempting to invoke edit actions with a readonly TextInput should + not change anything""" + widget.focus() + await probe.redraw("Widget focused") + widget.value = "About to be readonly" + await probe.redraw("Initial text is setup with focus") + await probe.type_character("x") + await probe.redraw("Typed x") + await probe.type_character("y") + await probe.redraw("Typed y") + if undo: + await probe.undo() # Undo once so Redo has potential to do things + + widget.readonly = True + if select: + probe.select_range(len(probe.value) - 2, 2) + await probe.redraw("Range selected") + app_probe.perform_edit_action(action) + await probe.redraw("Edit action performed; should be no-op") + assert widget.value == "About to be readonly" + "" if undo else "xy" diff --git a/winforms/tests_backend/app.py b/winforms/tests_backend/app.py index aaab18ea15..fc747b48c6 100644 --- a/winforms/tests_backend/app.py +++ b/winforms/tests_backend/app.py @@ -20,6 +20,7 @@ class AppProbe(BaseProbe, DialogsMixin): supports_key_mod3 = False supports_current_window_assignment = True supports_dark_mode = False + edit_menu_noop_enabled = False def __init__(self, app): super().__init__() From c906be645ae898ecb90c28f99b8f22cc7b2c8749 Mon Sep 17 00:00:00 2001 From: "John X. Zhou" Date: Sat, 4 Oct 2025 19:17:29 -0500 Subject: [PATCH 32/37] coverage fix --- qt/src/toga_qt/container.py | 5 ++--- qt/src/toga_qt/libs/testing.py | 5 ++++- qt/src/toga_qt/screens.py | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/qt/src/toga_qt/container.py b/qt/src/toga_qt/container.py index 506edd2a97..5ab394b837 100644 --- a/qt/src/toga_qt/container.py +++ b/qt/src/toga_qt/container.py @@ -25,7 +25,7 @@ def content(self): @content.setter def content(self, widget): - if self._content: + if self.content: self._content.container = None self._content.native.setParent(None) @@ -36,5 +36,4 @@ def content(self, widget): widget.native.setParent(self.native) def refreshed(self): - if self.on_refresh is not None: - self.on_refresh(self) + self.on_refresh(self) diff --git a/qt/src/toga_qt/libs/testing.py b/qt/src/toga_qt/libs/testing.py index b5acd42b58..53f5ab5d8e 100644 --- a/qt/src/toga_qt/libs/testing.py +++ b/qt/src/toga_qt/libs/testing.py @@ -1,11 +1,14 @@ import os -class AnyWithin: +class AnyWithin: # pragma: no cover """ An alternative to pytest.approx to use in tests that supports comparisons; used to work around the fact that Qt window size does not round-trip exactly. + + no-cover for test utility. We never know when some of these + functions gets actually used. """ def __init__(self, low, high): diff --git a/qt/src/toga_qt/screens.py b/qt/src/toga_qt/screens.py index 887d6b6f1d..410a081919 100644 --- a/qt/src/toga_qt/screens.py +++ b/qt/src/toga_qt/screens.py @@ -43,12 +43,12 @@ def get_size(self) -> Size: return Size(geometry.width(), geometry.height()) def get_image_data(self): - if not get_is_wayland(): + if not get_is_wayland(): # pragma: no-cover-if-linux-wayland grabbed = self.native.grabWindow(0) byte_array = QByteArray() buffer = QBuffer(byte_array) buffer.open(QIODevice.WriteOnly) grabbed.save(buffer, "PNG") return byte_array.data() - else: + else: # pragma: no-cover-if-linux-x self.interface.factory.not_implemented("Screen.get_image_data() on Wayland") From 7e8ff2e6fd88f38fbb83c2a1f72124f99bccd5a8 Mon Sep 17 00:00:00 2001 From: "John X. Zhou" Date: Sat, 4 Oct 2025 19:24:02 -0500 Subject: [PATCH 33/37] a missing skip --- testbed/tests/widgets/test_textinput.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/testbed/tests/widgets/test_textinput.py b/testbed/tests/widgets/test_textinput.py index 18ef3c6c61..b4c95ef345 100644 --- a/testbed/tests/widgets/test_textinput.py +++ b/testbed/tests/widgets/test_textinput.py @@ -321,6 +321,8 @@ async def test_no_event_on_style_change(widget, probe, on_change): ], ) async def test_edit_readonly_noop(widget, probe, app_probe, action, select, undo): + if toga.platform.current_platform not in {"macOS", "windows", "linux"}: + pytest.skip("Test is specific to desktop platforms") if not app_probe.edit_menu_noop_enabled: pytest.xfail("Platform does not have Edit menu that enables but no-ops") """Attempting to invoke edit actions with a readonly TextInput should From a693640ee4095d1cae60578e3b192707a76b7542 Mon Sep 17 00:00:00 2001 From: John Date: Sat, 4 Oct 2025 19:39:46 -0500 Subject: [PATCH 34/37] Update .github/workflows/ci.yml --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d62dec15dc..28d6ec5141 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -288,7 +288,7 @@ jobs: echo "Start window manager..." DISPLAY=:99 blackbox & sleep 1 - briefcase-run-prefix: 'DISPLAY=:99 TOGA_BACKEND=toga_gtk' + briefcase-run-prefix: 'DISPLAY=:99' setup-python: false # Use the system Python packages app-user-data-path: "$HOME/.local/share/testbed" From 9d12cd392654a2fb43888c5d9d8962face1fb5f5 Mon Sep 17 00:00:00 2001 From: John Date: Sat, 4 Oct 2025 19:39:53 -0500 Subject: [PATCH 35/37] Update .github/workflows/ci.yml --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28d6ec5141..8134dbbd53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -315,7 +315,7 @@ jobs: DISPLAY=:99 MUTTER_DEBUG_DUMMY_MODE_SPECS=2048x1536 \ mutter --nested --wayland --no-x11 --wayland-display toga & sleep 1 - briefcase-run-prefix: "WAYLAND_DISPLAY=toga TOGA_BACKEND=toga_gtk" + briefcase-run-prefix: "WAYLAND_DISPLAY=toga" setup-python: false # Use the system Python packages app-user-data-path: "$HOME/.local/share/testbed" From 535375caede54a46331b19ef0bef37b6ccce650f Mon Sep 17 00:00:00 2001 From: John Date: Sat, 4 Oct 2025 20:00:19 -0500 Subject: [PATCH 36/37] [desparately] github snow day... From 9606935f4cb6843a47a354efd897ebf7e2102470 Mon Sep 17 00:00:00 2001 From: "John X. Zhou" Date: Sat, 4 Oct 2025 22:37:21 -0500 Subject: [PATCH 37/37] Use system_pysie6 --- qt/pyproject.toml | 1 + qt/src/toga_qt/factory.py | 19 +------------------ 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/qt/pyproject.toml b/qt/pyproject.toml index 9badef2d59..615e221ca6 100644 --- a/qt/pyproject.toml +++ b/qt/pyproject.toml @@ -58,6 +58,7 @@ root = ".." dependencies = [ "qasync", "toga-core == {version}", + "system-pyside6 @ git+https://github.com/johnzhou721/system-pyside6.git", ] [tool.coverage.run] diff --git a/qt/src/toga_qt/factory.py b/qt/src/toga_qt/factory.py index b657672659..ba42e0e6ad 100644 --- a/qt/src/toga_qt/factory.py +++ b/qt/src/toga_qt/factory.py @@ -1,23 +1,6 @@ # ruff: noqa: E402 -# noqa for initializing some Qt stuff and setting up QApplication - -import site -import sys - - -def import_pyside6(): - """Temporarily break isolation to import system PySide6.""" - system_site = site.getsitepackages() - print(system_site) - old_path = sys.path.copy() - sys.path.extend(system_site) - import PySide6 # noqa - - sys.path = old_path - - -import_pyside6() +# noqa for setting up QApplication before importing from PySide6.QtWidgets import QApplication