diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ae3b73863c..51bc3542b5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -73,6 +73,14 @@ updates: day: "sunday" time: "20:00" + - package-ecosystem: "pip" + directory: "/qt" + schedule: + # Check for updates on Sunday, 8PM UTC + interval: "weekly" + day: "sunday" + time: "20:00" + - package-ecosystem: "pip" directory: "/testbed" ignore: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8efc585e08..852c2c5fac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,7 @@ jobs: - "demo" - "dummy" - "gtk" + - "qt" - "iOS" - "toga" - "positron" @@ -208,7 +209,7 @@ jobs: tox -e trav-compat testbed: - name: Testbed + name: Testbed (${{ matrix.backend }}) needs: [ package, core-and-travertino ] runs-on: ${{ matrix.runs-on }} strategy: @@ -231,6 +232,7 @@ jobs: briefcase-run-prefix: "" briefcase-run-args: "" setup-python: true + testbed-app: "testbed" - backend: "macOS-x86_64" platform: "macOS" @@ -277,7 +279,7 @@ jobs: - backend: "linux-wayland-gtk3" platform: "linux" runs-on: "ubuntu-24.04" - # The package list should be the same as in unix-prerequisites.rst, and the BeeWare + # The package list should be the same as in unix-prerequisites.md, and the BeeWare # tutorial, plus mutter to provide a window manager. pre-command: | sudo apt update -y @@ -306,7 +308,7 @@ jobs: runs-on: "ubuntu-24.04" env: XDG_RUNTIME_DIR: "/tmp" - # The package list should be build on the same base as unix-prerequisites.rst, + # The package list should be build on the same base as unix-prerequisites.md, # and the BeeWare tutorial. Additional packages will be added for window # management, and features such as web views and geolocation that aren't part # of the default/tutorial environment. @@ -333,6 +335,70 @@ jobs: setup-python: false # Use the system Python packages app-user-data-path: "$HOME/.local/share/testbed" + - backend: "linux-x11-qt" + platform: "linux" + runs-on: "ubuntu-24.04" + testbed-app: "testbed-qt" + # The package list should be dependencies listed in unix-prerequsites.md, plus we need a window + # manager that is reasonably lightweight, honors full screen mode, and + # treats the window position as the top-left corner of the *window*, not the + # top-left corner of the window *content*. The default GNOME window managers of + # most distros meet these requirements, but they're heavyweight; flwm doesn't + # work either. Blackbox is the lightest WM we've found that works. + # PySide6 must be installed in VM to test that system-pyside6 works properly. + pre-command: | + sudo apt update -y + sudo apt install -y --no-install-recommends \ + blackbox python3-dev xvfb \ + gnome-session-canberra build-essential \ + libfontconfig1-dev libfreetype-dev libgtk-3-dev \ + libx11-dev libx11-xcb-dev libxext-dev \ + libxfixes-dev libxi-dev libxkbcommon-dev \ + libxkbcommon-x11-dev libxrender-dev 'libxcb*-dev' \ + + # Start Virtual X Server + echo "Start X server..." + Xvfb :99 -screen 0 2048x1536x24 & + sleep 1 + + # Start Window Mmanager + echo "Start window manager..." + DISPLAY=:99 blackbox & + sleep 1 + briefcase-run-prefix: 'DISPLAY=:99' + setup-python: false # Use the system Python packages + app-user-data-path: "$HOME/.local/share/testbed" + + - backend: "linux-wayland-qt" + platform: "linux" + runs-on: "ubuntu-24.04" + testbed-app: "testbed-qt" + # The package list should be unix-prerequisites.md, plus mutter to provide a window + # manager. + pre-command: | + sudo apt update -y + sudo apt install -y --no-install-recommends \ + mutter python3-dev xvfb \ + gnome-session-canberra \ + libwayland-dev libwayland-egl1-mesa libwayland-server0 \ + libgles2-mesa-dev libxkbcommon-dev + + # Start Virtual X Server + echo "Start X server..." + Xvfb :99 -screen 0 2048x1536x24 & + sleep 1 + + # Start Window Manager + echo "Start window manager..." + # mutter is being run inside a virtual X server because mutter's headless + # mode does not provide a Gdk.Display + DISPLAY=:99 MUTTER_DEBUG_DUMMY_MODE_SPECS=2048x1536 \ + mutter --nested --wayland --no-x11 --wayland-display toga & + sleep 1 + briefcase-run-prefix: "WAYLAND_DISPLAY=toga" + setup-python: false # Use the system Python packages + app-user-data-path: "$HOME/.local/share/testbed" + - backend: "textual-linux" platform: "linux" runs-on: "ubuntu-latest" @@ -428,7 +494,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@v5.0.0 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a5f0c4505e..acc4ea4e27 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -25,6 +25,7 @@ jobs: - "toga_demo" - "toga_dummy" - "toga_gtk" + - "toga_qt" - "toga_positron" - "toga_textual" - "toga_ios" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 651c03d5fa..68a0d79884 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,6 +68,7 @@ jobs: - "toga_demo" - "toga_dummy" - "toga_gtk" + - "toga_qt" - "toga_ios" - "toga_positron" - "toga_textual" diff --git a/android/tests_backend/app.py b/android/tests_backend/app.py index 24d9c82d49..83e53d0e56 100644 --- a/android/tests_backend/app.py +++ b/android/tests_backend/app.py @@ -14,6 +14,7 @@ class AppProbe(BaseProbe, DialogsMixin): supports_key = False supports_dark_mode = True + edit_menu_noop_enabled = False def __init__(self, app): super().__init__(app) diff --git a/changes/1142.feature.md b/changes/1142.feature.md new file mode 100644 index 0000000000..8536dddae7 --- /dev/null +++ b/changes/1142.feature.md @@ -0,0 +1 @@ +Toga now provides a Qt backend for KDE-based desktops. diff --git a/cocoa/tests_backend/app.py b/cocoa/tests_backend/app.py index b270091894..d760a0400f 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/core/src/toga/command.py b/core/src/toga/command.py index 655a4e31b1..67125971a8 100644 --- a/core/src/toga/command.py +++ b/core/src/toga/command.py @@ -160,6 +160,8 @@ def key(self) -> tuple[tuple[int, int, str], ...]: """Window management commands""" HELP: Group """Help commands""" + SETTINGS: Group + """Preferences commands (used only for Qt backend by default)""" Group.APP = Group("*", order=-100) @@ -169,6 +171,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): diff --git a/docs/config.yml b/docs/config.yml index 1b569bac83..f539b4ec9f 100644 --- a/docs/config.yml +++ b/docs/config.yml @@ -4,7 +4,8 @@ not_in_nav: | /index.md reference/api/widgets/table-values.md reference/api/widgets/table-accessors.md - reference/platforms/unix-prerequisites.md + reference/platforms/gtk-prerequisites.md + reference/platforms/qt-prerequisites.md validation: omitted_files: warn diff --git a/docs/en/SUMMARY.md b/docs/en/SUMMARY.md index 2c7d6d0843..f2c0f28f39 100644 --- a/docs/en/SUMMARY.md +++ b/docs/en/SUMMARY.md @@ -53,7 +53,8 @@ - [Supported Platforms](reference/platforms/index.md) - [macOS](./reference/platforms/macOS.md) - [Windows](./reference/platforms/windows.md) - - [Linux/Unix](./reference/platforms/linux.md) + - [Linux/Unix (GTK)](./reference/platforms/gtk.md) + - [Linux/Unix (Qt)](./reference/platforms/qt.md) - [Android](./reference/platforms/android.md) - [iOS](./reference/platforms/iOS.md) - [Web](./reference/platforms/web.md) diff --git a/docs/en/how-to/contribute/code.md b/docs/en/how-to/contribute/code.md index de183a2642..723ba26683 100644 --- a/docs/en/how-to/contribute/code.md +++ b/docs/en/how-to/contribute/code.md @@ -14,7 +14,7 @@ View the [macOS prerequisites][macos-prerequisites]. /// tab | Linux -View the [Linux prerequisites][linux-prerequisites]. +View the [GTK prerequisites][gtk-prerequisites] or the [Qt prerequisites][qt-prerequisites]. /// @@ -735,24 +735,34 @@ The above test suites exercise `toga-core` and `travertino` - but what about the ### Running the testbed app -To run the testbed app, install [Briefcase](https://briefcase.readthedocs.io/en/latest/), and run the app in developer test mode: +To run the testbed app, install [Briefcase](https://briefcase.readthedocs.io/en/latest/), and run the app in developer test mode as described below. Note that you should have only 1 backend -- the backend that you're planning to test -- installed in your virtual environment when running the test suite in developer mode. /// tab | macOS ```console (.venv) $ python -m pip install briefcase (.venv) $ cd testbed -(.venv) $ briefcase dev --test +(.venv) $ briefcase dev --app testbed --test ``` /// /// tab | Linux +For testing the GTK backend: + ```console (.venv) $ python -m pip install briefcase (.venv) $ cd testbed -(.venv) $ briefcase dev --test +(.venv) $ briefcase dev --app testbed --test +``` + +For testing the Qt backend: + +```console +(.venv) $ python -m pip install briefcase +(.venv) $ cd testbed +(.venv) $ briefcase dev --app testbed-qt --test ``` /// @@ -762,7 +772,7 @@ To run the testbed app, install [Briefcase](https://briefcase.readthedocs.io/en/ ```doscon (.venv) C:\...>python -m pip install briefcase (.venv) C:\...>cd testbed -(.venv) C:\...>briefcase dev --test +(.venv) C:\...>briefcase dev --app testbed --test ``` /// @@ -780,7 +790,7 @@ So - to run *only* the button tests in slow mode, you could run: /// tab | macOS ```console -(.venv) $ briefcase dev --test -- tests/widgets/test_button.py --slow +(.venv) $ briefcase dev --app testbed --test -- tests/widgets/test_button.py --slow ``` /// @@ -788,7 +798,13 @@ So - to run *only* the button tests in slow mode, you could run: /// tab | Linux ```console -(.venv) $ briefcase dev --test -- tests/widgets/test_button.py --slow +(.venv) $ briefcase dev --app testbed --test -- tests/widgets/test_button.py --slow +``` + +or + +```console +(.venv) $ briefcase dev --app testbed-qt --test -- tests/widgets/test_button.py --slow ``` /// @@ -796,14 +812,14 @@ So - to run *only* the button tests in slow mode, you could run: /// tab | Windows ```doscon -(.venv) C:\...>briefcase dev --test -- tests/widgets/test_button.py --slow +(.venv) C:\...>briefcase dev --app testbed --test -- tests/widgets/test_button.py --slow ``` /// This test will take a lot longer to run, but you'll see the widget (Button, in this case) go through various color, format, and size changes as the test runs. You won't get a coverage report if you run a subset of the tests, or if you enable slow mode. -### Running testbed in developer mode +### Running the testbed for mobile platforms Developer mode is useful for testing desktop platforms (Cocoa, Winforms and GTK); but if you want to test a mobile backend, you'll need to use `briefcase run`. @@ -812,13 +828,13 @@ Developer mode is useful for testing desktop platforms (Cocoa, Winforms and GTK) To run the Android test suite: ```console -(.venv) $ briefcase run android --test +(.venv) $ briefcase run android --app testbed --test ``` To run the iOS test suite: ```console -(.venv) $ briefcase run iOS --test +(.venv) $ briefcase run iOS --app testbed --test ``` /// @@ -828,7 +844,7 @@ To run the iOS test suite: To run the Android test suite: ```console -(.venv) $ briefcase run android --test +(.venv) $ briefcase run android --app testbed --test ``` iOS tests can't be executed on Linux. @@ -840,7 +856,7 @@ iOS tests can't be executed on Linux. To run the Android test suite: ```doscon -(.venv) C:\...>briefcase run android --test +(.venv) C:\...>briefcase run android --app testbed --test ``` iOS tests can't be executed on Windows. diff --git a/docs/en/index.md b/docs/en/index.md index 8efb69eb10..0718d6bfc3 100644 --- a/docs/en/index.md +++ b/docs/en/index.md @@ -2,7 +2,7 @@ Toga is a Python native, OS native, cross-platform GUI toolkit. Toga consists of a library of base components with a shared interface to simplify platform-agnostic GUI development. -Toga is available on macOS, Windows, Linux (GTK), Android, iOS, for single-page web apps, and console apps. +Toga is available on macOS, Windows, Linux (GTK or Qt), Android, iOS, for single-page web apps, and console apps. /// tab | macOS @@ -16,7 +16,7 @@ Toga is available on macOS, Windows, Linux (GTK), Android, iOS, for single-page /// -/// tab | Linux +/// tab | Linux (GTK) ![/images/toga-demo-gtk.png](/images/toga-demo-gtk.png){ width="450" } diff --git a/docs/en/reference/data/apis_by_platform.yaml b/docs/en/reference/data/apis_by_platform.yaml index 57238edd94..e9a67e28ab 100644 --- a/docs/en/reference/data/apis_by_platform.yaml +++ b/docs/en/reference/data/apis_by_platform.yaml @@ -19,6 +19,7 @@ Core application components: beta: - web - textual + - qt DocumentWindow: description: A window that can be used as the main interface to a document-based app. @@ -27,6 +28,8 @@ Core application components: - android - web - textual + beta: + - qt General widgets: path: widgets @@ -49,6 +52,7 @@ General widgets: unsupported: - web - textual + - qt DateInput: description: A widget to select a calendar date. @@ -56,6 +60,7 @@ General widgets: - web unsupported: - textual + - qt DetailedList: description: An ordered list of content where each item has an icon, a main heading, and a line of supplementary text. @@ -64,6 +69,7 @@ General widgets: unsupported: - web - textual + - qt Divider: description: A separator used to visually distinguish two sections of content in a layout. @@ -71,12 +77,14 @@ General widgets: - web unsupported: - textual + - qt ImageView: description: A widget that displays an image. unsupported: - web - textual + - qt Label: description: A text label for annotating forms or interfaces. @@ -89,18 +97,21 @@ General widgets: unsupported: - web - textual + - qt MultilineTextInput: description: A scrollable panel that allows for the display and editing of multiple lines of text. unsupported: - web - textual + - qt NumberInput: description: A text input that is limited to numeric input. unsupported: - web - textual + - qt PasswordInput: description: A widget to allow the entry of a password. Any value typed by the user will be obscured, allowing the user to see the number of characters they have typed, but not the actual characters. @@ -108,6 +119,7 @@ General widgets: - web unsupported: - textual + - qt ProgressBar: description: A horizontal bar to visualize task progress. The task being monitored can be of known or indeterminate length. @@ -115,6 +127,7 @@ General widgets: - web unsupported: - textual + - qt Selection: description: A widget to select a single option from a list of alternatives. @@ -122,6 +135,7 @@ General widgets: - web unsupported: - textual + - qt Slider: description: A widget for selecting a value within a range. The range is shown as a horizontal line, and the selected value is shown as a draggable marker. @@ -129,6 +143,7 @@ General widgets: - web unsupported: - textual + - qt Switch: description: 'A clickable button with two stable states: True (on, checked); and False (off, unchecked). The button has a text label.' @@ -136,6 +151,7 @@ General widgets: - web unsupported: - textual + - qt Table: description: A widget for displaying columns of tabular data. @@ -145,20 +161,21 @@ General widgets: - iOS - web - textual + - qt TextInput: description: A widget for the display and editing of a single line of text. beta: - web - textual - unsupported: - - gtk - - textual TimeInput: description: A widget to select a clock time. beta: - web + unsupported: + - gtk + - qt Tree: description: A widget for displaying a hierarchical tree of tabular data. @@ -168,12 +185,14 @@ General widgets: - android - web - textual + - qt WebView: description: An embedded web browser. unsupported: - web - textual + - qt Widget: description: The abstract base class of all widgets. This class should not be be instantiated directly. @@ -198,6 +217,7 @@ Layout widgets: - web unsupported: - textual + - qt SplitContainer: description: A container that divides an area into two panels with a movable border. @@ -206,12 +226,14 @@ Layout widgets: - android - web - textual + - qt OptionContainer: description: A container that can display multiple labeled tabs of content. unsupported: - web - textual + - qt Resources: path: resources @@ -240,6 +262,7 @@ Resources: beta: - web - textual + - qt Document: description: A representation of a file on disk that will be displayed in one or more windows. @@ -257,6 +280,7 @@ Resources: unsupported: - web - textual + - qt Icon: description: A small, square image used to provide easily identifiable visual context to a widget. @@ -283,6 +307,7 @@ Resources: - android - web - textual + - qt Source: description: A base class for data source implementations. @@ -319,6 +344,7 @@ Hardware: - winforms - web - textual + - qt Location services: description: A sensor that can capture the geographical location of the device. @@ -328,6 +354,7 @@ Hardware: - winforms - web - textual + - qt Screen: description: A representation of a screen attached to a device. diff --git a/docs/en/reference/images/activityindicator-qt.png b/docs/en/reference/images/activityindicator-qt.png new file mode 100644 index 0000000000..51353abf6d Binary files /dev/null and b/docs/en/reference/images/activityindicator-qt.png differ diff --git a/docs/en/reference/images/button-qt.png b/docs/en/reference/images/button-qt.png new file mode 100644 index 0000000000..225def689b Binary files /dev/null and b/docs/en/reference/images/button-qt.png differ diff --git a/docs/en/reference/images/label-qt.png b/docs/en/reference/images/label-qt.png new file mode 100644 index 0000000000..76836ab012 Binary files /dev/null and b/docs/en/reference/images/label-qt.png differ diff --git a/docs/en/reference/images/mainwindow-qt.png b/docs/en/reference/images/mainwindow-qt.png new file mode 100644 index 0000000000..7fff1a05b5 Binary files /dev/null and b/docs/en/reference/images/mainwindow-qt.png differ diff --git a/docs/en/reference/images/textinput-qt.png b/docs/en/reference/images/textinput-qt.png new file mode 100644 index 0000000000..bb9fd8055e Binary files /dev/null and b/docs/en/reference/images/textinput-qt.png differ diff --git a/docs/en/reference/images/window-qt.png b/docs/en/reference/images/window-qt.png new file mode 100644 index 0000000000..86a725c75c Binary files /dev/null and b/docs/en/reference/images/window-qt.png differ diff --git a/docs/en/reference/platforms/unix-prerequisites.md b/docs/en/reference/platforms/gtk-prerequisites.md similarity index 99% rename from docs/en/reference/platforms/unix-prerequisites.md rename to docs/en/reference/platforms/gtk-prerequisites.md index 78daea64e2..e3f43d774a 100644 --- a/docs/en/reference/platforms/unix-prerequisites.md +++ b/docs/en/reference/platforms/gtk-prerequisites.md @@ -18,7 +18,7 @@ These instructions are different on almost every version of Linux and Unix; here If you're running on Ubuntu 22.04, Debian 11 or Debian 12, you'll also need to add a pin for `PyGObject==3.50.0`. Later versions of PyGObject require the `libgirepository-2.0-dev` library, which isn't available on older Debian-based distributions. -### Fedora +### Fedora 41+ ```console (venv) $ sudo dnf install git gcc make pkg-config python3-devel gobject-introspection-devel cairo-gobject-devel gtk3 libcanberra-gtk3 diff --git a/docs/en/reference/platforms/linux.md b/docs/en/reference/platforms/gtk.md similarity index 69% rename from docs/en/reference/platforms/linux.md rename to docs/en/reference/platforms/gtk.md index 401a67ca34..65883f2df4 100644 --- a/docs/en/reference/platforms/linux.md +++ b/docs/en/reference/platforms/gtk.md @@ -1,4 +1,4 @@ -# Linux/Unix +# Linux/Unix (GTK) ![image](../images/gtk.png){ width="300px" } @@ -8,13 +8,7 @@ -The Toga backend for Linux (and other Unix-like operating systems) is [`toga-gtk`](https://github.com/beeware/toga/tree/main/gtk). - -/// admonition | Qt support - -Toga does not currently have a Qt backend for KDE-based desktops. However, we would like to add one; see [this ticket](https://github.com/beeware/toga/issues/1142) for details. If you would like to contribute, please get in touch on that ticket, on [Mastodon](https://fosstodon.org/@beeware), or on [Discord](https://beeware.org/bee/chat/). - -/// +The Toga backend for Linux (and other Unix-like operating systems) running the GNOME desktop environment is [`toga-gtk`](https://github.com/beeware/toga/tree/main/gtk). /// admonition | GTK on Windows and macOS @@ -22,7 +16,11 @@ Although GTK *can* be installed on Windows and macOS, and the `toga-gtk` backend /// -## Prerequisites { #linux-prerequisites } +[](){ #linux-prerequisites } + +[](){ #gtk-prerequisites } + +## Prerequisites { #gtk-prerequisites } `toga-gtk` requires Python 3.10+, and GTK 3.22 or newer. @@ -30,7 +28,7 @@ Most testing occurs with GTK 3.24 as this is the version that has shipped with a The system packages that provide GTK must be installed manually: --8<- "reference/platforms/unix-prerequisites.md" +-8<- "reference/platforms/gtk-prerequisites.md" Toga does not currently support GTK 4. diff --git a/docs/en/reference/platforms/index.md b/docs/en/reference/platforms/index.md index 8fd885abf0..5081bf3d79 100644 --- a/docs/en/reference/platforms/index.md +++ b/docs/en/reference/platforms/index.md @@ -4,7 +4,8 @@ * [macOS](./macOS.md) * [Windows](./windows.md) -* [Linux](./linux.md) +* [Linux (GTK)](./gtk.md) +* [Linux (Qt)](./qt.md) ## Mobile diff --git a/docs/en/reference/platforms/qt-prerequisites.md b/docs/en/reference/platforms/qt-prerequisites.md new file mode 100644 index 0000000000..2e0ed85bf5 --- /dev/null +++ b/docs/en/reference/platforms/qt-prerequisites.md @@ -0,0 +1,62 @@ + + +These instructions are different on almost every version of Linux and Unix, in addition to whether Wayland or X11 is used; here are some of the common alternatives: + +### Ubuntu 24.04 / Debian 11+ + +/// tab | Wayland + +```console +(venv) $ sudo apt install git python3-dev build-essential \ + libwayland-dev libwayland-egl1-mesa libwayland-server0 \ + libgles2-mesa-dev libxkbcommon-dev gnome-session-canberra +``` + +/// + +/// tab | X11 + +```console +(venv) $ sudo apt install git python3-dev build-essential \ + libfontconfig1-dev libfreetype-dev libgtk-3-dev \ + libx11-dev libx11-xcb-dev libxext-dev \ + libxfixes-dev libxi-dev libxkbcommon-dev \ + libxkbcommon-x11-dev libxrender-dev 'libxcb*-dev' \ + gnome-session-canberra +``` + +/// + +### Fedora 41+ + +/// warning | Requirement to Update System Packages + +Fedora's packaging of some Qt and KDE-related packages lists incorrect dependency versions; installing certain packages that updates KWin or related things may brick your system. Therefore, it is highly recommended, as a general precaution, to upgrade all packages following the installation of these components. + +/// + +/// tab | Wayland + +```console +(venv) $ sudo dnf install git python3-devel \ + libcanberra-gtk3 wayland-devel libwayland-server \ + mesa-libEGL libxkbcommon-devel +(venv) $ sudo dnf upgrade --refresh +``` + +/// + +/// tab | X11 + +```console +(venv) $ sudo dnf install git python3-devel \ + libcanberra-gtk3 fontconfig-devel freetype-devel \ + gtk3-devel libX11-devel libX11-xcb libXext-devel \ + libXfixes-devel libXi-devel libxkbcommon-devel \ + libxkbcommon-x11-devel libXrender-devel 'xcb-util*devel' \ +(venv) $ sudo dnf upgrade --refresh +``` + +/// + +If you're not using one of these, you'll need to work out how to install the developer libraries for `python3`, [Qt's X11 dependencies](https://doc.qt.io/qt-6/linux-requirements.html), [Qt's Wayland dependencies](https://doc.qt.io/qt-6/wayland-requirements.html), and the executable ``canberra-gtk-play`` (and please let us know so we can improve this documentation!) diff --git a/docs/en/reference/platforms/qt.md b/docs/en/reference/platforms/qt.md new file mode 100644 index 0000000000..4276ed5264 --- /dev/null +++ b/docs/en/reference/platforms/qt.md @@ -0,0 +1,39 @@ +# Linux/Unix (Qt) + +The Toga backend for Linux (and other Unix-like operating systems) running KDE is [`toga-qt`](https://github.com/beeware/toga/tree/main/qt). + +`toga-qt` requires Python 3.10+, and Qt 6.8 or newer. + +/// warning | Experimental Backend + +While the GTK 3 backend is mostly completed in functionality, the Qt backend is currently a pre-alpha prototype. + +/// + +/// admonition | Qt on Windows and macOS + +Although Qt *can* be installed on Windows and macOS, and the `toga-qt` backend *may* work on those platforms, this is not officially supported by Toga. We recommend using `toga-winforms` on [Windows][], and `toga-cocoa` on [macOS][]. + +/// + +## Prerequisites { #qt-prerequisites } + +Most Qt testing occurs with Qt 6.10 as this is the version that is installable through ``pip``'s PySide6. + +The system packages that provide Qt must be installed manually: + +-8<- "reference/platforms/qt-prerequisites.md" + +## Installation + +`toga-qt` must be manually installed along with ``toga-core`` if its usage is desired: + +```console +$ python -m pip install toga-core[pyside6] +``` + +## Implementation Details + +The `toga-qt` backend uses Qt 6. + +The native APIs are accessed using the [PySide6 bindings](https://www.qt.io/development/qt-framework/python-bindings). diff --git a/docs/en/tutorial/tutorial-0.md b/docs/en/tutorial/tutorial-0.md index 23f96774e1..b56f44f3ea 100644 --- a/docs/en/tutorial/tutorial-0.md +++ b/docs/en/tutorial/tutorial-0.md @@ -53,9 +53,9 @@ No additional dependencies are necessary. /// tab | Linux -Before you install Toga, you'll need to install some system packages. +Before you install Toga, you'll need to install some system packages. The new beta Qt backend is not sufficient to run this tutorial yet. --8<- "reference/platforms/unix-prerequisites.md" +-8<- "reference/platforms/gtk-prerequisites.md" /// diff --git a/docs/main.py b/docs/main.py index 15dfaa8815..7b464fd9a9 100644 --- a/docs/main.py +++ b/docs/main.py @@ -14,7 +14,8 @@ def slugify(string): PLATFORMS_MAPPING = { "cocoa": "macOS", - "gtk": "GTK", + "gtk": "Linux (GTK)", + "qt": "Linux (Qt)", "winforms": "Windows", "iOS": "iOS", "android": "Android", diff --git a/docs/spelling_wordlist b/docs/spelling_wordlist index 67dfe7ca98..5c5eb9e0da 100644 --- a/docs/spelling_wordlist +++ b/docs/spelling_wordlist @@ -86,6 +86,7 @@ instantiation iOS iterable KDE +KWin linters ListSource macOS @@ -130,8 +131,10 @@ PyInstaller PyPI pyPlayground PyScript +PySide pytest pythonic +Qt's QuickLook Quickstart radiusx diff --git a/examples/screenshot/CHANGELOG b/examples/screenshot/CHANGELOG new file mode 100644 index 0000000000..c6b095235f --- /dev/null +++ b/examples/screenshot/CHANGELOG @@ -0,0 +1 @@ +See Toga releases for change notes. diff --git a/examples/screenshot/LICENSE b/examples/screenshot/LICENSE new file mode 100644 index 0000000000..f09583bfab --- /dev/null +++ b/examples/screenshot/LICENSE @@ -0,0 +1 @@ +Released under the same license as Toga. See the root of the Toga repository for details. diff --git a/examples/screenshot/pyproject.toml b/examples/screenshot/pyproject.toml index 920b5dad1c..0ce1d9179f 100644 --- a/examples/screenshot/pyproject.toml +++ b/examples/screenshot/pyproject.toml @@ -10,16 +10,22 @@ license.file = "LICENSE" author = "Tiberius Yak" author_email = "tiberius@beeware.org" -[tool.briefcase.app.screenshot] formal_name = "Screenshot Generator" description = "A testing app" -sources = ["screenshot"] requires = [ "../../travertino", "../../core", "pillow", ] +[tool.briefcase.app.screenshot] +sources = ["screenshot"] + + +[tool.briefcase.app.screenshot-qt] +sources = ["screenshot", "screenshot_qt"] + + [tool.briefcase.app.screenshot.macOS] requires = [ @@ -32,6 +38,12 @@ requires = [ "../../gtk", ] +[tool.briefcase.app.screenshot-qt.linux] +requires = [ + "../../qt", + "system-pyside6 @ git+https://github.com/johnzhou721/system-pyside6.git" +] + [tool.briefcase.app.screenshot.windows] requires = [ "../../winforms", diff --git a/examples/screenshot/screenshot_qt/__init__.py b/examples/screenshot/screenshot_qt/__init__.py new file mode 100644 index 0000000000..86826e638c --- /dev/null +++ b/examples/screenshot/screenshot_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.1" diff --git a/examples/screenshot/screenshot_qt/__main__.py b/examples/screenshot/screenshot_qt/__main__.py new file mode 100644 index 0000000000..1dfc6569a0 --- /dev/null +++ b/examples/screenshot/screenshot_qt/__main__.py @@ -0,0 +1,4 @@ +from screenshot.app import main + +if __name__ == "__main__": + main().main_loop() diff --git a/gtk/README.rst b/gtk/README.rst index b657498bf8..a055de698c 100644 --- a/gtk/README.rst +++ b/gtk/README.rst @@ -19,8 +19,8 @@ A GTK 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 `Linux platform documentation -`__. +For platform requirements, see the `GTK platform documentation +`__. For more details, see the `Toga project on GitHub`_. 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/iOS/tests_backend/app.py b/iOS/tests_backend/app.py index 68b49490f2..cc9d9f7627 100644 --- a/iOS/tests_backend/app.py +++ b/iOS/tests_backend/app.py @@ -16,6 +16,7 @@ class AppProbe(BaseProbe, DialogsMixin): supports_key = False supports_dark_mode = False + edit_menu_noop_enabled = False def __init__(self, app): super().__init__() 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.md b/qt/README.md new file mode 100644 index 0000000000..e8f5bd1c07 --- /dev/null +++ b/qt/README.md @@ -0,0 +1,29 @@ +# toga-qt + +[![Python Versions](https://img.shields.io/pypi/pyversions/toga-qt.svg)](https://pypi.python.org/pypi/toga-qt) +[![BSD-3-Clause License](https://img.shields.io/pypi/l/toga-qt.svg)](https://github.com/beeware/toga-qt/blob/main/LICENSE) +[![Project status](https://img.shields.io/pypi/status/toga-qt.svg)](https://pypi.python.org/pypi/toga-qt) + +A Qt backend for the [Toga widget toolkit](https://beeware.org/toga). + +This package isn't much use by itself; it needs to be combined with [the core Toga library](https://pypi.python.org/pypi/toga-core). + +For platform requirements, see the Qt platform documentation (TODO). + +For more details, see the [Toga project on GitHub](https://github.com/beeware/toga). + +## Community + +Toga is part of the [BeeWare suite](https://beeware.org). You can talk to the community through: + +- [@beeware@fosstodon.org on Mastodon](https://fosstodon.org/@beeware) +- [Discord](https://beeware.org/bee/chat/) +- The Toga [GitHub Discussions forum](https://github.com/beeware/toga/discussions) + +We foster a welcoming and respectful community as described in our [BeeWare Community Code of Conduct](https://beeware.org/community/behavior/). + +## Contributing + +If you experience problems with Toga, [log them on GitHub](https://github.com/beeware/toga/issues). + +If you'd like to contribute to Toga development, our [contribution guide](https://toga.beeware.org/en/latest/how-to/contribute) 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..8a88f88563 --- /dev/null +++ b/qt/pyproject.toml @@ -0,0 +1,80 @@ +[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", "dependencies"] +name = "toga-qt" +description = "An Qt (KDE) backend for the Toga widget toolkit." +readme = "README.md" +requires-python = ">= 3.10" +license = "BSD-3-Clause" +license-files = [ + "LICENSE" +] +authors = [ + {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", + "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 = "toga_qt" + +[tool.setuptools_scm] +root = ".." + +[tool.setuptools_dynamic_dependencies] +dependencies = [ + "qasync == 0.28.0", + "toga-core == {version}", +] + +[project.optional-dependencies] +pyside6 = [ + "PySide6-Essentials == 6.10.0", +] + +[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..1fb1a84eae --- /dev/null +++ b/qt/src/toga_qt/__init__.py @@ -0,0 +1,3 @@ +import travertino + +__version__ = travertino._package_version(__file__, __name__) diff --git a/qt/src/toga_qt/app.py b/qt/src/toga_qt/app.py new file mode 100644 index 0000000000..93b85c24f9 --- /dev/null +++ b/qt/src/toga_qt/app.py @@ -0,0 +1,216 @@ +import asyncio + +from PySide6.QtCore import QSize, Qt +from PySide6.QtGui import QCursor, QGuiApplication +from PySide6.QtWidgets import QApplication, QMessageBox +from qasync import QEventLoop + +import toga +from toga.command import Command, Group + +from .command import EditOperation +from .libs import create_qapplication +from .screens import Screen as ScreenImpl + + +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}

" + ) + 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 App: + # Qt apps exit when the last window is closed + CLOSE_ON_LAST_WINDOW = True + # Qt apps use default command line handling + HANDLES_COMMAND_LINE = False + + def __init__(self, interface): + self.interface = interface + self.interface._impl = self + + self.native = create_qapplication() + self.loop = QEventLoop(self.native) + asyncio.set_event_loop(self.loop) + self.app_close_event = asyncio.Event() + # Connect the native signal to an asyncio Event in order + # for the main event loop to finish running upon app exit + self.native.aboutToQuit.connect(self.app_close_event.set) + # Qt does not have a native "applicaction started" signal; + # however, tasks scheduled on the event loop will only start + # as soon as the application is running. + self.loop.call_soon_threadsafe(self.interface._startup) + + self.cursorhidden = False + + ###################################################################### + # Commands and menus + ###################################################################### + + def create_standard_commands(self): + # 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. + # Those KDE bundled apps only have one textfield in the application, + # so it's trivial for them to implement it. + self.interface.commands.add( + Command( + EditOperation("undo"), + "Undo", + shortcut=toga.Key.MOD_1 + "z", + group=Group.EDIT, + order=10, + ), + Command( + EditOperation("redo"), + "Redo", + shortcut=toga.Key.SHIFT + toga.Key.MOD_1 + "z", + group=Group.EDIT, + order=20, + ), + Command( + EditOperation("cut", True), + "Cut", + shortcut=toga.Key.MOD_1 + "x", + group=Group.EDIT, + section=10, + order=10, + ), + Command( + EditOperation("copy"), + "Copy", + shortcut=toga.Key.MOD_1 + "c", + group=Group.EDIT, + section=10, + order=20, + ), + Command( + EditOperation("paste", True), + "Paste", + shortcut=toga.Key.MOD_1 + "v", + group=Group.EDIT, + section=10, + order=30, + ), + ) + + def create_menus(self): + for window in self.interface.windows: + if hasattr(window._impl, "create_menus"): # pragma: no branch + 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 + + def set_main_window(self, window): + pass + + ###################################################################### + # App resources + ###################################################################### + + 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 screens] + + ###################################################################### + # App state + ###################################################################### + + def get_dark_mode_state(self): + return QGuiApplication.styleHints().colorScheme() == Qt.ColorScheme.Dark + + ###################################################################### + # App capabilities + ###################################################################### + + async def _beep(self): + process = await asyncio.create_subprocess_exec( + "canberra-gtk-play", "-i", "bell" + ) + await process.wait() + + def beep(self): + asyncio.create_task(self._beep()) + + def show_about_dialog(self): + # 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() + + ###################################################################### + # 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/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/command.py b/qt/src/toga_qt/command.py new file mode 100644 index 0000000000..a4d1e6fe26 --- /dev/null +++ b/qt/src/toga_qt/command.py @@ -0,0 +1,142 @@ +import sys + +from PySide6.QtGui import QAction +from PySide6.QtWidgets import QApplication + +from toga import Command as StandardCommand, Group, Key + +from .keys import toga_to_qt_key + + +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. + actions. + + :param: needwrite: Whether write access is required for the focus + widget. + """ + + 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 + if self.needwrite: + fnwrite = getattr(fw, "isReadOnly", None) + if fnwrite(): + return + fn = getattr(fw, self.method_name, None) + fn() + + +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: + return { + "text": "Quit", + "shortcut": Key.MOD_1 + "q", + "group": Group.FILE, + "section": sys.maxsize, + } + + # ---- 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": 10, + "order": 0, + } + + elif id == StandardCommand.SAVE: + return { + "text": "Save", + "shortcut": Key.MOD_1 + "s", + "group": Group.FILE, + "section": 20, + "order": 0, + } + elif id == StandardCommand.SAVE_AS: + return { + "text": "Save As...", + "shortcut": Key.MOD_1 + "S", + "group": Group.FILE, + "section": 20, + "order": 10, + } + elif id == StandardCommand.SAVE_ALL: + return { + "text": "Save All", + "shortcut": Key.MOD_1 + "l", + "group": Group.FILE, + "section": 20, + "order": 20, + } + # ---- Help menu ----------------------------------- + elif id == StandardCommand.VISIT_HOMEPAGE: + return None # KDE apps have homepage link in about dialog + 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 create_menu_item(self): + item = QAction(self.interface.text) + + if self.interface.icon: + item.setIcon(self.interface.icon._impl.native) + + item.triggered.connect(self.interface.action) + + if self.interface.shortcut is not None: + item.setShortcut(toga_to_qt_key(self.interface.shortcut)) + + item.setEnabled(self.interface.enabled) + + self.native.append(item) + + return item diff --git a/qt/src/toga_qt/container.py b/qt/src/toga_qt/container.py new file mode 100644 index 0000000000..5ab394b837 --- /dev/null +++ b/qt/src/toga_qt/container.py @@ -0,0 +1,39 @@ +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 + + @property + def width(self): + return self.layout_native.width() + + @property + def height(self): + return self.layout_native.height() + + @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): + 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..bbfe897620 --- /dev/null +++ b/qt/src/toga_qt/dialogs.py @@ -0,0 +1,80 @@ +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, + ) + # 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): + 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 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..0ebee67fd1 --- /dev/null +++ b/qt/src/toga_qt/factory.py @@ -0,0 +1,60 @@ +from toga import NotImplementedWarning + +try: + from . import dialogs + 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 .libs import get_testing + 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 +except ModuleNotFoundError as exc: # pragma: no cover + if exc.name == "PySide6": + raise ImportError( + "Cannot import PySide6. Did you install toga-qt with the extra[pyside6]?" + ) from exc + else: + raise + +__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): + 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/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..8433628259 --- /dev/null +++ b/qt/src/toga_qt/icons.py @@ -0,0 +1,47 @@ +import sys +from pathlib import Path + +from PySide6.QtGui import QIcon + +import toga + +from .libs import create_qapplication + +IMPL_DICT = {} + + +class Icon: + EXTENSIONS = [".png", ".jpeg", ".jpg", ".gif", ".bmp", ".ico"] + SIZES = None + + def __init__(self, interface, path): + # A QApplication must exist before pixmaps can be manipulated + create_qapplication() + self.interface = interface + + if path is None: + # 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" + for size in SIZES + if (hicolor / f"{size}x{size}/apps/{toga.App.app.app_id}.png").is_file() + } + + if not sizes: # pragma: no cover + raise FileNotFoundError("No icon variants found") + + path = sizes[max(sizes)] + + 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}") + + 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..747d0c8b6b --- /dev/null +++ b/qt/src/toga_qt/images.py @@ -0,0 +1,56 @@ +from pathlib import Path + +from PySide6.QtCore import QBuffer, QIODevice +from PySide6.QtGui import QImage + +from .libs import create_qapplication + + +class Image: + RAW_TYPE = QImage + + def __init__(self, interface, path=None, data=None, raw=None): + # A QApplication must exist before pixmaps can be manipulated + create_qapplication() + + 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"): # pragma: no cover + 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): # pragma: no cover + raise ValueError(f"Failed to save image to {path}") diff --git a/qt/src/toga_qt/keys.py b/qt/src/toga_qt/keys.py new file mode 100644 index 0000000000..94d9b08443 --- /dev/null +++ b/qt/src/toga_qt/keys.py @@ -0,0 +1,146 @@ +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, +} + +# 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? + 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, + 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", strict=False)) +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 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..e6981f6263 --- /dev/null +++ b/qt/src/toga_qt/libs/__init__.py @@ -0,0 +1,3 @@ +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/env.py b/qt/src/toga_qt/libs/env.py new file mode 100644 index 0000000000..8d3430df8f --- /dev/null +++ b/qt/src/toga_qt/libs/env.py @@ -0,0 +1,12 @@ +from PySide6.QtGui import QGuiApplication + +from .utils import create_qapplication + + +class LazyWaylandFlag: + def __bool__(self): + create_qapplication() + return QGuiApplication.platformName() == "wayland" + + +IS_WAYLAND = LazyWaylandFlag() diff --git a/qt/src/toga_qt/libs/testing.py b/qt/src/toga_qt/libs/testing.py new file mode 100644 index 0000000000..43c7427b5e --- /dev/null +++ b/qt/src/toga_qt/libs/testing.py @@ -0,0 +1,5 @@ +import os + + +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..4e3d88ae2e --- /dev/null +++ b/qt/src/toga_qt/libs/utils.py @@ -0,0 +1,20 @@ +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QApplication +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] + + +def create_qapplication(): + return QApplication.instance() or QApplication() 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 0000000000..eae2a2e753 Binary files /dev/null and b/qt/src/toga_qt/resources/toga.png differ diff --git a/qt/src/toga_qt/screens.py b/qt/src/toga_qt/screens.py new file mode 100644 index 0000000000..026c6bd280 --- /dev/null +++ b/qt/src/toga_qt/screens.py @@ -0,0 +1,52 @@ +from PySide6.QtCore import QBuffer, QByteArray, QIODevice + +from toga.screens import Screen as ScreenInterface +from toga.types import Position, Size + +from .libs import IS_WAYLAND + + +class Screen: + _instances = {} + + def __new__(cls, native): + if native in cls._instances: + return cls._instances[native] + else: + instance = super().__new__(cls) + instance.interface = ScreenInterface(_impl=instance) + instance.native = native + cls._instances[native] = instance + return instance + + def get_name(self): + # FIXME: What combinations of values are guaranteed to be + # unique? + return "|".join( + [ + self.native.name(), + self.native.model(), + self.native.manufacturer(), + self.native.serialNumber(), + ] + ) + + def get_origin(self) -> 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 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: # pragma: no-cover-if-linux-x + 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..3bf18bd983 --- /dev/null +++ b/qt/src/toga_qt/statusicons.py @@ -0,0 +1,35 @@ +import toga + +# Not implemented on Qt yet. + + +class StatusIcon: + def __init__(self, interface): + self.interface = interface + self.native = None + + def set_icon(self, icon): + pass + + def create(self): + toga.NotImplementedWarning.warn("Qt", "Status Icons") + + # Remove no-cover when this is implemented + def remove(self): # pragma: no cover + 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/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..e1a1556456 --- /dev/null +++ b/qt/src/toga_qt/widgets/activityindicator.py @@ -0,0 +1,48 @@ +from pathlib import Path + +from PySide6.QtCore import Qt +from PySide6.QtQuickWidgets import QQuickWidget + +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() + self.native.setSource( + str(Path(__file__).parent.parent / "resources/activityindicator.qml") + ) + self.native.setResizeMode(QQuickWidget.SizeRootObjectToView) + self.running = False + self.ai_hidden = True + 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..d9964a91ba --- /dev/null +++ b/qt/src/toga_qt/widgets/base.py @@ -0,0 +1,128 @@ +from abc import abstractmethod + +from PySide6.QtCore import Qt + +from ..colors import native_color, toga_color + + +class Widget: + def __init__(self, interface): + self.interface = interface + self._container = None + self.native = None + 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): + 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_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_text_align(self, alignment): + pass # If appropriate, a widget subclass will implement this. + + def set_color(self, color): + 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): + 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 + 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() + + # A subclass will implement rehint diff --git a/qt/src/toga_qt/widgets/box.py b/qt/src/toga_qt/widgets/box.py new file mode 100644 index 0000000000..026e6be969 --- /dev/null +++ b/qt/src/toga_qt/widgets/box.py @@ -0,0 +1,19 @@ +from PySide6.QtWidgets import QWidget +from travertino.constants import TRANSPARENT +from travertino.size import at_least + +from .base import Widget + + +class Box(Widget): + def create(self): + self.native = QWidget() + self.native.setAutoFillBackground(True) + # Background is not autofilled by default; but since we're + # enabling it here, let the default color be transparent + # so it autofills nothing. + self._default_background_color = TRANSPARENT + + 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..3bc2dec243 --- /dev/null +++ b/qt/src/toga_qt/widgets/button.py @@ -0,0 +1,52 @@ +from PySide6.QtCore import QSize +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.setIconSize(QSize(32, 32)) + + 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()) + # Qt does not round-trip the same instance of the icon back. + self._icon = icon + + def rehint(self): + width = self.native.sizeHint().width() + height = self.native.sizeHint().height() + + self.interface.intrinsic.width = at_least(width) + # Height of a button is known. + self.interface.intrinsic.height = height + + def set_color(self, color): + 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/widgets/label.py b/qt/src/toga_qt/widgets/label.py new file mode 100644 index 0000000000..5c4570f2a8 --- /dev/null +++ b/qt/src/toga_qt/widgets/label.py @@ -0,0 +1,31 @@ +from PySide6.QtWidgets import QLabel +from travertino.constants import TOP, TRANSPARENT +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() + self.native.setAutoFillBackground(True) + # Background is not autofilled by default; but since we're + # enabling it here, let the default color be transparent + # so it autofills nothing. + self._default_background_color = TRANSPARENT + + 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..ae4cf9eaa7 --- /dev/null +++ b/qt/src/toga_qt/window.py @@ -0,0 +1,329 @@ +from functools import partial + +from PySide6.QtCore import QBuffer, QEvent, QIODevice, Qt, QTimer +from PySide6.QtGui import QWindowStateChangeEvent +from PySide6.QtWidgets import QApplication, QMainWindow, QMenu + +from toga.command import Separator +from toga.constants import WindowState +from toga.types import Position, Size + +from .container import Container +from .libs import ( + IS_WAYLAND, +) +from .screens import Screen as ScreenImpl + + +class TogaMainWindow(QMainWindow): + def __init__(self, impl, *args, **kwargs): + super().__init__(*args, **kwargs) + self.interface = impl.interface + self.impl = impl + + def changeEvent(self, event): + if event.type() == QEvent.WindowStateChange: + old = event.oldState() + new = self.windowState() + # Those branches cannot be triggered reliably on Wayland, as + # Minimized is not a reliable window state. + if ( # pragma: no-cover-if-linux-wayland + not old & Qt.WindowMinimized and new & Qt.WindowMinimized + ): + self.interface.on_hide() + elif ( # pragma: no-cover-if-linux-wayland + old & Qt.WindowMinimized and not new & Qt.WindowMinimized + ): + self.interface.on_show() + if IS_WAYLAND: # pragma: no-cover-if-linux-x # pragma: no branch + # Hold clearing _pending_state_transition by 100ms to ensure that + # any window state changes in the meantime get batched. + impl = self.impl + impl._state_lock = True + impl._changeeventid += 1 + QTimer.singleShot( + 100, partial(impl._clear_pending, impl._changeeventid) + ) + elif event.type() == QEvent.ActivationChange: + if self.isActiveWindow(): + self.interface.on_gain_focus() + else: + self.interface.on_lose_focus() + super().changeEvent(event) + + +class Window: + def _clear_pending(self, changeeventid): # pragma: no-cover-if-linux-x + if self._changeeventid != changeeventid: + return + if self._pending_state_transition: + self._apply_state(self._pending_state_transition) + self._pending_state_transition = None + self._state_lock = False + + def __init__(self, interface, title, position, size): + self.interface = interface + self.interface._impl = self + self.container = Container(on_refresh=self.content_refreshed) + self.container.native.show() + self._changeeventid = 0 + + self.create() + + self._hidden_window_state = None + self._pending_state_transition = None + self._state_lock = False + + 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]) + + # 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: + self.interface.on_close() + + def create(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/ + # 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. + self._hidden_window_state = self.get_window_state(in_progress_state=True) + self._pending_state_transition = None + + self.native.hide() + # 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): + # 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 + self.native.show() + self.interface.on_show() + + def close(self): + 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): + return Size( + self.native.size().width(), + self.native.size().height(), + ) + + def set_size(self, size): + self.native.resize(size[0], size[1]) + + def resizeEvent(self, event): + if self.interface.content: + self.interface.content.refresh() + + def content_refreshed(self, container): + min_width = self.interface.content.layout.min_width + min_height = self.interface.content.layout.min_height + self.container.native.setMinimumSize(min_width, min_height) + self.container.min_width = min_width + self.container.min_height = 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 + # 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 ================ + def get_window_state(self, in_progress_state=False): + if self._hidden_window_state: + return self._hidden_window_state + # The following is no-covered because it has became an + # implementation detail only used by saving window state + # when hidden and a window state is in transition, and to + # stay consistency with the other backends providing + # this impl API. This functionality is also relatively + # minor. + if in_progress_state and self._pending_state_transition: # pragma: no cover + 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: # pragma: no-cover-if-linux-wayland + return WindowState.MINIMIZED + else: + return WindowState.NORMAL + + def set_window_state(self, state): + if self._state_lock: # pragma: no-cover-if-linux-x + self._pending_state_transition = state + return + + # 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 IS_WAYLAND: # pragma: no-cover-if-linux-x # pragma: no branch + # Hold clearing _pending_state_transition by 100ms to ensure that + # any window state changes in the meantime get batched. + self._state_lock = True + self._changeeventid += 1 + QTimer.singleShot(100, partial(self._clear_pending, self._changeeventid)) + self._apply_state(state) + + def _apply_state(self, state): + current_state = self.get_window_state() + current_native_state = self.native.windowState() + if ( + current_state == WindowState.MINIMIZED and not IS_WAYLAND + ): # pragma: no-cover-if-linux-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 + self.native.menuBar().show() + del self._before_presentation_mode_screen + self._in_presentation_mode = False + + if state == WindowState.MAXIMIZED: + self.native.showMaximized() + + # no-covered because MINIMIZED window state cannot be round-tripped + # or asserted on Wayland. + elif state == WindowState.MINIMIZED: # pragma: no-cover-if-linux-wayland + if not IS_WAYLAND: # pragma: no branch + 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 + self.native.menuBar().hide() + # Do this before showFullScreen because + # 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() + + def get_image_data(self): + pixmap = self.container.native.grab() + buffer = QBuffer() + buffer.open(QIODevice.WriteOnly) + pixmap.save(buffer, "PNG") + img_bytes = bytes(buffer.data()) + buffer.close() + return img_bytes + + def set_content(self, widget): + self.container.content = widget + + +class MainWindow(Window): + 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): + # Not implemented + pass diff --git a/qt/tests_backend/app.py b/qt/tests_backend/app.py new file mode 100644 index 0000000000..b579924650 --- /dev/null +++ b/qt/tests_backend/app.py @@ -0,0 +1,162 @@ +from pathlib import Path + +import PIL.Image +import pytest +from PySide6.QtCore import QSize, 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 IS_WAYLAND + +import toga + +from .probe import BaseProbe + + +class AppProbe(BaseProbe): + supports_key = True + supports_key_mod3 = True + supports_current_window_assignment = True + supports_dark_mode = True + edit_menu_noop_enabled = 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) + # KWin supports this but not mutter which is used in CI. + if IS_WAYLAND: + self.supports_current_window_assignment = False + + @property + def config_path(self): + return Path.home() / ".config/testbed_qt" + + @property + def data_path(self): + return Path.home() / ".local/share/testbed_qt" + + @property + def cache_path(self): + return Path.home() / ".cache/testbed_qt" + + @property + def logs_path(self): + return Path.home() / ".local/state/testbed_qt/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): + for window in self.app.windows: + # We have no real way to check we've got the right icon; use pixel peeping + # as a guess. Construct a PIL image from the current icon. + img = toga.Image( + window._impl.native.windowIcon().pixmap(QSize(64, 64)).toImage() + ).as_format(PIL.Image.Image) + + if icon: + # The explicit alt icon has blue background, with green at a point 1/3 + # into the image + assert img.getpixel((5, 5)) == (211, 230, 245) + mid_color = img.getpixel((img.size[0] // 3, img.size[1] // 3)) + assert mid_color == (0, 204, 9) + else: + # The default icon is transparent background, and brown in the center. + assert img.getpixel((5, 5))[3] == 0 + mid_color = img.getpixel((img.size[0] // 2, img.size[1] // 2)) + assert mid_color == (149, 119, 73, 255) + + def activate_menu_hide(self): + pytest.xfail("KDE apps do not include a Hide in the menu bar") + + 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 a Visit Homepage menu action") + + 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): + self.assert_menu_item(["Settings", "Configure Toga Testbed"], enabled=False) + self.assert_menu_item(["File", "Quit"], enabled=True) + + self.assert_menu_item(["File", "New Example Document"], enabled=True) + self.assert_menu_item(["File", "New Read-only Document"], enabled=True) + self.assert_menu_item(["File", "Open..."], enabled=True) + self.assert_menu_item(["File", "Save"], enabled=True) + self.assert_menu_item(["File", "Save As..."], enabled=True) + self.assert_menu_item(["File", "Save All"], enabled=True) + + self.assert_menu_item(["Help", "About Toga Testbed"], enabled=True) + + self.assert_menu_item(["Edit", "Undo"]) + self.assert_menu_item(["Edit", "Redo"]) + self.assert_menu_item(["Edit", "Cut"]) + self.assert_menu_item(["Edit", "Copy"]) + self.assert_menu_item(["Edit", "Paste"]) + + def activate_menu_close_window(self): + pytest.xfail("KDE apps do not include Close in the menu bar") + + def activate_menu_close_all_windows(self): + pytest.xfail("KDE apps do not include Close All in the menu bar") + + def activate_menu_minimize(self): + pytest.xfail("KDE apps do not include Minimize in the menu bar") + + def keystroke(self, combination): + return qt_to_toga_key(toga_to_qt_key(combination)) + + async def restore_standard_app(self): + # No special handling needed to restore standard app. + await self.redraw("Restore to standard app") + + async def open_initial_document(self, monkeypatch, document_path): + pytest.xfail("Qt doesn't require initial document support") + + def open_document_by_drag(self, document_path): + pytest.xfail("Qt doesn't support opening documents by drag") + + def has_status_icon(self, status_icon): + pytest.skip("Status Icons not yet implemented on Qt") + + def status_menu_items(self, status_icon): + pytest.skip("Status Icons not yet implemented on Qt") + + def activate_status_icon_button(self, item_id): + pytest.skip("Status Icons not yet implemented on Qt") + + 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/dialogs.py b/qt/tests_backend/dialogs.py new file mode 100644 index 0000000000..cc1fc3ccfd --- /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: + # 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("Qt: Dialog display", 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("Qt backend only implements info dialog so far") + + def setup_confirm_dialog_result(self, dialog, result): + pytest.skip("Qt backend only implements info dialog so far") + + def setup_error_dialog_result(self, dialog): + pytest.skip("Qt backend only implements info dialog so far") + + def setup_stack_trace_dialog_result(self, dialog, result): + pytest.skip("Qt backend only implements info dialog so far") + + def setup_save_file_dialog_result(self, dialog, result): + pytest.skip("Qt backend only implements info dialog so far") + + def setup_open_file_dialog_result(self, dialog, result, multiple_select): + pytest.skip("Qt backend only implements info dialog so far") + + def setup_select_folder_dialog_result(self, dialog, result, multiple_select): + 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: + # 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/fonts.py b/qt/tests_backend/fonts.py new file mode 100644 index 0000000000..b8ac00378e --- /dev/null +++ b/qt/tests_backend/fonts.py @@ -0,0 +1,20 @@ +import pytest + +from toga.fonts import NORMAL + + +class FontMixin: + supports_custom_fonts = False + supports_custom_variable_fonts = False + + def preinstalled_font(self): + pytest.skip("Qt backend doesn't implement fonts") + + def assert_font_family(self, expected): + pytest.skip("Qt backend doesn't implement fonts") + + def assert_font_size(self, expected): + pytest.skip("Qt backend doesn't implement fonts") + + def assert_font_options(self, weight=NORMAL, style=NORMAL, variant=NORMAL): + pytest.skip("Qt backend doesn't implement fonts") 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 new file mode 100644 index 0000000000..7bb1f00da2 --- /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-qt.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..332aa358d0 --- /dev/null +++ b/qt/tests_backend/probe.py @@ -0,0 +1,89 @@ +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): + 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) + else: + QApplication.processEvents() + + def assert_image_size(self, image_size, size, screen): + assert [s * screen._impl.native.devicePixelRatio() for s in size] == approx( + image_size, abs=1 + ) + + 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) + + 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/screens.py b/qt/tests_backend/screens.py new file mode 100644 index 0000000000..da38f41a07 --- /dev/null +++ b/qt/tests_backend/screens.py @@ -0,0 +1,20 @@ +import pytest +from toga_qt.libs import 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 IS_WAYLAND: + 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/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..b72b4896c0 --- /dev/null +++ b/qt/tests_backend/widgets/base.py @@ -0,0 +1,98 @@ +import pytest +from toga_qt.colors import toga_color + +from ..fonts import FontMixin +from ..probe import BaseProbe + + +class SimpleProbe(BaseProbe, FontMixin): + async def redraw(self, message=None, delay=0): + self.native.repaint() + await super().redraw(message=message, delay=delay) + + 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("Font not implemented on qt") + + @property + def enabled(self): + return self.native.isEnabled() + + @property + def color(self): + return toga_color(self.native.palette().color(self.native.foregroundRole())) + + @property + def background_color(self): + return toga_color(self.native.palette().color(self.native.backgroundRole())) + + @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.skip("Mouse event probe not yet implemented on Qt") + + @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): + await self.type_character("z", ctrl=True) + + async def redo(self): + await self.type_character("z", ctrl=True, shift=True) 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..0f9164b460 --- /dev/null +++ b/qt/tests_backend/widgets/button.py @@ -0,0 +1,21 @@ +from PySide6.QtCore import QSize +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): + assert self.native.iconSize() == QSize(32, 32) 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..ee0c4bcffb --- /dev/null +++ b/qt/tests_backend/widgets/textinput.py @@ -0,0 +1,51 @@ +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())) + + 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 new file mode 100644 index 0000000000..b410f2ae7e --- /dev/null +++ b/qt/tests_backend/window.py @@ -0,0 +1,113 @@ +import asyncio + +import pytest +from PySide6.QtCore import Qt +from toga_qt.libs import 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: disable + # the action of the close button. + supports_closable = False + supports_as_image = True + supports_focus = True + # 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 + 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 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): + # 0.15 seconds to allow window size tests to ensure + # the correct size amd retain correct focus. + await self.redraw(message, 0.15) + if state == WindowState.MINIMIZED and 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 (size.width(), size.height()) + + @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 instantaneous_state(self): + return self.window._impl.get_window_state(in_progress_state=False) + + def has_toolbar(self): + raise pytest.skip("Toolbar is not implemented on Qt yet") + + def assert_is_toolbar_separator(self, index, section=False): + 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 is not implemented on Qt yet") + + def press_toolbar_button(self, index): + raise pytest.skip("Toolbar is not implemented on Qt yet") diff --git a/testbed/pyproject.toml b/testbed/pyproject.toml index 0b7a1f41e7..bb572ffb98 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", @@ -121,3 +125,18 @@ android.defaultConfig.python { requires = [ "../web" ] + +[tool.briefcase.app.testbed-qt] +sources = [ + "src/testbed_qt", + "src/testbed", +] + +[tool.briefcase.app.testbed-qt.linux] +test_sources = [ + "../qt/tests_backend", +] +requires = [ + "../qt", + "PySide6-Essentials~=6.10.0", +] diff --git a/testbed/src/testbed/__main__.py b/testbed/src/testbed/__main__.py index 7c4c4d4546..8c0bfa67ae 100644 --- a/testbed/src/testbed/__main__.py +++ b/testbed/src/testbed/__main__.py @@ -1,4 +1,4 @@ from testbed.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 a7bcd4dcde..1998c30a4c 100644 --- a/testbed/src/testbed/app.py +++ b/testbed/src/testbed/app.py @@ -221,8 +221,8 @@ def task_factory(loop, coro, **kwargs): self.main_window.show() -def main(): +def main(appname): return Testbed( - app_name="testbed", + app_name=appname, document_types=[ExampleDoc, ReadonlyDoc], ) 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_app.py b/testbed/tests/app/test_app.py index a37666a387..0c8b32c6df 100644 --- a/testbed/tests/app/test_app.py +++ b/testbed/tests/app/test_app.py @@ -225,12 +225,15 @@ async def test_menu_items(app, app_probe): ) -async def test_beep(app): +async def test_beep(app, app_probe): """The machine can go Bing!""" # This isn't a very good test. It ensures coverage, which verifies that the method # can be invoked without raising an error, but there's no way to verify that the app # actually made a noise. app.beep() + # Qt's CI sometimes takes unnessacarily long to run the bell command. Ensure there + # are no dangling tasks with a long delay. + await app_probe.redraw("Application has sounded bell", delay=5) async def test_screens(app, app_probe): diff --git a/testbed/tests/app/test_desktop.py b/testbed/tests/app/test_desktop.py index 78a65b606d..e03618458c 100644 --- a/testbed/tests/app/test_desktop.py +++ b/testbed/tests/app/test_desktop.py @@ -939,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/app/test_document_app.py b/testbed/tests/app/test_document_app.py index 7992c1748b..c9fe444b09 100644 --- a/testbed/tests/app/test_document_app.py +++ b/testbed/tests/app/test_document_app.py @@ -35,7 +35,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 @@ -109,7 +109,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 @@ -138,7 +138,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 @@ -162,7 +162,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 diff --git a/testbed/tests/testbed.py b/testbed/tests/testbed.py index 400f58345d..dcac62f502 100644 --- a/testbed/tests/testbed.py +++ b/testbed/tests/testbed.py @@ -113,23 +113,26 @@ 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(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. - 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 @@ -185,7 +188,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 = testbed.app.main(main_package_name) thread = Thread( target=partial( @@ -209,3 +212,7 @@ def run_tests(app, cov, args, report_coverage, run_slow, running_in_ci): # Start the test app app.main_loop() + + +if __name__ == "__main__": + main("testbed") diff --git a/testbed/tests/testbed_qt.py b/testbed/tests/testbed_qt.py new file mode 100644 index 0000000000..5240eee2ce --- /dev/null +++ b/testbed/tests/testbed_qt.py @@ -0,0 +1,4 @@ +from .testbed import main + +if __name__ == "__main__": + main("testbed_qt", backend_override="toga_qt") diff --git a/testbed/tests/widgets/test_button.py b/testbed/tests/widgets/test_button.py index fa3f64718a..826f2f239e 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): diff --git a/testbed/tests/widgets/test_textinput.py b/testbed/tests/widgets/test_textinput.py index ffdc1edacb..bc6fd01717 100644 --- a/testbed/tests/widgets/test_textinput.py +++ b/testbed/tests/widgets/test_textinput.py @@ -309,3 +309,46 @@ 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): + """Attempting to invoke edit actions with a readonly TextInput should + not change anything""" + if not app_probe.edit_menu_noop_enabled: + pytest.xfail("Platform does not have Edit menu that enables but no-ops") + 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 + initial_text = widget.value + + 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 == initial_text + + widget.readonly = False + await probe.redraw("Widget is no longer readonly") + app_probe.perform_edit_action(action) + await probe.redraw("Widget is no longer readonly") + + # Non-readonly performs an action. + assert widget.value != initial_text diff --git a/testbed/tests/window/test_window.py b/testbed/tests/window/test_window.py index 10f3a71876..2660388716 100644 --- a/testbed/tests/window/test_window.py +++ b/testbed/tests/window/test_window.py @@ -5,9 +5,10 @@ from unittest.mock import Mock import pytest +from pytest import approx import toga -from toga.colors import CORNFLOWERBLUE, GOLDENROD, REBECCAPURPLE +from toga.colors import CORNFLOWERBLUE, GOLDENROD, LIGHTBLUE, REBECCAPURPLE from toga.constants import WindowState from toga.style.pack import COLUMN, Pack @@ -335,7 +336,8 @@ async def test_secondary_window(app, second_window, second_window_probe): assert second_window in app.windows assert second_window.title == "Toga Testbed" - assert second_window.size == (640, 480) + # Qt rendering results in a small change in window size + assert second_window.size == approx((640, 480), abs=2) # Position should be cascaded; the exact position depends on the platform, # and how many windows have been created. As long as it's not at (100,100). if second_window_probe.supports_placement: @@ -377,7 +379,8 @@ async def test_secondary_window_with_args(app, second_window, second_window_prob assert second_window in app.windows assert second_window.title == "Secondary Window" - assert second_window.size == (300, 200) + # Qt rendering can result in a small change in window size + assert second_window.size == approx((300, 200), abs=2) if second_window_probe.supports_placement: assert second_window.position == (200, 300) @@ -565,7 +568,8 @@ async def test_visibility(app, second_window, second_window_probe): assert second_window in app.windows assert second_window.visible - assert second_window.size == (640, 480) + # Qt rendering can result in a small change in window size + assert second_window.size == approx((640, 480), abs=2) if second_window_probe.supports_placement: assert second_window.position == (200, 150) @@ -573,7 +577,7 @@ async def test_visibility(app, second_window, second_window_probe): second_window.position = (250, 200) await second_window_probe.wait_for_window("Secondary window has been moved") - assert second_window.size == (640, 480) + assert second_window.size == approx((640, 480), abs=2) if second_window_probe.supports_placement: assert second_window.position == (250, 200) @@ -584,7 +588,7 @@ async def test_visibility(app, second_window, second_window_probe): "Secondary window has been resized; position has not changed" ) - assert second_window.size == (300, 250) + assert second_window.size == approx((300, 250), abs=2) # We can't confirm position here, because it may have changed. macOS rescales # windows relative to the bottom-left corner, which means the position of the # window has changed relative to the Toga coordinate frame. @@ -604,7 +608,7 @@ async def test_visibility(app, second_window, second_window_probe): ) assert second_window.visible - assert second_window.size == (250, 200) + assert second_window.size == approx((250, 200), abs=2) if ( second_window_probe.supports_move_while_hidden and second_window_probe.supports_placement @@ -631,7 +635,7 @@ async def test_visibility(app, second_window, second_window_probe): assert not second_window_probe.is_minimized # Window size hasn't changed as a result of min/unmin cycle - assert second_window.size == (250, 200) + assert second_window.size == approx((250, 200), abs=2) second_window_probe.close() await second_window_probe.wait_for_window("Secondary window has been closed") @@ -665,10 +669,14 @@ async def test_move_and_resize(second_window, second_window_probe): second_window.size = (200, 150) await second_window_probe.wait_for_window("Secondary window has been resized") - assert second_window.size == (200, 150) - assert second_window_probe.content_size == ( - 200 - extra_width, - 150 - extra_height, + # Qt rendering can result in a small change in window size + assert second_window.size == approx((200, 150), abs=2) + assert second_window_probe.content_size == approx( + ( + 200 - extra_width, + 150 - extra_height, + ), + abs=2, ) box1 = toga.Box(style=Pack(background_color=REBECCAPURPLE, width=10, height=10)) @@ -680,24 +688,40 @@ async def test_move_and_resize(second_window, second_window_probe): await second_window_probe.wait_for_window( "Secondary window has had height adjusted due to content" ) - assert second_window.size == (200, 210 + extra_height) - assert second_window_probe.content_size == (200 - extra_width, 210) + assert second_window.size == approx((200, 210 + extra_height), abs=2) + assert second_window_probe.content_size == approx( + (200 - extra_width, 210), abs=2 + ) # Alter the content width to exceed window size box1.style.width = 250 await second_window_probe.wait_for_window( "Secondary window has had width adjusted due to content" ) - assert second_window.size == (250 + extra_width, 210 + extra_height) - assert second_window_probe.content_size == (250, 210) + assert second_window.size == approx( + (250 + extra_width, 210 + extra_height), abs=2 + ) + + # Alter both height and width to exceed window size at once + box3 = toga.Box(style=Pack(background_color=LIGHTBLUE, width=300, height=90)) + second_window.content.add(box3) + await second_window_probe.wait_for_window( + "Secondary window has had width and height adjusted due to content" + ) + assert second_window.size == approx( + (300 + extra_width, 300 + extra_height), abs=2 + ) + assert second_window_probe.content_size == approx((300, 300), abs=2) # Try to resize to a size less than the content size second_window.size = (200, 150) await second_window_probe.wait_for_window( "Secondary window forced resize fails" ) - assert second_window.size == (250 + extra_width, 210 + extra_height) - assert second_window_probe.content_size == (250, 210) + assert second_window.size == approx( + (300 + extra_width, 300 + extra_height), abs=2 + ) + assert second_window_probe.content_size == approx((300, 300), abs=2) @pytest.mark.parametrize( "initial_state, final_state", @@ -1016,6 +1040,10 @@ async def test_window_state_when_window_hidden( await second_window_probe.wait_for_window("Secondary window is hidden") assert second_window.state == window_state_before_hidden + second_window.show() + await second_window_probe.wait_for_window("Secondary window shown") + assert second_window.state == window_state_before_hidden + @pytest.mark.parametrize( "second_window_class, second_window_kwargs", [ diff --git a/winforms/tests_backend/app.py b/winforms/tests_backend/app.py index ab5915e6ac..9bda172884 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__()