From 326b37cac571dda407f02d888247b7d4e2bf5d7a Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Mon, 25 May 2026 20:20:24 +0200 Subject: [PATCH 001/110] WinUI 3 proof of concept --- winui3/CONTRIBUTING.md | 7 + winui3/LICENSE | 27 ++ winui3/README.md | 33 ++ winui3/pyproject.toml | 135 +++++++ winui3/src/toga_winui3/__init__.py | 40 ++ winui3/src/toga_winui3/app.py | 150 +++++++ winui3/src/toga_winui3/colors.py | 45 +++ winui3/src/toga_winui3/command.py | 104 +++++ winui3/src/toga_winui3/container.py | 162 ++++++++ winui3/src/toga_winui3/factory.py | 102 +++++ winui3/src/toga_winui3/fonts.py | 6 + winui3/src/toga_winui3/hardware/__init__.py | 0 winui3/src/toga_winui3/icons.py | 88 +++++ winui3/src/toga_winui3/libs/__init__.py | 0 winui3/src/toga_winui3/libs/gdiplus.py | 69 ++++ winui3/src/toga_winui3/libs/misc.py | 58 +++ winui3/src/toga_winui3/libs/proactor.py | 183 +++++++++ winui3/src/toga_winui3/libs/winui3app.py | 162 ++++++++ winui3/src/toga_winui3/paths.py | 30 ++ winui3/src/toga_winui3/resources/__init__.py | 0 winui3/src/toga_winui3/resources/toga.png | Bin 0 -> 24581 bytes .../src/toga_winui3/resources/winui3app.xaml | 12 + winui3/src/toga_winui3/screens.py | 97 +++++ winui3/src/toga_winui3/statusicons.py | 20 + winui3/src/toga_winui3/widgets/__init__.py | 0 winui3/src/toga_winui3/widgets/base.py | 201 ++++++++++ winui3/src/toga_winui3/widgets/box.py | 8 + winui3/src/toga_winui3/widgets/button.py | 56 +++ winui3/src/toga_winui3/widgets/label.py | 30 ++ winui3/src/toga_winui3/window.py | 374 ++++++++++++++++++ 30 files changed, 2199 insertions(+) create mode 100644 winui3/CONTRIBUTING.md create mode 100644 winui3/LICENSE create mode 100644 winui3/README.md create mode 100644 winui3/pyproject.toml create mode 100644 winui3/src/toga_winui3/__init__.py create mode 100644 winui3/src/toga_winui3/app.py create mode 100644 winui3/src/toga_winui3/colors.py create mode 100644 winui3/src/toga_winui3/command.py create mode 100644 winui3/src/toga_winui3/container.py create mode 100644 winui3/src/toga_winui3/factory.py create mode 100644 winui3/src/toga_winui3/fonts.py create mode 100644 winui3/src/toga_winui3/hardware/__init__.py create mode 100644 winui3/src/toga_winui3/icons.py create mode 100644 winui3/src/toga_winui3/libs/__init__.py create mode 100644 winui3/src/toga_winui3/libs/gdiplus.py create mode 100644 winui3/src/toga_winui3/libs/misc.py create mode 100644 winui3/src/toga_winui3/libs/proactor.py create mode 100644 winui3/src/toga_winui3/libs/winui3app.py create mode 100644 winui3/src/toga_winui3/paths.py create mode 100644 winui3/src/toga_winui3/resources/__init__.py create mode 100644 winui3/src/toga_winui3/resources/toga.png create mode 100644 winui3/src/toga_winui3/resources/winui3app.xaml create mode 100644 winui3/src/toga_winui3/screens.py create mode 100644 winui3/src/toga_winui3/statusicons.py create mode 100644 winui3/src/toga_winui3/widgets/__init__.py create mode 100644 winui3/src/toga_winui3/widgets/base.py create mode 100644 winui3/src/toga_winui3/widgets/box.py create mode 100644 winui3/src/toga_winui3/widgets/button.py create mode 100644 winui3/src/toga_winui3/widgets/label.py create mode 100644 winui3/src/toga_winui3/window.py diff --git a/winui3/CONTRIBUTING.md b/winui3/CONTRIBUTING.md new file mode 100644 index 0000000000..9df409a8e6 --- /dev/null +++ b/winui3/CONTRIBUTING.md @@ -0,0 +1,7 @@ +# 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.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/winui3/LICENSE b/winui3/LICENSE new file mode 100644 index 0000000000..dc34c2a43f --- /dev/null +++ b/winui3/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2014 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/winui3/README.md b/winui3/README.md new file mode 100644 index 0000000000..838ea70d73 --- /dev/null +++ b/winui3/README.md @@ -0,0 +1,33 @@ +# toga-winui3 + +TODO: UPDATE THIS PAGE! + + +[![Python Versions](https://img.shields.io/pypi/pyversions/toga-winforms.svg)](https://pypi.python.org/pypi/toga-winforms) +[![BSD-3-Clause License](https://img.shields.io/pypi/l/toga-winforms.svg)](https://github.com/beeware/toga-winforms/blob/main/LICENSE) +[![Project status](https://img.shields.io/pypi/status/toga-winforms.svg)](https://pypi.python.org/pypi/toga-winforms) + + +A Microsoft WinRT backend for the [Toga widget toolkit](https://beeware.org/toga) utilizing the WinUI 3 API. + +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 [Windows platform documentation](https://toga.beeware.org/en/latest/reference/platforms/windows#prerequisites). + +For more details, see [Toga's documentation](https://toga.beeware.org), or 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/winui3/pyproject.toml b/winui3/pyproject.toml new file mode 100644 index 0000000000..df11729b6f --- /dev/null +++ b/winui3/pyproject.toml @@ -0,0 +1,135 @@ +[build-system] +requires = [ + "setuptools==82.0.1", + "setuptools_scm==10.0.5", + "setuptools_dynamic_dependencies==1.0.0", +] +build-backend = "setuptools.build_meta" + +[project] +dynamic = ["version", "dependencies"] +name = "toga-winui3" +description = "A Windows backend for the Toga widget toolkit using the WinUI 3 API." +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", + "windows", + "winui 3", + "toga", + "desktop", + "winrt", +] +classifiers = [ + "Development Status :: 1 - Planning", + "Intended Audience :: Developers", + "Operating System :: Microsoft :: Windows", + "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.urls] +Homepage = "https://beeware.org/project/projects/libraries/toga/" +Funding = "https://beeware.org/contributing/membership/" +Documentation = "https://toga.beeware.org/" +Tracker = "https://github.com/beeware/toga/issues" +Source = "https://github.com/beeware/toga" +Changelog = "https://toga.beeware.org/en/stable/background/project/releases" + +[project.entry-points."toga.backends"] +windows = "toga_winui3" + +[project.entry-points."toga_core.backend.toga_winui3"] +App = "toga_winui3.app:App" +Command = "toga_winui3.command:Command" +Font = "toga_winui3.fonts:Font" +Icon = "toga_winui3.icons:Icon" +# Image = "toga_winui3.images:Image" +Paths = "toga_winui3.paths:Paths" +# dialogs = "toga_winui3.dialogs" +resources = "toga_winui3.resources" + +# Hardware +# Camera = "toga_winui3.hardware.camera:Camera" +# Location = "toga_winui3.hardware.location:Location" + +# Status Icons +# MenuStatusIcon = "toga_winui3.statusicons:MenuStatusIcon" +# SimpleStatusIcon = "toga_winui3.statusicons:SimpleStatusIcon" +StatusIconSet = "toga_winui3.statusicons:StatusIconSet" + +# Widgets +# ActivityIndicator = "toga_winui3.widgets.activityindicator:ActivityIndicator" +Box = "toga_winui3.widgets.box:Box" +Button = "toga_winui3.widgets.button:Button" +# Canvas = "toga_winui3.widgets.canvas:Canvas" +# DateInput = "toga_winui3.widgets.dateinput:DateInput" +# DetailedList = "toga_winui3.widgets.detailedlist:DetailedList" +# Divider = "toga_winui3.widgets.divider:Divider" +# ImageView = "toga_winui3.widgets.imageview:ImageView" +Label = "toga_winui3.widgets.label:Label" +# MapView = "toga_winui3.widgets.mapview:MapView" +# MultilineTextInput = "toga_winui3.widgets.multilinetextinput:MultilineTextInput" +# NumberInput = "toga_winui3.widgets.numberinput:NumberInput" +# OptionContainer = "toga_winui3.widgets.optioncontainer:OptionContainer" +# PasswordInput = "toga_winui3.widgets.passwordinput:PasswordInput" +# ProgressBar = "toga_winui3.widgets.progressbar:ProgressBar" +# ScrollContainer = "toga_winui3.widgets.scrollcontainer:ScrollContainer" +# Selection = "toga_winui3.widgets.selection:Selection" +# Slider = "toga_winui3.widgets.slider:Slider" +# SplitContainer = "toga_winui3.widgets.splitcontainer:SplitContainer" +# Switch = "toga_winui3.widgets.switch:Switch" +# Table = "toga_winui3.widgets.table:Table" +# TextInput = "toga_winui3.widgets.textinput:TextInput" +# TimeInput = "toga_winui3.widgets.timeinput:TimeInput" +# Tree = "toga_winui3.widgets.tree:Tree" +# WebView = "toga_winui3.widgets.webview:WebView" + +# Windows +MainWindow = "toga_winui3.window:MainWindow" +Window = "toga_winui3.window:Window" + +[tool.setuptools_scm] +root = ".." + +[tool.setuptools_dynamic_dependencies] +dependencies = [ + "toga-core == {version}", + "win32more >= 0.8.1", + "winui3-Microsoft.UI.Interop", + "winrt-runtime", +] + +[tool.coverage.run] +parallel = true +branch = true +relative_files = true + +# See notes in the root pyproject.toml file. +source = ["src"] +source_pkgs = ["toga_winui3"] + +[tool.coverage.paths] +source = [ + "src/toga_winui3", + "**/toga_winui3", +] diff --git a/winui3/src/toga_winui3/__init__.py b/winui3/src/toga_winui3/__init__.py new file mode 100644 index 0000000000..d7623404f3 --- /dev/null +++ b/winui3/src/toga_winui3/__init__.py @@ -0,0 +1,40 @@ +from ctypes import WinError +from sys import getwindowsversion +from warnings import warn + +from travertino import _package_version +from win32more.Windows.Win32.Foundation import ERROR_ACCESS_DENIED, GetLastError +from win32more.Windows.Win32.UI.HiDpi import ( + DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, + SetProcessDpiAwarenessContext, +) + +if getwindowsversion().build < 17763: + # https://learn.microsoft.com/en-us/windows/apps/winui/winui3/ + raise WinError( + descr="WinUI 3 only runs on Windows 10, version 1809 (build 17763) and later." + ) + + +# Set the application to be aware of per-monitor dpi values. +success = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) + + +# According to the Microsoft documentation, if SetProcessDpiAwarenessContext fails with +# ERROR_ACCESS_DENIED, then the ProcessDpiAwarenessContext has already been set. +if not success: + dpi_error = GetLastError() + if dpi_error == ERROR_ACCESS_DENIED: + warn( + "SetProcessDpiAwarenessContext has been set twice.", + stacklevel=1, + ) + else: + warn( + f"SetProcessDpiAwarenessContext failed with error code {dpi_error}.", + stacklevel=1, + ) + + +# Travertino package_version. +__version__ = _package_version(__file__, __name__) diff --git a/winui3/src/toga_winui3/app.py b/winui3/src/toga_winui3/app.py new file mode 100644 index 0000000000..eb2dfbf10e --- /dev/null +++ b/winui3/src/toga_winui3/app.py @@ -0,0 +1,150 @@ +from win32more import String +from win32more.Microsoft.UI.Windowing import DisplayArea +from win32more.Microsoft.UI.Xaml import ApplicationTheme +from win32more.Windows.Win32.Media.Audio import SND_ALIAS, SND_ASYNC, PlaySound +from win32more.Windows.Win32.UI.WindowsAndMessaging import ShowCursor + +from .libs.proactor import WinUI3ProactorEventLoop +from .libs.winui3app import WinUI3App +from .screens import Screen as ScreenImpl + + +class App: + # Windows applications exit when the last window is closed. + CLOSE_ON_LAST_WINDOW = True + # Windows applications use default command line handling. + HANDLES_COMMAND_LINE = False + + def __init__(self, interface): + self.interface = interface + self.interface._impl = self + + # Track whether the app is exiting. + self._is_exiting = False + self._exiting_presentation = False + + # The Win32 function ShowCursor is used to show and hide the cursor. Here cursor + # visibility is represented by a display count. For example, if hide is called N + # times then to make the cursor re-appear, show must be called N times as well. + # Hence, a local boolean is stored to avoid a deep stack. + self._cursor_visible = True + + self.loop = WinUI3ProactorEventLoop() + self.native_instance: WinUI3App + + def create(self): + self.native = WinUI3App + + # TODO Ensure that TLS1.2 and TLS1.3 are enabled. See Winforms. + + # Populate the main window as soon as the event loop is running. + self.loop.call_soon_threadsafe(self.interface._startup) + + #################################################################################### + # Commands and menus + #################################################################################### + + def create_standard_commands(self): + # The standard commands for WinUI 3 are already created by the Toga core + # interface by calling _create_standard_commands() during _startup(). + pass + + def create_menus(self): + """Creates menu bars for the windows with the 'create_menus' attribute.""" + for window in self.interface.windows: + # From toga_winforms: + # It's difficult to trigger this on a simple window, because we can't easily + # modify the set of app-level commands that are registered, and a simple + # window doesn't exist when the app starts up. Therefore, no-branch the else + # case. + if hasattr(window._impl, "create_menus"): # pragma: no branch + window._impl.create_menus() + + #################################################################################### + # App lifecycle + #################################################################################### + + def exit(self): # pragma: no cover + # FIXME: App doesn't shutdown correctly with self._is_exiting = True + self._is_exiting = True + self.native.Exit(self.native_instance) + + def main_loop(self): + self.create() + self.loop.run_forever(self) + + def set_icon(self, icon): + # Icons are set in Window.set_app(). + pass + + def set_main_window(self, window): + self.interface.factory.not_implemented("App.set_main_window") + pass + + #################################################################################### + # App resources + #################################################################################### + + def get_primary_screen(self): + """Returns the WinUI 3 Screen object for the primary screen.""" + return ScreenImpl(DisplayArea.Primary) + + def get_screens(self): + """Gets a list of WinUI 3 Screen objects corresponding to the system's screens. + + The primary screen has index 0 within the returned list. + """ + primary_screen = self.get_primary_screen() + screen_list = [primary_screen] + [ + ScreenImpl(native=screen) + for screen in DisplayArea.FindAll() + if ScreenImpl(native=screen) != primary_screen + ] + return screen_list + + #################################################################################### + # App state + #################################################################################### + + def get_dark_mode_state(self) -> bool: + """Returns True if the WinUI3App instance is in dark mode.""" + return self.native_instance.RequestedTheme == ApplicationTheme.Dark + + #################################################################################### + # App capabilities + #################################################################################### + + def beep(self): + """Plays the 'SystemAsterisk' sound.""" + # learn.microsoft.com/windows/win32/multimedia/the-playsound-function + PlaySound(String("SystemAsterisk"), None, SND_ALIAS | SND_ASYNC) + + def show_about_dialog(self): + self.interface.factory.not_implemented("App.show_about_dialog") + + #################################################################################### + # Cursor control + #################################################################################### + + def hide_cursor(self): + # learn.microsoft.com/windows/win32/api/winuser/nf-winuser-showcursor + ShowCursor(False) + + def show_cursor(self): + # learn.microsoft.com/windows/win32/api/winuser/nf-winuser-showcursor + ShowCursor(True) + + #################################################################################### + # Window control + #################################################################################### + + def get_current_window(self): + """Returns the currently activated window if one exists, otherwise None.""" + for window in self.interface.windows: + if window._impl.is_activated: + return window._impl + return None + + def set_current_window(self, window): + """Brings a given window to the foreground and gives it input focus.""" + window._impl.native.Activate() diff --git a/winui3/src/toga_winui3/colors.py b/winui3/src/toga_winui3/colors.py new file mode 100644 index 0000000000..eda052fc64 --- /dev/null +++ b/winui3/src/toga_winui3/colors.py @@ -0,0 +1,45 @@ +from win32more.Microsoft.UI.Xaml.Media import SolidColorBrush +from win32more.Windows.UI import Color as NativeColor + +from toga import rgba as TogaColor +from toga.constants import TRANSPARENT + +COLOR_CACHE = {} +BRUSH_CACHE = {} + + +def native_color(toga_color): + if not toga_color: + return None + + if toga_color == TRANSPARENT: + toga_color = TogaColor(0, 0, 0, 0) + + try: + color = COLOR_CACHE[toga_color] + except KeyError: + color = NativeColor() + color.R = toga_color.rgb.r + color.G = toga_color.rgb.g + color.B = toga_color.rgb.b + color.A = round(toga_color.rgb.a * 255) + + COLOR_CACHE[toga_color] = color + + return color + + +def native_brush(toga_color): + if not toga_color: + return None + + color = native_color(toga_color) + + try: + brush = BRUSH_CACHE[toga_color] + except KeyError: + brush = SolidColorBrush(color) + + BRUSH_CACHE[toga_color] = brush + + return brush diff --git a/winui3/src/toga_winui3/command.py b/winui3/src/toga_winui3/command.py new file mode 100644 index 0000000000..f8d6b2d9e2 --- /dev/null +++ b/winui3/src/toga_winui3/command.py @@ -0,0 +1,104 @@ +import sys + +from toga import Command as StandardCommand, Group, Key + + +class Command: + def __init__(self, interface): + self.interface = interface + self.native = [] + + @classmethod + def standard(self, app, id): + # ---- File menu ----------------------------------- + if id == StandardCommand.NEW: + return { + "text": "New", + "shortcut": Key.MOD_1 + "n", + "group": Group.FILE, + "section": 0, + "order": 0, + } + elif id == StandardCommand.OPEN: + return { + "text": "Open...", + "shortcut": Key.MOD_1 + "o", + "group": Group.FILE, + "section": 0, + "order": 10, + } + elif id == StandardCommand.SAVE: + return { + "text": "Save", + "shortcut": Key.MOD_1 + "s", + "group": Group.FILE, + "section": 0, + "order": 20, + } + elif id == StandardCommand.SAVE_AS: + return { + "text": "Save As...", + "shortcut": Key.MOD_1 + "S", + "group": Group.FILE, + "section": 0, + "order": 21, + } + elif id == StandardCommand.SAVE_ALL: + return { + "text": "Save All", + "shortcut": Key.MOD_1 + Key.MOD_2 + "s", + "group": Group.FILE, + "section": 0, + "order": 22, + } + elif id == StandardCommand.PREFERENCES: + # Preferences should be towards the end of the File menu. + return { + "text": "Preferences", + "group": Group.FILE, + "section": sys.maxsize - 1, + } + elif id == StandardCommand.EXIT: + # Quit should always be the last item, in a section on its own. + return { + "text": "Exit", + "group": Group.FILE, + "section": sys.maxsize, + } + # ---- Help menu ----------------------------------- + elif id == StandardCommand.VISIT_HOMEPAGE: + return { + "text": "Visit homepage", + "enabled": app.home_page is not None, + "group": Group.HELP, + } + elif id == StandardCommand.ABOUT: + return { + "text": f"About {app.formal_name}", + "group": Group.HELP, + "section": sys.maxsize, + } + + raise ValueError(f"Unknown standard command {id!r}") + + def native_event_Click(self, sender, args): + return self.interface.action() + + def set_enabled(self, value): + if self.native: + for widget in self.native: + widget.Enabled = self.interface.enabled + + def create_menu_item(self, NativeClass): + item = NativeClass() + item.Text = self.interface.text + item.add_Click(self.native_event_Click) + + if self.interface.shortcut is not None: + self.interface.factory.not_implemented("Command shortcuts") + + item.Enabled = self.interface.enabled + + self.native.append(item) + + return item diff --git a/winui3/src/toga_winui3/container.py b/winui3/src/toga_winui3/container.py new file mode 100644 index 0000000000..f7ae5f9d53 --- /dev/null +++ b/winui3/src/toga_winui3/container.py @@ -0,0 +1,162 @@ +from math import ceil + +from win32more.Microsoft.UI.Xaml import HorizontalAlignment, VerticalAlignment +from win32more.Microsoft.UI.Xaml.Controls import Canvas, RelativePanel + + +class ContainerWidgets: + """A class used to add, remove and keep a record of the Container's widgets.""" + + def __init__(self, container): + self._container = container + self._widgets = [] + + @property + def _native(self): + return self._container.native + + def clear(self): + if len(self._widgets) < 1: + return + + for widget in self._widgets: + widget.container = None + self._widgets = [] + + self._native.Children.Clear() + + def add(self, widget): + self._widgets.append(widget) + self._native.Children.Append(widget.native) + + def remove(self, widget): + index = self._widgets.index(widget) + self._widgets.remove(widget) + self._native.Children.RemoveAt(index) + + +class ContainerStagingArea(ContainerWidgets): + """A class used to calculate content-based constraints for WinUI 3 widgets. + + Some WinUI 3 widgets such as Button require minimum size constraints that are only + be calculated once they are attached to a window. ContainerStagingArea uses a hidden + panel (self._native) which allows the widgets to resize based on native parameters. + + This class should be use in conjunction with WidgetStager + """ + + @property + def _native(self): + return self._container._staging_panel + + def remove(self, widget): + """Removes a widget and triggers a layout refresh when the widget list empties. + + The refresh mechanism here is to avoid excessive refreshes. A single refresh + call will cause every widget in the Container to be refreshed. For example if a + Container had 100 buttons attached, then each button would be refreshed 100 + times with 10,000 total refresh calls (compared with the ~1 refresh call here). + """ + non_empty_initial = len(self._widgets) > 0 + super().remove(widget) + empty_final = len(self._widgets) == 0 + + if non_empty_initial and empty_final: + if self._container._content: + self._container._content.interface.refresh() + + +class Container: + """A container used for laying out WinUI 3 Toga widgets. + + A Container represents a region of window where the dimensions are controlled by + the native runtime. It primarily does: + - Reports the dimensions of the region to the Toga core interface. + - Notifies the Toga core interface when the dimensions change. + - Provides a native panel where the widgets.native classes can be attached. + + The actual layout of the widgets attached to a Container is determined by the style + applicator of the Toga core interface. + + Attributes: + native: The WinUI 3 panel where the widgets.native classes will be attached. + widgets: A ContainerWidgets instance which adds, removes and keeps a record of + the widgets attached to the native panel. + staging_area: A ContainerStagingArea instance which is used to stage widgets + that require a native panel to calculate content-based constraints. + """ + + def __init__(self, native_panel: Canvas): + """Initialize a Container using a given native panel. + + :param container_native: The native panel where the widgets.native classes can + be attached. + """ + self.native = native_panel + self.native.HorizontalAlignment = HorizontalAlignment.Stretch + self.native.VerticalAlignment = VerticalAlignment.Stretch + self.native.SizeChanged += self.native_event_size_changed + + self._content = None + self.widgets = ContainerWidgets(self) + + self._staging_panel = RelativePanel() + self._staging_panel.Visible = False + self.native.Children.Append(self._staging_panel) + + self.staging_area = ContainerStagingArea(self) + + #################################################################################### + # Container geometry + # + # Note: WinUI 3 sizes are given in CSS pixels and can be factional valued. However, + # the Toga core interface uses whole CSS pixels for layouts. When the native + # values are rounded down noticeable bars appear at the right side and bottom + # of the container during resize. Rounding up causing the content to overlap + # with the edge and the bars disappear. This is judged to be the lesser of two + # evils. + #################################################################################### + + @property + def width(self): + return ceil(self.native.ActualSize.X) + + @property + def height(self): + return ceil(self.native.ActualSize.Y) + + #################################################################################### + # Container content + #################################################################################### + + @property + def content(self): + """The root widget for the tree of widgets laid out by the container. + + All children of the root widget will also be added to the container as a result + of assigning content. + + If the container already has content, the old content will be replaced. The old + root widget and all its children will be removed from the container. + """ + return self._content + + @content.setter + def content(self, widget): + if self._content: + self._content.container = None + + self._content = widget + if widget: + widget.container = self + + #################################################################################### + # Container refreshing + #################################################################################### + + def native_event_size_changed(self, sender, args): + if self.content is not None: + self.content.interface.refresh() + + def refreshed(self): + pass diff --git a/winui3/src/toga_winui3/factory.py b/winui3/src/toga_winui3/factory.py new file mode 100644 index 0000000000..8be7922994 --- /dev/null +++ b/winui3/src/toga_winui3/factory.py @@ -0,0 +1,102 @@ +import warnings + +from toga import NotImplementedWarning + +# from . import dialogs +from .app import App +from .command import Command +from .fonts import Font +from .icons import Icon + +# from .images import Image +from .paths import Paths +from .statusicons import StatusIconSet # , MenuStatusIcon, SimpleStatusIcon, + +# from .widgets.activityindicator import ActivityIndicator +from .widgets.box import Box +from .widgets.button import Button + +# from .widgets.canvas import Canvas +# from .widgets.dateinput import DateInput +# from .widgets.detailedlist import DetailedList +# from .widgets.divider import Divider +# from .widgets.imageview import ImageView +from .widgets.label import Label + +# from .widgets.mapview import MapView +# from .widgets.multilinetextinput import MultilineTextInput +# from .widgets.numberinput import NumberInput +# from .widgets.optioncontainer import OptionContainer +# from .widgets.passwordinput import PasswordInput +# from .widgets.progressbar import ProgressBar +# from .widgets.scrollcontainer import ScrollContainer +# from .widgets.selection import Selection +# from .widgets.slider import Slider +# from .widgets.splitcontainer import SplitContainer +# from .widgets.switch import Switch +# from .widgets.table import Table +# from .widgets.textinput import TextInput +# from .widgets.timeinput import TimeInput +# from .widgets.tree import Tree +# from .widgets.webview import WebView +from .window import MainWindow, Window + +warnings.warn( + "Factory modules are deprecated. Use 'toga.platform.get_factory' instead.", + DeprecationWarning, + stacklevel=1, +) + + +def not_implemented(feature): # pragma: no cover + NotImplementedWarning.warn("WinUI 3", feature) + + +__all__ = [ + "not_implemented", + "App", + "Command", + # Resources + "Font", + "Icon", + # "Image", + "Paths", + # "dialogs", + # Status Icons + # "MenuStatusIcon", + # "SimpleStatusIcon", + "StatusIconSet", + # Widgets + # "ActivityIndicator", + "Box", + "Button", + # "Canvas", + # "DateInput", + # "DetailedList", + # "Divider", + # "ImageView", + "Label", + # "MapView", + # "MultilineTextInput", + # "NumberInput", + # "OptionContainer", + # "PasswordInput", + # "ProgressBar", + # "ScrollContainer", + # "Selection", + # "Slider", + # "SplitContainer", + # "Switch", + # "Table", + # "TextInput", + # "TimeInput", + # "Tree", + # "WebView", + # Windows + "Window", + "MainWindow", +] + + +def __getattr__(name): + raise NotImplementedError(f"Toga's WinUI 3 backend doesn't implement {name}") diff --git a/winui3/src/toga_winui3/fonts.py b/winui3/src/toga_winui3/fonts.py new file mode 100644 index 0000000000..cb5623bd40 --- /dev/null +++ b/winui3/src/toga_winui3/fonts.py @@ -0,0 +1,6 @@ +class Font: + def __init__(self, interface): + print("Not yet implemented on WinUI3 - Font") + + def load_predefined_system_font(self): + pass diff --git a/winui3/src/toga_winui3/hardware/__init__.py b/winui3/src/toga_winui3/hardware/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/winui3/src/toga_winui3/icons.py b/winui3/src/toga_winui3/icons.py new file mode 100644 index 0000000000..2d4f62d59d --- /dev/null +++ b/winui3/src/toga_winui3/icons.py @@ -0,0 +1,88 @@ +from ctypes import POINTER, byref +from pathlib import Path + +from win32more import String +from win32more.Microsoft.UI import IconId +from win32more.Microsoft.UI.Xaml.Controls import BitmapIcon +from win32more.Windows.Foundation import Uri +from win32more.Windows.Win32.Graphics.GdiPlus import ( + GdipCreateBitmapFromFile, + GdipCreateHICONFromBitmap, + GpBitmap, +) +from win32more.Windows.Win32.UI.WindowsAndMessaging import HICON + +# Use pywinrt is until Microsoft.Ui.Interop is included in a release of win32more: +# https://github.com/ynkdir/py-win32more/issues/184 +from winui3.microsoft.ui.interop import get_icon_id_from_icon + +from .libs.gdiplus import gdi_plus, status_dict + +BitmapPtr = POINTER(GpBitmap) + + +class Icon: + EXTENSIONS = [".png", ".bmp"] + SIZES = None + + def __init__(self, interface, path): + self.interface = interface + self._handle: None | HICON = None + self._id: None | IconId = None + self._bitmap_icon: None | BitmapIcon = None + + if path is None: + self.path = Path(__file__).parent / "resources" / "toga.png" + else: + self.path = Path(path) + + @property + def uri(self) -> Uri: + return Uri(f"ms-appx:///{self.path.as_posix()}") + + @property + def handle(self) -> HICON: + """The handle to the Win32 icon object created using the icon's path.""" + if self._handle is None: + bitmap_ptr = BitmapPtr() + self._handle = HICON() + + bitmap_status = 1 + handle_status = 1 + with gdi_plus: + bitmap_status = GdipCreateBitmapFromFile( + String(str(self.path)), byref(bitmap_ptr) + ) + handle_status = GdipCreateHICONFromBitmap( + bitmap_ptr, byref(self._handle) + ) + + if bitmap_status != 0 or handle_status != 0: + message = f"Unable to create icon bitmap from {self.path}.\n" + if bitmap_status != 0: + message += "GdipCreateBitmapFromFile code: " + message += str(status_dict[bitmap_status]) + else: + message += "GdipCreateHICONFromBitmap code: " + message += str(status_dict[handle_status]) + + raise ValueError(message) + + return self._handle + + @property + def id(self) -> IconId: + """The IconId to the WinRT icon object created using the icon's path.""" + if self._id is None: + icon_id = get_icon_id_from_icon(int(self.handle.value)) + self._id = IconId(icon_id.value) + + return self._id + + @property + def bitmap_icon(self) -> BitmapIcon: + # FIXME: NOT WORKING + if self._bitmap_icon is None: + self._bitmap_icon = BitmapIcon() + self._bitmap_icon.UriSource = self.uri + return self._bitmap_icon diff --git a/winui3/src/toga_winui3/libs/__init__.py b/winui3/src/toga_winui3/libs/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/winui3/src/toga_winui3/libs/gdiplus.py b/winui3/src/toga_winui3/libs/gdiplus.py new file mode 100644 index 0000000000..ecc7f095d1 --- /dev/null +++ b/winui3/src/toga_winui3/libs/gdiplus.py @@ -0,0 +1,69 @@ +from ctypes import WinError, byref + +from win32more.Windows.Win32.Foundation import BOOL, UIntPtr +from win32more.Windows.Win32.Graphics.GdiPlus import ( + GdiplusShutdown, + GdiplusStartup, + GdiplusStartupInput, + GdiplusStartupOutput, +) + +status_dict = { + 0: "Ok", + 1: "GenericError", + 2: "InvalidParameter", + 3: "OutOfMemory", + 4: "ObjectBusy", + 5: "InsufficientBuffer", + 6: "NotImplemented", + 7: "Win32Error", + 8: "WrongState", + 9: "Aborted", + 10: "FileNotFound", + 11: "ValueOverflow", + 12: "AccessDenied", + 13: "UnknownImageFormat", + 14: "FontFamilyNotFound", + 15: "FontStyleNotFound", + 16: "NotTrueTypeFont", + 17: "UnsupportedGdiplusVersion", + 18: "GdiplusNotInitialized", + 19: "PropertyNotFound", + 20: "PropertyNotSupported", + 21: "ProfileNotFound", +} + + +class GdiPlus: + """A context manager for running GdiPlus functions.""" + + def __init__(self): + self._input = GdiplusStartupInput() + self._input.GdiplusVersion = 1 + self._input.DebugEventCallback = 0 + self._input.SuppressBackgroundThread = BOOL(0) + self._input.SuppressExternalCodecs = BOOL(0) + + self._token = UIntPtr() + self._output = GdiplusStartupOutput() + + def __enter__(self): + status = GdiplusStartup( + byref(self._token), + byref(self._input), + byref(self._output), + ) + + if status != 0: + raise WinError( + descr=f"GdiplusStartup failed with code: {status_dict[status]}" + ) + + def __exit__(self, exc_type, exc_value, traceback): + GdiplusShutdown(self._token) + + def __del__(self): + pass + + +gdi_plus = GdiPlus() diff --git a/winui3/src/toga_winui3/libs/misc.py b/winui3/src/toga_winui3/libs/misc.py new file mode 100644 index 0000000000..a429d9902d --- /dev/null +++ b/winui3/src/toga_winui3/libs/misc.py @@ -0,0 +1,58 @@ +from win32more.Microsoft.UI.Xaml import GridLength, GridUnitType +from win32more.Microsoft.UI.Xaml.Controls import ( + ColumnDefinition, + RowDefinition, +) + + +def grid_length_auto(): + grid_length = GridLength() + grid_length.GridUnitType = GridUnitType.Auto + + return grid_length + + +def grid_length_star(value: int = 1): + grid_length = GridLength() + grid_length.GridUnitType = GridUnitType.Star + grid_length.Value = value + + return grid_length + + +def column_definition_star(value: int = 1): + column_definition = ColumnDefinition() + column_definition.Width = grid_length_star(value) + + return column_definition + + +def row_definition_auto(): + row_definition = RowDefinition() + row_definition.Height = grid_length_auto() + + return row_definition + + +def row_definition_star(value: int = 1): + row_definition = RowDefinition() + row_definition.Height = grid_length_star(value) + + return row_definition + + +def is_based_on_recursive(cls, ancestor): + for parent in cls.__bases__: + if parent == ancestor: + return True + elif is_based_on_recursive(parent, ancestor): + return True + + return False + + +def is_based_on(cls, ancestor): + if cls == ancestor: + return True + else: + return is_based_on_recursive(cls, ancestor) diff --git a/winui3/src/toga_winui3/libs/proactor.py b/winui3/src/toga_winui3/libs/proactor.py new file mode 100644 index 0000000000..7c7641ceb2 --- /dev/null +++ b/winui3/src/toga_winui3/libs/proactor.py @@ -0,0 +1,183 @@ +import asyncio +import sys +import threading +import traceback +from asyncio import events +from functools import partial + +from win32more.Microsoft.UI.Dispatching import DispatcherQueue, DispatcherQueueTimer +from win32more.Windows.Foundation import TimeSpan + +# TODO: This is largely copied from WinformsProactorEventLoop which has the same 5ms +# polling delay. Remove this delay in a future version. + +# FIXME: Loop doesn't shut down correctly. + + +def native_app_launched(loop, winui3_app, args): + """A function to be used as an override of the OnLauched method of WinUI3App.""" + asyncio.set_event_loop(loop) + loop.dispatcher = DispatcherQueue.GetForCurrentThread() + loop.queue_timer = loop.dispatcher.CreateTimer() + loop.queue_timer.IsRepeating = False + loop.queue_timer.Tick += loop.tick + + loop.app.native_instance = winui3_app + + loop.enqueue_tick() + + +# Can't get coverage for app shutdown, so this handler must be no-cover. +def native_app_exited(loop, winui3_app): # pragma: no cover + """Perform cleanup that needs to occur when the app exits. + + This largely duplicates the "finally" behavior of the default Proactor + run_forever implementation. + """ + if sys.version_info < (3, 13): + # If we're stopping, we can do the "finally" handling from + # the BaseEventLoop run_forever(). In Python 3.13.0a2, this + # was refactored into the `_run_forever_cleanup()` helper. + # We run testbed on Py3.12, so the else branch is marked + # nocover. + # === START BaseEventLoop.run_forever() finally handling === + loop._stopping = False + loop._thread_id = None + events._set_running_loop(None) + loop._set_coroutine_origin_tracking(False) + sys.set_asyncgen_hooks(*loop._old_agen_hooks) + # === END BaseEventLoop.run_forever() finally handling === + else: # pragma: no cover + loop._run_forever_cleanup() + + +class WinUI3ProactorEventLoop(asyncio.ProactorEventLoop): + def run_forever(self, app): + """Set up the asyncio event loop, integrate it with the native event loop, and + start the application. + + This largely duplicates the setup behavior of the default Proactor + run_forever implementation. + + :param app_context: The WinForms.ApplicationContext instance + controlling the lifecycle of the app. + """ + # Python 3.8 added an implementation of run_forever() in + # ProactorEventLoop. The only part that actually matters is the + # refactoring that moved the initial call to stage _loop_self_reading; + # it now needs to be created as part of run_forever; otherwise the + # event loop locks up, because there won't be anything for the + # select call to process. + self.call_soon(self._loop_self_reading) + + # Remember the application. + self.app = app + + # Set up the Proactor. + if sys.version_info < (3, 13): + # The code between the following markers should be exactly the same + # as the official CPython implementation, up to the start of the + # `while True:` part of run_forever() (see + # BaseEventLoop.run_forever() in Lib/ascynio/base_events.py). In + # Python 3.13.0a2, this was refactored into the + # `_run_forever_setup()` helper. We run testbed on Py3.10, so the + # else branch is marked nocover. + # === START BaseEventLoop.run_forever() setup === + self._check_closed() + if self.is_running(): # pragma: no cover + raise RuntimeError("This event loop is already running") + if events._get_running_loop() is not None: # pragma: no cover + raise RuntimeError( + "Cannot run the event loop while another loop is running" + ) + self._thread_id = threading.get_ident() + self._old_agen_hooks = sys.get_asyncgen_hooks() + sys.set_asyncgen_hooks( + firstiter=self._asyncgen_firstiter_hook, + finalizer=self._asyncgen_finalizer_hook, + ) + + events._set_running_loop(self) + # === END BaseEventLoop.run_forever() setup === + else: # pragma: no cover + self._orig_state = self._run_forever_setup() + + # Rather than going into a `while True:` loop, we're going to use the + # native event loop to queue a tick() message that will cause a + # single iteration of the asyncio event loop to be executed. Each time + # we do this, we queue *another* tick() message in 5ms time. In this + # way, we'll get a continuous stream of tick() calls, without blocking + # the native event loop. We also add a handler for ApplicationExit + # to ensure that loop cleanup occurs when the app exits. + + self.dispatcher: DispatcherQueue + self.queue_timer: DispatcherQueueTimer + self._inner_loop = None + + app.native.OnLaunched = partial(native_app_launched, self) + app.native.OnExited = partial(native_app_exited, self) + + # Start the native event loop. + app.native.Start() + + def enqueue_tick(self, delay=5): + # Queue a call to tick in a specified delay. + # TimeSpan is given in 100-nanosecond units i.e. 1E-7. + self.queue_timer.Interval = TimeSpan(delay * 10000) + self.queue_timer.Start() + + # This function doesn't report as covered because it runs on a + # non-Python-created thread (see App.run_app). But it must actually be + # covered, otherwise nothing would work. + def tick(self, *args, **kwargs): # pragma: no cover + """Cause a single iteration of the event loop to run on the main GUI thread.""" + # FIXME: For some reason the queue timer doesn't work properly when the + # following line is removed. + self.queue_timer.IsRunning # noqa: B018 + self.run_once_recurring() + + # Call native thread blocking methods via this method to ensure the inner loop is + # correctly linked with this Python loop. + def start_inner_loop(self, callback, *args): + assert self._inner_loop is None + self._inner_loop = (callback, args) + + def run_once_recurring(self): + """Run one iteration of the event loop, and enqueue the next iteration (if we're + not stopping). + """ + try: + # If the app is exiting, stop the asyncio event loop. + # Otherwise, perform one more tick of the event loop. + # We can't get coverage of app shutdown, so that branch + # is marked no cover + if self.app._is_exiting: + self.stop() # pragma: no cover + else: + self._run_once() + + # Enqueue the next tick, and make sure there will be *something* + # to be processed. If you don't ensure there is at least one + # message on the queue, the select() call will block, locking + # the app. Determine the delay of the tick by checking if + # there are events that can be processed sooner than 5ms, as + # we do not want to hold them back from being processed. + if self._ready: + delay = 0 + elif self._scheduled: + first = self._scheduled[0] + ms_until = int(max(0, (first.when() - self.time()) * 1000)) + delay = min(5, ms_until) + else: + delay = 5 + self.enqueue_tick(delay=delay) + self.call_soon(self._loop_self_reading) + + if self._inner_loop: + callback, args = self._inner_loop + self._inner_loop = None + callback(*args) + + # Exceptions thrown by this method will be silently ignored. + except BaseException: # pragma: no cover + traceback.print_exc() diff --git a/winui3/src/toga_winui3/libs/winui3app.py b/winui3/src/toga_winui3/libs/winui3app.py new file mode 100644 index 0000000000..8649731689 --- /dev/null +++ b/winui3/src/toga_winui3/libs/winui3app.py @@ -0,0 +1,162 @@ +######################################################################################## +# WinUI3App is derived from Yukihiro Nakadaira's XamlApplication: +# github.com/ynkdir/py-win32more/blob/main/packages/appsdk/src/win32more/winui3/__init__.py # noqa: E501 +# +# ====================================================================================== +# +# MIT License +# +# Copyright (c) 2022 Yukihiro Nakadaira +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# ====================================================================================== +# +######################################################################################## + +from __future__ import annotations + +import inspect +from pathlib import Path + +from win32more import FAILED, Char, ComClass, WinError +from win32more.Microsoft.UI.Xaml import Application, IApplicationOverrides, Window +from win32more.Microsoft.UI.Xaml.Markup import IXamlMetadataProvider +from win32more.Microsoft.UI.Xaml.XamlTypeInfo import XamlControlsXamlMetaDataProvider +from win32more.Microsoft.Windows.ApplicationModel.Resources import ( + ResourceCandidate, + ResourceCandidateKind, + ResourceManager, +) +from win32more.Windows.Foundation import Uri +from win32more.Windows.Win32.System.Com import ( + COINIT_APARTMENTTHREADED, + CoInitializeEx, + CoUninitialize, +) +from win32more.Windows.Win32.System.LibraryLoader import GetModuleFileName + +# TODO: Clean up code. +# TODO: Needs to be commented and explained. +# FIXME: Fix resources. + + +class WinUI3App(ComClass, Application, IApplicationOverrides, IXamlMetadataProvider): + def __init__(self): + WinUI3App.__current = self + self._provider = None + super().__init__(own=True) + self.InitializeComponent() + self.ResourceManagerRequested += self.OnResourceManagerRequested + + def InitializeComponent(self): + xaml_path = Path(__file__).parent.parent / "resources" / "winui3app.xaml" + resource_locator = Uri(f"ms-appx:///{xaml_path.as_posix()}") + Application.LoadComponent(self, resource_locator) + + def OnLaunched(self, args): ... + + def OnExited(self): ... + + # FIXME: Find a way to remove this method. + def CreateWindow(self): + return Window() + + def GetXamlType(self, type): + return self.AppProvider().GetXamlType(type) + + # TODO: Is it needed to provide information for primitive or winui type? + def GetXamlTypeByFullName(self, fullName): + return self.AppProvider().GetXamlTypeByFullName(fullName) + + def GetXmlnsDefinitions(self): + return self.AppProvider().GetXmlnsDefinitions() + + def AppProvider(self): + if self._provider is None: + self._provider = XamlControlsXamlMetaDataProvider() + return self._provider + + # FIXME: When executing app execution alias, sys.executable points alias. + # sys.executable => $LOCALAPPDATA\Microsoft\WindowsApps\python.exe + # We need resolved path instead. + def AppExecutable(self) -> Path: + buf = (Char * 1024)() + r = GetModuleFileName(None, buf, 1024) + if r == 0: + raise WinError() + return Path(buf.value) + + # Application root path. This is used to convert "ms-appx:///" path. See + # OnResourceNotFound(). This does not affect to WindowsAppSDK and may not work + # in specific situation. Appsdk's default is directory of python.exe file. + def AppRoot(self) -> Path: + # return self.AppExecutable().parent + return Path(inspect.getfile(type(self))).parent + + def OnResourceManagerRequested(self, sender, e): + # Workaround to avoid FileNotFoundError with default constructor (file does + # not need to exist). https://github.com/microsoft/WindowsAppSDK/issues/5814 + manager = ResourceManager("resources.pri") + manager.ResourceNotFound += self.OnResourceNotFound + e.CustomResourceManager = manager + + def OnResourceNotFound(self, sender, e): + name = e.Name + print(f"OnResourceNotFound: {name}") + if name.startswith("Files/file:///") and name.endswith(".png"): + resource_candidate = ResourceCandidate( + ResourceCandidateKind.FilePath, str(name) + ) + e.SetResolvedCandidate(resource_candidate) + # ignore absolute path + pass + elif e.Name.startswith("Files/"): + # convert relative path from ms-appx:///path/to/file to + # AppRoot()/path/to/file + name = name.removeprefix("Files/") + filepath = self.__tmp_resource_file.pop(name, self.AppRoot() / name) + if filepath.exists(): + resource_candidate = ResourceCandidate( + ResourceCandidateKind.FilePath, str(filepath) + ) + e.SetResolvedCandidate(resource_candidate) + + __tmp_resource_file = {} + + __current = None + + @classmethod + def Start(cls): + + hr = CoInitializeEx(None, COINIT_APARTMENTTHREADED) + if FAILED(hr): + raise WinError(hr) + + def ApplicationInitializationCallback(*_args): + return cls() + + Application.Start(ApplicationInitializationCallback) + + # FIXME: force Release() to avoid exit with error code. + if WinUI3App.__current is not None: + WinUI3App.__current.OnExited() + WinUI3App.__current.Release() + + CoUninitialize() diff --git a/winui3/src/toga_winui3/paths.py b/winui3/src/toga_winui3/paths.py new file mode 100644 index 0000000000..cbe9b4113f --- /dev/null +++ b/winui3/src/toga_winui3/paths.py @@ -0,0 +1,30 @@ +from functools import cached_property +from pathlib import Path + +from toga import App + + +class Paths: + def __init__(self, interface): + self.interface = interface + + @cached_property + def _app_dir(self): + # No coverage testing of this because we can't easily configure + # the app to have no author. + author = "Unknown" if App.app.author is None else App.app.author + return Path.home() / f"AppData/Local/{author}/{App.app.formal_name}" + + # The rest are cached at the interface level: + + def get_config_path(self): + return self._app_dir / "Config" + + def get_data_path(self): + return self._app_dir / "Data" + + def get_cache_path(self): + return self._app_dir / "Cache" + + def get_logs_path(self): + return self._app_dir / "Logs" diff --git a/winui3/src/toga_winui3/resources/__init__.py b/winui3/src/toga_winui3/resources/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/winui3/src/toga_winui3/resources/toga.png b/winui3/src/toga_winui3/resources/toga.png new file mode 100644 index 0000000000000000000000000000000000000000..3524138b8a06458d6d0cc8bb0dcfde3636365aaf GIT binary patch literal 24581 zcmc$l1zS{I+lGe*>5x`xC`sw=P7z7zR3Ew#7*gp*x&#RU>F%x}1q7rUq`To;yno`; z!((7@bnms-y5qXeJM@j3JoYp4XAlSkTTwygEd+u9zC?haqkI5<+lxixN<@u|4bkdp=1bz#38*=Lj?Q))l6Al z2J-myJG-SI9=wC$prGdrfnX9o{Xu}FrjtS-#%PK%lJ7kJ?zgz7Q%-#=zklbd7JvG| z=?$@x0=j=cAG&!NHsY4OZJR&^T!{68ysX8jyyfDvdqoBIqJ>S{NN=-ATn7{Z5#xJ0 zZ)z-$WW4j%oAZ0eCa=auuN(%Y^E=pZcG1DVqPfdVuY(3gzo`G$Un3r*a)Oz-%_Q{< zH-|V98M|pyDco4?8}0J`Qxg1~oYVYy+}sHeUr0cRA$s_4+Q1_(gsNOA0%1u+d5;I-5v%mGVL8W+aQq73n@B&kQ-raSM`_7_L^(*Tpo(Cb0I3pl&)K(oo%rI;Kq z$Dcv+@FV>3AZE4vqMgLA7{9IT>m!nk!ebymWXkzINT^DQ#=-CGUJ|91XPVyw|1GjN zYQ;O_z~uALhkJy-OIsN2nGO%}682~Fg3Cw)B#-R5k25cqZyI2qGxRpNvV+HOyM0H` zv5hQd@N8eE{fMTua>A>+wI6#BQ;lGqD20qLWIUR$l6N3ZMx1W2Y&D{|Sbgh}_(Qq{ zhin-vt;@>boB2>t)G6cl&Yb^P${$7ghgaKT581%jB3f3-fs`XWH}`HpX0?{t7;8Av z>zzq_kd^3t`d~Mn8L_$r{gJc@irE{+RVh}&*7gu}^@uwEMESrS9nXT1!i7fJ8sa z?z&~nkruWkke8R2kdW}kz##o@QAy@;JjKVm{nGCdTJ$@*;}Hdx;myeaIe|oeFs}Z9 zuT~J>*x0x}`YwA8f!*a5-ff@bzkmPwhlW%bA7EoOY(c)0Q;FBBwZHAbgfLya^!KI=93O4cgul^t$9xr~$?()<3`zmBn zH2~=ixH;uSR!I#aC1vFbT^?l^i5dKjwcQRPxxC;OL_1FypAf<;bBQ9k2U*|Pzz>z_ zhr`z@;q1x}^Fk)42I3VX5#?W@*!KH>Yru+RGd)8=K@p&g>Yk80%kaC73hG}^SnWw; zf~>Buug9dNg%=f(Vxg9{TxNJs|6Hqpp^@r3n_l9U{YkwVp*4}7o z_1M*&mb$mF%(8G|UJ;pL7gim;MslYWkwV}k#hUk6i?pz|?qfs>?}@Wx%nqr@MRncG zw2?W>MkOoX*wrIw zy$I#`1`U-BC#F$M-LPQ)Xho9Y`Q;1GR|zuH_E2FDiby#nC8d}Rd=68v+lGcL=4x#R zP^l@7F1Hb+Ff(YE;n2OkWEBRxrhUF=&z`+SmeAAFJJB}bYlP^cmryhR%EDinBwU(y zY%m}qCjJC-fyipq?^VL<4;pu`CbAw9yvI2Zvyhsf0-T@26d&E5@N9@>s_17GIe`dd z{I+T8BgZ(_M+$zs^~T*&y6Nx=f<}P@B$1C>&2&Sm#XC zDk#s$4C>7jazr;u*AbSLmIm(=e{igl2e^5TQ;ZMTL29VUAT*!3g=7E(t?kB0txBxuzB>ek5IibY;&phJ_2G~z{)M{> zP!8-i5iOpVx>wjQiDiEN_p`f;zP$}G3vw~ys%+r5PEA=}ic-r-kih$k`7fW{eE;pz zBMb{wTK}A?-K@f-DzDyermv-bM1?lC`gvNGt@R9rg~?3TRi;)1E0U&lICR(U*=y^V zrh~@t%}pzummxI1ig$6p&k4y4 zk&=~FpaKpE>sGUlf~=v45SeHE!po>M5T38X(S6cT0+ zxs=aTk(N0Q%uj;aZ#0QO*Nu!;vd3f%aK6YU4+$1D{ zw1isU!^Dlsl0f+EmC_*Mvzck%61x;^Jy-oyoo)(HZ)P4R$_r8`Ef=T}6)9DB_kP%v;e0&oRkL1!) zb`W;^rjVhEX1jlWd5FAx>9LG3Z#P`x#C*iPoaNVt_v%Q+L^h}lM)dk9Jh=_FBPX*) zLXG9&8{WTu<7|Q^<#pq9=J{JJaN$!9&yuX6CywieV8UioxteKF8be}9CVn;SVVW!452lpLOKRIw@- zoKmEFa_H?iz3OJ)xgFW*I&jQuNSTM-U$0|O!wHn24^8@tM~l3)FjC{a<>h5+Ha4~$ z??=rL6wC#;Ia_2zgqN@@)33q7giCL6?~unUeEraM(%HX(4BzuW9Gl`MwK70Z!Mn%9 z?`zg1GiTLY5j6H@F?dX0joRuiVJn6{^o~-P<-cdTS~^UqEmI*-#p6Sbgh`Hg`cPB* zn2g}}p4a=_XSg5#1c0C@cI1!iu$AF%R-@nd;_kJ+zJA=7d)?6w(omA0NQ>bLs;D7->VAoLkf9+BTgD6`bGY51IvZa&v>YR(CRLg%WtVX!!H$D! zq;FZQg}v3DCW%Y3j4pOI`>HN(La#_m8R~GR!ojgG)-{mWN|c+mV7HY^bzCAQ)dF9q|E>zMdn-x@Z9op4^4|4f)Z>U>nc&0IuzWb+q=Pvarm zbcDM>Leuf@vc9*9kOU8_P?IWVI?|!f5m6&cpyhP1n4?|p&jruzN68~c$*Z6@ANm~9 zv9N@rz4Y*#C{Sl?rPtQlMnez3Kti8#e+3Uv_P@=+&8Q4F<>_Cyp3^gkRklre6U$9W zJYCFmx!RsCZ^i(qA{H(#x^j#XUn5PG(}_ooItGz?-o)-!UQrR7pU?28M30+U6|xL6VQzhW za`4N~A=Adb8E+IO5ZFRoS5RdFI)m{>qzSK0u|rJ0*~cYxkhWVS{CqWFwH8J|tg{}w zQEA4hkm_2VGX#5#g%r>FO_?!r%#DPldY9@ME1Lf&ZvW!rTKBBcoxelssN&2otIBj@ z)!QgJm4zjr>d1Ysw4H;~A3Q}+MOMScgr{u5_P&-HeiDS{yLUP~Je)4zDCOLo&y%)? zMbQw~ozscM$I8(|fTKSbTGs_S)fyVaUDfF>dI)u`{yhQ(N(iC zx3g1<&wf$G&W;^izyNzLb;NTD(w}_u3aLR~Fp}}};E$0(4qA9G+L{I8-`BEk=EMIh z)Jq5)CdahQ?8!1k>^|uG#P=rd&`eep;dxI=y-%keontZSK-{R$tM2e zh=byXGEoXIbeVHdnfE;q&o92~dBx2gKWEnz3A)_isbU@VodlSxlmtyxUEJe)?~*>J00{*FTcWzM8^1et&n8(E6+4WD;MR+bq zVE)_{BRt{{5w%j4BMctyp4xrou~eh{lLOvi5x2FFP1O?FZW6;h=vZ08Km`JbSK0eq zY>?Es_XCjw@r#tJc%>xbWRPkv>pd?bHASk{i1tMf61Mo$h7H_iE$5v_MQ!G4Bh%B< z9WSpZXcP(V@9*!f`o!~FT0~*Z)O{V;6vY+#Z(HQ2ww;yYf_iMI8N1{x6^U7PpVj{; z4Y8c^B-&~ypHpEfQWD6?k?DPo&fe{G?iA!a>4h&h`8h}eou-yT+F*tVvJ_n_oj#nz zYIaJ$(O)U!RhO3JmPM0o_~}7>d1po(EKVe6zHMj*DJf}oX=zlRa*~>ostN-KN3?1h zUxmd0!Dlb8oxMGk13%OtrMBmPu zcxCdMtdMmer(@Y(G&QW`9Q`9Z0ycV6Hxd+uRV&Hrw1YyYy!w@|T-~WvS~IJ# z>qf&F?)CN_g=4kGyKkFOG<(`~dDmo=!V_Io#zG?$Xz%5+wGSG$Ue?;o226d* z_+3IJ9S8mT_3PCNuCIdmn!>7H!NL%m&SZSr$F$#OBKb7^l|jRwKwToapF^qr=Xqsg zYe?e>K+Si|+Lg4cBui@Az6*_|?-v~>h-{Ec>UgNgOM#vHoC%+Tbw4bF88hf?eA z0@|FyYFBLMd>=os;lAfiXPn$I#D{`S2SMW7oU|S&SndRbB&7KvbNn!mGQ`} zJm*C*Xpk#Rn>e{E`hXP3x>BCPJm~sXC$sC{4XySR@f)2?X}f&vTuFwU!l2IBibQV$ zx`$&p>43)>dyoWEWacGZA{M43bpl=Wim>9518+aUv~8go{O>dTLmI_BvA-ixNNfYb zoNaP8U46I=&mbr(`JcJX%7|q(KG@rD{Z{6Pw8qH9adLK^t^P;|deYpswycqnk@W>s z=`&N_4(x9|Dc;My&lA?IBkF3Zvn%vf-hHzGr9mEdhb=!|#MtbvqQ)h?VoqaY>Ztc7 z_q^-0;pFsmR#IbD2rIn0w)Uju&_})MqVB!wyp!b%de2fa?w&roC2D1Xed4gwbk?6k zmaaZD#h);L8YQSE!o$X?3B#CVXa_wIJ0f(2%OnIwnsL9M+eyr{VWIouS-|!=lkpJu zg3%)x>>OgDp~^|$98SNv>JFot7L%PEU$OMXTR*A=>DW21c38Mi@C8{!Fh1_??wWu2 z5CR?rJxNe9S5XlyoC|0r297?r z;Q==XayMMQ=zpAS{ZOe`boHn%2DJV{?mN?7f{mTo=E<8a*grlU5PYfo#tb$NRY z_H#c=S#yqH9ZU~H=T|W)|7RDWP|?J3>iCuNH~PzFAGKZT}GL6u}EhVLBOPGSX z26c0FrRsZ#X{r~~@DNw5WcK7}q|mRDn@CZGI}bPDAx_K63L)6B^r6^rWsgJNaL_0f zzLKGRHhLh?1Ka;gC9_lhNIyUv#|j||qJQ{IV6{|bJ!)1r*RUR^%+r84nlw{svN8D0 z=5IBIUgF1(A3F}G-8{kv+PzBkhv?70tm$8y2dbm2g!1ndSdYmcW=H$eh}#*5ynzNt zCcY-OSpWF1y)vYZo>GJK1bI6WX1x>>P2=t-!HK1i9KnUE_fiM_rytykfpMbum|GM% zK%XLU)@Jhr&JkUF>*?vqT?zfXSJCIO9xKoDwGEV>hfP)(4Jj9bX$d5=M-RL+ zMlhDedSH{OIUz;WGn*)S`|vN7-a*{nwuJf<8ElMlVeX(h^(UmXWd#qy) z*9HUc6-?vhKkw~daO-s^Wa%ejAR~IOQ7kQoGG>mYH{Hd_&cp7VhU7daaY!^h(H3B- z`e*{Pzm?xlb4njT79~R7=!PDu3)@&#a+f5KSiUvRl$O42eL*NkBih*#TFR- zHzvUKOTQ-Usg#Y2i^CB9PxY!Hr*o%aD|5~GVZ(U);3WnlFS*5{kCy1Pp+4^vd{n?J z{M}x>9PP80#v!FJT#$Rx?I@NFE;&U5jRL;+%lO(qkDXpYKSDlY42u0MUWL3TI^IELBrur$KDRswujtWT(R_7-^vvwVz4fwA$kmsha*e5YV%YrDe=aCUb6W$(sWlB#d$5zVs(x_c2C^n5#)foe zuI-CF42HVEd&FrYzJ6S@m%~iMri;@lk&-DypvCWQ^|?K|y)V(NFw>t-gS0)~A9{kk zxB8gqx6Ajg!pc$OFO^&zG++hY{)J#)F2_sr(PNyiBL0fA=$-gq7A2|V(>{MVpoDgP zq~V_!(fw+x7?3zZ^KuCmx8=^~R5~Y``T3`$R>v-`$q3|^p%sG0nCg4o^c@K6dI zXS}%tV~LV|alho_UptF7d}JV+t-b*3)JtxE=B@a(dnw7>C{gL7b(tPu6QICM_ zW_1>s%dO|hR6IB$A62CFhVb|kJCroEKBG2+`a7_LT0=j^e>~;}rlmFO)f~7a>YMO& zQ;Zh>yt#Fn1bt#`US20F_;&!7!3ED(ZH!6-ZM7_lv3ge=Z0!GqZ8%OOD;#udr}0^L zL67%KkE$4#Y$MK2eZq;F+AgLxZN2Gn7JA(VMC&1Wvqg=F+8pdTwXDH=SS(@B{7pP~ zB7ghiA-~5Dyn$l)^C>3d&&B#iz8QEVo*}w72t!VS_K%c1DH_u7+ZC;Rg%|x~8SOFS zqouhS&NSb@JI-696qr@yOwU2#lW8-b*4^hcLY)o7vj8!CmV4U4a`SYW7aYI z3!|}ciuOX?-2SotXLu4vL3ue|)ocXlC5BSCDFqz-TD&fQ&(D`=%+ZAnKE!8WH6qO) zM{Oi9VsmMs1Wyg!N$a7{)B`x8_GkOl%I0 zec14ydI`>wSPvEi&itUJJE`1z>d*Dw`}I-fw0I@csV-0oP03nkF&e(nBRhH?)NwQy zGBhI@REwwlaAMS5Vmw5;v%qSF1om4=37d(h>q`_tHI?4j>|Y8pEARfJ;y4uRs^AvQ z{g@ey?I{1XOGmEUNhZj=JZ` z03&YzHEGn*>H#+?78iKt9Ut&|`wWBUz)}->#OGYv@KH<~IAgmndNQW*+k>uyW50yz z*%twy8yU5>VH>GVQGBjd zw;zP9N3fy)p>TC_Jd%X$*NW4XTpmgZ%;?GAwkE57P_%qm{O?&I>qsa5)Ms;EGRYv% zw%W3ky6D=qi#%T8ApFj);*h&%Yh2@#diU*aK@_V>1jzE%){M6o`%eWL(%$Ah2Y*G1K$A zKGnPhK8Gs*LU$_fe*&)m5;kw2YvnaK$JivzunE9wlf;`nSS>TOG&Q^2TTfp+98*2U zfEV^!!Q2FhTxB54x}%hpQt~)&8^g@+^N-aUdR4B-e;X51XwU)TJek z;InZPggWfkO!s1XsG;g5&bIlAUoN;)HM#Bo_BKj{vj5~*jdEwr<$1hI^2AJ;$({RU z^(wqdS0hc`C_h6|-OtpXQxBKzaqo?rxQ&ZOkII#l;hYrKrYrn_kiE@YpTWbB$;vsHSe%YVDxu za8r2c8i$vcH=ZjjMTOzt?ry`$APcDTR@MjNkDX&fl;op>L8~^}GTYx^r`pK<|2?PZN4Sde0EG#XLFXrt3%WUy#5AU(a&S#acQZ?u7_wc5{;dSc~ z4p}<1?)r-TPDe-mRRvo6#s1v&CV$(&@me46RwQZ)oP&AIXPTXcvCB;Z_rc;_P^ytp zde6x}ZyRE|44+I3acr{JVsEVw16ef^cGoMP=RK_#?=H(vHh70AcR0}$29h{wl*7?G z#^@H8^o(Hr39@U68A@0&dYCGYB{`03j*Wu zh9%a@D5CWo+SSB=*mss&9~*B(cSa|v;-2!r18o0KIoxhD zk?q`|sg=Mv;8@ez2HYoWG4bsQsLYs!QR9%)wAoiLC=xZaQX|6wYd{AV^y1_`B=15WzJuLYf8uznh_#HODXy}p|+MUk;7Qc zYICNSPD{&kGsRlKW&6#YuG-hg;y>Em)9rE4PKqq=bLc(BoDWEx-jBCS)&p_$TgRs# znP0t-MeYn<$OwGEPEVw!=5Er$*J*=?ohjX-*%%Z-yK-DwKD8mnu(_3{g=Qq5k zL!H>$vst_zagS;^i?d7t`Q_#3Z5r4`RaV~m`6#^7dCpaNwm!$@Z_X_{u^BwzudLZ_ zd5tr=aoqR%rls%&tDF)}X%A)wYGnc=-;lbf>f;&ZzFGGiZrfCWpt25MBwt(+I1iDL z;L^)Ga$3_qXDE8L0<;qdY<<(M7rap~g!wilFuq7JzjtI0Cad~rff^U{;eb>{FVG$c zaKQNGTA2U5`|CJn!{XDYPrxG*du?~$J@6LDK8#|=7{k;DR40xnC4b2JOkxI{KJH@! zdFaZ!RU?TOB$~y=k-D~fTiAKAabCB6!gH&mm0v7+O+P;La?u-acb|~k7-$U2+Z)O1 znSR}D?xR}$IZf`d+aK~>FAho@))_moAab&f0DDZzt#-rCOXF+_`Aa^B_v%sR^g|Tf zMa482zw~Pxt>kkJt#5`+ZjOg7mzB<2Bh?w)n{KHFR)e2je_AXZ$JOw97E zu)&RtjkQm=J-BAVZgvtxA9|iQc&~42O(t#qd-ceZT|`TN8EmJosHK>=nLbUmsy?Ug zu>Fn%veg)T-G$kAwL^F-_A$U&FE};p^5vq2+5pE_B2NFw?r6QJ zDLdY)8V$?-0+qxIm5GZF0HQBXX?@3=;xB zq2RjGA0^b%{1>WpVMT8>5(dB>Nbb|Bk}IPwAiVUt7V!1nA$`1_d1*8^ozCYfOv}3{ zP9v4z`vqMt)h(Y}14h-!bgT|Vd0AakbNtKRC;X{h0QT7aUTG0?Rh;C@(AgyD%0;rl zlof~w^#<%#VZVRBEh;YN?E~-vFZY>?IVY)Z%jcSWpZP^<=}bSXh2%X7fMW1mgw1Rj zd#s@RFJE^BPKV=xob}3SpuLhlg8FrgpRUOgHuif{r)H(ZQD%>7WpYN_B)eCJJW?xnj3vT( z2RZORwm!bVhamWkZmn61-;%Yow9NZlE&#M4%Ih^o&h=8YZES4p>jP`ap(yLo%)FYK z=egqws;bxkKv^p~wyF}Or*EA4^kS+;Am!l!rj{Y}WqLO+JKHZcH5H>_*UG};1M+)l zj*37vI6)#WaoetXIG#k5w}_v)D2eSFDx=`q>|w;J<7kg6xkayPEF12orj)PFor5Rn z9kuYL;;9f}!DBzHBUA7o)O&8easpVQe&}BzK~s;%eN+W?bx9mPJId=@ThYR#=6Wy! zMI*i+x!~QW7`^mxQRj$I?s07Pq*R~y<9eG65m|@j{P30sjy(T`kp>X}`UD^LmmVVT zZ!gzQulq+ww>H!4H}W2zJ5k* zG3R;6tE{WV7*r~DVU4S$rKhDM;bl)h+PoM;X(a-g4R=yceu0v+U{eosyDZjyMl7_}Zp+8-Tt;r@rZ4=RJ}`7JSd zCZg35;X}&P(}5^1ySlXR-@m`EiEg{w;P5_)&zkg@;pOK)1)rGs^QYJM@zPiG%^S{t zTTP9PJo~0xD+rWd=cf)Y~c8pYHVl zsmufPhkZ6B=6z8g4`xldSIX0RDeJ*1d*9A2ZI1drRBcwpA;XKnlNl~9WP^iULP>zR z)4Fp){>=Gpk*!b6kCVl!9-D9QeF-T29ggB0+15x#Zkhd=WpvTAE-j%aW>R zMqFiW4>!qrUn$7RL6y{BxD`z)D5b0%zNu?$Ol9A8e^HnPicQnQ#g^Pm+~BU}yAycs zClQc4_7+>-JF`xFr0IHHTVV%qg5F-aVOLANAK?#Ukt_gZ*hzfw^vfv%Ttoe5A6cio zf2_;waY{?Ws8xC#Ug#@rca}lQYeBF3xxUfsk-wMJ)FcE|4AnXJ%V_JN|4;)vh|qqJ zf|{!!^Yg0>@}i=XNlo?FBIz(?K0Ne@&%>m4bzbif%I%k0i4fjuq^GQ2ald59_5bgW zR^|SUrG@p(S%>&&y^9_*D7oTrjYIooM2d&<0dL!TtuCXNi_yngEHRv8!<9h?_XBc; zy6=tV=g*&i!>o>1d(3Zow>CH58X09?%rCZhX>t+vdEB2*dp=xmO=QAHM+-_yY=*4$ z8eKl>ANEL4x6?(76xN_qbD5gj^C($+RzPl(%%>orL1A!3qOg_d?ZMsDRLPl*Nj9>#~DGir>^|29l!jq zvXDCPo9ma>T9W`;=2ATCNxiiQ`9?I8Pw^+(L{Z%_Q_ zCI=_)Xr-&fxGfWyLBl=8MMd=wH@iOz3$sCQi%r4zzor4$IprV5X@|Y&V$q|;ZkUX( z2X%OQrK<+o|K{}oJ>{56NlObbQz2a|Ccu*>|F&pX=RC|iU2Zi@esX&Hv#e|jNY5L) z#*ae$^A6Hsm#Zn<)>n61;#p>+x(2a*s!7v7Y!LDZAvkHFxC`}hz#}>>%FYR6da+a!as9%QzFTPISEWira2jK*L9@U1pT=_F#VP_$f|0gEH$Afj`9Ht#SW8NXbOnA3jl#Htf6! z0i85b-P|zf#(AcB(uP^}DuaH#uRCeIY>6%|Z46soT}?>2213MYY16^#7ZRf}%25~n z`t!ee37ghG20YSVK`Oazo|^wA*E7lxp;O#kNjW|R%1_%$E;yAmgYM$u=Pc^!*VGW! z_7^G=5C~AlxiW~=_%0cW)Lh)%PeHdeJ~1J!r$+{=y|tpw@8FV_yyMG!?{`Jc#-y1& zF$TlAX58+%K7mUqd5a@TNa9cv8&|*m>{1 zUox96HDvzpA;&-iJvT=HQ%h@&zI((K`#%^faNulg)^`fBqM9BCD=8n1w6u<|y28ys`J20nTnYu>J7=ISj904v{9%l^jcD@SjF0U2Dv(n6*X-~h z#7G|ckgTy8Si^)=!Qx=J*KWz8biJ&~BL4!Jn)PQ>bo!clSte!8tC$75g6isofPerG zSSLs)BN@U#zu78OakkMlIaP|9j6wmpOnh^>tRIqKC+1Tj0xe0T} zLHl1&P!Rm*k2avDCSW60ubsMjKb5vrJE>r{LOZJLc}~`g>3njj=Q@_=TQi$&f?N0N z+5srVy^kbvwmpaC#dCj*Ad|mDW|?n}V1=beu#;42sGa$_#ec#Ui5Z6jaC4w-O$d|D z1<6;}(76B4a1BtnHBsY>ioomjm@12>>Ga1%k;&A!AsT|vvGH+9dHKGeR!zjPlk|BP zPd30)nDQvxTr9{25iG2-54$_g=Z>RupgH+3{xtM78&XE#OnGRl=Zfw$K(nW`v7BDv z`;6)xpNWEvno`+`Rcc^3lG^h{mn~O#tgr}xfrDP9MFp+2!8#Y|d4Ntck4|PvQN#R? zn54qe7DcuJN2A^8RiLpMY}-jlv-1-^id0nAc*Oaq?DTL6NFg+jS36-wfwWDhxJW4x z-V2U*rJLewGzuZX`59FJ5*hh^m_(q56OiFxZhRqoI)#=0yty*8C0^+G0ZXQXx(fIp zsj{=_0(0;O!i;n={`U5A-=Qm?v>pY(aph@wmXvZ>Xh@=>28u9$|7MV;7N;=4Q(WZ` z1J!+(96D>EkgiaPN8iKnZuP&SAv6R$8fGOj;q1a{N0?v_2DG zhm=Hnhv==@baXW&%PPSo85bAw*;|rGR+J-8I}~TnL+@9tNUqrtb9~lupR|_Nuq(Y> zD>qC%?DL|WnD3jkpZy;a;=7bxiSYpIk@vM3DKA1p%R?v`w?&Sg88?*!kN7_t!(vrQ zEqp~O!t!LS)UZk+Ox!@pu)o(n*~+`ZnQ_%=gaUEs&Vk~j2m#fVXn(Kte_ejiO7 zkC5xwdY2%iNk3<)igWLVxMn>fio2;?Ca4@hGe&~Wy?v33R`W3yN<$gX3$dQeF03kd z6dWS0?l99f`A1*I*zS!F8HSP~27V98M=w;v!tmvc%R#UZ`qfz83{fZz`NH}8eZE^w z=xbwS34X1v8hzir{fHc0oQ1DsHK2sd@AKd-o zY@cC0?8iPplJ6uUBH1zA#ER=f>q(^3vI(>`ef{mm&XwFWepz?Yz*{_<~yL{U~RLgVcgVamuPlfgPk7^5*$BQ*|ZVF&dfk zWioRy42YkoMDwZ~Q_0xaZ~lS@$nkcEpk1;Fr<8JE%%*N4T*)ZWkRM_M38D>Sm~c&C zx2iWg@Ay^WqdZ2!a&9YJ zFiwO-t%)pGBlEQ>wc^ihV7@4iBMp0j)?IW=-&vq-6Bx3psmI6V$&u?;e}U`pqm?%4=7h2` zUHiv}{Hz^X0u8~bzh#QZaR8jqOV1m9AVDU0kRUatPWR4nex;L!b*cfOM8u&Rq(^eN zQ{k94BFDqw*cx8}P%2TBL#Sy4`YT-)^~0h5b0q67Qd+crvMB6M+_suSv_;%mF_1cb1%)(Sz9HFHkQ@KD7-|SO zMCpS3{Q#TLB!5#;bHC$!Z?oi_n5!yU?=w-X_l54+uMBAb108R6X!DgaZJcO@_YYSF z3b}G;KotS<`0*71!X)cp$0t6u;{5O2@(P@Ls^ek+uIe8~26yCF81BX&gVxD%H1?A{ zae7@{=)f=$iaHN-qx`UPe$SRmjE}%c9$9Zt>hU@jOd|lb1Mp*4_poLo4pC82z{Uf& zFLG{QPNP~GRqIPD4V5pfDmJh{r~yd6yGe;AuPF+k%|x;r3Yv8c*vSC0s%7=U@qL-G zBZK(0MN&$?fM1;CCo04NS%cT(zrA(rDDfrpbc$o*>*^yU>U#d+t0GB8DBFmVT3;XvH-e(jyumb#&0>fH zk~-uNno3b`-`_W$ZwJol8cd+}S0!+@M)y8syL0mXEb1|PlI`c6;A{YO2?c8Pkd^af zoOs*}w`a`kz8fVD8a+MzlRGMWP#*|z;nUS{j+0JIj=mMsGD>2@94Z!1}kgJHQ$*!{aO=P#`A5`%`gro+}Wb)SElCGYE^A|fI_q#lJ@ zK2swikjss|yYpsmIeQDFbtx+=t7cRaT9Asdhg<6Rtdvd+*BbfzY8kV9)`(uT(Ct35 zCVl&r_TPZR(1#q-3_WO0A+Xitg0;t}kYU4E_DCbv2%PAXkfa`*`WBi&Om3zD_dH&h z@_5Xk^`bzJa?|LCc`F1bC#S2UVDTZ6wkugMqu>Xyyrb+a^pup8r>g_~&hq80pz~&g z9K{hDUHYe;E;FvT+R3l2jy3fX1pwWGX`k`O=JA)2L4UrMWuw;_iVRc9(M{{-I**4v zqA#?A7sil0iUk|Qv#6x`hSfCFB?Ko=)Uq%AP@ zH+t40J4fKZ$wTZf&ojXaSStizd#z&%?1_{ogtfdd#cCRZwPUR%*4#vr7{wFS<+Q{4YAF*Y4 z+(zTp5=vlD_j0*-!(g@4+py*ov)2D0rqsyuQaplY0y)`5<;_CIgs8Wh-?cLr#ub<( z_meDBfIA%fqz=E+(i(r#xq&N?>A6V;#`~U_S3v%P9*Qt)wC``tf3MRRGXswY7_-(N zZV$J{zDu87UQSF;_dSsU4bF_=#B5JsUu7j103#L6&FS{{_m4-vc0RNIi#9MyaE?fb z5kxC;{Z}!SFu)hID8SUhIBaWp&~nMgR!kIdRujml9}kQnx|4?1A>gH6Pow;?4Jn6yi0Urg;ttz-yTU*=XOOV@Ah0&Nf z2BG{DQDMpVmzmqkd39>xl#Uwz;Le_XmQ%?pr)++jTX!)6V`Bq=76?VV^`oMxv>au^ z{LYV!C{FiXA_sT?HTT2F0r*0n=kGq=VyI?gwNL374~t&TOT$`c+;;c&EI^-^3kx0H zG6zmL@ceoK+yFFX`<{YMmxOC^_R3D9e>pk>}0EI|90I&D3GaYVN{1%Rp> z6t&jvaAk0!EFNl7BW?8FM@{fm;l~P9sS6OS*xX8-tW*sJkWjN?AwZ5;kMvjl!IXl> zt^&nV;voF08sV8 z7|p7jn5;%25F@A_R;UV#iheiORn%kw1})pYzt4E_)2hwenZv=qh>6;pJ9cd(q;bH= z#`#q1nM)~mg)k(5DGR}ZJSs}TczijB*2>aN9udYBHuG#19X`xBM!uI;pWys1@MEaq;o-ZW2F>lt6Ucoi6LMt0N4R zaS)&cJ1yYp(wYH@2HsR3#LWg#2!QXs;$W|gYUvpEEVwU;=GP*a@TKWhT43B>9v0;0 z(rtJH0Kd+-ErB&jmH*DrufMW8#Tlu&c}t~o;_oOvUzwiicQ#h7Ws-JbKekXtGwlZ; z2x+wJg6v*W647>@*0xgicvBWCL#xY5Oo`K%U=U!b_k@I6gJ9^$*1qBZ{=Yt6h9a=C z(nyAeKYJJFRVWS{iZVMX3rcT{v%9;RYxMk3{Qz(-Mx2TYq18G;Frj-xBut8qJOk8p z;GqE4IQj-mqQ*ioil@Sct-36P+6slhL=-93j13={f~#az?~gXQJXo0beY|IWA1au_ zxA+*f0YK9U>jz9C#kg&XljE~p?168YOQ@}*tUn*iIZ+@XHb1@mcqKmWWdqjX@!|ey zjQah1;pxj{VgQQ(`T26mR~)F2sm`8yZU-KHPTG@1+kuaWa*5CjO8c!}UF3)2&ppsR z90y4<5izwOkaQ$Bq+JC(M4)W|s{~B-%DK9p8!Y7l_T&lYdSXwWxSw?Ra5X&^%;(Iw zP{EHMZ*yl_eSD$p^~)@j)(%#WNBYVb6bQPiiMRtZ1{@)}uM5y~KZ}aUor~@GVl7h$ z8Pyz?d@dc=`|*bxKz(34&0(&UA$DrK-5=Mz(#vi%>#V)@5Bu)rRojrml|2`xA;fPC z8HZ*S<(U~;aB#4o%Qjw3O$~sYhIdL{!MP$MBj??he87G+c4qG~UHP+{SZU=rKFMBM zWfB81rXI>N5=}|JGl#ss%}U+(YQ#f^NMJe;)u#txNdbq3QphEwsOS|CoVR4q`zxP( zkE$+5rORezP>dJRzFmHT3 z=Q~gD+t78QL!|bW_Tz^7vZq56k|h?2hnP|UE-(;y6BO|SGKPkRBG+3gU@9dn=jf>% ze3G1V<)c51ydRcZbhQ-KDPc}?Np;Tn5(@30K+~VCXrrGUIYmB_-ZZ%g##Dzuc&LNg z8K_y@#&*w706GJFay3s+K_FUAh@FCQ7FIZr^-lWfqK}^D;(GRHfM$03+!e%le~mwA zk3D`+MVGVB9(x;W1B#!#{K}|#rUh4|h#(NgoHA_a407iRXJ0gxHSlIY6w=VZXHR0u z#P=xbuLQIUh!`hN*9$O#B{pvID$SQPM&Ho^W@TWq1j8`0z z0nNAgd6x~08Z!glCwM9AU;no(+bu(#$qKuV6|I|XAz(byr*H0hqbfKq-1q;Ax$b|e zqyK-eaglYeEqmn}Wn5(MYh^^;qNIDdu9*>uvMHCiR)vI;tWs84#dTdIo05^0m5kiX z6n&4+_xsED@%;n7e&LsMo%?>D*Ez5AT%t@>dT zL))>`=FsFXyQgIf4!Ji}2v4`Zn67WSx~gn=VTI1Bp6dq$j-yLnC&ots7=X+kZp%;c zOTAw6S**%Rm6iRg$@(?s!#--s*%$bknYXNFOo+jA4@}RI3OK8!(`Rcdi&V2xy3dr! z_jb6dexH5EFHtQW_}7}#AZj0bUHu1S!+pkxAh+-()H#_>{4Nv;Orj0x=^rSx7pZ0{ z$K6#${>MMz1~hw&V18mh$)=K0pecOmIj*G8f6iH8`A9lx%;Z5lxX8I)&Xtc5Bn7AcsBnJb*$Sl6s zxnyH*nQ^2S?F~xMO1uA(57DrXTMXzo5MezjHDHY}rE=GbXQWyY-&R3R%)n-3rRxPa z8|i_V6L?wR_JyBgJ(dadPnk>Qs}<3CsYPD9$n!kBXv|!cyCucto0@Qg^w#&-YUh}qJv|EY!&=UAc&Tp>| zhB;$EJ_OLo8<}hptET)=&)?Z)hzmG`MtX4TaMsEE+zE~Lr&sE^k^9_)AHpp~B#ty5 zBWCRYw6|B`c3;5o;<(f1aw<}OP0#NV*X{#MeMrPYZDfC9&HG0rrH_@pVicUr^S@1# z-j6QGDgXw{b%-O~P#HjmbKTLSGyl3@nIRTRY&HZGqiuKz{sDRC7wfZjK8O;$6q*kkbx#Pa1ClP-~I?e|HK zrDOE2bfzLVsosi+B%~KZbmH<~7HO7MzKV}xY-r{?ym%)`c=E>AKC{E>8o31*cz4HZ ziHy;{%fV1(typptB+62E)L?$8DvJa>Q`ozAqD_&yLLyfw8djYJvRvZh9G6n41QpfE z)7gbk1}Wfl@$*mXys9MuYq=nG4v`;u6{0PBXcH873Ub1ZNiu#D`>hqMl*rw`h`tcE zKF3PCaF#T)?YtC{7>9ZB!R_L4j@jVY0q;jUGKNVoIB8&NZgDqCi{kSqH}l@DQgSAF zk~y^{EcFzmxI=&DCp0BH6G4vGx>V&vwXz*8#NQiq#@qw<1O3(o<}XI+_kedhtc{Eb?X(jn0#Dx? z>lSwLhB+o25*A&E>eyYsg7-&5k-=}X^-F8vHzUoJf;*(a+ zMu?`^c~kkNC~-?>03p$$3ba0~ODz>XAQt~Ta}ZNmyeY^&^fLJGS>K6k`4-@GemUlU zT5!SZ=6MI`J>c+GUR0(@l>ZJFo!zK;ax_H*Eb=aKZKOVGn`3|PfYm$+&0Ql^4$>I&TI!e0R{xC}YsM>(#|8*f zqXZ?%a{f-*p0V!1E&7)59e8hEoCViX1^XTyjw>tw#53HYc5|R^^WnsrRI-ULV^xD zP%(v^+l;B>m8l5}6j~?)EM<^O*dNpjutWTuG+MkhdnW*4(Os=N7vzBK&3( zmKsI+_(9Bs{`r}HvN;@PY-6|jV|OEgpY*FnmNQ^07&yQ+Gp%=6Gd>3lE8Yl4V={xr zdj$(cYInK(6TBYA6EdN0<&e-gR_sRHD`*eHAH;J0dg9@YcH4N$u>_$e&4^b8pSRIK z{$_;^EY5Dl+%SfsoA2lJizi19f2EyV{6sSFXNWE@_Jo{x2CWIar7nRh@tz1s>LFcch0LL#M6_bi4t>g)uq z*G%x4QT`6e-*%W55i{`45ehIVLyHNH3pIge!j)_=!FyU2khGi>U z+h{?wpHT<-79nC^LMK0@sJXf3+FZ0}N&&dB2g+uNc_ z8eZ*^Lr(Yp^Y(W)=7nvp9Xvb!MG|pG!Y*3EPQ0PgRQQEEVWfFkJ5pT0mLoF9UYbQ( z`suEDmT^*{@;t}Prq4~2m-hYb%Y4?$l2U?Bbc&(iC2o#FZhwmby*KbJ0fNjhXbEnf z+o%;;oUkKmcc}u~bI&{rGJGZ2)T)Vr2)F0?CUw31i|wA>>OUFk4CZ`#oHCa^ZQrCm z!dU8u(AMDC($l7Cll~`4T9YB4ZiMYm7>z<4?k1js1fNJgxqn;>t5R4B&|D|y{4-aa zIKI4mUG&_ISo}5;U>?igY09hY$wDz6sQ#XwtXFfLbOCs$56 zX_t`#bltkm;%!rqI6vACv0rmLjI_Pj&;uML{X_P&m%5$5#uKWCr4+;=e%}}kR#IwS z(WCgp!P`|QTdYg&yd0;y@QDx3d{fuhu#i)Chha3>tG@G6^`j;UpwDp+cwhqr_u@=)2`zGrZYQQ*=y#EpcF~Xk(lO z_3^@FEl%ZL-oyAmC`q;$R?#bv9^DWwwP?pjWq6rODc5!-9@`xY%Hvtka(eG5z)IiB z%p}yfH@Rv4EpWpuF8Q-=uOFGkvB!+9TDdVsnNEM_SD?M1*6iUOHg1YxO5aY6m$ceP zErtk{U#|>f~-eve%baUF|)m zQ3kk|b3$yY>FFc`SxY0lQ@n1|Dxc~VKI2*D-ZhiJH94?TqzzP=G2-Y>1hX4@#CrNd zB!g1ChCRj8*t$o>_sJ%b5nRbRs*z>H zoc=U6u)dSt6ma7i`-ncDMYz}ck`(@vsE3>X zHwTQvd-UTK4)&ecTT+tTmnp7R3d}d4Pf!vcA%fD$uqwN}cL{ok^HlwC$)HcPeyEb| zY%Dsus8oK+kol@Om4}9D=fwp*ncq0UTav zL=-E1+o2^R4Qb`g{L-CIJMz(j)d-K$#k4&|1ls-2R1HnOk$al}vd&&ErjyHZ3?D88 zH$w!SA>5^>AOum5Uop>0ZvSUdZ6BQY)+TECg?17(O{*yLt30>1_jaD1zAZ9x^D82M zGB9b%$bU=cxyo=cdWhNh1zJGC!<5qjtrg(&!|%)@&jik;YDNs3FO@mZz+fyi8daV7 z_7(2b6&DI%lK|PSBE)XbBt+G~QapIjf;Sd(<)ofSyl+(kP{=mQw_vBoBnB94KN_n^4BL`)t&U*?J5Ok(^8*%VfA!>2BbZv0vmDj5at zuJR1ua(;JvmuyRrchpAc@K!O+^OL5etDy?Uh`771o0vj6@i%jJ?u#`lcL%NQF?~0h zynY5AMNG_l=m5E1dciedl_quLTJdS%8uB*=SYo5!iYTI*u6U)n`!>R!pcq$^Gxe=7 z=qrB?WOV?n8Z+ie_BuURn_J~%?Px7IaZR&uVElOs^W!qSxXS$U3V47afWSf~6|zj( zKlAF()q#1f!_nE?2MozD#NEfXa1wt(fIhQP_6Q97h_bNA3yvEn2SPJY+JozyU!BfE zZ@GN=1`HMf%(fjZ_3glWJ}e65Hwpe*yIgrnu`IuQ{OAX}QFg)%3|`dv~!jz_%lRPXhCWy~8e zZtd6uyYETPEO(uy8$I@kk=)}d>%~vTtIl3;04OZb3MMx<>jPX?Kv!>nX4gN}pj1qdP~EevIt!9Kj*hO6j~gC^fX1Fqd1lgJ3cliP8E}9BO@=smV!3{*B zwf|acu!Tdg|;|eVQL_&z#T(kY_M0p!Tyc zm6l#xS$Wpr#N$p{gRN?f^p%g(ft6|3Bo3rPMmMA1ca970z_{(4qTl6Q`<-Kc^hQom z{Dt9nUpsatr1vRVa`D|`eDS$li-YRX0$ve^n0|6ONg6g8^e%7V^KZq9kK9$ihePM8 z6+v794oH07jc;8hP^0sGb*N%rvC3H0=Z_Fv6K!}^oQ6;4IsnWIk%3V7Gl0VT7Z^~Q z>OLKLexKQ!VftR{xsoiGZru$dbjp$P)-8A6GF}xkJem=Q8*?;%sO+#>QL6j;_~y|z z@aHe$t&4rP%ncwQ>R{m)5M+1aqWuFsi|+O%dYcL_9~avZ&PazfC70P_fMfgb z-?F%uPR`VQ_jcf^z`n`ms+LKaJXIyk1eqlo-)zZ^D`^}JD^2Os-}Nl$&AD}^pA+;N zHJ)nGrk`)BmX>RKcm4$JZ0-^%UP=zDciTRU#4{>xO@!)KbGS1baajN*Z3g;pqC|unwdX_e9Nt>KXg^%>T)VO z>ula6n#Hv;i$)P~cF_-CNjxf)8~>B=ckXjQvfh{2lkh@tOrlX|T~X8cio^19(in}t zRr!x?a6o-k?l)aoAlwCNp7gA1o$g>4$Waj}s=p+}eXQ9q8|NPl+=$wJv z@#9CU)sR2&nd=RXZ+!&;SP2{&D!RJ5KdyVp_xsn3Ar%$zgN0}{b@f3IWwG}# z`yw9eJ*~Xk<1CL6iFU!F&LVW+%naX`mnC?P;i?>g823Z9E(EHX3xnq|?g0U5 z`T3%;t=Mo6GM3n3P~^o4G>u+fgP|M*clRnFkp(e{a*B%53JMC%xm***d2FyaXhNHj zi;E~PFYl+35eH}IyEU%yU`iHV`s?b5d}>Spd|gvEo#NpX^VGp5}TcLc$gfVeAA z;b4|XB$B*v9al4^cHy7EGC?265yyOsi;Cg_#0MCc?_f&-0VcDvv-R}7rfSLA_I-YJ znR$6OwzeF=(&nfL9*;9u2(@uaZ@rz-XlA-_Ex_$tP{X7jP%3EN-cbb9H^|YtD41LlM%XH^owM zLBZ@90-&SPT$WCB(jnpCPcUcT$OK&C-$;YV7K5qZZhdNrA_jys>(vd7T$`Xt>Q5_5 z$2|M(3i32$8aid-plhToP}J0vyT0y+ck1xf=!}56K8h(XmkXrtYNkt!Q`gbSU0q$> zqs?Em-c0fmk}`9^Y~FkvV$ro*nFcU{9&V%g!4-x>7Dv0GA%zocfO7|i76^?bn0qJV z%bRC6z_b9QN?i^vewbF^jY?@8#X-XMAoV*0kn=~ zSUz<(weHjLduo)x@-=0D#{s?`#&|kiR$uyNs4!DU?fG_?ul1VE>SONZy)GqzKzXaj z%Nf&|B*#v>nQ%h0KK1&H9=R??%KGcWG~;UV$CkwnB8!ZTFy*FNaqWZGaG%-S7!2`w zYrKiCjOC+Dl0gcpAXZ@SD$^l%y>LC3Bhx2|mn$%VrXM-dmgho{kB)gYcZcV%DKCLL z;EsLtFu9G~A9Fn(Ue8tvwL(m|b5O<85ELURaY7B~?d5XOCyF~8%#E1arzIRxJft#8 zFUY+61xHozraJ~}4Mx5|9y2j90sfi(P@&TDk7Tl8burJ0t}kv+!PTLm{hlL-usIt< zfzq$fJ&XTF6m8*NKVTcX@pHZ#od}LwV}2C!C|X0bhx5 zO_(XqH?hJfsAkY28{fSY+i`NEFR>0X>=H7p(PlFVWU^vyBG4{wD1~H8#&*u`1C4fIdGwhf2QT&H9(2w<1xg`c4gt<5p#Qg9I$O)Xr)rEm=}6Fg z5Z|X(&5T3UYy6IH#fnQw#Q~`d*jxa;oZY(^sKVQ$?z5(l52ix-(qZRUdBn^|U01Bth+CBqL37!Py0+)alDb2Q`;mp$SHZ01J1(0^AEF>b_8?P!YEgS2;-Eo@8))8 zbX{FxwjizSgIVaBmcSt4-C&l^I0rqmQ9vul9zGZvfRjXhE6I=fzNKL%zqMbe#Od0x zD-NR=i*0}g`~UT4xbGjQdQ*C#@es`d60ks z^D64v$|!A=ih{BxFzmnLd?Wh523+&UdlAC^?*LWQe-DWF5atB~AeLxb(^tmW*#82u ChZI2o literal 0 HcmV?d00001 diff --git a/winui3/src/toga_winui3/resources/winui3app.xaml b/winui3/src/toga_winui3/resources/winui3app.xaml new file mode 100644 index 0000000000..bfa2d9feb7 --- /dev/null +++ b/winui3/src/toga_winui3/resources/winui3app.xaml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/winui3/src/toga_winui3/screens.py b/winui3/src/toga_winui3/screens.py new file mode 100644 index 0000000000..b68351b352 --- /dev/null +++ b/winui3/src/toga_winui3/screens.py @@ -0,0 +1,97 @@ +from ctypes import byref +from decimal import ROUND_HALF_EVEN, Decimal + +from travertino.size import at_least +from win32more.Windows.Win32.Graphics.Gdi import HMONITOR +from win32more.Windows.Win32.UI.Shell import GetScaleFactorForMonitor +from win32more.Windows.Win32.UI.Shell.Common import DEVICE_SCALE_FACTOR +from winui3.microsoft.ui import DisplayId + +# Microsoft.Ui.Interop functionality noy yet included in win32more. So pywinrt is used. +# https://github.com/ynkdir/py-win32more/issues/184 +from winui3.microsoft.ui.interop import get_monitor_from_display_id + +from toga import App +from toga.screens import Screen as ScreenInterface +from toga.types import Position, Size + + +def round_pixels(value, rounding=ROUND_HALF_EVEN) -> int: + if rounding is None: + return value + return int(Decimal(value).to_integral(rounding)) + + +class Screen: + _instances = {} + + def __new__(cls, native): + native_id = str(native.DisplayId.Value) + if native_id in cls._instances: + return cls._instances[native_id] + else: + instance = super().__new__(cls) + instance.interface = ScreenInterface(_impl=instance) + instance.native = native + cls._instances[native_id] = instance + return instance + + def __eq__(self, other) -> bool: + return self.get_name() == other.get_name() + + @property + def handle(self) -> HMONITOR: + return HMONITOR( + get_monitor_from_display_id(DisplayId(self.native.DisplayId.Value)) + ) + + def get_name(self) -> str: + device_id = str(self.native.DisplayId.Value) + return "screen-" + device_id + + #################################################################################### + # DPI scaling + #################################################################################### + + @property + def dpi_scale(self) -> float: + p_scale = DEVICE_SCALE_FACTOR() + GetScaleFactorForMonitor(self.handle, byref(p_scale)) + return p_scale.value / 100 + + def pixels_to_physical(self, value): + return round_pixels(value * self.dpi_scale) + + def pixels_to_css(self, value): + if isinstance(value, at_least): + return at_least(self.pixels_to_css(value.value)) + else: + return round_pixels(value / self.dpi_scale) + + #################################################################################### + # Size and position + #################################################################################### + + # Screen.origin is scaled according to the DPI of the primary screen, because there + # is no better choice that could cover screens of multiple DPIs. + def get_origin(self) -> Position: + native_bounds = self.native.OuterBounds + pixels_to_css = App.app._impl.get_primary_screen().pixels_to_css + + return Position(pixels_to_css(native_bounds.X), pixels_to_css(native_bounds.Y)) + + # Screen.size is scaled according to the screen's own DPI, to be consistent with the + # scaling of Window size and content. + def get_size(self) -> Size: + native_bounds = self.native.OuterBounds + return Size( + self.pixels_to_css(native_bounds.Width), + self.pixels_to_css(native_bounds.Width), + ) + + #################################################################################### + # Screen capabilities + #################################################################################### + + def get_image_data(self): + print("Not yet implemented on WinUI3 - Screen.get_image_data") diff --git a/winui3/src/toga_winui3/statusicons.py b/winui3/src/toga_winui3/statusicons.py new file mode 100644 index 0000000000..175db4cd0e --- /dev/null +++ b/winui3/src/toga_winui3/statusicons.py @@ -0,0 +1,20 @@ +class StatusIcon: + pass + + +class SimpleStatusIcon(StatusIcon): + pass + + +class MenuStatusIcon(StatusIcon): + pass + + +class StatusIconSet: + def __init__(self, interface): + print("Not yet implemented on WinUI3 - StatusIconSet") + self.interface = interface + self._menu_items = {} + + def create(self): + pass diff --git a/winui3/src/toga_winui3/widgets/__init__.py b/winui3/src/toga_winui3/widgets/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/winui3/src/toga_winui3/widgets/base.py b/winui3/src/toga_winui3/widgets/base.py new file mode 100644 index 0000000000..ba79caca0c --- /dev/null +++ b/winui3/src/toga_winui3/widgets/base.py @@ -0,0 +1,201 @@ +from abc import ABC, abstractmethod +from warnings import warn + +from travertino.constants import TRANSPARENT +from travertino.size import at_least +from win32more.Microsoft.UI.Xaml import FocusState +from win32more.Microsoft.UI.Xaml.Controls import Canvas, Control, Panel + +from ..colors import native_brush +from ..libs.misc import is_based_on + + +class WidgetStager: + def __init__(self, widget): + self.width = 0 + self.height = 0 + self._widget = widget + self._widget_copy = None + + def refresh(self): + self._widget_copy = type(self._widget)(None) + self._widget_copy.native.SizeChanged += self.native_event_size_changed + self._widget.container.staging_area.add(self._widget_copy) + self._widget_copy.native.Content = self._widget.content_creator() + + def native_event_size_changed(self, sender, args): + self.width = self._widget_copy.native.ActualSize.X + self.height = self._widget_copy.native.ActualSize.Y + + self._widget.rehint() + self._widget.container.staging_area.remove(self._widget_copy) + self._widget_copy = None + + +class Widget(ABC): + #################################################################################### + # Widget creation. + #################################################################################### + + def __init__(self, interface): + super().__init__() + self.interface = interface + self._container = None + self._content = None + self._constraints = None + self.native = None + + self.create() + + @abstractmethod + def create(self): ... + + def set_app(self, app): # noqa B027 + self.interface.factory.not_implemented("Widget.set_app") + + def set_window(self, window): + self.interface.factory.not_implemented("Widget.set_window") + + #################################################################################### + # Methods relating to the container. + #################################################################################### + + @property + def container(self): + return self._container + + @container.setter + def container(self, container): + if self._container: + self._container.widgets.remove(self) + + self._container = container + if container: + container.widgets.add(self) + if self._constraints: + self._constraints.refresh() + + for child in self.interface.children: + child._impl.container = container + + self.rehint() + + #################################################################################### + # Methods relating to children. + #################################################################################### + + 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 + + #################################################################################### + # Methods called by the Toga style applicator. + #################################################################################### + + def set_background_color(self, color): + cls_native = type(self.native) + if color is None: + if is_based_on(cls_native, Control): + self.native.ClearValue(Control.BackgroundProperty) + elif is_based_on(cls_native, Panel): + self.native.Background = native_brush(TRANSPARENT) + else: + warn( + "Widget.set_background_color(None) has not been configured for the " + + f"class {cls_native}.", + stacklevel=1, + ) + else: + self.native.Background = native_brush(color) + + def set_bounds(self, x, y, width, height): + self.native.Width = width + self.native.Height = height + Canvas.SetLeft(self.native, x) + Canvas.SetTop(self.native, y) + + def set_color(self, color): + cls_native = type(self.native) + + # WinUI 3 controls based on the Panel class do not have a Foreground property. + if is_based_on(cls_native, Panel): + return + + if color is None: + if is_based_on(cls_native, Control): + self.native.ClearValue(Control.ForegroundProperty) + else: + warn( + "Widget.set_background_color(None) has not been configured for the " + + f"class {cls_native}.", + stacklevel=1, + ) + else: + self.native.Foreground = native_brush(color) + + def set_font(self, font): + self.interface.factory.not_implemented("Widget.set_font()") + + def set_hidden(self, hidden): + self.interface.factory.not_implemented("Widget.set_hidden()") + + def set_text_align(self, alignment): + self.interface.factory.not_implemented("Widget.set_text_align()") + + #################################################################################### + # Other methods called by the Toga core interface. + #################################################################################### + + def get_enabled(self): + return self.native.IsEnabled + + def set_enabled(self, value): + self.native.IsEnabled = value + + @property + def has_focus(self): + return self.native.FocusState != FocusState.Unfocused + + def focus(self): + self.native.Focus(FocusState.Programmatic) + + def get_tab_index(self): + return self.native.TabIndex + + def set_tab_index(self, tab_index): + self.native.TabIndex = tab_index + + def refresh(self): + self.rehint() + + def rehint(self): + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) + + #################################################################################### + # Content. + # + # These methods are to be used by Widgets that have minimum size constraints based + # on their content. When these widgets are staged in the container staging area, a + # copy of the native content needs to be created. It's difficult to created a direct + # copy of the content, so here a mechanism is used to create a new version of the + # content using a "content creator". + #################################################################################### + + @property + def content_creator(self): + return self._content_creator + + @content_creator.setter + def content_creator(self, creator): + self._content_creator = creator + self.content = creator() + self.native.Content = self.content + + if self._container: + self._constraints.refresh() diff --git a/winui3/src/toga_winui3/widgets/box.py b/winui3/src/toga_winui3/widgets/box.py new file mode 100644 index 0000000000..67521e4ebe --- /dev/null +++ b/winui3/src/toga_winui3/widgets/box.py @@ -0,0 +1,8 @@ +from win32more.Microsoft.UI.Xaml.Controls import Canvas + +from .base import Widget + + +class Box(Widget): + def create(self): + self.native = Canvas() diff --git a/winui3/src/toga_winui3/widgets/button.py b/winui3/src/toga_winui3/widgets/button.py new file mode 100644 index 0000000000..84fa31dd91 --- /dev/null +++ b/winui3/src/toga_winui3/widgets/button.py @@ -0,0 +1,56 @@ +from travertino.size import at_least +from win32more.Microsoft.UI.Xaml.Controls import ( + Button as NativeButton, + Symbol, + SymbolIcon, +) + +from .base import Widget, WidgetStager + + +class Button(Widget): + def create(self): + self.native = NativeButton() + self._constraints = WidgetStager(self) + self._icon = None + self._text = "" + + self.native.Click += self.native_event_click + + def native_event_click(self, sender, args): + self.interface.on_press() + + def get_text(self): + return self._text + + def set_text(self, text): + self._text = text + + if self._icon is not None: + return + + def creator(text=text): + # "\u200b" (ZERO WIDTH SPACE) instead of "" ensures correct button height. + return "\u200b" if text == "" else text + + self.content_creator = creator + + def get_icon(self): + return self._icon + + def set_icon(self, icon): + self._icon = icon + + if icon is None: + return + + def creator(): + symbol_icon = SymbolIcon() + symbol_icon.Symbol = Symbol.Document + return symbol_icon + + self.content_creator = creator + + def rehint(self): + self.interface.intrinsic.width = at_least(self._constraints.width) + self.interface.intrinsic.height = self._constraints.height diff --git a/winui3/src/toga_winui3/widgets/label.py b/winui3/src/toga_winui3/widgets/label.py new file mode 100644 index 0000000000..3b409bc42d --- /dev/null +++ b/winui3/src/toga_winui3/widgets/label.py @@ -0,0 +1,30 @@ +from travertino.size import at_least +from win32more.Microsoft.UI.Xaml.Controls import TextBlock + +from .base import Widget, WidgetStager + + +class Label(Widget): + def create(self): + self.native = TextBlock() + self._constraints = WidgetStager(self) + self._text = "" + + def set_text_align(self, value): + pass + # self.native.TextAlign = TextAlignment(value) + + def get_text(self): + return self.native.Text + + def set_text(self, text): + self._text = text + + def creator(text=text): + return text + + self.content_creator = creator + + def rehint(self): + self.interface.intrinsic.width = at_least(self._constraints.width) + self.interface.intrinsic.height = self._constraints.height diff --git a/winui3/src/toga_winui3/window.py b/winui3/src/toga_winui3/window.py new file mode 100644 index 0000000000..501358327c --- /dev/null +++ b/winui3/src/toga_winui3/window.py @@ -0,0 +1,374 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from win32more.Microsoft.UI.Windowing import ( + AppWindowPresenterKind, + DisplayArea, + DisplayAreaFallback, + OverlappedPresenter, + OverlappedPresenterState, + TitleBarTheme, +) +from win32more.Microsoft.UI.Xaml import ( + HorizontalAlignment, + VerticalAlignment, + WindowActivationState, +) +from win32more.Microsoft.UI.Xaml.Controls import ( + Canvas, + Grid, + MenuBar, + MenuBarItem, + MenuFlyoutItem, + MenuFlyoutSeparator, + MenuFlyoutSubItem, +) +from win32more.Microsoft.UI.Xaml.Media import MicaBackdrop +from win32more.Windows.Graphics import PointInt32, SizeInt32 + +from toga import App +from toga.command import Separator +from toga.constants import WindowState +from toga.types import Position, Size + +from .container import Container +from .libs.misc import column_definition_star, row_definition_auto, row_definition_star +from .screens import Screen as ScreenImpl, round_pixels + +if TYPE_CHECKING: # pragma: no cover + from toga.types import PositionT, SizeT + + +class Window: + def __init__(self, interface, title, position, size): + self.interface = interface + + self.is_activated = False + self.create() + + self._set_restrictions() + self.set_title(title) + self.set_size(size) + + # Use default behavior for position, rather than Toga's re-implementation. + if position: + self.set_position(position) + + # Create the window content and attach it. + self.create_content() + self.native.Content = self.content_native + + def create(self): + self.native = App.app._impl.native_instance.CreateWindow() + self.native.SystemBackdrop = MicaBackdrop() + self.native.AppWindow.TitleBar.PreferredTheme = TitleBarTheme.UseDefaultAppMode + + # TODO: Decide if these event handlers need to be a weak reference. + self.native.Activated += self.native_event_activated + self.native.AppWindow.Changed += self.native_event_changed + + def create_content(self): + """Construct the container.""" + self.content_native = Canvas() + self.container = Container(self.content_native) + + def _set_restrictions(self): + """Sets the window properties of being minimizable and resizable.""" + presenter = self.native.AppWindow.Presenter + if presenter.Kind != AppWindowPresenterKind.Overlapped: + return + + # Cast presenter as an instance of OverlappedPresenter and set the restrictions. + overlapped_presenter = OverlappedPresenter(value=presenter.value) + overlapped_presenter.IsMinimizable = self.interface.minimizable + overlapped_presenter.IsResizable = self.interface.resizable + + #################################################################################### + # Native event handlers. + #################################################################################### + + def native_event_activated(self, sender, args): + """Event that fires when the window is activated or deactivated.""" + # learn.microsoft.com/windows/windows-app-sdk/api/winrt/microsoft.ui.xaml.window.activated # noqa: E501 + if args.WindowActivationState == WindowActivationState.Deactivated: + self.is_activated = False + self.interface.on_lose_focus() + else: + self.is_activated = True + self.interface.on_gain_focus() + + def native_event_changed(self, sender, args): + + if args.DidPositionChange: + pass + + if args.DidSizeChange: + self.interface.on_resize() + + if args.DidVisibilityChange: + if self.native.AppWindow.IsVisible: + self.interface.on_show() + else: + self.interface.on_hide() + + if args.DidPresenterChange: + self._set_restrictions() + + #################################################################################### + # Window properties + #################################################################################### + + def get_title(self) -> str: + """Gets the title of the window, i.e. the text on the title bar.""" + return self.native.AppWindow.Title + + def set_title(self, title: str): + """Sets the title of the window, i.e. the text on the title bar.""" + self.native.AppWindow.Title = title + + #################################################################################### + # Window lifecycle + #################################################################################### + + def close(self): + self.interface.factory.not_implemented("Window.close") + self.native.Close() + + def set_app(self, app): + """Sets the window icon to be the icon associated to the given app.""" + self.native.AppWindow.SetIconWithIconId(app.interface.icon._impl.id) + + def show(self): + if self.interface.content is not None: + self.interface.content.refresh() + + self.native.AppWindow.Show() + + #################################################################################### + # Window content and resources. + #################################################################################### + + def content_refreshed(self, container): + # TODO: Minimum size constraints: overlapped_presenter.PreferredMinimumWidth. + self.interface.factory.not_implemented("Window.content_refreshed") + + def set_content(self, widget): + """Sets the content of the window's container to be the given Toga widget.""" + self.container.content = widget + + #################################################################################### + # Window size (CSS pixels). + # + # Toga terminology <-> Microsoft terminology: + # - Physical pixels <-> Device pixels + # - The individual physical pixels that comprise the screen. + # - CSS pixels <-> Effective pixels () + # - A virtual unit of measurement used for internal window properties so that + # a window appears the on screens with different scale factors. + # + # Example: For a 200% scale factor 1 css pixel is a 2x2 block of physical pixels. + #################################################################################### + + def get_size(self) -> Size: + """Gets the size of the window in effective pixels (CSS pixels).""" + # self.native.Bounds returns values in effective pixels, but they are not always + # integer values. + return Size( + round_pixels(self.native.Bounds.Width), + round_pixels(self.native.Bounds.Height), + ) + + def set_size(self, size: SizeT): + """Sets the size of the window in effective pixels (CSS pixels).""" + pixels_to_physical = self.get_current_screen().pixels_to_physical + + self.native.AppWindow.Resize( + SizeInt32(pixels_to_physical(size[0]), pixels_to_physical(size[1])) + ) + + #################################################################################### + # Window position (CSS pixels, see window size for terminology). + #################################################################################### + + def get_current_screen(self): + return ScreenImpl( + DisplayArea.GetFromWindowId( + self.native.AppWindow.Id, + DisplayAreaFallback.Primary, + ) + ) + + # Window.position is scaled according to the DPI of the primary screen, because the + # interface layer assumes that Screen.origin, Window.position and + # Window.screen_position are all in the same coordinate system. + # + # TODO: remove that assumption, and make Window.position return coordinates relative + # to the current screen's origin and DPI. + def get_position(self) -> Position: + position = self.native.AppWindow.Position + pixels_to_css = App.app._impl.get_primary_screen().pixels_to_css + + return Position(pixels_to_css(position.X), pixels_to_css(position.Y)) + + def set_position(self, position: PositionT): + pixels_to_physical = App.app._impl.get_primary_screen().pixels_to_physical + + self.native.AppWindow.Move( + PointInt32(pixels_to_physical(position.x), pixels_to_physical(position.y)) + ) + + #################################################################################### + # Window visibility. + #################################################################################### + + def get_visible(self) -> bool: + """Returns True if the window is visible and False otherwise.""" + return self.native.Visible + + def hide(self): + """Hides but does not destroy the window.""" + self.native.Hide() + + #################################################################################### + # Window state. + #################################################################################### + + def get_window_state(self, in_progress_state=False) -> WindowState: + """Gets the current state of the window. + + :param in_progress_state: Not supported on WinUI 3. + :return: A WindowState constant determined by NORMAL, MAXIMIZED, MINIMIZED or + PRESENTATION. FULLSCREEN is not supported. + """ + presenter = self.native.AppWindow.Presenter + + if presenter.Kind == AppWindowPresenterKind.FullScreen: + # Fullscreen here corresponds to Toga 'PRESENTATION' window state. From the + # Microsoft documentation: 'The window does not have a border or title bar, + # and hides the system task bar. + # learn.microsoft.com/en-us/windows/apps/develop/ui/manage-app-windows + return WindowState.PRESENTATION + else: + # Assume presenter.Kind == AppWindowPresenterKind.Overlapped, since the + # third alternative 'CompactOverlay' is not implemented by Toga. + # learn.microsoft.com/en-us/windows/apps/develop/ui/manage-app-windows + # + # Hence, cast presenter as an instance of OverlappedPresenter. + overlapped_presenter = OverlappedPresenter(value=presenter.value) + + if overlapped_presenter.State == OverlappedPresenterState.Maximized: + return WindowState.MAXIMIZED + elif overlapped_presenter.State == OverlappedPresenterState.Minimized: + return WindowState.MINIMIZED + else: + return WindowState.NORMAL + + def set_window_state(self, state: WindowState): + """Sets the state of the window. + + :state: A WindowState constant determined by NORMAL, MAXIMIZED, MINIMIZED or + PRESENTATION. FULLSCREEN is not supported and will revert to MAXIMIZED. + """ + current_state = self.get_window_state() + + if state == current_state: + return + elif current_state == WindowState.PRESENTATION: + self.native.AppWindow.SetPresenter(AppWindowPresenterKind.Overlapped) + + match state: + case WindowState.PRESENTATION: + self.native.AppWindow.SetPresenter(AppWindowPresenterKind.FullScreen) + + case WindowState.NORMAL: + self.native.AppWindow.Presenter.Restore() + + case WindowState.MINIMIZED: + self.native.AppWindow.Presenter.Minimize() + + case _: + self.native.AppWindow.Presenter.Maximize() + + #################################################################################### + # Window capabilities + #################################################################################### + + def get_image_data(self): + self.interface.factory.not_implemented("Window.get_image_data") + + +class MainWindow(Window): + def create(self): + super().create() + self.toolbar_native = None + + def create_content(self): + # Row 0 is allocated for the menu + # Row 1 is allocated for toolbar + self.content_native = Grid() + self.content_native.ColumnDefinitions.Append(column_definition_star(1)) + self.content_native.RowDefinitions.Append(row_definition_auto()) + self.content_native.RowDefinitions.Append(row_definition_auto()) + self.content_native.RowDefinitions.Append(row_definition_star(1)) + + self.content_native.HorizontalAlignment = HorizontalAlignment.Stretch + self.content_native.VerticalAlignment = VerticalAlignment.Stretch + + self.container_native = Canvas() + Grid.SetRow(self.container_native, 2) + Grid.SetColumn(self.container_native, 0) + self.content_native.Children.Append(self.container_native) + + self.container = Container(self.container_native) + + # Attach the content to the window. + self.native.Content = self.content_native + + def _submenu(self, group, group_cache): + try: + return group_cache[group] + except KeyError: + parent_menu = self._submenu(group.parent, group_cache) + + # If group.parent is None, then parent_menu is the MenuBar instance and the + # type of items that can be added are MenuBarItem. Otherwise, parent_menu is + # of type MenuBarItem or of type MenuFlyoutSubItem and submenus are added + # with MenuFlyoutSubItem. + if group.parent is None: + submenu = MenuBarItem() + submenu.Title = group.text + else: + submenu = MenuFlyoutSubItem() + submenu.Text = group.text + + parent_menu.Items.Append(submenu) + + group_cache[group] = submenu + return submenu + + def create_menus(self): + self.menu_native = MenuBar() + self.menu_native.VerticalAlignment = VerticalAlignment.Top + Grid.SetRow(self.menu_native, 0) + Grid.SetColumn(self.menu_native, 0) + + group_cache = {None: self.menu_native} + + submenu = None + for cmd in self.interface.app.commands: + submenu = self._submenu(cmd.group, group_cache) + if isinstance(cmd, Separator): + item = MenuFlyoutSeparator() + else: + item = cmd._impl.create_menu_item(MenuFlyoutItem) + + submenu.Items.Append(item) + + self.content_native.Children.Append(self.menu_native) + + def create_toolbar(self): + if not self.interface.toolbar: + return + + self.interface.factory.not_implemented("Window.create_toolbars") From 164fc7827332860dcdcda6d232c39583b9511a07 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:45:05 +0200 Subject: [PATCH 002/110] Added Status Icons --- winui3/pyproject.toml | 5 +- winui3/src/toga_winui3/libs/comctl32.py | 24 ++ winui3/src/toga_winui3/libs/misc.py | 23 ++ winui3/src/toga_winui3/libs/shell.py | 12 + winui3/src/toga_winui3/libs/win32constants.py | 23 ++ .../src/toga_winui3/libs/win32structures.py | 57 ++++ winui3/src/toga_winui3/screens.py | 10 +- winui3/src/toga_winui3/statusicons.py | 245 +++++++++++++++++- 8 files changed, 389 insertions(+), 10 deletions(-) create mode 100644 winui3/src/toga_winui3/libs/comctl32.py create mode 100644 winui3/src/toga_winui3/libs/shell.py create mode 100644 winui3/src/toga_winui3/libs/win32constants.py create mode 100644 winui3/src/toga_winui3/libs/win32structures.py diff --git a/winui3/pyproject.toml b/winui3/pyproject.toml index df11729b6f..a663d93e79 100644 --- a/winui3/pyproject.toml +++ b/winui3/pyproject.toml @@ -73,8 +73,8 @@ resources = "toga_winui3.resources" # Location = "toga_winui3.hardware.location:Location" # Status Icons -# MenuStatusIcon = "toga_winui3.statusicons:MenuStatusIcon" -# SimpleStatusIcon = "toga_winui3.statusicons:SimpleStatusIcon" +MenuStatusIcon = "toga_winui3.statusicons:MenuStatusIcon" +SimpleStatusIcon = "toga_winui3.statusicons:SimpleStatusIcon" StatusIconSet = "toga_winui3.statusicons:StatusIconSet" # Widgets @@ -116,6 +116,7 @@ dependencies = [ "toga-core == {version}", "win32more >= 0.8.1", "winui3-Microsoft.UI.Interop", + "winui3-Microsoft.UI", "winrt-runtime", ] diff --git a/winui3/src/toga_winui3/libs/comctl32.py b/winui3/src/toga_winui3/libs/comctl32.py new file mode 100644 index 0000000000..26ce5c2048 --- /dev/null +++ b/winui3/src/toga_winui3/libs/comctl32.py @@ -0,0 +1,24 @@ +from ctypes import windll +import ctypes.wintypes as wt + +from . import win32structures as ws + +comctl32 = windll.comctl32 + + +# https://learn.microsoft.com/en-us/windows/win32/api/commctrl/nf-commctrl-defsubclassproc +DefSubclassProc = comctl32.DefSubclassProc +DefSubclassProc.restype = ws.LRESULT +DefSubclassProc.argtypes = [wt.HWND, wt.UINT, wt.WPARAM, wt.LPARAM] + + +# https://learn.microsoft.com/en-us/windows/win32/api/commctrl/nf-commctrl-setwindowsubclass +RemoveWindowSubclass = comctl32.RemoveWindowSubclass +RemoveWindowSubclass.restype = wt.BOOL +RemoveWindowSubclass.argtypes = [wt.HWND, ws.SUBCLASSPROC, ws.UINT_PTR] + + +# https://learn.microsoft.com/en-us/windows/win32/api/commctrl/nf-commctrl-setwindowsubclass +SetWindowSubclass = comctl32.SetWindowSubclass +SetWindowSubclass.restype = wt.BOOL +SetWindowSubclass.argtypes = [wt.HWND, ws.SUBCLASSPROC, ws.UINT_PTR, ws.DWORD_PTR] diff --git a/winui3/src/toga_winui3/libs/misc.py b/winui3/src/toga_winui3/libs/misc.py index a429d9902d..91d745a78f 100644 --- a/winui3/src/toga_winui3/libs/misc.py +++ b/winui3/src/toga_winui3/libs/misc.py @@ -1,3 +1,6 @@ +from ctypes.wintypes import SHORT + + from win32more.Microsoft.UI.Xaml import GridLength, GridUnitType from win32more.Microsoft.UI.Xaml.Controls import ( ColumnDefinition, @@ -56,3 +59,23 @@ def is_based_on(cls, ancestor): return True else: return is_based_on_recursive(cls, ancestor) + + +# https://learn.microsoft.com/en-us/windows/win32/winmsg/loword +def loword(lparam: int) -> int: + """Keeps the lower 16 bits of a value with at least 16 bits.""" + return lparam & 0b1111111111111111 + + +# https://learn.microsoft.com/en-us/windows/win32/winmsg/hiword +def hiword(lparam: int) -> int: + """Keeps the upper 16 bits of value with at least 32 bits.""" + return (lparam >> 16) & 0b1111111111111111 + +# https://learn.microsoft.com/en-us/windows/win32/api/windowsx/nf-windowsx-get_x_lparam +def get_x_lparam(lparam: int) -> int: + return SHORT(loword(lparam)).value + +# https://learn.microsoft.com/en-us/windows/win32/api/windowsx/nf-windowsx-get_y_lparam +def get_y_lparam(lparam: int) -> int: + return SHORT(hiword(lparam)).value \ No newline at end of file diff --git a/winui3/src/toga_winui3/libs/shell.py b/winui3/src/toga_winui3/libs/shell.py new file mode 100644 index 0000000000..e00679279a --- /dev/null +++ b/winui3/src/toga_winui3/libs/shell.py @@ -0,0 +1,12 @@ +from ctypes import POINTER, windll +import ctypes.wintypes as wt + +from . import win32structures as ws + +shell32 = windll.shell32 + + +# https://learn.microsoft.com/windows/win32/api/shellapi/nf-shellapi-shell_notifyiconw +Shell_NotifyIconW = shell32.Shell_NotifyIconW +Shell_NotifyIconW.restype = wt.BOOL +Shell_NotifyIconW.argtypes = [wt.DWORD, POINTER(ws.NOTIFYICONDATAW)] diff --git a/winui3/src/toga_winui3/libs/win32constants.py b/winui3/src/toga_winui3/libs/win32constants.py new file mode 100644 index 0000000000..9b6c2400ac --- /dev/null +++ b/winui3/src/toga_winui3/libs/win32constants.py @@ -0,0 +1,23 @@ +# Win32 constants + +# Integral Type Constants +# https://learn.microsoft.com/cpp/c-runtime-library/data-type-constants +SHRT_MAX = 32767 + +# NotifyIcon Flags +NIF_MESSAGE = 0x00000001 +NIF_ICON = 0x00000002 + +# NotifyIcon Messages +NIM_ADD = 0x00000000 +NIM_MODIFY = 0x00000001 +NIM_DELETE = 0x00000002 +NIM_SETVERSION = 0x00000004 + +# NotifyIcon Notifications +NIN_SELECT = 0x00000400 + +# NotifyIcon Versions +NOTIFYICON_VERSION_4 = 4 + + diff --git a/winui3/src/toga_winui3/libs/win32structures.py b/winui3/src/toga_winui3/libs/win32structures.py new file mode 100644 index 0000000000..86c0b5bdfa --- /dev/null +++ b/winui3/src/toga_winui3/libs/win32structures.py @@ -0,0 +1,57 @@ +import ctypes.wintypes as wt +from ctypes import c_size_t, Structure as c_Structure, Union, WINFUNCTYPE + +from win32more import Guid + +######################################################################################## +# Types missing from wintypes +######################################################################################## + +LRESULT = wt.LPARAM +UINT_PTR = c_size_t +DWORD_PTR = c_size_t + + +######################################################################################## +# Structures +######################################################################################## + +# https://learn.microsoft.com/windows/win32/api/shellapi/ns-shellapi-notifyicondataw +class _TIMEOUT_VERSION_UNION(Union): + _fields_ = [ + ("uTimeout", wt.UINT), + ("uVersion", wt.UINT), + ] + +class NOTIFYICONDATAW(c_Structure): + _fields_ = [ + ("cbSize", wt.DWORD), + ("hWnd", wt.HWND), + ("uID", wt.UINT), + ("uFlags", wt.UINT), + ("uCallbackMessage", wt.UINT), + ("hIcon", wt.HICON), + ("szTip", wt.WCHAR * 128), + ("dwState", wt.DWORD), + ("dwStateMask", wt.DWORD), + ("szInfo", wt.WCHAR * 256), + ("_", _TIMEOUT_VERSION_UNION), + ("szInfoTitle", wt.WCHAR * 64), + ("dwInfoFlags", wt.DWORD), + ("guidItem", Guid), + ("hBalloonIcon", wt.HICON), + ] + + +# https://learn.microsoft.com/windows/win32/api/commctrl/nc-commctrl-subclassproc +SUBCLASSPROC = WINFUNCTYPE( + # Return type: + LRESULT, + # Argument types: + wt.HWND, + wt.UINT, + wt.WPARAM, + wt.LPARAM, + UINT_PTR, + DWORD_PTR, +) diff --git a/winui3/src/toga_winui3/screens.py b/winui3/src/toga_winui3/screens.py index b68351b352..1cb9419362 100644 --- a/winui3/src/toga_winui3/screens.py +++ b/winui3/src/toga_winui3/screens.py @@ -5,11 +5,14 @@ from win32more.Windows.Win32.Graphics.Gdi import HMONITOR from win32more.Windows.Win32.UI.Shell import GetScaleFactorForMonitor from win32more.Windows.Win32.UI.Shell.Common import DEVICE_SCALE_FACTOR -from winui3.microsoft.ui import DisplayId -# Microsoft.Ui.Interop functionality noy yet included in win32more. So pywinrt is used. +######################################################################################## +# FIXME: Microsoft.Ui.Interop functionality will be included in a future win32more +# release. Update this code when that happens. # https://github.com/ynkdir/py-win32more/issues/184 +from winui3.microsoft.ui import DisplayId from winui3.microsoft.ui.interop import get_monitor_from_display_id +######################################################################################## from toga import App from toga.screens import Screen as ScreenInterface @@ -41,9 +44,12 @@ def __eq__(self, other) -> bool: @property def handle(self) -> HMONITOR: + ################################################################################ + # FIXME: See interop note above. return HMONITOR( get_monitor_from_display_id(DisplayId(self.native.DisplayId.Value)) ) + ################################################################################ def get_name(self) -> str: device_id = str(self.native.DisplayId.Value) diff --git a/winui3/src/toga_winui3/statusicons.py b/winui3/src/toga_winui3/statusicons.py index 175db4cd0e..9b07c38b89 100644 --- a/winui3/src/toga_winui3/statusicons.py +++ b/winui3/src/toga_winui3/statusicons.py @@ -1,20 +1,253 @@ +from ctypes import byref, sizeof, wintypes as wt + +from win32more.Microsoft.UI.Windowing import OverlappedPresenter +from win32more.Microsoft.UI.Xaml.Controls import ( + MenuFlyout, + MenuFlyoutItem, + MenuFlyoutSeparator, + MenuFlyoutSubItem, + RelativePanel, +) +from win32more.Windows.Foundation import Point +from win32more.Windows.Graphics import PointInt32, SizeInt32 +from win32more.Windows.Win32.Foundation import POINT, RECT +from win32more.Windows.Win32.Graphics.Gdi import ScreenToClient +from win32more.Windows.Win32.UI.WindowsAndMessaging import ( + IDC_ARROW, + LoadCursorW, + SetCursor, + SetForegroundWindow, + WM_NCDESTROY, + WM_APP, +) + +######################################################################################## +# FIXME: Microsoft.Ui.Interop functionality will be included in a future win32more +# release. Update this code and the flagged code below when that happens. +# https://github.com/ynkdir/py-win32more/issues/184 +from winui3.microsoft.ui import WindowId +from winui3.microsoft.ui.interop import get_window_from_window_id +######################################################################################## + +from toga import App, Icon +from toga.command import Group, Separator + +from .libs import win32constants as wc, win32structures as ws +from .libs.comctl32 import ( + DefSubclassProc, + RemoveWindowSubclass, + SetWindowSubclass, +) +from .libs.shell import Shell_NotifyIconW +from .libs.misc import loword, get_x_lparam, get_y_lparam + + + class StatusIcon: - pass + def __init__(self, interface): + self.interface = interface + self.native_window = None + + def set_icon(self, icon: Icon): + if self.native_window is not None: + notify_icon_data = self._notify_icon_data(self._icon_handle(icon)) + Shell_NotifyIconW(wc.NIM_MODIFY, byref(notify_icon_data)) + + def create(self): + self.native_window = App.app._impl.native_instance.CreateWindow() + + + self.native_window.AppWindow.Resize(SizeInt32(1, 1)) + self.native_window.AppWindow.Move(PointInt32(wc.SHRT_MAX - 1, wc.SHRT_MAX - 1)) + self.native_window.AppWindow.IsShownInSwitchers = False + + presenter = self.native_window.AppWindow.Presenter + overlapped_presenter = OverlappedPresenter(value=presenter.value) + + overlapped_presenter.SetBorderAndTitleBar(False, False) + overlapped_presenter.IsAlwaysOnTop = True + + + # Subclass the native_window to recieve the WM_COMMAND messages. + self._pfn_subclass = ws.SUBCLASSPROC(self._subclass_proc) + SetWindowSubclass(self._hwnd, self._pfn_subclass, 0, 0) + + icon_handle = self._icon_handle(self.interface.icon) + notify_icon_data = self._notify_icon_data(icon_handle) + Shell_NotifyIconW(wc.NIM_ADD, byref(notify_icon_data)) + + # NOTIFYICON_VERSION_4 is the recommended version from Windows Vista onwards. + notify_icon_data._.uVersion = wc.NOTIFYICON_VERSION_4 + Shell_NotifyIconW(wc.NIM_SETVERSION, byref(notify_icon_data)) + + def _icon_handle(self, icon: Icon): + return icon._impl.handle if icon else App.app.icon._impl.handle + + def _notify_icon_data(self, icon_handle): + """Creates a NOTIFYICONDATAW instance for a given icon.""" + notify_icon_data = ws.NOTIFYICONDATAW() + notify_icon_data.cbSize = sizeof(ws.NOTIFYICONDATAW) + notify_icon_data.hWnd = self._hwnd + notify_icon_data.uID = 1 + notify_icon_data.uCallbackMessage = WM_APP + 1 + notify_icon_data.uFlags = wc.NIF_ICON | wc.NIF_MESSAGE + notify_icon_data.hIcon = icon_handle + return notify_icon_data + + def remove(self): + notify_icon_data = self._notify_icon_data(None) + Shell_NotifyIconW(wc.NIM_DELETE, byref(notify_icon_data)) + + @property + def _hwnd(self): + ################################################################################ + # FIXME: See interop note above. + window_id = WindowId(self.native_window.AppWindow.Id.Value) + return get_window_from_window_id(window_id) + ################################################################################ + + def _subclass_proc( + self, + hWnd: int, + uMsg: int, + wParam: int, + lParam: int, + uIdSubclass: int, + dwRefData: int, + ): + # Remove the window subclass in the way recommended by Raymond Chen here: + # https://devblogs.microsoft.com/oldnewthing/20031111-00/?p=41883 + if uMsg == WM_NCDESTROY: + RemoveWindowSubclass(hWnd, self._pfn_subclass, uIdSubclass) + + elif uMsg == WM_APP + 1: + message = loword(lParam) + if message == wc.NIN_SELECT: + self.native_event_click(get_x_lparam(wParam), get_y_lparam(wParam)) + + # Call the original window procedure + return DefSubclassProc( + wt.HWND(hWnd), + wt.UINT(uMsg), + wt.WPARAM(wParam), + wt.LPARAM(lParam), + ) + + def native_event_click(self, x, y): + ... class SimpleStatusIcon(StatusIcon): - pass + + def native_event_click(self, x, y): + print(f"x:{x}, y:{y}") class MenuStatusIcon(StatusIcon): - pass + + def __init__(self, interface): + super().__init__(interface) + self._native_menu = None + self.native_content = None + + def create(self): + super().create() + self.native_content = RelativePanel() + self.native_window.Content = self.native_content + + @property + def native_menu(self): + return self._native_menu + + @native_menu.setter + def native_menu(self, native_menu_instance: MenuFlyout): + assert isinstance(native_menu_instance, MenuFlyout) + + native_menu_instance.add_Closing(self.native_event_Closing) + self._native_menu = native_menu_instance + + def native_event_Closing(self, sender, args): + self.native_window.AppWindow.Hide() + + @property + def _content_hwnd(self): + window_id = WindowId(self.native_window.AppWindow.Id.Value) + return get_window_from_window_id(window_id) + + def native_event_click(self, x, y): + coords = POINT(x, y) + ScreenToClient(self._hwnd, byref(coords)) + relative_coords = Point(coords.x/2, coords.y/2) + + self.native_window.AppWindow.Show() + SetForegroundWindow(self._hwnd) + self.native_menu.ShowAt(self.native_content, relative_coords) + + h_cursor = LoadCursorW(None, IDC_ARROW) + SetCursor(h_cursor) class StatusIconSet: def __init__(self, interface): - print("Not yet implemented on WinUI3 - StatusIconSet") + """The WinUI 3 implementation of an ordered collection of status icons.""" self.interface = interface - self._menu_items = {} + + def _submenu(self, group, group_cache): + try: + return group_cache[group] + except KeyError as exc: + if group is None: + raise ValueError("Unknown top level item") from exc + else: + parent_menu = self._submenu(group.parent, group_cache) + + submenu = MenuFlyoutSubItem() + submenu.Text = group.text + + parent_menu.Items.Append(submenu) + + group_cache[group] = submenu + return submenu def create(self): - pass + """Create + + This is called directly in App._startup() and also when the status icon command + set is changed. + """ + + # Menu status icons are the only icons that have extra construction needs. + # Clear existing menus + for menu_status_icon in self.interface._menu_status_icons: + menu_status_icon._impl.native_menu = MenuFlyout() + + # Determine the primary status icon. + primary_group = self.interface._primary_menu_status_icon + if primary_group is None: # pragma: no cover + # If there isn't at least one menu status icon, then there aren't any menus + # to populate. This can't be replicated in the testbed. + return + + # Add the menu status items to the cache + group_cache = { + menu_status_icon: menu_status_icon._impl.native_menu + for menu_status_icon in self.interface._menu_status_icons + } + # Map the COMMANDS group to the primary status icon's menu. + group_cache[Group.COMMANDS] = primary_group._impl.native_menu + + for cmd in self.interface.commands: + try: + submenu = self._submenu(cmd.group, group_cache) + except ValueError as exc: + raise ValueError( + f"Command {cmd.text!r} does not belong to a current status icon " + "group." + ) from exc + else: + if isinstance(cmd, Separator): + menu_item = MenuFlyoutSeparator() + else: + menu_item = cmd._impl.create_menu_item(MenuFlyoutItem) + + submenu.Items.Append(menu_item) From 68389013370af8568139ed1685f9b48341bed768 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:27:34 +0200 Subject: [PATCH 003/110] Change to procator start for version compatibility --- winui3/src/toga_winui3/libs/proactor.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/winui3/src/toga_winui3/libs/proactor.py b/winui3/src/toga_winui3/libs/proactor.py index 7c7641ceb2..d65c663ca8 100644 --- a/winui3/src/toga_winui3/libs/proactor.py +++ b/winui3/src/toga_winui3/libs/proactor.py @@ -114,8 +114,14 @@ def run_forever(self, app): self.queue_timer: DispatcherQueueTimer self._inner_loop = None - app.native.OnLaunched = partial(native_app_launched, self) - app.native.OnExited = partial(native_app_exited, self) + def on_lauched(winui3_app, args): + return native_app_launched(self, winui3_app, args) + + def on_exited(winui3_app): + return native_app_exited(self, winui3_app) + + app.native.OnLaunched = on_lauched + app.native.OnExited = on_exited # Start the native event loop. app.native.Start() From e67e6e29a01d346e8f6475bc681c0a37cca9e732 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:47:21 +0200 Subject: [PATCH 004/110] Minor fixes and changes --- winui3/src/toga_winui3/app.py | 2 +- winui3/src/toga_winui3/command.py | 8 ++++---- winui3/src/toga_winui3/factory.py | 6 +++--- winui3/src/toga_winui3/screens.py | 20 +++++++++++--------- winui3/src/toga_winui3/window.py | 22 +++++++++++----------- 5 files changed, 30 insertions(+), 28 deletions(-) diff --git a/winui3/src/toga_winui3/app.py b/winui3/src/toga_winui3/app.py index eb2dfbf10e..882b1f50c3 100644 --- a/winui3/src/toga_winui3/app.py +++ b/winui3/src/toga_winui3/app.py @@ -78,7 +78,7 @@ def set_icon(self, icon): pass def set_main_window(self, window): - self.interface.factory.not_implemented("App.set_main_window") + # Everything is already handled by the Toga core interface. pass #################################################################################### diff --git a/winui3/src/toga_winui3/command.py b/winui3/src/toga_winui3/command.py index f8d6b2d9e2..45152a847c 100644 --- a/winui3/src/toga_winui3/command.py +++ b/winui3/src/toga_winui3/command.py @@ -85,9 +85,9 @@ def native_event_Click(self, sender, args): return self.interface.action() def set_enabled(self, value): - if self.native: - for widget in self.native: - widget.Enabled = self.interface.enabled + is_enabled = self.interface.enabled + for item in self.native: + item.IsEnabled = is_enabled def create_menu_item(self, NativeClass): item = NativeClass() @@ -97,7 +97,7 @@ def create_menu_item(self, NativeClass): if self.interface.shortcut is not None: self.interface.factory.not_implemented("Command shortcuts") - item.Enabled = self.interface.enabled + item.IsEnabled = self.interface.enabled self.native.append(item) diff --git a/winui3/src/toga_winui3/factory.py b/winui3/src/toga_winui3/factory.py index 8be7922994..bef68e295d 100644 --- a/winui3/src/toga_winui3/factory.py +++ b/winui3/src/toga_winui3/factory.py @@ -10,7 +10,7 @@ # from .images import Image from .paths import Paths -from .statusicons import StatusIconSet # , MenuStatusIcon, SimpleStatusIcon, +from .statusicons import MenuStatusIcon, SimpleStatusIcon, StatusIconSet # from .widgets.activityindicator import ActivityIndicator from .widgets.box import Box @@ -63,8 +63,8 @@ def not_implemented(feature): # pragma: no cover "Paths", # "dialogs", # Status Icons - # "MenuStatusIcon", - # "SimpleStatusIcon", + "MenuStatusIcon", + "SimpleStatusIcon", "StatusIconSet", # Widgets # "ActivityIndicator", diff --git a/winui3/src/toga_winui3/screens.py b/winui3/src/toga_winui3/screens.py index 1cb9419362..21fc5990da 100644 --- a/winui3/src/toga_winui3/screens.py +++ b/winui3/src/toga_winui3/screens.py @@ -12,8 +12,8 @@ # https://github.com/ynkdir/py-win32more/issues/184 from winui3.microsoft.ui import DisplayId from winui3.microsoft.ui.interop import get_monitor_from_display_id -######################################################################################## +######################################################################################## from toga import App from toga.screens import Screen as ScreenInterface from toga.types import Position, Size @@ -45,7 +45,7 @@ def __eq__(self, other) -> bool: @property def handle(self) -> HMONITOR: ################################################################################ - # FIXME: See interop note above. + # FIXME: See interop note above. return HMONITOR( get_monitor_from_display_id(DisplayId(self.native.DisplayId.Value)) ) @@ -65,12 +65,12 @@ def dpi_scale(self) -> float: GetScaleFactorForMonitor(self.handle, byref(p_scale)) return p_scale.value / 100 - def pixels_to_physical(self, value): + def css_to_physical(self, value): return round_pixels(value * self.dpi_scale) - def pixels_to_css(self, value): + def physical_to_css(self, value): if isinstance(value, at_least): - return at_least(self.pixels_to_css(value.value)) + return at_least(self.physical_to_css(value.value)) else: return round_pixels(value / self.dpi_scale) @@ -82,17 +82,19 @@ def pixels_to_css(self, value): # is no better choice that could cover screens of multiple DPIs. def get_origin(self) -> Position: native_bounds = self.native.OuterBounds - pixels_to_css = App.app._impl.get_primary_screen().pixels_to_css + physical_to_css = App.app._impl.get_primary_screen().physical_to_css - return Position(pixels_to_css(native_bounds.X), pixels_to_css(native_bounds.Y)) + return Position( + physical_to_css(native_bounds.X), physical_to_css(native_bounds.Y) + ) # Screen.size is scaled according to the screen's own DPI, to be consistent with the # scaling of Window size and content. def get_size(self) -> Size: native_bounds = self.native.OuterBounds return Size( - self.pixels_to_css(native_bounds.Width), - self.pixels_to_css(native_bounds.Width), + self.physical_to_css(native_bounds.Width), + self.physical_to_css(native_bounds.Width), ) #################################################################################### diff --git a/winui3/src/toga_winui3/window.py b/winui3/src/toga_winui3/window.py index 501358327c..401dc2865c 100644 --- a/winui3/src/toga_winui3/window.py +++ b/winui3/src/toga_winui3/window.py @@ -57,7 +57,6 @@ def __init__(self, interface, title, position, size): # Create the window content and attach it. self.create_content() - self.native.Content = self.content_native def create(self): self.native = App.app._impl.native_instance.CreateWindow() @@ -70,8 +69,9 @@ def create(self): def create_content(self): """Construct the container.""" - self.content_native = Canvas() - self.container = Container(self.content_native) + self.container_native = Canvas() + self.container = Container(self.container_native) + self.native.Content = self.container_native def _set_restrictions(self): """Sets the window properties of being minimizable and resizable.""" @@ -163,7 +163,7 @@ def set_content(self, widget): # Toga terminology <-> Microsoft terminology: # - Physical pixels <-> Device pixels # - The individual physical pixels that comprise the screen. - # - CSS pixels <-> Effective pixels () + # - CSS pixels <-> Effective pixels # - A virtual unit of measurement used for internal window properties so that # a window appears the on screens with different scale factors. # @@ -181,10 +181,10 @@ def get_size(self) -> Size: def set_size(self, size: SizeT): """Sets the size of the window in effective pixels (CSS pixels).""" - pixels_to_physical = self.get_current_screen().pixels_to_physical + css_to_physical = self.get_current_screen().css_to_physical self.native.AppWindow.Resize( - SizeInt32(pixels_to_physical(size[0]), pixels_to_physical(size[1])) + SizeInt32(css_to_physical(size[0]), css_to_physical(size[1])) ) #################################################################################### @@ -203,19 +203,19 @@ def get_current_screen(self): # interface layer assumes that Screen.origin, Window.position and # Window.screen_position are all in the same coordinate system. # - # TODO: remove that assumption, and make Window.position return coordinates relative + # TODO: Remove that assumption, and make Window.position return coordinates relative # to the current screen's origin and DPI. def get_position(self) -> Position: position = self.native.AppWindow.Position - pixels_to_css = App.app._impl.get_primary_screen().pixels_to_css + physical_to_css = App.app._impl.get_primary_screen().physical_to_css - return Position(pixels_to_css(position.X), pixels_to_css(position.Y)) + return Position(physical_to_css(position.X), physical_to_css(position.Y)) def set_position(self, position: PositionT): - pixels_to_physical = App.app._impl.get_primary_screen().pixels_to_physical + css_to_physical = App.app._impl.get_primary_screen().css_to_physical self.native.AppWindow.Move( - PointInt32(pixels_to_physical(position.x), pixels_to_physical(position.y)) + PointInt32(css_to_physical(position.x), css_to_physical(position.y)) ) #################################################################################### From 0fff5f52fbe9981c1a65f64257ff03a2c2f5b63d Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:52:13 +0200 Subject: [PATCH 005/110] Add fonts and refactor gdiplus --- winui3/src/toga_winui3/fonts.py | 162 ++++++++++++++++++++++++- winui3/src/toga_winui3/icons.py | 35 +----- winui3/src/toga_winui3/libs/gdiplus.py | 100 ++++++++++++++- 3 files changed, 259 insertions(+), 38 deletions(-) diff --git a/winui3/src/toga_winui3/fonts.py b/winui3/src/toga_winui3/fonts.py index cb5623bd40..a26cefb72b 100644 --- a/winui3/src/toga_winui3/fonts.py +++ b/winui3/src/toga_winui3/fonts.py @@ -1,6 +1,164 @@ +from win32more.Microsoft.UI.Xaml.Media import FontFamily +from win32more.Windows.UI.Text import FontStyle, FontWeight, FontWeights + +from toga.fonts import ( + # MISC + _IMPL_CACHE, + _REGISTERED_FONT_CACHE, + # FONT_WEIGHTS + BOLD, + # SYSTEM_DEFAULT_FONTS + CURSIVE, + FANTASY, + # FONT_STYLES + ITALIC, + MESSAGE, + MONOSPACE, + OBLIQUE, + SANS_SERIF, + SERIF, + SYSTEM, + SYSTEM_DEFAULT_FONT_SIZE, + UnknownFontError, +) + +from .libs.gdiplus import is_font_installed + + +class NativeFont: + def __init__( + self, + family: FontFamily | None, + size: int | None, + style: FontStyle, + weight: FontWeight, + ): + """The Toga font attributes that can be set in WinUI 3. + + :param family: The font to use. A None value means that the system default + will be used. + :param font_size: The size (line height) of a font given in CSS pixels. A None + value means that the system default will be used. + :param font_style: The style of the font, e.g. normal, italic. Given as a + FontStyle object. + :param font_weight: The weight of the font, e.g. light, bold, etc. Given as a + FontWeight object. + """ + self.FontFamily = self._creator(family) + self.FontSize = self._creator(size) + self.FontStyle = self._creator(style) + self.FontWeight = self._creator(weight) + + def _creator(self, property): + def property_creator(property=property): + return property + + return property_creator + + class Font: def __init__(self, interface): - print("Not yet implemented on WinUI3 - Font") + """A Toga WinUI 3 font object created from the core interface. + + Notes about default settings: + - The WinUI 3 implementation doesn't assume that system defaults for size and + family are consistent across the UI. In the native classes, these properties + are 'dependency properties' which means that they can be reset to default by + clearing the set value. + - Italics goes against the Windows design prinicpals, so it is safe to set the + Normal font style by default. See: + learn.microsoft.com/windows/apps/design/signature-experiences/typography + + """ + self.interface = interface + + #################################################################################### + # Font loading + #################################################################################### def load_predefined_system_font(self): - pass + """Use one of the system font names Toga predefines.""" + try: + font_family = { + SYSTEM: SYSTEM, + MESSAGE: SYSTEM, + SERIF: FontFamily("Times New Roman"), + SANS_SERIF: FontFamily("Segoe UI"), + CURSIVE: FontFamily("Segoe Script"), + FANTASY: FontFamily("Impact"), + MONOSPACE: FontFamily("Courier New"), + }[self.interface.family] + except KeyError as exc: + msg = f"{self.interface} not a predefined system font" + raise UnknownFontError(msg) from exc + + self._assign_native(font_family) + + def load_user_registered_font(self): + """Use a font that has been registered in the user's code.""" + font_key = self.interface._registered_font_key( + self.interface.family, + weight=self.interface.weight, + style=self.interface.style, + variant=self.interface.variant, + ) + try: + font_path = _REGISTERED_FONT_CACHE[font_key] + except KeyError as exc: + msg = f"{self.interface} not a user-registered font" + raise UnknownFontError(msg) from exc + + self.interface.factory.not_implemented("Font.load_user_registered_font()") + print(f"Font with path {font_path} not loaded.") + ################################################################################ + # TODO: Need to understand how to load external resources. + ################################################################################ + + def load_arbitrary_system_font(self): + """Use a font available on the system.""" + font_installed = is_font_installed(self.interface.family) + + # WinUI 3 does not throw an exception if the font is not installed, so use GDI+. + if not font_installed: + raise ValueError( + f"{self.interface} not installed on system. Check that the font family " + + "name exactly matches the name in the system's font settings." + ) + + font_family = FontFamily(self.interface.family) + self._assign_native(font_family) + + #################################################################################### + # Assign loaded font + #################################################################################### + + def _assign_native(self, font_family): + # Font family + if font_family == SYSTEM: + family = None + else: + family = font_family + + # Font size + if self.interface.size == SYSTEM_DEFAULT_FONT_SIZE: + size = None + else: + # Toga uses CSS points. Convert to CSS pixels. + size = self.interface.size * 96 / 72 + + # Font style + if self.interface.style == ITALIC: + style = FontStyle.Italic + elif self.interface.style == OBLIQUE: + style = FontStyle.Oblique + else: + style = FontStyle.Normal + + # Font weight + if self.interface.weight == BOLD: + weight = FontWeights.get_Bold() + else: + weight = FontWeights.get_Normal() + + self.native = NativeFont(family, size, style, weight) + _IMPL_CACHE[self.interface] = self diff --git a/winui3/src/toga_winui3/icons.py b/winui3/src/toga_winui3/icons.py index 2d4f62d59d..f2cc4af9f6 100644 --- a/winui3/src/toga_winui3/icons.py +++ b/winui3/src/toga_winui3/icons.py @@ -1,24 +1,15 @@ -from ctypes import POINTER, byref from pathlib import Path -from win32more import String from win32more.Microsoft.UI import IconId from win32more.Microsoft.UI.Xaml.Controls import BitmapIcon from win32more.Windows.Foundation import Uri -from win32more.Windows.Win32.Graphics.GdiPlus import ( - GdipCreateBitmapFromFile, - GdipCreateHICONFromBitmap, - GpBitmap, -) from win32more.Windows.Win32.UI.WindowsAndMessaging import HICON # Use pywinrt is until Microsoft.Ui.Interop is included in a release of win32more: # https://github.com/ynkdir/py-win32more/issues/184 from winui3.microsoft.ui.interop import get_icon_id_from_icon -from .libs.gdiplus import gdi_plus, status_dict - -BitmapPtr = POINTER(GpBitmap) +from .libs.gdiplus import create_icon class Icon: @@ -44,29 +35,7 @@ def uri(self) -> Uri: def handle(self) -> HICON: """The handle to the Win32 icon object created using the icon's path.""" if self._handle is None: - bitmap_ptr = BitmapPtr() - self._handle = HICON() - - bitmap_status = 1 - handle_status = 1 - with gdi_plus: - bitmap_status = GdipCreateBitmapFromFile( - String(str(self.path)), byref(bitmap_ptr) - ) - handle_status = GdipCreateHICONFromBitmap( - bitmap_ptr, byref(self._handle) - ) - - if bitmap_status != 0 or handle_status != 0: - message = f"Unable to create icon bitmap from {self.path}.\n" - if bitmap_status != 0: - message += "GdipCreateBitmapFromFile code: " - message += str(status_dict[bitmap_status]) - else: - message += "GdipCreateHICONFromBitmap code: " - message += str(status_dict[handle_status]) - - raise ValueError(message) + self._handle = create_icon(str(self.path)) return self._handle diff --git a/winui3/src/toga_winui3/libs/gdiplus.py b/winui3/src/toga_winui3/libs/gdiplus.py index ecc7f095d1..db5394a110 100644 --- a/winui3/src/toga_winui3/libs/gdiplus.py +++ b/winui3/src/toga_winui3/libs/gdiplus.py @@ -1,12 +1,33 @@ -from ctypes import WinError, byref +from ctypes import POINTER, WinError, byref +from win32more import String from win32more.Windows.Win32.Foundation import BOOL, UIntPtr from win32more.Windows.Win32.Graphics.GdiPlus import ( + FontStyleBold, + FontStyleBoldItalic, + FontStyleItalic, + # GDI+ Fonts + FontStyleRegular, + FontStyleStrikeout, + FontStyleUnderline, + # GDI+ Icons + GdipCreateBitmapFromFile, + GdipCreateFontFamilyFromName, + GdipCreateHICONFromBitmap, + GdipIsStyleAvailable, + # Main GDI+ operations GdiplusShutdown, GdiplusStartup, GdiplusStartupInput, GdiplusStartupOutput, + GpBitmap, + GpFontFamily, ) +from win32more.Windows.Win32.UI.WindowsAndMessaging import HICON + +######################################################################################## +# Main GDI+ operations +######################################################################################## status_dict = { 0: "Ok", @@ -34,7 +55,7 @@ } -class GdiPlus: +class GdiPlusContext: """A context manager for running GdiPlus functions.""" def __init__(self): @@ -66,4 +87,77 @@ def __del__(self): pass -gdi_plus = GdiPlus() +gdi_plus_context = GdiPlusContext() + + +######################################################################################## +# GDI+ fonts +######################################################################################## + +FontFamilyPtr = POINTER(GpFontFamily) + + +ALL_FONT_STYLES = ( + FontStyleRegular + | FontStyleBold + | FontStyleItalic + | FontStyleBoldItalic + | FontStyleUnderline + | FontStyleStrikeout +) + + +def is_font_installed(font_family_name: str): + """Checks whether a font is installed on the current system. + + Note that the font family name must be exactly as it appears in the Windows Settings + under Personalization > Fonts. + + For example, "Times New Roman" will load but variations such as "Times New", "Times + New Roman Bold" will not. + """ + with gdi_plus_context: + font_family_ptr = FontFamilyPtr() + font_available = BOOL() + + # Attempt to create the font family. + GdipCreateFontFamilyFromName( + String(font_family_name), None, byref(font_family_ptr) + ) + + # Check if the font family has been created. + GdipIsStyleAvailable(font_family_ptr, ALL_FONT_STYLES, byref(font_available)) + + return font_available.value == 1 + + +######################################################################################## +# GDI+ icons +######################################################################################## + +BitmapPtr = POINTER(GpBitmap) + + +def create_icon(icon_path: str) -> HICON: + """Creates a Win32 icon from a file.""" + bitmap_ptr = BitmapPtr() + icon_handle = HICON() + + bitmap_status = 1 + handle_status = 1 + with gdi_plus_context: + bitmap_status = GdipCreateBitmapFromFile(String(icon_path), byref(bitmap_ptr)) + handle_status = GdipCreateHICONFromBitmap(bitmap_ptr, byref(icon_handle)) + + if bitmap_status != 0 or handle_status != 0: + message = f"Unable to create icon bitmap from {icon_path}.\n" + if bitmap_status != 0: + message += "GdipCreateBitmapFromFile code: " + message += str(status_dict[bitmap_status]) + else: + message += "GdipCreateHICONFromBitmap code: " + message += str(status_dict[handle_status]) + + raise ValueError(message) + + return icon_handle From 65231a5dc4aa5b366ff8152c06a4b0647fc8e302 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:03:29 +0200 Subject: [PATCH 006/110] Refactor native properties and staged properties --- winui3/src/toga_winui3/container.py | 61 ++------ winui3/src/toga_winui3/libs/misc.py | 24 +-- winui3/src/toga_winui3/widgets/base.py | 139 ++++++------------ winui3/src/toga_winui3/widgets/box.py | 12 ++ winui3/src/toga_winui3/widgets/button.py | 39 +++-- winui3/src/toga_winui3/widgets/label.py | 44 ++++-- .../toga_winui3/widgets/properties/native.py | 81 ++++++++++ .../toga_winui3/widgets/properties/staged.py | 122 +++++++++++++++ 8 files changed, 332 insertions(+), 190 deletions(-) create mode 100644 winui3/src/toga_winui3/widgets/properties/native.py create mode 100644 winui3/src/toga_winui3/widgets/properties/staged.py diff --git a/winui3/src/toga_winui3/container.py b/winui3/src/toga_winui3/container.py index f7ae5f9d53..cce75fbd38 100644 --- a/winui3/src/toga_winui3/container.py +++ b/winui3/src/toga_winui3/container.py @@ -1,7 +1,9 @@ from math import ceil from win32more.Microsoft.UI.Xaml import HorizontalAlignment, VerticalAlignment -from win32more.Microsoft.UI.Xaml.Controls import Canvas, RelativePanel +from win32more.Microsoft.UI.Xaml.Controls import Canvas + +from .widgets.properties.staged import StagingArea class ContainerWidgets: @@ -15,57 +17,16 @@ def __init__(self, container): def _native(self): return self._container.native - def clear(self): - if len(self._widgets) < 1: - return - - for widget in self._widgets: - widget.container = None - self._widgets = [] - - self._native.Children.Clear() - def add(self, widget): self._widgets.append(widget) self._native.Children.Append(widget.native) def remove(self, widget): - index = self._widgets.index(widget) - self._widgets.remove(widget) + index = self._native_widgets.index(widget) + self._native_widgets.remove(widget.native) self._native.Children.RemoveAt(index) -class ContainerStagingArea(ContainerWidgets): - """A class used to calculate content-based constraints for WinUI 3 widgets. - - Some WinUI 3 widgets such as Button require minimum size constraints that are only - be calculated once they are attached to a window. ContainerStagingArea uses a hidden - panel (self._native) which allows the widgets to resize based on native parameters. - - This class should be use in conjunction with WidgetStager - """ - - @property - def _native(self): - return self._container._staging_panel - - def remove(self, widget): - """Removes a widget and triggers a layout refresh when the widget list empties. - - The refresh mechanism here is to avoid excessive refreshes. A single refresh - call will cause every widget in the Container to be refreshed. For example if a - Container had 100 buttons attached, then each button would be refreshed 100 - times with 10,000 total refresh calls (compared with the ~1 refresh call here). - """ - non_empty_initial = len(self._widgets) > 0 - super().remove(widget) - empty_final = len(self._widgets) == 0 - - if non_empty_initial and empty_final: - if self._container._content: - self._container._content.interface.refresh() - - class Container: """A container used for laying out WinUI 3 Toga widgets. @@ -89,8 +50,8 @@ class Container: def __init__(self, native_panel: Canvas): """Initialize a Container using a given native panel. - :param container_native: The native panel where the widgets.native classes can - be attached. + :param native_panel: The native panel where the widgets.native classes can be + attached. """ self.native = native_panel self.native.HorizontalAlignment = HorizontalAlignment.Stretch @@ -98,13 +59,9 @@ def __init__(self, native_panel: Canvas): self.native.SizeChanged += self.native_event_size_changed self._content = None - self.widgets = ContainerWidgets(self) - self._staging_panel = RelativePanel() - self._staging_panel.Visible = False - self.native.Children.Append(self._staging_panel) - - self.staging_area = ContainerStagingArea(self) + self.widgets = ContainerWidgets(self) + self.staging_area = StagingArea(self) #################################################################################### # Container geometry diff --git a/winui3/src/toga_winui3/libs/misc.py b/winui3/src/toga_winui3/libs/misc.py index 91d745a78f..8489078c3f 100644 --- a/winui3/src/toga_winui3/libs/misc.py +++ b/winui3/src/toga_winui3/libs/misc.py @@ -1,6 +1,5 @@ from ctypes.wintypes import SHORT - from win32more.Microsoft.UI.Xaml import GridLength, GridUnitType from win32more.Microsoft.UI.Xaml.Controls import ( ColumnDefinition, @@ -44,23 +43,6 @@ def row_definition_star(value: int = 1): return row_definition -def is_based_on_recursive(cls, ancestor): - for parent in cls.__bases__: - if parent == ancestor: - return True - elif is_based_on_recursive(parent, ancestor): - return True - - return False - - -def is_based_on(cls, ancestor): - if cls == ancestor: - return True - else: - return is_based_on_recursive(cls, ancestor) - - # https://learn.microsoft.com/en-us/windows/win32/winmsg/loword def loword(lparam: int) -> int: """Keeps the lower 16 bits of a value with at least 16 bits.""" @@ -72,10 +54,12 @@ def hiword(lparam: int) -> int: """Keeps the upper 16 bits of value with at least 32 bits.""" return (lparam >> 16) & 0b1111111111111111 + # https://learn.microsoft.com/en-us/windows/win32/api/windowsx/nf-windowsx-get_x_lparam def get_x_lparam(lparam: int) -> int: - return SHORT(loword(lparam)).value + return SHORT(loword(lparam)).value + # https://learn.microsoft.com/en-us/windows/win32/api/windowsx/nf-windowsx-get_y_lparam def get_y_lparam(lparam: int) -> int: - return SHORT(hiword(lparam)).value \ No newline at end of file + return SHORT(hiword(lparam)).value diff --git a/winui3/src/toga_winui3/widgets/base.py b/winui3/src/toga_winui3/widgets/base.py index ba79caca0c..4ee89e69dd 100644 --- a/winui3/src/toga_winui3/widgets/base.py +++ b/winui3/src/toga_winui3/widgets/base.py @@ -1,35 +1,14 @@ from abc import ABC, abstractmethod -from warnings import warn -from travertino.constants import TRANSPARENT from travertino.size import at_least -from win32more.Microsoft.UI.Xaml import FocusState -from win32more.Microsoft.UI.Xaml.Controls import Canvas, Control, Panel +from win32more.Microsoft.UI.Xaml import FocusState, Visibility +from win32more.Microsoft.UI.Xaml.Controls import Canvas, Panel -from ..colors import native_brush -from ..libs.misc import is_based_on - - -class WidgetStager: - def __init__(self, widget): - self.width = 0 - self.height = 0 - self._widget = widget - self._widget_copy = None - - def refresh(self): - self._widget_copy = type(self._widget)(None) - self._widget_copy.native.SizeChanged += self.native_event_size_changed - self._widget.container.staging_area.add(self._widget_copy) - self._widget_copy.native.Content = self._widget.content_creator() - - def native_event_size_changed(self, sender, args): - self.width = self._widget_copy.native.ActualSize.X - self.height = self._widget_copy.native.ActualSize.Y +from toga.constants import TRANSPARENT - self._widget.rehint() - self._widget.container.staging_area.remove(self._widget_copy) - self._widget_copy = None +from ..colors import native_brush +from .properties.native import NativeProperties, is_based_on +from .properties.staged import StagedProperties class Widget(ABC): @@ -40,21 +19,28 @@ class Widget(ABC): def __init__(self, interface): super().__init__() self.interface = interface - self._container = None - self._content = None - self._constraints = None self.native = None + self._container = None + + self._native_properties = NativeProperties(self) + self._staged_properties = StagedProperties(self) + + self._min_width = self.interface._MIN_WIDTH + self._min_height = self.interface._MIN_HEIGHT + self.create() @abstractmethod def create(self): ... def set_app(self, app): # noqa B027 - self.interface.factory.not_implemented("Widget.set_app") + # Everything is already handled by the Toga core interface. + pass - def set_window(self, window): - self.interface.factory.not_implemented("Widget.set_window") + def set_window(self, window): # noqa B027 + # Everything is already handled by the Toga core interface. + pass #################################################################################### # Methods relating to the container. @@ -72,8 +58,7 @@ def container(self, container): self._container = container if container: container.widgets.add(self) - if self._constraints: - self._constraints.refresh() + self._staged_properties.refresh() for child in self.interface.children: child._impl.container = container @@ -98,20 +83,14 @@ def remove_child(self, child): #################################################################################### def set_background_color(self, color): - cls_native = type(self.native) - if color is None: - if is_based_on(cls_native, Control): - self.native.ClearValue(Control.BackgroundProperty) - elif is_based_on(cls_native, Panel): - self.native.Background = native_brush(TRANSPARENT) - else: - warn( - "Widget.set_background_color(None) has not been configured for the " - + f"class {cls_native}.", - stacklevel=1, - ) + if color is not None: + brush = native_brush(color) + elif is_based_on(type(self.native), Panel): + brush = native_brush(TRANSPARENT) else: - self.native.Background = native_brush(color) + brush = None + + self._native_properties.Background = brush def set_bounds(self, x, y, width, height): self.native.Width = width @@ -120,32 +99,29 @@ def set_bounds(self, x, y, width, height): Canvas.SetTop(self.native, y) def set_color(self, color): - cls_native = type(self.native) - - # WinUI 3 controls based on the Panel class do not have a Foreground property. - if is_based_on(cls_native, Panel): - return - - if color is None: - if is_based_on(cls_native, Control): - self.native.ClearValue(Control.ForegroundProperty) - else: - warn( - "Widget.set_background_color(None) has not been configured for the " - + f"class {cls_native}.", - stacklevel=1, - ) + if color is not None: + brush = native_brush(color) else: - self.native.Foreground = native_brush(color) + brush = None + + self._native_properties.Foreground = brush def set_font(self, font): - self.interface.factory.not_implemented("Widget.set_font()") + native_font = font._impl.native + staged_properties = self._staged_properties + + staged_properties.FontFamily = native_font.FontFamily + staged_properties.FontSize = native_font.FontSize + staged_properties.FontStyle = native_font.FontStyle + staged_properties.FontWeight = native_font.FontWeight def set_hidden(self, hidden): - self.interface.factory.not_implemented("Widget.set_hidden()") + state = Visibility.Collapsed if hidden else Visibility.Visible + self.native.Visibility = state - def set_text_align(self, alignment): - self.interface.factory.not_implemented("Widget.set_text_align()") + def set_text_align(self, alignment): # noqa B027 + # Where appropriate, this is implement on a widget by widget basis. + pass #################################################################################### # Other methods called by the Toga core interface. @@ -174,28 +150,5 @@ def refresh(self): self.rehint() def rehint(self): - self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) - - #################################################################################### - # Content. - # - # These methods are to be used by Widgets that have minimum size constraints based - # on their content. When these widgets are staged in the container staging area, a - # copy of the native content needs to be created. It's difficult to created a direct - # copy of the content, so here a mechanism is used to create a new version of the - # content using a "content creator". - #################################################################################### - - @property - def content_creator(self): - return self._content_creator - - @content_creator.setter - def content_creator(self, creator): - self._content_creator = creator - self.content = creator() - self.native.Content = self.content - - if self._container: - self._constraints.refresh() + self.interface.intrinsic.width = at_least(self._min_width) + self.interface.intrinsic.height = at_least(self._min_height) diff --git a/winui3/src/toga_winui3/widgets/box.py b/winui3/src/toga_winui3/widgets/box.py index 67521e4ebe..59c43ce884 100644 --- a/winui3/src/toga_winui3/widgets/box.py +++ b/winui3/src/toga_winui3/widgets/box.py @@ -6,3 +6,15 @@ class Box(Widget): def create(self): self.native = Canvas() + + #################################################################################### + # Overrides of methods called by the Toga style applicator. + #################################################################################### + + def set_color(self, font): + # Canvas has no Foreground attributes to set. + pass + + def set_font(self, font): + # Canvas has no font attributes to set. + pass diff --git a/winui3/src/toga_winui3/widgets/button.py b/winui3/src/toga_winui3/widgets/button.py index 84fa31dd91..9ac24e46cb 100644 --- a/winui3/src/toga_winui3/widgets/button.py +++ b/winui3/src/toga_winui3/widgets/button.py @@ -5,13 +5,12 @@ SymbolIcon, ) -from .base import Widget, WidgetStager +from .base import Widget class Button(Widget): def create(self): self.native = NativeButton() - self._constraints = WidgetStager(self) self._icon = None self._text = "" @@ -29,11 +28,11 @@ def set_text(self, text): if self._icon is not None: return - def creator(text=text): - # "\u200b" (ZERO WIDTH SPACE) instead of "" ensures correct button height. - return "\u200b" if text == "" else text + self._staged_properties.Content = self.text - self.content_creator = creator + def text(self): + # "\u200b" (ZERO WIDTH SPACE) instead of "" ensures correct button height. + return "\u200b" if self._text == "" else self._text def get_icon(self): return self._icon @@ -44,13 +43,27 @@ def set_icon(self, icon): if icon is None: return - def creator(): - symbol_icon = SymbolIcon() - symbol_icon.Symbol = Symbol.Document - return symbol_icon + self._staged_properties.Content = self.icon - self.content_creator = creator + def icon(self): + symbol_icon = SymbolIcon() + symbol_icon.Symbol = Symbol.Document + return symbol_icon + + #################################################################################### + # Overrides of methods called by the Toga style applicator. + #################################################################################### + + def set_text_align(self, alignment): + # FIXME: WinUI 3 has the ability to set the content alignment of a button, but + # the Toga style will default to either left-aligned or right-aligned which is + # different from the default WinUI 3 value of center-aligned. + pass + + #################################################################################### + # Overrides of other methods called by the Toga core interface. + #################################################################################### def rehint(self): - self.interface.intrinsic.width = at_least(self._constraints.width) - self.interface.intrinsic.height = self._constraints.height + self.interface.intrinsic.width = at_least(self._min_width) + self.interface.intrinsic.height = self._min_height diff --git a/winui3/src/toga_winui3/widgets/label.py b/winui3/src/toga_winui3/widgets/label.py index 3b409bc42d..f6fe258856 100644 --- a/winui3/src/toga_winui3/widgets/label.py +++ b/winui3/src/toga_winui3/widgets/label.py @@ -1,30 +1,50 @@ +from travertino.constants import CENTER, JUSTIFY, LEFT, RIGHT from travertino.size import at_least +from win32more.Microsoft.UI.Xaml import TextAlignment from win32more.Microsoft.UI.Xaml.Controls import TextBlock -from .base import Widget, WidgetStager +from .base import Widget class Label(Widget): def create(self): self.native = TextBlock() - self._constraints = WidgetStager(self) self._text = "" - def set_text_align(self, value): - pass - # self.native.TextAlign = TextAlignment(value) - def get_text(self): - return self.native.Text + return self._text def set_text(self, text): self._text = text + self._staged_properties.Text = self.text + + def text(self): + return self._text + + #################################################################################### + # Overrides of methods called by the Toga style applicator. + #################################################################################### + + def set_background_color(self, color): + # TextBlock has no Background attribute to set. + pass + + def set_text_align(self, alignment): + property_dict = { + CENTER: "Center", + JUSTIFY: "Justify", + LEFT: "Left", + RIGHT: "Right", + } + property = property_dict[alignment] + native_alignment = getattr(TextAlignment, property) - def creator(text=text): - return text + self._native_properties.HorizontalTextAlignment = native_alignment - self.content_creator = creator + #################################################################################### + # Overrides of other methods called by the Toga core interface. + #################################################################################### def rehint(self): - self.interface.intrinsic.width = at_least(self._constraints.width) - self.interface.intrinsic.height = self._constraints.height + self.interface.intrinsic.width = at_least(self._min_width) + self.interface.intrinsic.height = self._min_height diff --git a/winui3/src/toga_winui3/widgets/properties/native.py b/winui3/src/toga_winui3/widgets/properties/native.py new file mode 100644 index 0000000000..9ec9f3dc8f --- /dev/null +++ b/winui3/src/toga_winui3/widgets/properties/native.py @@ -0,0 +1,81 @@ +def is_based_on_recursive(cls, ancestor): + for parent in cls.__bases__: + if parent == ancestor: + return True + elif is_based_on_recursive(parent, ancestor): + return True + + return False + + +def is_based_on(cls, ancestor): + if cls == ancestor: + return True + else: + return is_based_on_recursive(cls, ancestor) + + +def get_attribute_base_recursive(cls, attribute): + for parent in cls.__bases__: + if hasattr(parent, attribute): + return parent + + branch_result = get_attribute_base_recursive(parent, attribute) + if branch_result is not None: + return branch_result + + return None + + +def get_attribute_base(cls, attribute): + if hasattr(cls, attribute): + return cls + else: + return get_attribute_base_recursive(cls, attribute) + + +class NativeProperties: + """Sets the native properties of a widget and clears dependency properties. + + In WinUI 3, a there is a special type of property called a 'denpendency property'. + These properties are characterised by being dependent on values of the application + which can change e.g. DPI, darkmode theme. When a dependency property is manually + set to a value, it can lose the ability to listen to these changes. + + Using this class to set a dependency property to None reset the property to the + default value and restore ability to listen to changes. + """ + + def __init__(self, widget): + self._widget = widget + + def __setattr__(self, name, value): + """Sets the native property value for a name with a capital first character.""" + if not name[0].isupper(): + super().__setattr__(name, value) + return + + self.set_native_property(name, value) + + def set_native_property(self, name, value): + native_instance = self._widget.native + + if not hasattr(native_instance, name): + raise AttributeError(f"{native_instance} has no attribute named {name}.") + + # For non-None values, set the property as normal. + if value is not None: + setattr(native_instance, name, value) + return + + native_cls = type(native_instance) + dependency_property = name + "Property" + dependency_ancestor = get_attribute_base(native_cls, dependency_property) + + if dependency_ancestor is not None: + # Clear the dependency property. + dependency_attribute = getattr(dependency_ancestor, dependency_property) + native_instance.ClearValue(dependency_attribute) + else: + # Fallback to the usual setattr for non-dependeny properties. + setattr(native_instance, name, value) diff --git a/winui3/src/toga_winui3/widgets/properties/staged.py b/winui3/src/toga_winui3/widgets/properties/staged.py new file mode 100644 index 0000000000..9b5aa065d4 --- /dev/null +++ b/winui3/src/toga_winui3/widgets/properties/staged.py @@ -0,0 +1,122 @@ +from win32more.Microsoft.UI.Xaml.Controls import RelativePanel + +""" +Overview of content staging + +ISSUE: Some Toga widgets (e.g. Button) use minimum size constraints that are based on +their content. The native WinUI 3 widget will resize itself according to this content, +but only if size values have not been manually set. Since the size values are manually +set by the Toga style applicator, the native widget will not resize. + +SOLUTION: The work-around used here is to 'stage' the properties that lead to resizing. +In practice, this means that when a property is changed, a copy of the widget is created +in a hidden panel and allowed to resize. Upon resize, the copy is destroyed and the new +minimum size measurements are then sent to the Toga style applicator. + The main advantage of copying the widget is that flicker is reduced: The displayed +widget will only change appearance when the new size has been calculated. + +IMPORTANT: The values of staged properties are set as 'value creator' callables that +create new instances of the desired content. This is because not all native classes can +be children of multiple native classes. +""" + + +class StagingArea: + """A class used to calculate content-based constraints for WinUI 3 widgets. + + A StagingArea has a hidden native panel that allows widgets with the staged content + resize themselves. Every StagingArea is attached to a Container and its hidden + native panel is a child of a Container's own native panel. + """ + + def __init__(self, container): + """Create an instance of a StagingArea. + + :param container: The Container where the StagingArea will be attached. + """ + self._native = RelativePanel() + self._native.Visible = False + + self._native_widgets = [] + + # Add the container + self._container = container + self._container.native.Children.InsertAt(0, self._native) + + def add(self, native_widget): + self._native_widgets.append(native_widget) + self._native.Children.Append(native_widget) + + def remove(self, widget): + """Removes a widget and triggers a layout refresh when the widget list empties. + + The refresh mechanism here is to avoid excessive refresh calls. + """ + non_empty_initial = len(self._native_widgets) > 0 + index = self._native_widgets.index(widget) + self._native_widgets.remove(widget) + self._native.Children.RemoveAt(index) + empty_final = len(self._native_widgets) == 0 + + if non_empty_initial and empty_final: + if self._container._content: + self._container._content.interface.refresh() + + +class StagedProperties: + def __init__(self, widget): + self._widget = widget + self._duplicate = None + self._staged_properties = {} + + self._font_keys = {"FontFamily", "FontSize", "FontStyle", "FontWeight"} + + def __setattr__(self, name, value): + """Sets the native property value for a name with a capital first character. + + Note that the 'value' of a staged property must be a 'value creator' callable + that creates a new instance of the desired content. + """ + if not name[0].isupper(): + super().__setattr__(name, value) + return + + if not callable(value): + raise ValueError( + "The 'value' of a staged property must be callable i.e. a " + + "'value creator'." + ) + + # Set and cache the native property. + setattr(self._widget._native_properties, name, value()) + self._staged_properties[name] = value + + self.refresh() + + def refresh(self): + if not self._widget._container: + return + + # The properties in self._font_keys are only staged if other content such as + # text is being staged as well. + if set(self._staged_properties.keys()) - self._font_keys == set(): + return + + widget = self._widget + self._duplicate = type(widget.native)() + self._duplicate.SizeChanged += self.native_event_size_changed + + for attribute, value_creator in self._staged_properties.items(): + value = value_creator() + if value is not None: + setattr(self._duplicate, attribute, value) + + widget.container.staging_area.add(self._duplicate) + + def native_event_size_changed(self, sender, args): + self._widget._min_width = self._duplicate.ActualSize.X + self._widget._min_height = self._duplicate.ActualSize.Y + self._widget.rehint() + + self._widget.container.staging_area.remove(self._duplicate) + self._duplicate = None From 754ee470206d7f954b8279e11a94a1c4e6fdddea Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:23:15 +0200 Subject: [PATCH 007/110] Add minimum window and container sizes --- winui3/src/toga_winui3/container.py | 30 ++++++-- winui3/src/toga_winui3/widgets/button.py | 4 ++ winui3/src/toga_winui3/widgets/label.py | 4 ++ winui3/src/toga_winui3/window.py | 92 +++++++++++++++++------- 4 files changed, 101 insertions(+), 29 deletions(-) diff --git a/winui3/src/toga_winui3/container.py b/winui3/src/toga_winui3/container.py index cce75fbd38..8a72fbbff6 100644 --- a/winui3/src/toga_winui3/container.py +++ b/winui3/src/toga_winui3/container.py @@ -41,17 +41,20 @@ class Container: Attributes: native: The WinUI 3 panel where the widgets.native classes will be attached. - widgets: A ContainerWidgets instance which adds, removes and keeps a record of - the widgets attached to the native panel. + on_refresh: A callback to be notified when this container's layout is refreshed. staging_area: A ContainerStagingArea instance which is used to stage widgets that require a native panel to calculate content-based constraints. + widgets: A ContainerWidgets instance which adds, removes and keeps a record of + the widgets attached to the native panel. """ - def __init__(self, native_panel: Canvas): + def __init__(self, native_panel: Canvas, on_refresh=None): """Initialize a Container using a given native panel. :param native_panel: The native panel where the widgets.native classes can be attached. + :param on_refresh: A callback to be notified when this container's layout is + refreshed. """ self.native = native_panel self.native.HorizontalAlignment = HorizontalAlignment.Stretch @@ -59,6 +62,7 @@ def __init__(self, native_panel: Canvas): self.native.SizeChanged += self.native_event_size_changed self._content = None + self._on_refresh = on_refresh self.widgets = ContainerWidgets(self) self.staging_area = StagingArea(self) @@ -82,6 +86,22 @@ def width(self): def height(self): return ceil(self.native.ActualSize.Y) + @property + def min_width(self): + return self.native.MinWidth + + @min_width.setter + def min_width(self, width): + self.native.MinWidth = width + + @property + def min_height(self): + return self.native.MinHeight + + @min_height.setter + def min_height(self, height): + self.native.MinHeight = height + #################################################################################### # Container content #################################################################################### @@ -116,4 +136,6 @@ def native_event_size_changed(self, sender, args): self.content.interface.refresh() def refreshed(self): - pass + self.min_width = self.content.interface.layout.min_width + self.min_height = self.content.interface.layout.min_height + self._on_refresh() diff --git a/winui3/src/toga_winui3/widgets/button.py b/winui3/src/toga_winui3/widgets/button.py index 9ac24e46cb..3fff3491d2 100644 --- a/winui3/src/toga_winui3/widgets/button.py +++ b/winui3/src/toga_winui3/widgets/button.py @@ -14,6 +14,10 @@ def create(self): self._icon = None self._text = "" + # Initial minimum sizes are 0 so that the staged properties are sized up. + self._min_width = 0 + self._min_height = 0 + self.native.Click += self.native_event_click def native_event_click(self, sender, args): diff --git a/winui3/src/toga_winui3/widgets/label.py b/winui3/src/toga_winui3/widgets/label.py index f6fe258856..8d88356d71 100644 --- a/winui3/src/toga_winui3/widgets/label.py +++ b/winui3/src/toga_winui3/widgets/label.py @@ -11,6 +11,10 @@ def create(self): self.native = TextBlock() self._text = "" + # Initial minimum sizes are 0 so that the staged properties are sized up. + self._min_width = 0 + self._min_height = 0 + def get_text(self): return self._text diff --git a/winui3/src/toga_winui3/window.py b/winui3/src/toga_winui3/window.py index 401dc2865c..01d490465c 100644 --- a/winui3/src/toga_winui3/window.py +++ b/winui3/src/toga_winui3/window.py @@ -6,6 +6,7 @@ AppWindowPresenterKind, DisplayArea, DisplayAreaFallback, + FullScreenPresenter, OverlappedPresenter, OverlappedPresenterState, TitleBarTheme, @@ -70,19 +71,19 @@ def create(self): def create_content(self): """Construct the container.""" self.container_native = Canvas() - self.container = Container(self.container_native) + self.container = Container(self.container_native, self.content_refreshed) self.native.Content = self.container_native def _set_restrictions(self): """Sets the window properties of being minimizable and resizable.""" - presenter = self.native.AppWindow.Presenter + presenter, _ = self._presenter + if presenter.Kind != AppWindowPresenterKind.Overlapped: return - # Cast presenter as an instance of OverlappedPresenter and set the restrictions. - overlapped_presenter = OverlappedPresenter(value=presenter.value) - overlapped_presenter.IsMinimizable = self.interface.minimizable - overlapped_presenter.IsResizable = self.interface.resizable + # Set the restrictions. + presenter.IsMinimizable = self.interface.minimizable + presenter.IsResizable = self.interface.resizable #################################################################################### # Native event handlers. @@ -149,9 +150,15 @@ def show(self): # Window content and resources. #################################################################################### - def content_refreshed(self, container): - # TODO: Minimum size constraints: overlapped_presenter.PreferredMinimumWidth. - self.interface.factory.not_implemented("Window.content_refreshed") + def content_refreshed(self): + presenter, _ = self._presenter + + if presenter.Kind != AppWindowPresenterKind.Overlapped: + return + + min_size = self.min_size + presenter.PreferredMinimumWidth = min_size.width + presenter.PreferredMinimumHeight = min_size.height def set_content(self, widget): """Sets the content of the window's container to be the given Toga widget.""" @@ -171,7 +178,7 @@ def set_content(self, widget): #################################################################################### def get_size(self) -> Size: - """Gets the size of the window in effective pixels (CSS pixels).""" + """Gets the size of the window in CSS pixels (effective pixels).""" # self.native.Bounds returns values in effective pixels, but they are not always # integer values. return Size( @@ -180,13 +187,42 @@ def get_size(self) -> Size: ) def set_size(self, size: SizeT): - """Sets the size of the window in effective pixels (CSS pixels).""" + """Sets the size of the window in CSS pixels (effective pixels).""" css_to_physical = self.get_current_screen().css_to_physical self.native.AppWindow.Resize( SizeInt32(css_to_physical(size[0]), css_to_physical(size[1])) ) + @property + def min_size(self): + """The minimum size of the window in physical pixels (device pixels).""" + css_to_physical = self.get_current_screen().css_to_physical + + # Window and client sizes are in physical pixels. + window_size = self.native.AppWindow.Size + client_size = self.native.AppWindow.ClientSize + + # Menu, toolbar and layout values are in CSS pixels. + menu_native = getattr(self, "menu_native", None) + menu_height = menu_native.ActualSize.Y if menu_native else 0 + + toolbar_native = getattr(self, "toolbar_native", None) + toolbar_height = toolbar_native.ActualSize.Y if toolbar_native else 0 + + layout = self.interface.content.layout + + # Compute the minimum values for the client area. + client_min_width = css_to_physical(layout.min_width) + client_min_height = css_to_physical( + layout.min_height + menu_height + toolbar_height + ) + + return Size( + window_size.Width - client_size.Width + client_min_width, + window_size.Height - client_size.Height + client_min_height, + ) + #################################################################################### # Window position (CSS pixels, see window size for terminology). #################################################################################### @@ -205,6 +241,7 @@ def get_current_screen(self): # # TODO: Remove that assumption, and make Window.position return coordinates relative # to the current screen's origin and DPI. + # See: https://github.com/beeware/toga/issues/2947 def get_position(self) -> Position: position = self.native.AppWindow.Position physical_to_css = App.app._impl.get_primary_screen().physical_to_css @@ -234,6 +271,19 @@ def hide(self): # Window state. #################################################################################### + @property + def _presenter(self): + raw_presenter = self.native.AppWindow.Presenter + + if raw_presenter.Kind == AppWindowPresenterKind.Overlapped: + # Cast presenter as an instance of OverlappedPresenter. + return OverlappedPresenter(value=raw_presenter.value), raw_presenter + elif raw_presenter.Kind == AppWindowPresenterKind.FullScreen: + # Cast presenter as an instance of FullScreenPresenter. + return FullScreenPresenter(value=raw_presenter.value), raw_presenter + else: + raise ValueError("CompactOverlay is not a supported presenter type.") + def get_window_state(self, in_progress_state=False) -> WindowState: """Gets the current state of the window. @@ -241,25 +291,20 @@ def get_window_state(self, in_progress_state=False) -> WindowState: :return: A WindowState constant determined by NORMAL, MAXIMIZED, MINIMIZED or PRESENTATION. FULLSCREEN is not supported. """ - presenter = self.native.AppWindow.Presenter + presenter, _ = self._presenter if presenter.Kind == AppWindowPresenterKind.FullScreen: # Fullscreen here corresponds to Toga 'PRESENTATION' window state. From the # Microsoft documentation: 'The window does not have a border or title bar, - # and hides the system task bar. + # and hides the system task bar.' # learn.microsoft.com/en-us/windows/apps/develop/ui/manage-app-windows return WindowState.PRESENTATION else: # Assume presenter.Kind == AppWindowPresenterKind.Overlapped, since the # third alternative 'CompactOverlay' is not implemented by Toga. - # learn.microsoft.com/en-us/windows/apps/develop/ui/manage-app-windows - # - # Hence, cast presenter as an instance of OverlappedPresenter. - overlapped_presenter = OverlappedPresenter(value=presenter.value) - - if overlapped_presenter.State == OverlappedPresenterState.Maximized: + if presenter.State == OverlappedPresenterState.Maximized: return WindowState.MAXIMIZED - elif overlapped_presenter.State == OverlappedPresenterState.Minimized: + elif presenter.State == OverlappedPresenterState.Minimized: return WindowState.MINIMIZED else: return WindowState.NORMAL @@ -296,13 +341,10 @@ def set_window_state(self, state: WindowState): def get_image_data(self): self.interface.factory.not_implemented("Window.get_image_data") + # Windows.Graphics.Capture class MainWindow(Window): - def create(self): - super().create() - self.toolbar_native = None - def create_content(self): # Row 0 is allocated for the menu # Row 1 is allocated for toolbar @@ -320,7 +362,7 @@ def create_content(self): Grid.SetColumn(self.container_native, 0) self.content_native.Children.Append(self.container_native) - self.container = Container(self.container_native) + self.container = Container(self.container_native, self.content_refreshed) # Attach the content to the window. self.native.Content = self.content_native From fdcf9f4ad1d58b4636fe87bc454c657745523f5d Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:40:13 +0200 Subject: [PATCH 008/110] Minor fix to container --- winui3/src/toga_winui3/container.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/winui3/src/toga_winui3/container.py b/winui3/src/toga_winui3/container.py index 8a72fbbff6..672b2de731 100644 --- a/winui3/src/toga_winui3/container.py +++ b/winui3/src/toga_winui3/container.py @@ -22,8 +22,8 @@ def add(self, widget): self._native.Children.Append(widget.native) def remove(self, widget): - index = self._native_widgets.index(widget) - self._native_widgets.remove(widget.native) + index = self._widgets.index(widget) + self._widgets.remove(widget) self._native.Children.RemoveAt(index) From a0a47a12592564a229b4b505442238586d3c5a9f Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:56:03 +0200 Subject: [PATCH 009/110] Refactor GDI+ functions and add get_pixels --- winui3/src/toga_winui3/libs/gdiplus.py | 124 +++++++++++++++++-------- 1 file changed, 85 insertions(+), 39 deletions(-) diff --git a/winui3/src/toga_winui3/libs/gdiplus.py b/winui3/src/toga_winui3/libs/gdiplus.py index db5394a110..97d861100c 100644 --- a/winui3/src/toga_winui3/libs/gdiplus.py +++ b/winui3/src/toga_winui3/libs/gdiplus.py @@ -1,34 +1,30 @@ -from ctypes import POINTER, WinError, byref +from ctypes import POINTER, WinError, byref, cast +from ctypes.wintypes import UINT +import win32more.Windows.Win32.Graphics.GdiPlus as gdi_plus from win32more import String from win32more.Windows.Win32.Foundation import BOOL, UIntPtr from win32more.Windows.Win32.Graphics.GdiPlus import ( FontStyleBold, FontStyleBoldItalic, FontStyleItalic, - # GDI+ Fonts FontStyleRegular, FontStyleStrikeout, FontStyleUnderline, - # GDI+ Icons - GdipCreateBitmapFromFile, - GdipCreateFontFamilyFromName, - GdipCreateHICONFromBitmap, - GdipIsStyleAvailable, - # Main GDI+ operations - GdiplusShutdown, - GdiplusStartup, GdiplusStartupInput, GdiplusStartupOutput, GpBitmap, GpFontFamily, + GpImage, ) from win32more.Windows.Win32.UI.WindowsAndMessaging import HICON ######################################################################################## -# Main GDI+ operations +# GDI+ return status processing. ######################################################################################## +# https://learn.microsoft.com/en-us/windows/win32/gdiplus/-gdiplus-flatapi-flat +# https://learn.microsoft.com/windows/win32/api/Gdiplustypes/ne-gdiplustypes-status status_dict = { 0: "Ok", 1: "GenericError", @@ -55,6 +51,28 @@ } +def gdi_plus_function(function): + def wrapper(*args, **kwargs): + status_code = function(*args, **kwargs) + if status_code != 0 and status_code is not None: + global status_dict + error = str(status_dict[status_code]) + message = f"The GDI+ function {function.__name__} exit with status {error}." + + raise WinError(descr=message) + + return wrapper + + +######################################################################################## +# GDI+ context manager. +######################################################################################## + +# Wrap functions to check exit status code. +GdiplusStartup = gdi_plus_function(gdi_plus.GdiplusStartup) +GdiplusShutdown = gdi_plus_function(gdi_plus.GdiplusShutdown) + + class GdiPlusContext: """A context manager for running GdiPlus functions.""" @@ -69,16 +87,7 @@ def __init__(self): self._output = GdiplusStartupOutput() def __enter__(self): - status = GdiplusStartup( - byref(self._token), - byref(self._input), - byref(self._output), - ) - - if status != 0: - raise WinError( - descr=f"GdiplusStartup failed with code: {status_dict[status]}" - ) + GdiplusStartup(byref(self._token), byref(self._input), byref(self._output)) def __exit__(self, exc_type, exc_value, traceback): GdiplusShutdown(self._token) @@ -94,9 +103,6 @@ def __del__(self): # GDI+ fonts ######################################################################################## -FontFamilyPtr = POINTER(GpFontFamily) - - ALL_FONT_STYLES = ( FontStyleRegular | FontStyleBold @@ -107,6 +113,14 @@ def __del__(self): ) +FontFamilyPtr = POINTER(GpFontFamily) + + +# Wrap functions to check exit status code. +GdipCreateFontFamilyFromName = gdi_plus_function(gdi_plus.GdipCreateFontFamilyFromName) +GdipIsStyleAvailable = gdi_plus_function(gdi_plus.GdipIsStyleAvailable) + + def is_font_installed(font_family_name: str): """Checks whether a font is installed on the current system. @@ -122,7 +136,9 @@ def is_font_installed(font_family_name: str): # Attempt to create the font family. GdipCreateFontFamilyFromName( - String(font_family_name), None, byref(font_family_ptr) + String(font_family_name), + None, + byref(font_family_ptr), ) # Check if the font family has been created. @@ -136,6 +152,16 @@ def is_font_installed(font_family_name: str): ######################################################################################## BitmapPtr = POINTER(GpBitmap) +ImagePtr = POINTER(GpImage) + + +# Wrap functions to check exit status code. +GdipCreateBitmapFromFile = gdi_plus_function(gdi_plus.GdipCreateBitmapFromFile) +GdipCreateBitmapFromHICON = gdi_plus_function(gdi_plus.GdipCreateBitmapFromHICON) +GdipCreateHICONFromBitmap = gdi_plus_function(gdi_plus.GdipCreateHICONFromBitmap) +GdipBitmapGetPixel = gdi_plus_function(gdi_plus.GdipBitmapGetPixel) +GdipGetImageHeight = gdi_plus_function(gdi_plus.GdipGetImageHeight) +GdipGetImageWidth = gdi_plus_function(gdi_plus.GdipGetImageWidth) def create_icon(icon_path: str) -> HICON: @@ -143,21 +169,41 @@ def create_icon(icon_path: str) -> HICON: bitmap_ptr = BitmapPtr() icon_handle = HICON() - bitmap_status = 1 - handle_status = 1 with gdi_plus_context: - bitmap_status = GdipCreateBitmapFromFile(String(icon_path), byref(bitmap_ptr)) - handle_status = GdipCreateHICONFromBitmap(bitmap_ptr, byref(icon_handle)) + GdipCreateBitmapFromFile(String(icon_path), byref(bitmap_ptr)) + GdipCreateHICONFromBitmap(bitmap_ptr, byref(icon_handle)) - if bitmap_status != 0 or handle_status != 0: - message = f"Unable to create icon bitmap from {icon_path}.\n" - if bitmap_status != 0: - message += "GdipCreateBitmapFromFile code: " - message += str(status_dict[bitmap_status]) - else: - message += "GdipCreateHICONFromBitmap code: " - message += str(status_dict[handle_status]) + return icon_handle - raise ValueError(message) - return icon_handle +def color_to_rgba(color): + return ( + (color >> 16) & 0b11111111, # Red + (color >> 8) & 0b11111111, # Green + color & 0b11111111, # Blue + (color >> 24) & 0b11111111, # Alpha + ) + + +def icon_pixels(icon_handle: HICON): + bitmap_ptr = BitmapPtr() + pixel_array = [] + + with gdi_plus_context: + GdipCreateBitmapFromHICON(icon_handle, byref(bitmap_ptr)) + image_ptr = cast(bitmap_ptr, ImagePtr) + + width = UINT() + height = UINT() + GdipGetImageWidth(image_ptr, byref(width)) + GdipGetImageHeight(image_ptr, byref(height)) + + color = UINT() + for x in range(width.value): + pixel_array.append([]) + for y in range(height.value): + GdipBitmapGetPixel(bitmap_ptr, x, y, byref(color)) + argb = color_to_rgba(color.value) + pixel_array[x].append(argb) + + return pixel_array From 0b6f6ec0afa2385136e49f84f85311ea6a5a9403 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:06:18 +0200 Subject: [PATCH 010/110] Improve Window closing, states and visibility --- winui3/src/toga_winui3/window.py | 156 +++++++++++++++++++++++++++---- 1 file changed, 139 insertions(+), 17 deletions(-) diff --git a/winui3/src/toga_winui3/window.py b/winui3/src/toga_winui3/window.py index 01d490465c..a1b7ae1478 100644 --- a/winui3/src/toga_winui3/window.py +++ b/winui3/src/toga_winui3/window.py @@ -27,7 +27,24 @@ ) from win32more.Microsoft.UI.Xaml.Media import MicaBackdrop from win32more.Windows.Graphics import PointInt32, SizeInt32 +from win32more.Windows.Win32.UI.WindowsAndMessaging import ( + MF_BYCOMMAND, + MF_DISABLED, + MF_ENABLED, + MF_GRAYED, + SC_CLOSE, + EnableMenuItem, + GetSystemMenu, +) + +######################################################################################## +# FIXME: Microsoft.Ui.Interop functionality will be included in a future win32more +# release. Update this code and the flagged code below when that happens. +# https://github.com/ynkdir/py-win32more/issues/184 +from winui3.microsoft.ui import WindowId +from winui3.microsoft.ui.interop import get_window_from_window_id +######################################################################################## from toga import App from toga.command import Separator from toga.constants import WindowState @@ -48,6 +65,14 @@ def __init__(self, interface, title, position, size): self.is_activated = False self.create() + # From a native WinUI 3 point of view, presentation mode is indistinguishable + # from fullscreen mode. Use this variable to distinguish between them. + self._in_presentation_mode = False + + # In WinUI 3 a minimized window is not considered visible. This variable keeps + # track of this property. + self._visible = self.native.Visible + self._set_restrictions() self.set_title(title) self.set_size(size) @@ -62,11 +87,14 @@ def __init__(self, interface, title, position, size): def create(self): self.native = App.app._impl.native_instance.CreateWindow() self.native.SystemBackdrop = MicaBackdrop() + + # Match the title bar theme to the app. self.native.AppWindow.TitleBar.PreferredTheme = TitleBarTheme.UseDefaultAppMode # TODO: Decide if these event handlers need to be a weak reference. self.native.Activated += self.native_event_activated self.native.AppWindow.Changed += self.native_event_changed + self.native.AppWindow.Closing += self.native_event_closing def create_content(self): """Construct the container.""" @@ -74,6 +102,14 @@ def create_content(self): self.container = Container(self.container_native, self.content_refreshed) self.native.Content = self.container_native + @property + def _hwnd(self): + ################################################################################ + # FIXME: See interop note above. + window_id = WindowId(self.native.AppWindow.Id.Value) + return get_window_from_window_id(window_id) + ################################################################################ + def _set_restrictions(self): """Sets the window properties of being minimizable and resizable.""" presenter, _ = self._presenter @@ -85,6 +121,22 @@ def _set_restrictions(self): presenter.IsMinimizable = self.interface.minimizable presenter.IsResizable = self.interface.resizable + if self.interface.closable: + self._enable_close_button() + else: + self._disable_close_button() + + def _disable_close_button(self): + # The close button is controlled by the system menu and not the title bar. For + # an explanation see: + # https://devblogs.microsoft.com/oldnewthing/20100604-00/?p=13803 + hmenu = GetSystemMenu(self._hwnd, False) + EnableMenuItem(hmenu, SC_CLOSE, MF_BYCOMMAND | MF_DISABLED | MF_GRAYED) + + def _enable_close_button(self): + hmenu = GetSystemMenu(self._hwnd, False) + EnableMenuItem(hmenu, SC_CLOSE, MF_BYCOMMAND | MF_ENABLED) + #################################################################################### # Native event handlers. #################################################################################### @@ -108,14 +160,34 @@ def native_event_changed(self, sender, args): self.interface.on_resize() if args.DidVisibilityChange: + # Minimize is not considered visible but it also doesn't trigger this event. if self.native.AppWindow.IsVisible: + self._visible = True self.interface.on_show() else: + self._visible = False self.interface.on_hide() if args.DidPresenterChange: self._set_restrictions() + def native_event_closing(self, sender, args): + # Note: This event is raised when clicking on the close button, but not when + # self.native.Close() is called. + + if not self.interface.app._impl._is_exiting: + # In this branch the close request is cancelled and the on_close() method is + # called. on_close() determines whether a close should occur and then, if + # appropriate, it will programmatically close the window and remove this + # handler. + args.Cancel = True + self.interface.on_close() + + else: # pragma: no cover + # In this branch the app is exiting and the window will close. This can't be + # triggered in test conditions, so it is as marked no-cover. + pass + #################################################################################### # Window properties #################################################################################### @@ -133,7 +205,8 @@ def set_title(self, title: str): #################################################################################### def close(self): - self.interface.factory.not_implemented("Window.close") + # The native event `Closing` is not called when the Close() method is called + # programmatically. self.native.Close() def set_app(self, app): @@ -144,6 +217,7 @@ def show(self): if self.interface.content is not None: self.interface.content.refresh() + self._visible = True self.native.AppWindow.Show() #################################################################################### @@ -261,10 +335,11 @@ def set_position(self, position: PositionT): def get_visible(self) -> bool: """Returns True if the window is visible and False otherwise.""" - return self.native.Visible + return self._visible def hide(self): """Hides but does not destroy the window.""" + self._visible = False self.native.Hide() #################################################################################### @@ -288,8 +363,8 @@ def get_window_state(self, in_progress_state=False) -> WindowState: """Gets the current state of the window. :param in_progress_state: Not supported on WinUI 3. - :return: A WindowState constant determined by NORMAL, MAXIMIZED, MINIMIZED or - PRESENTATION. FULLSCREEN is not supported. + :return: A WindowState constant determined by NORMAL, MAXIMIZED, MINIMIZED, + FULLSCREEN or PRESENTATION. """ presenter, _ = self._presenter @@ -298,7 +373,10 @@ def get_window_state(self, in_progress_state=False) -> WindowState: # Microsoft documentation: 'The window does not have a border or title bar, # and hides the system task bar.' # learn.microsoft.com/en-us/windows/apps/develop/ui/manage-app-windows - return WindowState.PRESENTATION + if self._in_presentation_mode: + return WindowState.PRESENTATION + else: + return WindowState.FULLSCREEN else: # Assume presenter.Kind == AppWindowPresenterKind.Overlapped, since the # third alternative 'CompactOverlay' is not implemented by Toga. @@ -312,28 +390,72 @@ def get_window_state(self, in_progress_state=False) -> WindowState: def set_window_state(self, state: WindowState): """Sets the state of the window. - :state: A WindowState constant determined by NORMAL, MAXIMIZED, MINIMIZED or - PRESENTATION. FULLSCREEN is not supported and will revert to MAXIMIZED. + :state: A WindowState constant determined by NORMAL, MAXIMIZED, MINIMIZED + FULLSCREEN or PRESENTATION. """ - current_state = self.get_window_state() + # If the app is in presentation mode, but this window isn't, then exit app + # presentation mode before setting the requested state — unless we're + # entering presentation mode ourselves (to allow multiple windows). + if state != WindowState.PRESENTATION and any( + window.state == WindowState.PRESENTATION + for window in self.interface.app.windows + if window != self.interface + ): + self.interface.app.exit_presentation_mode() + + print("set_window_state") + from_state = self.get_window_state() + print(f"from_state:{from_state}") + if from_state == state: + return + + from_overlapped = from_state not in { + WindowState.FULLSCREEN, + WindowState.PRESENTATION, + } + to_overlapped = state not in {WindowState.FULLSCREEN, WindowState.PRESENTATION} + + if from_overlapped and not to_overlapped: + # Change from overlapped presenter to fullscreen presenter. + self.native.AppWindow.SetPresenterByKind(AppWindowPresenterKind.FullScreen) + + elif not from_overlapped and to_overlapped: + # Change from fullscreen presenter to overlapped presenter. + self.native.AppWindow.SetPresenterByKind(AppWindowPresenterKind.Overlapped) + + if state == WindowState.PRESENTATION: + self._in_presentation_mode = True + if hasattr(self, "menu_native"): + self.menu_native.Visible = False + + if hasattr(self, "toolbar_native"): + self.toolbar_native.Visible = False - if state == current_state: return - elif current_state == WindowState.PRESENTATION: - self.native.AppWindow.SetPresenter(AppWindowPresenterKind.Overlapped) - match state: - case WindowState.PRESENTATION: - self.native.AppWindow.SetPresenter(AppWindowPresenterKind.FullScreen) + self._in_presentation_mode = False + if hasattr(self, "menu_native"): + self.menu_native.Visible = True + if hasattr(self, "toolbar_native"): + self.toolbar_native.Visible = True + + match state: case WindowState.NORMAL: - self.native.AppWindow.Presenter.Restore() + presenter, _ = self._presenter + presenter.Restore() case WindowState.MINIMIZED: - self.native.AppWindow.Presenter.Minimize() + presenter, _ = self._presenter + presenter.Minimize() + + case WindowState.MAXIMIZED: + presenter, _ = self._presenter + presenter.Maximize() case _: - self.native.AppWindow.Presenter.Maximize() + # WindowState.FULLSCREEN + pass #################################################################################### # Window capabilities From 8926d5057783d48be35b79daf2cc38f756a05a0a Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:03:13 +0200 Subject: [PATCH 011/110] Fix cursor and window show/hide --- winui3/src/toga_winui3/app.py | 36 ++++++++++++++++++++++++-------- winui3/src/toga_winui3/window.py | 2 +- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/winui3/src/toga_winui3/app.py b/winui3/src/toga_winui3/app.py index 882b1f50c3..53b822f571 100644 --- a/winui3/src/toga_winui3/app.py +++ b/winui3/src/toga_winui3/app.py @@ -1,4 +1,5 @@ from win32more import String +from win32more.Microsoft.UI.Input import InputSystemCursor, InputSystemCursorShape from win32more.Microsoft.UI.Windowing import DisplayArea from win32more.Microsoft.UI.Xaml import ApplicationTheme from win32more.Windows.Win32.Media.Audio import SND_ALIAS, SND_ASYNC, PlaySound @@ -22,11 +23,6 @@ def __init__(self, interface): # Track whether the app is exiting. self._is_exiting = False self._exiting_presentation = False - - # The Win32 function ShowCursor is used to show and hide the cursor. Here cursor - # visibility is represented by a display count. For example, if hide is called N - # times then to make the cursor re-appear, show must be called N times as well. - # Hence, a local boolean is stored to avoid a deep stack. self._cursor_visible = True self.loop = WinUI3ProactorEventLoop() @@ -74,8 +70,8 @@ def main_loop(self): self.loop.run_forever(self) def set_icon(self, icon): - # Icons are set in Window.set_app(). - pass + for window in self.interface.windows: + window._impl.set_app(self) def set_main_window(self, window): # Everything is already handled by the Toga core interface. @@ -124,16 +120,38 @@ def show_about_dialog(self): #################################################################################### # Cursor control + # + # To show/hide the cursor for the entire app, a combination of the Win32 function + # ShowCursor and the WinUI 3 property ProtectedCursor is used: + # - ShowCursor: Only works on the non-client area i.e. title bar, etc. + # - ProtectedCursor: Only works on UIElement descendants e.g. Panels. + # #################################################################################### def hide_cursor(self): - # learn.microsoft.com/windows/win32/api/winuser/nf-winuser-showcursor + if not self._cursor_visible: + return + + self._cursor_visible = False ShowCursor(False) + for window in self.interface.windows: + # The idea to hide the cursor by disposing of it comes from: + # https://github.com/microsoft/WindowsAppSDK/discussions/3601 + placeholder_cursor = InputSystemCursor.Create(InputSystemCursorShape.Arrow) + window._impl.native.Content.ProtectedCursor = placeholder_cursor + placeholder_cursor.Close() + def show_cursor(self): - # learn.microsoft.com/windows/win32/api/winuser/nf-winuser-showcursor + if self._cursor_visible: + return + + self._cursor_visible = True ShowCursor(True) + for window in self.interface.windows: + window._impl.native.Content.ProtectedCursor = None + #################################################################################### # Window control #################################################################################### diff --git a/winui3/src/toga_winui3/window.py b/winui3/src/toga_winui3/window.py index a1b7ae1478..ebce03b6e4 100644 --- a/winui3/src/toga_winui3/window.py +++ b/winui3/src/toga_winui3/window.py @@ -340,7 +340,7 @@ def get_visible(self) -> bool: def hide(self): """Hides but does not destroy the window.""" self._visible = False - self.native.Hide() + self.native.AppWindow.Hide() #################################################################################### # Window state. From adfcd66d1d42f165682ea1bfd882ad6d3fd4b85d Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:07:00 +0200 Subject: [PATCH 012/110] Fix Icon for use with Button --- winui3/src/toga_winui3/icons.py | 38 +++++++++++++++++------- winui3/src/toga_winui3/widgets/button.py | 10 ++----- 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/winui3/src/toga_winui3/icons.py b/winui3/src/toga_winui3/icons.py index f2cc4af9f6..99f3e37c21 100644 --- a/winui3/src/toga_winui3/icons.py +++ b/winui3/src/toga_winui3/icons.py @@ -1,14 +1,18 @@ from pathlib import Path from win32more.Microsoft.UI import IconId -from win32more.Microsoft.UI.Xaml.Controls import BitmapIcon +from win32more.Microsoft.UI.Xaml.Controls import ImageIcon +from win32more.Microsoft.UI.Xaml.Media.Imaging import BitmapImage from win32more.Windows.Foundation import Uri from win32more.Windows.Win32.UI.WindowsAndMessaging import HICON -# Use pywinrt is until Microsoft.Ui.Interop is included in a release of win32more: +######################################################################################## +# FIXME: Microsoft.Ui.Interop functionality will be included in a future win32more +# release. Update this code and the flagged code below when that happens. # https://github.com/ynkdir/py-win32more/issues/184 from winui3.microsoft.ui.interop import get_icon_id_from_icon +######################################################################################## from .libs.gdiplus import create_icon @@ -20,7 +24,7 @@ def __init__(self, interface, path): self.interface = interface self._handle: None | HICON = None self._id: None | IconId = None - self._bitmap_icon: None | BitmapIcon = None + self._bitmap_image: None | BitmapImage = None if path is None: self.path = Path(__file__).parent / "resources" / "toga.png" @@ -29,7 +33,7 @@ def __init__(self, interface, path): @property def uri(self) -> Uri: - return Uri(f"ms-appx:///{self.path.as_posix()}") + return Uri(f"file:///{self.path.as_posix()}") @property def handle(self) -> HICON: @@ -43,15 +47,29 @@ def handle(self) -> HICON: def id(self) -> IconId: """The IconId to the WinRT icon object created using the icon's path.""" if self._id is None: + ################################################################################ + # FIXME: See interop note above. icon_id = get_icon_id_from_icon(int(self.handle.value)) self._id = IconId(icon_id.value) + ################################################################################ return self._id @property - def bitmap_icon(self) -> BitmapIcon: - # FIXME: NOT WORKING - if self._bitmap_icon is None: - self._bitmap_icon = BitmapIcon() - self._bitmap_icon.UriSource = self.uri - return self._bitmap_icon + def bitmap_image(self) -> BitmapImage: + """The WinUI 3 BitmapImage used as a source for the icon.""" + if self._bitmap_image is None: + self._bitmap_image = BitmapImage() + self._bitmap_image.UriSource = self.uri + + return self._bitmap_image + + @property + def image_icon(self): + """The WinUI 3 icon implementation.""" + image_icon = ImageIcon() + image_icon.Height = 16 + image_icon.Width = 16 + image_icon.Source = self.bitmap_image + + return image_icon diff --git a/winui3/src/toga_winui3/widgets/button.py b/winui3/src/toga_winui3/widgets/button.py index 3fff3491d2..16ed687e82 100644 --- a/winui3/src/toga_winui3/widgets/button.py +++ b/winui3/src/toga_winui3/widgets/button.py @@ -1,9 +1,5 @@ from travertino.size import at_least -from win32more.Microsoft.UI.Xaml.Controls import ( - Button as NativeButton, - Symbol, - SymbolIcon, -) +from win32more.Microsoft.UI.Xaml.Controls import Button as NativeButton from .base import Widget @@ -50,9 +46,7 @@ def set_icon(self, icon): self._staged_properties.Content = self.icon def icon(self): - symbol_icon = SymbolIcon() - symbol_icon.Symbol = Symbol.Document - return symbol_icon + return self._icon._impl.image_icon #################################################################################### # Overrides of methods called by the Toga style applicator. From 4dc682566c9dcd89a4894a3783d479b7c762d53c Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Fri, 26 Jun 2026 09:23:17 +0200 Subject: [PATCH 013/110] Fixes to property staging --- .../toga_winui3/widgets/properties/staged.py | 38 ++++++++++--------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/winui3/src/toga_winui3/widgets/properties/staged.py b/winui3/src/toga_winui3/widgets/properties/staged.py index 9b5aa065d4..34c7c04f9d 100644 --- a/winui3/src/toga_winui3/widgets/properties/staged.py +++ b/winui3/src/toga_winui3/widgets/properties/staged.py @@ -34,28 +34,28 @@ def __init__(self, container): :param container: The Container where the StagingArea will be attached. """ - self._native = RelativePanel() - self._native.Visible = False + self.native = RelativePanel() + self.native.Opacity = 0 self._native_widgets = [] # Add the container self._container = container - self._container.native.Children.InsertAt(0, self._native) + self._container.widgets.add(self) def add(self, native_widget): self._native_widgets.append(native_widget) - self._native.Children.Append(native_widget) + self.native.Children.Append(native_widget) - def remove(self, widget): + def remove(self, native_widget): """Removes a widget and triggers a layout refresh when the widget list empties. The refresh mechanism here is to avoid excessive refresh calls. """ non_empty_initial = len(self._native_widgets) > 0 - index = self._native_widgets.index(widget) - self._native_widgets.remove(widget) - self._native.Children.RemoveAt(index) + index = self._native_widgets.index(native_widget) + self._native_widgets.remove(native_widget) + self.native.Children.RemoveAt(index) empty_final = len(self._native_widgets) == 0 if non_empty_initial and empty_final: @@ -66,7 +66,6 @@ def remove(self, widget): class StagedProperties: def __init__(self, widget): self._widget = widget - self._duplicate = None self._staged_properties = {} self._font_keys = {"FontFamily", "FontSize", "FontStyle", "FontWeight"} @@ -103,20 +102,23 @@ def refresh(self): return widget = self._widget - self._duplicate = type(widget.native)() - self._duplicate.SizeChanged += self.native_event_size_changed + duplicate = type(widget.native)() + + def size_changed(sender, args, duplicate=duplicate): + self.native_event_size_changed(sender, args, duplicate) + + duplicate.SizeChanged += size_changed for attribute, value_creator in self._staged_properties.items(): value = value_creator() if value is not None: - setattr(self._duplicate, attribute, value) + setattr(duplicate, attribute, value) - widget.container.staging_area.add(self._duplicate) + widget.container.staging_area.add(duplicate) - def native_event_size_changed(self, sender, args): - self._widget._min_width = self._duplicate.ActualSize.X - self._widget._min_height = self._duplicate.ActualSize.Y + def native_event_size_changed(self, sender, args, duplicate): + self._widget._min_width = duplicate.ActualSize.X + self._widget._min_height = duplicate.ActualSize.Y self._widget.rehint() - self._widget.container.staging_area.remove(self._duplicate) - self._duplicate = None + self._widget.container.staging_area.remove(duplicate) From 9b12c539f5f549bed747e9aaa5068556a76b396c Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:56:40 +0200 Subject: [PATCH 014/110] More fixes to property staging --- .../toga_winui3/widgets/properties/staged.py | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/winui3/src/toga_winui3/widgets/properties/staged.py b/winui3/src/toga_winui3/widgets/properties/staged.py index 34c7c04f9d..ca54649426 100644 --- a/winui3/src/toga_winui3/widgets/properties/staged.py +++ b/winui3/src/toga_winui3/widgets/properties/staged.py @@ -1,4 +1,5 @@ from win32more.Microsoft.UI.Xaml.Controls import RelativePanel +from win32more.Windows.UI.Text import FontStyle """ Overview of content staging @@ -67,6 +68,7 @@ class StagedProperties: def __init__(self, widget): self._widget = widget self._staged_properties = {} + self._latest = None self._font_keys = {"FontFamily", "FontSize", "FontStyle", "FontWeight"} @@ -103,6 +105,7 @@ def refresh(self): widget = self._widget duplicate = type(widget.native)() + self._latest = duplicate def size_changed(sender, args, duplicate=duplicate): self.native_event_size_changed(sender, args, duplicate) @@ -117,8 +120,20 @@ def size_changed(sender, args, duplicate=duplicate): widget.container.staging_area.add(duplicate) def native_event_size_changed(self, sender, args, duplicate): - self._widget._min_width = duplicate.ActualSize.X - self._widget._min_height = duplicate.ActualSize.Y - self._widget.rehint() + if duplicate == self._latest: + self._widget._min_width = self._adjusted_width(duplicate) + self._widget._min_height = duplicate.ActualSize.Y + self._widget.rehint() + + self._latest = None self._widget.container.staging_area.remove(duplicate) + + def _adjusted_width(self, duplicate): + # The staging method doesn't calculate a large enough width for italic and + # oblique font styles. Add 0.25em for each of these. + if duplicate.FontStyle in {FontStyle.Oblique, FontStyle.Italic}: + font_size = duplicate.FontSize + return duplicate.ActualSize.X + round(font_size * 96 / 72 / 4, 0) + + return duplicate.ActualSize.X From 0b348c8baa2941dc711beab077ffdb212a23f6d2 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:23:36 +0200 Subject: [PATCH 015/110] Fixes to font exception types --- winui3/src/toga_winui3/fonts.py | 10 ++++------ winui3/src/toga_winui3/libs/gdiplus.py | 27 ++++++++++++++++---------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/winui3/src/toga_winui3/fonts.py b/winui3/src/toga_winui3/fonts.py index a26cefb72b..808c517abc 100644 --- a/winui3/src/toga_winui3/fonts.py +++ b/winui3/src/toga_winui3/fonts.py @@ -108,11 +108,9 @@ def load_user_registered_font(self): msg = f"{self.interface} not a user-registered font" raise UnknownFontError(msg) from exc - self.interface.factory.not_implemented("Font.load_user_registered_font()") - print(f"Font with path {font_path} not loaded.") - ################################################################################ - # TODO: Need to understand how to load external resources. - ################################################################################ + raise ValueError( + f"Couldn't load {font_path}. User registered fonts are not implemented yet." + ) def load_arbitrary_system_font(self): """Use a font available on the system.""" @@ -120,7 +118,7 @@ def load_arbitrary_system_font(self): # WinUI 3 does not throw an exception if the font is not installed, so use GDI+. if not font_installed: - raise ValueError( + raise UnknownFontError( f"{self.interface} not installed on system. Check that the font family " + "name exactly matches the name in the system's font settings." ) diff --git a/winui3/src/toga_winui3/libs/gdiplus.py b/winui3/src/toga_winui3/libs/gdiplus.py index 97d861100c..a0cfe3bf00 100644 --- a/winui3/src/toga_winui3/libs/gdiplus.py +++ b/winui3/src/toga_winui3/libs/gdiplus.py @@ -57,7 +57,8 @@ def wrapper(*args, **kwargs): if status_code != 0 and status_code is not None: global status_dict error = str(status_dict[status_code]) - message = f"The GDI+ function {function.__name__} exit with status {error}." + function_name = str(function._prototype.__name__) + message = f"The GDI+ function {function_name} exit with status {error}." raise WinError(descr=message) @@ -134,15 +135,21 @@ def is_font_installed(font_family_name: str): font_family_ptr = FontFamilyPtr() font_available = BOOL() - # Attempt to create the font family. - GdipCreateFontFamilyFromName( - String(font_family_name), - None, - byref(font_family_ptr), - ) - - # Check if the font family has been created. - GdipIsStyleAvailable(font_family_ptr, ALL_FONT_STYLES, byref(font_available)) + try: + # Attempt to create the font family. + GdipCreateFontFamilyFromName( + String(font_family_name), + None, + byref(font_family_ptr), + ) + + # Check if the font family has been created. + GdipIsStyleAvailable( + font_family_ptr, ALL_FONT_STYLES, byref(font_available) + ) + + except OSError: + return False return font_available.value == 1 From d84665c36403ab4f7bdf1b5c8ad273fc030b9e09 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:34:10 +0200 Subject: [PATCH 016/110] Fix Icon error handling and add new file formats --- winui3/src/toga_winui3/icons.py | 26 ++++++++++++++++--- winui3/src/toga_winui3/libs/misc.py | 15 +++++++++++ .../toga_winui3/widgets/properties/staged.py | 4 +-- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/winui3/src/toga_winui3/icons.py b/winui3/src/toga_winui3/icons.py index 99f3e37c21..1038235be8 100644 --- a/winui3/src/toga_winui3/icons.py +++ b/winui3/src/toga_winui3/icons.py @@ -14,10 +14,11 @@ ######################################################################################## from .libs.gdiplus import create_icon +from .libs.misc import load_icon class Icon: - EXTENSIONS = [".png", ".bmp"] + EXTENSIONS = [".png", ".ico", ".bmp", ".jpg", ".jpeg", ".gif", ".tif", ".tiff"] SIZES = None def __init__(self, interface, path): @@ -27,7 +28,9 @@ def __init__(self, interface, path): self._bitmap_image: None | BitmapImage = None if path is None: - self.path = Path(__file__).parent / "resources" / "toga.png" + raise ValueError( + f"Unable to use path={path}. The app bundle icon is not implemented." + ) else: self.path = Path(path) @@ -35,11 +38,24 @@ def __init__(self, interface, path): def uri(self) -> Uri: return Uri(f"file:///{self.path.as_posix()}") + def _use_default_icon(self, property): + print( + f"WARNING: Unable to load icon {self.path}; falling back to default icon." + ) + self.path = Path(__file__).parent / "resources" / "toga.png" + return getattr(self, property) + @property def handle(self) -> HICON: """The handle to the Win32 icon object created using the icon's path.""" if self._handle is None: - self._handle = create_icon(str(self.path)) + try: + if self.path.suffix != ".ico": + self._handle = create_icon(str(self.path)) + else: + self._handle = load_icon(str(self.path)) + except OSError: + return self._use_default_icon("handle") return self._handle @@ -60,10 +76,14 @@ def bitmap_image(self) -> BitmapImage: """The WinUI 3 BitmapImage used as a source for the icon.""" if self._bitmap_image is None: self._bitmap_image = BitmapImage() + self._bitmap_image.ImageFailed += self.native_event_image_failed self._bitmap_image.UriSource = self.uri return self._bitmap_image + def native_event_image_failed(self, sender, args): + self._bitmap_image.UriSource = self._use_default_icon("uri") + @property def image_icon(self): """The WinUI 3 icon implementation.""" diff --git a/winui3/src/toga_winui3/libs/misc.py b/winui3/src/toga_winui3/libs/misc.py index 8489078c3f..968f93dd20 100644 --- a/winui3/src/toga_winui3/libs/misc.py +++ b/winui3/src/toga_winui3/libs/misc.py @@ -5,6 +5,13 @@ ColumnDefinition, RowDefinition, ) +from win32more.Windows.Win32.Foundation import PWSTR +from win32more.Windows.Win32.UI.WindowsAndMessaging import ( + HICON, + IMAGE_ICON, + LR_LOADFROMFILE, + LoadImageW, +) def grid_length_auto(): @@ -63,3 +70,11 @@ def get_x_lparam(lparam: int) -> int: # https://learn.microsoft.com/en-us/windows/win32/api/windowsx/nf-windowsx-get_y_lparam def get_y_lparam(lparam: int) -> int: return SHORT(hiword(lparam)).value + + +def load_icon(path: str) -> HICON: + """Creates an icon resource from an .ico file.""" + hwnd = LoadImageW(None, PWSTR(path), IMAGE_ICON, 0, 0, LR_LOADFROMFILE) + if hwnd is None: + raise OSError(f"LoadImageW failed to load {path}.") + return HICON(hwnd) diff --git a/winui3/src/toga_winui3/widgets/properties/staged.py b/winui3/src/toga_winui3/widgets/properties/staged.py index ca54649426..dbfee02ad5 100644 --- a/winui3/src/toga_winui3/widgets/properties/staged.py +++ b/winui3/src/toga_winui3/widgets/properties/staged.py @@ -130,8 +130,8 @@ def native_event_size_changed(self, sender, args, duplicate): self._widget.container.staging_area.remove(duplicate) def _adjusted_width(self, duplicate): - # The staging method doesn't calculate a large enough width for italic and - # oblique font styles. Add 0.25em for each of these. + # FIXME: The staging method doesn't calculate a large enough width for italic + # and oblique font styles. Add 0.25em for each of these. if duplicate.FontStyle in {FontStyle.Oblique, FontStyle.Italic}: font_size = duplicate.FontSize return duplicate.ActualSize.X + round(font_size * 96 / 72 / 4, 0) From 8524c160b88a21c128e824d0880ae23a29be7762 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:43:14 +0200 Subject: [PATCH 017/110] Improvements to status icons --- winui3/src/toga_winui3/statusicons.py | 66 +++++++++++++-------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/winui3/src/toga_winui3/statusicons.py b/winui3/src/toga_winui3/statusicons.py index 9b07c38b89..866f371f69 100644 --- a/winui3/src/toga_winui3/statusicons.py +++ b/winui3/src/toga_winui3/statusicons.py @@ -10,15 +10,15 @@ ) from win32more.Windows.Foundation import Point from win32more.Windows.Graphics import PointInt32, SizeInt32 -from win32more.Windows.Win32.Foundation import POINT, RECT +from win32more.Windows.Win32.Foundation import POINT from win32more.Windows.Win32.Graphics.Gdi import ScreenToClient from win32more.Windows.Win32.UI.WindowsAndMessaging import ( IDC_ARROW, + WM_APP, + WM_NCDESTROY, LoadCursorW, SetCursor, SetForegroundWindow, - WM_NCDESTROY, - WM_APP, ) ######################################################################################## @@ -27,20 +27,19 @@ # https://github.com/ynkdir/py-win32more/issues/184 from winui3.microsoft.ui import WindowId from winui3.microsoft.ui.interop import get_window_from_window_id -######################################################################################## +######################################################################################## from toga import App, Icon from toga.command import Group, Separator from .libs import win32constants as wc, win32structures as ws from .libs.comctl32 import ( - DefSubclassProc, - RemoveWindowSubclass, - SetWindowSubclass, + DefSubclassProc, + RemoveWindowSubclass, + SetWindowSubclass, ) +from .libs.misc import get_x_lparam, get_y_lparam, loword from .libs.shell import Shell_NotifyIconW -from .libs.misc import loword, get_x_lparam, get_y_lparam - class StatusIcon: @@ -54,24 +53,24 @@ def set_icon(self, icon: Icon): Shell_NotifyIconW(wc.NIM_MODIFY, byref(notify_icon_data)) def create(self): + # Create a WinUI 3 Window instance to receive the messages. self.native_window = App.app._impl.native_instance.CreateWindow() - + # Hide the Window. self.native_window.AppWindow.Resize(SizeInt32(1, 1)) self.native_window.AppWindow.Move(PointInt32(wc.SHRT_MAX - 1, wc.SHRT_MAX - 1)) self.native_window.AppWindow.IsShownInSwitchers = False presenter = self.native_window.AppWindow.Presenter overlapped_presenter = OverlappedPresenter(value=presenter.value) - overlapped_presenter.SetBorderAndTitleBar(False, False) overlapped_presenter.IsAlwaysOnTop = True - - # Subclass the native_window to recieve the WM_COMMAND messages. + # Subclass the native_window to receive the WM_COMMAND messages. self._pfn_subclass = ws.SUBCLASSPROC(self._subclass_proc) SetWindowSubclass(self._hwnd, self._pfn_subclass, 0, 0) + # Set the icon. icon_handle = self._icon_handle(self.interface.icon) notify_icon_data = self._notify_icon_data(icon_handle) Shell_NotifyIconW(wc.NIM_ADD, byref(notify_icon_data)) @@ -82,7 +81,7 @@ def create(self): def _icon_handle(self, icon: Icon): return icon._impl.handle if icon else App.app.icon._impl.handle - + def _notify_icon_data(self, icon_handle): """Creates a NOTIFYICONDATAW instance for a given icon.""" notify_icon_data = ws.NOTIFYICONDATAW() @@ -97,15 +96,17 @@ def _notify_icon_data(self, icon_handle): def remove(self): notify_icon_data = self._notify_icon_data(None) Shell_NotifyIconW(wc.NIM_DELETE, byref(notify_icon_data)) + self.native_window.Close() + self.native_window = None @property def _hwnd(self): ################################################################################ - # FIXME: See interop note above. + # FIXME: See interop note above. window_id = WindowId(self.native_window.AppWindow.Id.Value) return get_window_from_window_id(window_id) ################################################################################ - + def _subclass_proc( self, hWnd: int, @@ -127,24 +128,21 @@ def _subclass_proc( # Call the original window procedure return DefSubclassProc( - wt.HWND(hWnd), - wt.UINT(uMsg), - wt.WPARAM(wParam), + wt.HWND(hWnd), + wt.UINT(uMsg), + wt.WPARAM(wParam), wt.LPARAM(lParam), ) - - def native_event_click(self, x, y): - ... + def native_event_click(self, x, y): ... -class SimpleStatusIcon(StatusIcon): +class SimpleStatusIcon(StatusIcon): def native_event_click(self, x, y): - print(f"x:{x}, y:{y}") + self.interface.on_press() class MenuStatusIcon(StatusIcon): - def __init__(self, interface): super().__init__(interface) self._native_menu = None @@ -158,14 +156,14 @@ def create(self): @property def native_menu(self): return self._native_menu - + @native_menu.setter def native_menu(self, native_menu_instance: MenuFlyout): assert isinstance(native_menu_instance, MenuFlyout) - + native_menu_instance.add_Closing(self.native_event_Closing) self._native_menu = native_menu_instance - + def native_event_Closing(self, sender, args): self.native_window.AppWindow.Hide() @@ -177,12 +175,14 @@ def _content_hwnd(self): def native_event_click(self, x, y): coords = POINT(x, y) ScreenToClient(self._hwnd, byref(coords)) - relative_coords = Point(coords.x/2, coords.y/2) + relative_coords = Point(coords.x / 2, coords.y / 2) + # Show the menu. The parent window must be visible for the menu to be visible. self.native_window.AppWindow.Show() SetForegroundWindow(self._hwnd) self.native_menu.ShowAt(self.native_content, relative_coords) + # Reload the standard cursor to prevent the busy cursor showing. h_cursor = LoadCursorW(None, IDC_ARROW) SetCursor(h_cursor) @@ -191,7 +191,7 @@ class StatusIconSet: def __init__(self, interface): """The WinUI 3 implementation of an ordered collection of status icons.""" self.interface = interface - + def _submenu(self, group, group_cache): try: return group_cache[group] @@ -210,10 +210,10 @@ def _submenu(self, group, group_cache): return submenu def create(self): - """Create - + """Create + This is called directly in App._startup() and also when the status icon command - set is changed. + set is changed. """ # Menu status icons are the only icons that have extra construction needs. From 77ebc23eb9ef90796ee88ab09abd2d7d1360d0ca Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:31:42 +0200 Subject: [PATCH 018/110] Add reactive proactor --- winui3/src/toga_winui3/libs/proactor.py | 334 +++++++++++++++++------- 1 file changed, 241 insertions(+), 93 deletions(-) diff --git a/winui3/src/toga_winui3/libs/proactor.py b/winui3/src/toga_winui3/libs/proactor.py index d65c663ca8..a165f68c5f 100644 --- a/winui3/src/toga_winui3/libs/proactor.py +++ b/winui3/src/toga_winui3/libs/proactor.py @@ -1,57 +1,186 @@ +import _overlapped +import _winapi import asyncio import sys import threading import traceback from asyncio import events -from functools import partial +from collections import deque +from ctypes import byref -from win32more.Microsoft.UI.Dispatching import DispatcherQueue, DispatcherQueueTimer +from win32more import UInt64 +from win32more.Microsoft.UI.Dispatching import DispatcherQueue from win32more.Windows.Foundation import TimeSpan +from win32more.Windows.Win32.System.WindowsProgramming import QueryInterruptTimePrecise -# TODO: This is largely copied from WinformsProactorEventLoop which has the same 5ms -# polling delay. Remove this delay in a future version. -# FIXME: Loop doesn't shut down correctly. +class ReadyDeque(deque): + """A deque that enqueues a WinForms event tick when a value is appended.""" + def __init__(self, loop): + self._loop = loop + super().__init__(loop._ready) -def native_app_launched(loop, winui3_app, args): - """A function to be used as an override of the OnLauched method of WinUI3App.""" - asyncio.set_event_loop(loop) - loop.dispatcher = DispatcherQueue.GetForCurrentThread() - loop.queue_timer = loop.dispatcher.CreateTimer() - loop.queue_timer.IsRepeating = False - loop.queue_timer.Tick += loop.tick + def append(self, value): + super().append(value) - loop.app.native_instance = winui3_app + if self._loop._idle: + self._loop.enqueue_tick(delay=0) - loop.enqueue_tick() +class TwoThreadIocpProactor(asyncio.IocpProactor): + """A version of the IocpProactor class where the IOCP will run on its own thread.""" -# Can't get coverage for app shutdown, so this handler must be no-cover. -def native_app_exited(loop, winui3_app): # pragma: no cover - """Perform cleanup that needs to occur when the app exits. + #################################################################################### + # Overrides of asyncio.IocpProactor methods + #################################################################################### - This largely duplicates the "finally" behavior of the default Proactor - run_forever implementation. - """ - if sys.version_info < (3, 13): - # If we're stopping, we can do the "finally" handling from - # the BaseEventLoop run_forever(). In Python 3.13.0a2, this - # was refactored into the `_run_forever_cleanup()` helper. - # We run testbed on Py3.12, so the else branch is marked - # nocover. - # === START BaseEventLoop.run_forever() finally handling === - loop._stopping = False - loop._thread_id = None - events._set_running_loop(None) - loop._set_coroutine_origin_tracking(False) - sys.set_asyncgen_hooks(*loop._old_agen_hooks) - # === END BaseEventLoop.run_forever() finally handling === - else: # pragma: no cover - loop._run_forever_cleanup() + def __init__(self): + super().__init__() + self._listener_lock = threading.Lock() + + def select(self, timeout=None): + """A minimal select method so that _run_once doesn't poll the IOCP.""" + # Clear the results of the processed IOCP messages. + self._results = [] + return [] + + # This method is part of the app shutdown procedure, which can't have test coverage. + # So this method is marked as no cover. + def close(self): # pragma: no cover + if self._iocp is None: + # Already closed. + return + + # The loop needs the app to run and visa versa. So ensure that the app is exited + # if `close()` is called. + self._loop.app._is_exiting = True + + # Wait until the IOCP listener has stopped before closing the loop. + with self._listener_lock: + self._remove_unregistered_futures() + + super().close() + + #################################################################################### + # Methods that run in the IOCP listener thread. + #################################################################################### + + def _iocp_listener(self): + """Listens for IOCP events and adds them to the queue.""" + app = self._loop.app + task_enqueuer = self._loop.task_enqueuer + GetQueuedCompletionStatus = _overlapped.GetQueuedCompletionStatus + + def exit_native(): + app.native.Exit(app.native_instance) + + # The listener lock forces the close method to wait until the listener + # loop is closed. + with self._listener_lock: + while not app._is_exiting: + # Use a timeout (100 milliseconds) only for exiting the thread. + status = GetQueuedCompletionStatus(self._iocp, 100) + + if status is None: + + def iocp_action(): + return self._remove_unregistered_futures() + + else: + + def iocp_action(status=status): + return self._iocp_action(status) + + # Queue/run the actions to run synchronously on the main thread. + task_enqueuer(iocp_action) + + ######################################################################## + # From here onward is part of the app shutdown procedure, which can't + # have test coverage. So use no cover. + ######################################################################## + + # Exit the application. Call here to avoid dispatcher calls after + # app.native is exited. + + task_enqueuer(exit_native) # pragma: no cover + + #################################################################################### + # Methods that run in the main application thread. + #################################################################################### + + def start_iocp_listener(self): + self._iocp_thread = threading.Thread( + target=self._iocp_listener, + ) + self._iocp_thread.start() + + def _iocp_action(self, status): + # The following codeblock is the part of asyncio.IocpProactor._poll(timeout) + # that processes the received IOCP messages. + # + # Use no cover for the KeyError and OSError codeblocks since these should not be + # accessed under normal operations. + # + # Use no cover obj in self._stopped_serving since this list is only populated + # by the self._stop_serving method, which is only called in the loop.close + # method. The loop.close method is part of the shutdown procedure, so no cover. + # + # Use no branch for f.done() since it is not consistently hit during normal + # operations. + # + # fmt: off + # ruff: disable[UP031] + # =================================== BEGIN =================================== + err, transferred, key, address = status + try: + f, ov, obj, callback = self._cache.pop(address) + except KeyError: # pragma: no cover + if self._loop.get_debug(): + self._loop.call_exception_handler({ + 'message': ('GetQueuedCompletionStatus() returned an ' + 'unexpected event'), + 'status': ('err=%s transferred=%s key=%#x address=%#x' + % (err, transferred, key, address)), + }) + + # key is either zero, or it is used to return a pipe + # handle which should be closed to avoid a leak. + if key not in (0, _overlapped.INVALID_HANDLE_VALUE): + _winapi.CloseHandle(key) + return + + if obj in self._stopped_serving: # pragma: no cover + f.cancel() + # Don't call the callback if _register() already read the result or + # if the overlapped has been cancelled + elif not f.done(): # pragma: no branch + try: + value = callback(transferred, key, ov) + except OSError as e: # pragma: no cover + f.set_exception(e) + self._results.append(f) + else: + f.set_result(value) + self._results.append(f) + finally: + f = None + # ==================================== END ==================================== + # ruff: enable[UP031] + # fmt: on + + def _remove_unregistered_futures(self): + # Remove unregistered futures + for ov in self._unregistered: + self._cache.pop(ov.address, None) + self._unregistered.clear() class WinUI3ProactorEventLoop(asyncio.ProactorEventLoop): + def __init__(self): + super().__init__(proactor=TwoThreadIocpProactor()) + self._idle = True + def run_forever(self, app): """Set up the asyncio event loop, integrate it with the native event loop, and start the application. @@ -62,14 +191,6 @@ def run_forever(self, app): :param app_context: The WinForms.ApplicationContext instance controlling the lifecycle of the app. """ - # Python 3.8 added an implementation of run_forever() in - # ProactorEventLoop. The only part that actually matters is the - # refactoring that moved the initial call to stage _loop_self_reading; - # it now needs to be created as part of run_forever; otherwise the - # event loop locks up, because there won't be anything for the - # select call to process. - self.call_soon(self._loop_self_reading) - # Remember the application. self.app = app @@ -102,56 +223,89 @@ def run_forever(self, app): else: # pragma: no cover self._orig_state = self._run_forever_setup() - # Rather than going into a `while True:` loop, we're going to use the - # native event loop to queue a tick() message that will cause a - # single iteration of the asyncio event loop to be executed. Each time - # we do this, we queue *another* tick() message in 5ms time. In this - # way, we'll get a continuous stream of tick() calls, without blocking - # the native event loop. We also add a handler for ApplicationExit - # to ensure that loop cleanup occurs when the app exits. - - self.dispatcher: DispatcherQueue - self.queue_timer: DispatcherQueueTimer - self._inner_loop = None + # Change the ready deque to an instance of ReadyDeque. + self._ready = ReadyDeque(self) def on_lauched(winui3_app, args): - return native_app_launched(self, winui3_app, args) - - def on_exited(winui3_app): - return native_app_exited(self, winui3_app) - + return self.native_app_launched(winui3_app, args) + app.native.OnLaunched = on_lauched - app.native.OnExited = on_exited # Start the native event loop. app.native.Start() + def time(self): + """A timer that is accurate to 100 nanoseconds. + + The standard asyncio time method uses the CPython time.monotonic function + which obtains the time from GetProcessTimes. This has a resolution of 15.6 ms, + which comes from the default Windows system timer. + """ + precise_time = UInt64() + QueryInterruptTimePrecise(byref(precise_time)) + return precise_time.value / 10000000 + + # Can't get coverage for app shutdown, so this handler must be no-cover. + def app_exiting(loop, winui3_app): # pragma: no cover + """Perform cleanup that needs to occur when the app exits. + + This largely duplicates the "finally" behavior of the default Proactor + run_forever implementation. + """ + if sys.version_info < (3, 13): + # If we're stopping, we can do the "finally" handling from + # the BaseEventLoop run_forever(). In Python 3.13.0a2, this + # was refactored into the `_run_forever_cleanup()` helper. + # We run testbed on Py3.12, so the else branch is marked + # nocover. + # === START BaseEventLoop.run_forever() finally handling === + loop._stopping = False + loop._thread_id = None + events._set_running_loop(None) + loop._set_coroutine_origin_tracking(False) + sys.set_asyncgen_hooks(*loop._old_agen_hooks) + # === END BaseEventLoop.run_forever() finally handling === + else: # pragma: no cover + loop._run_forever_cleanup() + + def native_app_launched(self, winui3_app, args): + """A function to be used as an override of the OnLauched method of WinUI3App.""" + dispatcher = DispatcherQueue.GetForCurrentThread() + self.task_enqueuer = dispatcher.TryEnqueue + + self.tick_scheduler = dispatcher.CreateTimer() + self.tick_scheduler.IsRepeating = False + self.tick_scheduler.Tick += self.tick + + # Start the IOCP listener thread. + self._proactor.start_iocp_listener() + + self.app.native_instance = winui3_app + asyncio.set_event_loop(self) + self.enqueue_tick() + def enqueue_tick(self, delay=5): # Queue a call to tick in a specified delay. - # TimeSpan is given in 100-nanosecond units i.e. 1E-7. - self.queue_timer.Interval = TimeSpan(delay * 10000) - self.queue_timer.Start() + # delay is given in 100-nanosecond units i.e. 1E-7 seconds. + self.tick_scheduler.Interval = TimeSpan(delay) + self.tick_scheduler.Start() - # This function doesn't report as covered because it runs on a - # non-Python-created thread (see App.run_app). But it must actually be - # covered, otherwise nothing would work. def tick(self, *args, **kwargs): # pragma: no cover """Cause a single iteration of the event loop to run on the main GUI thread.""" # FIXME: For some reason the queue timer doesn't work properly when the # following line is removed. - self.queue_timer.IsRunning # noqa: B018 + self.tick_scheduler.IsRunning # noqa: B018 self.run_once_recurring() - # Call native thread blocking methods via this method to ensure the inner loop is - # correctly linked with this Python loop. - def start_inner_loop(self, callback, *args): - assert self._inner_loop is None - self._inner_loop = (callback, args) - def run_once_recurring(self): """Run one iteration of the event loop, and enqueue the next iteration (if we're not stopping). """ + # run_once_recurring is called asynchronously by the native WinForms loop. The + # tasks that triggered the call may have already been processed. + if len(self._ready) < 1 and len(self._scheduled) < 1: + return + try: # If the app is exiting, stop the asyncio event loop. # Otherwise, perform one more tick of the event loop. @@ -160,29 +314,23 @@ def run_once_recurring(self): if self.app._is_exiting: self.stop() # pragma: no cover else: + self._idle = False self._run_once() + self._idle = True - # Enqueue the next tick, and make sure there will be *something* - # to be processed. If you don't ensure there is at least one - # message on the queue, the select() call will block, locking - # the app. Determine the delay of the tick by checking if - # there are events that can be processed sooner than 5ms, as - # we do not want to hold them back from being processed. - if self._ready: - delay = 0 - elif self._scheduled: - first = self._scheduled[0] - ms_until = int(max(0, (first.when() - self.time()) * 1000)) - delay = min(5, ms_until) + # Enqueue the next tick. Determine the delay of the tick by checking if + # there are events in the ready list, otherwise then calculating a delay + # for scheduled events. If neither of these then the loop becomes idle + # until it is woken by the ReadyDeque instance or the safety catch. + if len(self._ready) > 0: + # Run ready events immediately. + self.enqueue_tick(delay=0) else: - delay = 5 - self.enqueue_tick(delay=delay) - self.call_soon(self._loop_self_reading) - - if self._inner_loop: - callback, args = self._inner_loop - self._inner_loop = None - callback(*args) + if self._scheduled: + # Calculate a delay for scheduled events and enqueue a tick. + first = self._scheduled[0] + delay = int(max(0, (first.when() - self.time()) * 10000000)) + self.enqueue_tick(delay=delay) # Exceptions thrown by this method will be silently ignored. except BaseException: # pragma: no cover From 41ec3e0cd96015ca1b20d6083b319c5f577de16d Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:53:03 +0200 Subject: [PATCH 019/110] Update interop to win32more --- winui3/src/toga_winui3/app.py | 2 -- winui3/src/toga_winui3/icons.py | 14 ++------------ winui3/src/toga_winui3/screens.py | 16 ++-------------- winui3/src/toga_winui3/statusicons.py | 20 ++------------------ winui3/src/toga_winui3/window.py | 15 ++------------- 5 files changed, 8 insertions(+), 59 deletions(-) diff --git a/winui3/src/toga_winui3/app.py b/winui3/src/toga_winui3/app.py index 53b822f571..4e9e8fea78 100644 --- a/winui3/src/toga_winui3/app.py +++ b/winui3/src/toga_winui3/app.py @@ -61,9 +61,7 @@ def create_menus(self): #################################################################################### def exit(self): # pragma: no cover - # FIXME: App doesn't shutdown correctly with self._is_exiting = True self._is_exiting = True - self.native.Exit(self.native_instance) def main_loop(self): self.create() diff --git a/winui3/src/toga_winui3/icons.py b/winui3/src/toga_winui3/icons.py index 1038235be8..27aaf73ba3 100644 --- a/winui3/src/toga_winui3/icons.py +++ b/winui3/src/toga_winui3/icons.py @@ -1,18 +1,12 @@ from pathlib import Path from win32more.Microsoft.UI import IconId +from win32more.Microsoft.UI.Interop import GetIconIdFromIcon from win32more.Microsoft.UI.Xaml.Controls import ImageIcon from win32more.Microsoft.UI.Xaml.Media.Imaging import BitmapImage from win32more.Windows.Foundation import Uri from win32more.Windows.Win32.UI.WindowsAndMessaging import HICON -######################################################################################## -# FIXME: Microsoft.Ui.Interop functionality will be included in a future win32more -# release. Update this code and the flagged code below when that happens. -# https://github.com/ynkdir/py-win32more/issues/184 -from winui3.microsoft.ui.interop import get_icon_id_from_icon - -######################################################################################## from .libs.gdiplus import create_icon from .libs.misc import load_icon @@ -63,11 +57,7 @@ def handle(self) -> HICON: def id(self) -> IconId: """The IconId to the WinRT icon object created using the icon's path.""" if self._id is None: - ################################################################################ - # FIXME: See interop note above. - icon_id = get_icon_id_from_icon(int(self.handle.value)) - self._id = IconId(icon_id.value) - ################################################################################ + self._id = GetIconIdFromIcon(self.handle) return self._id diff --git a/winui3/src/toga_winui3/screens.py b/winui3/src/toga_winui3/screens.py index 21fc5990da..184e3c7fcb 100644 --- a/winui3/src/toga_winui3/screens.py +++ b/winui3/src/toga_winui3/screens.py @@ -2,18 +2,11 @@ from decimal import ROUND_HALF_EVEN, Decimal from travertino.size import at_least +from win32more.Microsoft.UI.Interop import GetMonitorFromDisplayId from win32more.Windows.Win32.Graphics.Gdi import HMONITOR from win32more.Windows.Win32.UI.Shell import GetScaleFactorForMonitor from win32more.Windows.Win32.UI.Shell.Common import DEVICE_SCALE_FACTOR -######################################################################################## -# FIXME: Microsoft.Ui.Interop functionality will be included in a future win32more -# release. Update this code when that happens. -# https://github.com/ynkdir/py-win32more/issues/184 -from winui3.microsoft.ui import DisplayId -from winui3.microsoft.ui.interop import get_monitor_from_display_id - -######################################################################################## from toga import App from toga.screens import Screen as ScreenInterface from toga.types import Position, Size @@ -44,12 +37,7 @@ def __eq__(self, other) -> bool: @property def handle(self) -> HMONITOR: - ################################################################################ - # FIXME: See interop note above. - return HMONITOR( - get_monitor_from_display_id(DisplayId(self.native.DisplayId.Value)) - ) - ################################################################################ + return GetMonitorFromDisplayId(self.native.DisplayId) def get_name(self) -> str: device_id = str(self.native.DisplayId.Value) diff --git a/winui3/src/toga_winui3/statusicons.py b/winui3/src/toga_winui3/statusicons.py index 866f371f69..6e9c87b784 100644 --- a/winui3/src/toga_winui3/statusicons.py +++ b/winui3/src/toga_winui3/statusicons.py @@ -1,5 +1,6 @@ from ctypes import byref, sizeof, wintypes as wt +from win32more.Microsoft.UI.Interop import GetWindowFromWindowId from win32more.Microsoft.UI.Windowing import OverlappedPresenter from win32more.Microsoft.UI.Xaml.Controls import ( MenuFlyout, @@ -21,14 +22,6 @@ SetForegroundWindow, ) -######################################################################################## -# FIXME: Microsoft.Ui.Interop functionality will be included in a future win32more -# release. Update this code and the flagged code below when that happens. -# https://github.com/ynkdir/py-win32more/issues/184 -from winui3.microsoft.ui import WindowId -from winui3.microsoft.ui.interop import get_window_from_window_id - -######################################################################################## from toga import App, Icon from toga.command import Group, Separator @@ -101,11 +94,7 @@ def remove(self): @property def _hwnd(self): - ################################################################################ - # FIXME: See interop note above. - window_id = WindowId(self.native_window.AppWindow.Id.Value) - return get_window_from_window_id(window_id) - ################################################################################ + return GetWindowFromWindowId(self.native_window.AppWindow.Id) def _subclass_proc( self, @@ -167,11 +156,6 @@ def native_menu(self, native_menu_instance: MenuFlyout): def native_event_Closing(self, sender, args): self.native_window.AppWindow.Hide() - @property - def _content_hwnd(self): - window_id = WindowId(self.native_window.AppWindow.Id.Value) - return get_window_from_window_id(window_id) - def native_event_click(self, x, y): coords = POINT(x, y) ScreenToClient(self._hwnd, byref(coords)) diff --git a/winui3/src/toga_winui3/window.py b/winui3/src/toga_winui3/window.py index ebce03b6e4..093c653320 100644 --- a/winui3/src/toga_winui3/window.py +++ b/winui3/src/toga_winui3/window.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING +from win32more.Microsoft.UI.Interop import GetWindowFromWindowId from win32more.Microsoft.UI.Windowing import ( AppWindowPresenterKind, DisplayArea, @@ -37,14 +38,6 @@ GetSystemMenu, ) -######################################################################################## -# FIXME: Microsoft.Ui.Interop functionality will be included in a future win32more -# release. Update this code and the flagged code below when that happens. -# https://github.com/ynkdir/py-win32more/issues/184 -from winui3.microsoft.ui import WindowId -from winui3.microsoft.ui.interop import get_window_from_window_id - -######################################################################################## from toga import App from toga.command import Separator from toga.constants import WindowState @@ -104,11 +97,7 @@ def create_content(self): @property def _hwnd(self): - ################################################################################ - # FIXME: See interop note above. - window_id = WindowId(self.native.AppWindow.Id.Value) - return get_window_from_window_id(window_id) - ################################################################################ + return GetWindowFromWindowId(self.native.AppWindow.Id) def _set_restrictions(self): """Sets the window properties of being minimizable and resizable.""" From 352c5f3df139a153a03e02d1c1f0b9e67ce49ee6 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Fri, 3 Jul 2026 19:29:58 +0200 Subject: [PATCH 020/110] Fix Button icon size and background color --- winui3/src/toga_winui3/icons.py | 7 +++---- winui3/src/toga_winui3/widgets/button.py | 12 +++++++++++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/winui3/src/toga_winui3/icons.py b/winui3/src/toga_winui3/icons.py index 27aaf73ba3..34d3f7d247 100644 --- a/winui3/src/toga_winui3/icons.py +++ b/winui3/src/toga_winui3/icons.py @@ -74,12 +74,11 @@ def bitmap_image(self) -> BitmapImage: def native_event_image_failed(self, sender, args): self._bitmap_image.UriSource = self._use_default_icon("uri") - @property - def image_icon(self): + def image_icon(self, size=16): """The WinUI 3 icon implementation.""" image_icon = ImageIcon() - image_icon.Height = 16 - image_icon.Width = 16 + image_icon.Height = size + image_icon.Width = size image_icon.Source = self.bitmap_image return image_icon diff --git a/winui3/src/toga_winui3/widgets/button.py b/winui3/src/toga_winui3/widgets/button.py index 16ed687e82..1d599100cc 100644 --- a/winui3/src/toga_winui3/widgets/button.py +++ b/winui3/src/toga_winui3/widgets/button.py @@ -1,6 +1,8 @@ from travertino.size import at_least from win32more.Microsoft.UI.Xaml.Controls import Button as NativeButton +from toga.constants import TRANSPARENT + from .base import Widget @@ -19,6 +21,10 @@ def create(self): def native_event_click(self, sender, args): self.interface.on_press() + #################################################################################### + # Button content + #################################################################################### + def get_text(self): return self._text @@ -46,12 +52,16 @@ def set_icon(self, icon): self._staged_properties.Content = self.icon def icon(self): - return self._icon._impl.image_icon + return self._icon._impl.image_icon(32) #################################################################################### # Overrides of methods called by the Toga style applicator. #################################################################################### + def set_background_color(self, color): + color = None if color is TRANSPARENT else color + super().set_background_color(color) + def set_text_align(self, alignment): # FIXME: WinUI 3 has the ability to set the content alignment of a button, but # the Toga style will default to either left-aligned or right-aligned which is From d8aeddf1d49ca165313f1f74ea455f1428417bc9 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 8 Jul 2026 09:15:21 +0200 Subject: [PATCH 021/110] Improve native event handling --- winui3/src/toga_winui3/command.py | 6 +- winui3/src/toga_winui3/container.py | 2 +- winui3/src/toga_winui3/icons.py | 7 +- winui3/src/toga_winui3/libs/nativeevents.py | 114 ++++++++++++++++++ winui3/src/toga_winui3/libs/winui3app.py | 4 +- winui3/src/toga_winui3/statusicons.py | 7 +- winui3/src/toga_winui3/widgets/base.py | 3 +- winui3/src/toga_winui3/widgets/box.py | 2 +- winui3/src/toga_winui3/widgets/button.py | 4 +- winui3/src/toga_winui3/widgets/label.py | 2 +- .../toga_winui3/widgets/properties/staged.py | 2 +- winui3/src/toga_winui3/window.py | 12 +- 12 files changed, 144 insertions(+), 21 deletions(-) create mode 100644 winui3/src/toga_winui3/libs/nativeevents.py diff --git a/winui3/src/toga_winui3/command.py b/winui3/src/toga_winui3/command.py index 45152a847c..ccd67c8ad3 100644 --- a/winui3/src/toga_winui3/command.py +++ b/winui3/src/toga_winui3/command.py @@ -2,6 +2,8 @@ from toga import Command as StandardCommand, Group, Key +from .libs.nativeevents import events_handled + class Command: def __init__(self, interface): @@ -90,9 +92,9 @@ def set_enabled(self, value): item.IsEnabled = is_enabled def create_menu_item(self, NativeClass): - item = NativeClass() + item = events_handled(NativeClass) item.Text = self.interface.text - item.add_Click(self.native_event_Click) + item.event_handler.Click += self.native_event_Click if self.interface.shortcut is not None: self.interface.factory.not_implemented("Command shortcuts") diff --git a/winui3/src/toga_winui3/container.py b/winui3/src/toga_winui3/container.py index 672b2de731..964b424603 100644 --- a/winui3/src/toga_winui3/container.py +++ b/winui3/src/toga_winui3/container.py @@ -59,7 +59,7 @@ def __init__(self, native_panel: Canvas, on_refresh=None): self.native = native_panel self.native.HorizontalAlignment = HorizontalAlignment.Stretch self.native.VerticalAlignment = VerticalAlignment.Stretch - self.native.SizeChanged += self.native_event_size_changed + self.native.event_handler.SizeChanged += self.native_event_size_changed self._content = None self._on_refresh = on_refresh diff --git a/winui3/src/toga_winui3/icons.py b/winui3/src/toga_winui3/icons.py index 34d3f7d247..f3632e0151 100644 --- a/winui3/src/toga_winui3/icons.py +++ b/winui3/src/toga_winui3/icons.py @@ -9,6 +9,7 @@ from .libs.gdiplus import create_icon from .libs.misc import load_icon +from .libs.nativeevents import events_handled class Icon: @@ -65,8 +66,10 @@ def id(self) -> IconId: def bitmap_image(self) -> BitmapImage: """The WinUI 3 BitmapImage used as a source for the icon.""" if self._bitmap_image is None: - self._bitmap_image = BitmapImage() - self._bitmap_image.ImageFailed += self.native_event_image_failed + self._bitmap_image = events_handled(BitmapImage) + self._bitmap_image.event_handler.ImageFailed += ( + self.native_event_image_failed + ) self._bitmap_image.UriSource = self.uri return self._bitmap_image diff --git a/winui3/src/toga_winui3/libs/nativeevents.py b/winui3/src/toga_winui3/libs/nativeevents.py new file mode 100644 index 0000000000..302fc8a933 --- /dev/null +++ b/winui3/src/toga_winui3/libs/nativeevents.py @@ -0,0 +1,114 @@ +from win32more import ComError + +from toga import App +from toga.handlers import WeakrefCallable + + +class NativeEvent: + _cleared_callbacks = {} + + def __init__(self, owner, name: str): + split_name = name.split("_") + + self._owner = owner + for attribute in split_name[:-1]: + self._owner = getattr(self._owner, attribute) + + self._name = split_name[-1] + self._registry = {} + + def __iadd__(self, callback): + event_adder = getattr(self._owner, "add_" + self._name) + + # Don't allow the external process to keep a reference to the callback. + token = event_adder(WeakrefCallable(callback)) + + # Keep a local reference to the callback. + self._registry[id(token)] = (token, callback) + + def clear(self): + event_remover = getattr(self._owner, "remove_" + self._name) + for token, callback in self._registry.values(): + try: + event_remover(token) + + except ComError: + # This error occurs when the actual WinUI 3 object has been removed, for + # example when its parent is destroyed, but the python win32more object + # remains. Since the actual WinUI 3 object has been removed, and hence + # will not raise any events, this error is ignored. + pass + + NativeEvent._clear_callback(callback) + + self._registry = {} + + @classmethod + def _clear_callback(cls, callback): + # There is potentially still a call to the callback in the message queue + # after the event has been deregistered. So the task to clear the callback + # is placed at the back of the queue, and only deletes the + # reference to the callback after any calls have been made. + callback_id = id(callback) + cls._cleared_callbacks[callback_id] = callback + + def clear_callback_task(cls=cls, callback_id=callback_id): + del cls._cleared_callbacks[callback_id] + + App.app.loop.call_soon_threadsafe(clear_callback_task) + + +class NativeEventsHandler: + def __init__(self, owner): + self._owner = owner + self._event_registry = {} + + def __getattr__(self, name): + """Gets the native event for a name with a capital first character.""" + if name not in self._event_registry.keys(): + self._event_registry[name] = NativeEvent(self._owner, name) + + return self._event_registry[name] + + def clear(self): + for event in self._event_registry.values(): + event.clear() + + self._event_registry = {} + + +class NativeEventsMixin: + @property + def native_class(self): + return type(self).__bases__[1] + + def __del__(self): + if getattr(self, "_event_handler", None): + self.event_handler.clear() + + if hasattr(self.native_class, "__del__"): + super().__del__() + + @property + def event_handler(self): + # Lazy load an EventHandler instance. + if not getattr(self, "_event_handler", None): + self._event_handler = NativeEventsHandler(self) + + return self._event_handler + + +def events_handled(native_cls): + cls_name = native_cls.__name__ + "Handled" + bases = (NativeEventsMixin, native_cls) + return type(cls_name, bases, {})() + + +class EventsHandledMixin: + @property + def native_cls(self): + return type(self.native) + + @native_cls.setter + def native_cls(self, cls): + self.native = events_handled(cls) diff --git a/winui3/src/toga_winui3/libs/winui3app.py b/winui3/src/toga_winui3/libs/winui3app.py index 8649731689..8ed28161d4 100644 --- a/winui3/src/toga_winui3/libs/winui3app.py +++ b/winui3/src/toga_winui3/libs/winui3app.py @@ -52,6 +52,8 @@ ) from win32more.Windows.Win32.System.LibraryLoader import GetModuleFileName +from .nativeevents import events_handled + # TODO: Clean up code. # TODO: Needs to be commented and explained. # FIXME: Fix resources. @@ -76,7 +78,7 @@ def OnExited(self): ... # FIXME: Find a way to remove this method. def CreateWindow(self): - return Window() + return events_handled(Window) def GetXamlType(self, type): return self.AppProvider().GetXamlType(type) diff --git a/winui3/src/toga_winui3/statusicons.py b/winui3/src/toga_winui3/statusicons.py index 6e9c87b784..e8d302f12b 100644 --- a/winui3/src/toga_winui3/statusicons.py +++ b/winui3/src/toga_winui3/statusicons.py @@ -32,6 +32,7 @@ SetWindowSubclass, ) from .libs.misc import get_x_lparam, get_y_lparam, loword +from .libs.nativeevents import events_handled from .libs.shell import Shell_NotifyIconW @@ -150,10 +151,10 @@ def native_menu(self): def native_menu(self, native_menu_instance: MenuFlyout): assert isinstance(native_menu_instance, MenuFlyout) - native_menu_instance.add_Closing(self.native_event_Closing) + native_menu_instance.event_handler.Closing += self.native_event_closing self._native_menu = native_menu_instance - def native_event_Closing(self, sender, args): + def native_event_closing(self, sender, args): self.native_window.AppWindow.Hide() def native_event_click(self, x, y): @@ -203,7 +204,7 @@ def create(self): # Menu status icons are the only icons that have extra construction needs. # Clear existing menus for menu_status_icon in self.interface._menu_status_icons: - menu_status_icon._impl.native_menu = MenuFlyout() + menu_status_icon._impl.native_menu = events_handled(MenuFlyout) # Determine the primary status icon. primary_group = self.interface._primary_menu_status_icon diff --git a/winui3/src/toga_winui3/widgets/base.py b/winui3/src/toga_winui3/widgets/base.py index 4ee89e69dd..c22573241b 100644 --- a/winui3/src/toga_winui3/widgets/base.py +++ b/winui3/src/toga_winui3/widgets/base.py @@ -7,11 +7,12 @@ from toga.constants import TRANSPARENT from ..colors import native_brush +from ..libs.nativeevents import EventsHandledMixin from .properties.native import NativeProperties, is_based_on from .properties.staged import StagedProperties -class Widget(ABC): +class Widget(EventsHandledMixin, ABC): #################################################################################### # Widget creation. #################################################################################### diff --git a/winui3/src/toga_winui3/widgets/box.py b/winui3/src/toga_winui3/widgets/box.py index 59c43ce884..fa1b5ef1f8 100644 --- a/winui3/src/toga_winui3/widgets/box.py +++ b/winui3/src/toga_winui3/widgets/box.py @@ -5,7 +5,7 @@ class Box(Widget): def create(self): - self.native = Canvas() + self.native_cls = Canvas #################################################################################### # Overrides of methods called by the Toga style applicator. diff --git a/winui3/src/toga_winui3/widgets/button.py b/winui3/src/toga_winui3/widgets/button.py index 1d599100cc..040b17d2fa 100644 --- a/winui3/src/toga_winui3/widgets/button.py +++ b/winui3/src/toga_winui3/widgets/button.py @@ -8,7 +8,7 @@ class Button(Widget): def create(self): - self.native = NativeButton() + self.native_cls = NativeButton self._icon = None self._text = "" @@ -16,7 +16,7 @@ def create(self): self._min_width = 0 self._min_height = 0 - self.native.Click += self.native_event_click + self.native.event_handler.Click += self.native_event_click def native_event_click(self, sender, args): self.interface.on_press() diff --git a/winui3/src/toga_winui3/widgets/label.py b/winui3/src/toga_winui3/widgets/label.py index 8d88356d71..fe2fdf9e02 100644 --- a/winui3/src/toga_winui3/widgets/label.py +++ b/winui3/src/toga_winui3/widgets/label.py @@ -8,7 +8,7 @@ class Label(Widget): def create(self): - self.native = TextBlock() + self.native_cls = TextBlock self._text = "" # Initial minimum sizes are 0 so that the staged properties are sized up. diff --git a/winui3/src/toga_winui3/widgets/properties/staged.py b/winui3/src/toga_winui3/widgets/properties/staged.py index dbfee02ad5..09b511ee8e 100644 --- a/winui3/src/toga_winui3/widgets/properties/staged.py +++ b/winui3/src/toga_winui3/widgets/properties/staged.py @@ -110,7 +110,7 @@ def refresh(self): def size_changed(sender, args, duplicate=duplicate): self.native_event_size_changed(sender, args, duplicate) - duplicate.SizeChanged += size_changed + duplicate.event_handler.SizeChanged += size_changed for attribute, value_creator in self._staged_properties.items(): value = value_creator() diff --git a/winui3/src/toga_winui3/window.py b/winui3/src/toga_winui3/window.py index 093c653320..f20f7929e4 100644 --- a/winui3/src/toga_winui3/window.py +++ b/winui3/src/toga_winui3/window.py @@ -45,6 +45,7 @@ from .container import Container from .libs.misc import column_definition_star, row_definition_auto, row_definition_star +from .libs.nativeevents import events_handled from .screens import Screen as ScreenImpl, round_pixels if TYPE_CHECKING: # pragma: no cover @@ -84,14 +85,13 @@ def create(self): # Match the title bar theme to the app. self.native.AppWindow.TitleBar.PreferredTheme = TitleBarTheme.UseDefaultAppMode - # TODO: Decide if these event handlers need to be a weak reference. - self.native.Activated += self.native_event_activated - self.native.AppWindow.Changed += self.native_event_changed - self.native.AppWindow.Closing += self.native_event_closing + self.native.event_handler.Activated += self.native_event_activated + self.native.event_handler.AppWindow_Changed += self.native_event_changed + self.native.event_handler.AppWindow_Closing += self.native_event_closing def create_content(self): """Construct the container.""" - self.container_native = Canvas() + self.container_native = events_handled(Canvas) self.container = Container(self.container_native, self.content_refreshed) self.native.Content = self.container_native @@ -468,7 +468,7 @@ def create_content(self): self.content_native.HorizontalAlignment = HorizontalAlignment.Stretch self.content_native.VerticalAlignment = VerticalAlignment.Stretch - self.container_native = Canvas() + self.container_native = events_handled(Canvas) Grid.SetRow(self.container_native, 2) Grid.SetColumn(self.container_native, 0) self.content_native.Children.Append(self.container_native) From 204f84bcab2bd47510ef21bcaa77b5aecd8a2a54 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:43:16 +0200 Subject: [PATCH 022/110] Add background to Label widget --- winui3/src/toga_winui3/widgets/label.py | 103 +++++++++++++++++- .../toga_winui3/widgets/properties/staged.py | 2 +- 2 files changed, 98 insertions(+), 7 deletions(-) diff --git a/winui3/src/toga_winui3/widgets/label.py b/winui3/src/toga_winui3/widgets/label.py index fe2fdf9e02..5aa2a70a3e 100644 --- a/winui3/src/toga_winui3/widgets/label.py +++ b/winui3/src/toga_winui3/widgets/label.py @@ -1,14 +1,76 @@ from travertino.constants import CENTER, JUSTIFY, LEFT, RIGHT from travertino.size import at_least -from win32more.Microsoft.UI.Xaml import TextAlignment -from win32more.Microsoft.UI.Xaml.Controls import TextBlock +from win32more.Microsoft.UI.Xaml import ( + FocusState, + HorizontalAlignment, + TextAlignment, + VerticalAlignment, +) +from win32more.Microsoft.UI.Xaml.Controls import Grid, Panel, TextBlock +from toga.constants import TRANSPARENT + +from ..colors import native_brush +from ..libs.misc import column_definition_star, row_definition_auto +from ..libs.nativeevents import EventsHandledMixin from .base import Widget +from .properties.native import NativeProperties, is_based_on +from .properties.staged import StagedProperties + + +class LabelText(EventsHandledMixin): + def __init__(self, label): + self._label = label + + self.native_cls = TextBlock + + self._native_properties = NativeProperties(self) + self._staged_properties = StagedProperties(self) + + Grid.SetRow(self.native, 0) + Grid.SetColumn(self.native, 0) + label.native.Children.Append(self.native) + + self.native.HorizontalAlignment = HorizontalAlignment.Stretch + self.native.VerticalAlignment = VerticalAlignment.Stretch + + @property + def container(self): + return self._label.container + + @property + def _min_width(self): + return self._label._min_width + + @_min_width.setter + def _min_width(self, value): + self._label._min_width = value + + @property + def _min_height(self): + return self._label._min_height + + @_min_height.setter + def _min_height(self, value): + self._label._min_height = value + + def rehint(self): + self._label.rehint() class Label(Widget): def create(self): - self.native_cls = TextBlock + self.native_cls = Grid + + self._background_properties = self._native_properties + + self.native.ColumnDefinitions.Append(column_definition_star(1)) + self.native.RowDefinitions.Append(row_definition_auto()) + + self.label_text = LabelText(self) + self._native_properties = self.label_text._native_properties + self._staged_properties = self.label_text._staged_properties + self._text = "" # Initial minimum sizes are 0 so that the staged properties are sized up. @@ -30,8 +92,14 @@ def text(self): #################################################################################### def set_background_color(self, color): - # TextBlock has no Background attribute to set. - pass + if color is not None: + brush = native_brush(color) + elif is_based_on(type(self.native), Panel): + brush = native_brush(TRANSPARENT) + else: + brush = None + + self._background_properties.Background = brush def set_text_align(self, alignment): property_dict = { @@ -43,12 +111,35 @@ def set_text_align(self, alignment): property = property_dict[alignment] native_alignment = getattr(TextAlignment, property) - self._native_properties.HorizontalTextAlignment = native_alignment + self._native_properties.TextAlignment = native_alignment #################################################################################### # Overrides of other methods called by the Toga core interface. #################################################################################### + def get_enabled(self): + # Neither TextBlock or Grid has the IsEnabled property. + return True + + def set_enabled(self, value): + # Neither TextBlock or Grid has the IsEnabled property. + pass + + @property + def has_focus(self): + grid_has_focus = self.native.FocusState != FocusState.Unfocused + text_has_focus = self.label_text.native.FocusState != FocusState.Unfocused + return grid_has_focus or text_has_focus + + def focus(self): + self.label_text.native.Focus(FocusState.Programmatic) + + def get_tab_index(self): + return self.label_text.native.TabIndex + + def set_tab_index(self, tab_index): + self.label_text.native.TabIndex = tab_index + def rehint(self): self.interface.intrinsic.width = at_least(self._min_width) self.interface.intrinsic.height = self._min_height diff --git a/winui3/src/toga_winui3/widgets/properties/staged.py b/winui3/src/toga_winui3/widgets/properties/staged.py index 09b511ee8e..b265a519a7 100644 --- a/winui3/src/toga_winui3/widgets/properties/staged.py +++ b/winui3/src/toga_winui3/widgets/properties/staged.py @@ -95,7 +95,7 @@ def __setattr__(self, name, value): self.refresh() def refresh(self): - if not self._widget._container: + if not self._widget.container: return # The properties in self._font_keys are only staged if other content such as From a372601475d9f8b5e492c57da4a73339ccfd5101 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:24:16 +0200 Subject: [PATCH 023/110] Align Window states with Toga API requirements --- winui3/src/toga_winui3/window.py | 99 ++++++++++++++++++++++++-------- 1 file changed, 76 insertions(+), 23 deletions(-) diff --git a/winui3/src/toga_winui3/window.py b/winui3/src/toga_winui3/window.py index f20f7929e4..014ddd9826 100644 --- a/winui3/src/toga_winui3/window.py +++ b/winui3/src/toga_winui3/window.py @@ -63,6 +63,12 @@ def __init__(self, interface, title, position, size): # from fullscreen mode. Use this variable to distinguish between them. self._in_presentation_mode = False + # Keep a record of the current state to access after state changes. + self._cached_state = WindowState.NORMAL + + # Keep a record of the window size in the NORMAL state. + self._cached_size = size + # In WinUI 3 a minimized window is not considered visible. This variable keeps # track of this property. self._visible = self.native.Visible @@ -146,7 +152,26 @@ def native_event_changed(self, sender, args): pass if args.DidSizeChange: - self.interface.on_resize() + old_state = self._cached_state + new_state = self.get_window_state() + + # DidSizeChange is triggered by entering and leaving a Minimized state. + self._cached_state = new_state + + # Update the cached size. + self._cached_size = self._normal_size + + if {old_state, new_state} != {WindowState.MINIMIZED, WindowState.NORMAL}: + self.interface.on_resize() + + if old_state != new_state: + if old_state == WindowState.MINIMIZED: + print("Showing after minimize") + self.interface.on_show() + + elif new_state == WindowState.MINIMIZED: + print("Hiding via minimize") + self.interface.on_hide() if args.DidVisibilityChange: # Minimize is not considered visible but it also doesn't trigger this event. @@ -160,6 +185,11 @@ def native_event_changed(self, sender, args): if args.DidPresenterChange: self._set_restrictions() + # Notes: + # - DidSizeChange occurs before DidPresenterChange. + # - DidPresenterChange is not triggered by Minimized -> Normal. + self._cached_state = self.get_window_state() + def native_event_closing(self, sender, args): # Note: This event is raised when clicking on the close button, but not when # self.native.Close() is called. @@ -242,6 +272,11 @@ def set_content(self, widget): def get_size(self) -> Size: """Gets the size of the window in CSS pixels (effective pixels).""" + # If the window is minimized from a maxmimized state, then toga expects the size + # of window in its normal state. + if self._cached_state == WindowState.MINIMIZED: + return self._cached_size + # self.native.Bounds returns values in effective pixels, but they are not always # integer values. return Size( @@ -253,8 +288,17 @@ def set_size(self, size: SizeT): """Sets the size of the window in CSS pixels (effective pixels).""" css_to_physical = self.get_current_screen().css_to_physical + current_bounds = self.native.Bounds + current_size = self.native.AppWindow.Size + + diff_width = current_size.Width - css_to_physical(current_bounds.Width) + diff_height = current_size.Height - css_to_physical(current_bounds.Height) + self.native.AppWindow.Resize( - SizeInt32(css_to_physical(size[0]), css_to_physical(size[1])) + SizeInt32( + css_to_physical(size[0]) + diff_width, + css_to_physical(size[1]) + diff_height, + ) ) @property @@ -286,6 +330,13 @@ def min_size(self): window_size.Height - client_size.Height + client_min_height, ) + @property + def _normal_size(self): + if self._cached_state == WindowState.NORMAL: + return self.get_size() + + return self._cached_size + #################################################################################### # Window position (CSS pixels, see window size for terminology). #################################################################################### @@ -392,9 +443,7 @@ def set_window_state(self, state: WindowState): ): self.interface.app.exit_presentation_mode() - print("set_window_state") from_state = self.get_window_state() - print(f"from_state:{from_state}") if from_state == state: return @@ -420,31 +469,35 @@ def set_window_state(self, state: WindowState): if hasattr(self, "toolbar_native"): self.toolbar_native.Visible = False - return + else: + self._in_presentation_mode = False + if hasattr(self, "menu_native"): + self.menu_native.Visible = True - self._in_presentation_mode = False - if hasattr(self, "menu_native"): - self.menu_native.Visible = True + if hasattr(self, "toolbar_native"): + self.toolbar_native.Visible = True - if hasattr(self, "toolbar_native"): - self.toolbar_native.Visible = True + match state: + case WindowState.NORMAL: + presenter, _ = self._presenter + presenter.Restore() - match state: - case WindowState.NORMAL: - presenter, _ = self._presenter - presenter.Restore() + case WindowState.MINIMIZED: + presenter, _ = self._presenter + presenter.Minimize() - case WindowState.MINIMIZED: - presenter, _ = self._presenter - presenter.Minimize() + case WindowState.MAXIMIZED: + presenter, _ = self._presenter + presenter.Maximize() - case WindowState.MAXIMIZED: - presenter, _ = self._presenter - presenter.Maximize() + case _: + # WindowState.FULLSCREEN + pass - case _: - # WindowState.FULLSCREEN - pass + if not from_overlapped and not to_overlapped: + # Toga expects an on_resize event to from FULLSCREEN <-> PRESENTATION, but + # this is not a native event so trigger it manually. + self.interface.on_resize() #################################################################################### # Window capabilities From 0b6016cd67edbd0e62c20b4f4daf729b85a4bcd6 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:57:41 +0200 Subject: [PATCH 024/110] Add WinUI 3 testbed --- testbed/pyproject.toml | 18 ++++++++++++++++++ testbed/src/testbed_winui3/__init__.py | 0 testbed/src/testbed_winui3/__main__.py | 4 ++++ testbed/tests/testbed_winui3.py | 4 ++++ 4 files changed, 26 insertions(+) create mode 100644 testbed/src/testbed_winui3/__init__.py create mode 100644 testbed/src/testbed_winui3/__main__.py create mode 100644 testbed/tests/testbed_winui3.py diff --git a/testbed/pyproject.toml b/testbed/pyproject.toml index 663ca6a6ba..676cad5e92 100644 --- a/testbed/pyproject.toml +++ b/testbed/pyproject.toml @@ -160,6 +160,24 @@ requires = [ "psutil==7.2.2 ; python_version < '3.13'", ] +[tool.briefcase.app.testbed-winui3] +formal_name = "Toga Testbed (WinUI 3)" +sources = [ + "src/testbed_winui3", + "src/testbed", +] +test_sources = [ + "../winui3/tests_backend", +] + +[tool.briefcase.app.testbed-winui3.windows] +requires = [ + "../winui3", + # psutil would ideally be top-level test dependency. However, for Python < 3.13, + # Android identifies as Linux, and psutil isn't available for Android. + "psutil==7.2.2 ; python_version < '3.13'", +] + [tool.briefcase.app.testbed-textual] formal_name = "Toga Testbed (Textual)" console_app = true diff --git a/testbed/src/testbed_winui3/__init__.py b/testbed/src/testbed_winui3/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testbed/src/testbed_winui3/__main__.py b/testbed/src/testbed_winui3/__main__.py new file mode 100644 index 0000000000..86a4f09aa4 --- /dev/null +++ b/testbed/src/testbed_winui3/__main__.py @@ -0,0 +1,4 @@ +from testbed.app import main + +if __name__ == "__main__": + main("testbed-winui3").main_loop() diff --git a/testbed/tests/testbed_winui3.py b/testbed/tests/testbed_winui3.py new file mode 100644 index 0000000000..fd0664d17c --- /dev/null +++ b/testbed/tests/testbed_winui3.py @@ -0,0 +1,4 @@ +from .testbed import main + +if __name__ == "__main__": + main("testbed-winui3", backend_override="toga_winui3") From 2b10166d20ff190e04c07486519bf405c44ff295 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:45:55 +0200 Subject: [PATCH 025/110] Add test skips for unimplemented modules --- testbed/tests/app/test_dialogs.py | 5 +++++ testbed/tests/test_images.py | 4 ++++ testbed/tests/test_keys.py | 4 ++++ testbed/tests/widgets/canvas/conftest.py | 3 +++ testbed/tests/widgets/canvas/test_canvas.py | 2 +- testbed/tests/widgets/conftest.py | 1 + testbed/tests/widgets/test_activityindicator.py | 7 +++++-- testbed/tests/widgets/test_dateinput.py | 5 ++++- testbed/tests/widgets/test_detailedlist.py | 4 +++- testbed/tests/widgets/test_divider.py | 4 +++- testbed/tests/widgets/test_imageview.py | 6 +++++- testbed/tests/widgets/test_mapview.py | 7 ++++++- testbed/tests/widgets/test_multilinetextinput.py | 7 ++++++- testbed/tests/widgets/test_numberinput.py | 5 +++-- testbed/tests/widgets/test_optioncontainer.py | 3 +++ testbed/tests/widgets/test_passwordinput.py | 4 +++- testbed/tests/widgets/test_progressbar.py | 4 +++- testbed/tests/widgets/test_scrollcontainer.py | 3 +++ testbed/tests/widgets/test_selection.py | 3 +++ testbed/tests/widgets/test_slider.py | 5 ++++- testbed/tests/widgets/test_splitcontainer.py | 6 +++--- testbed/tests/widgets/test_switch.py | 3 +++ testbed/tests/widgets/test_table.py | 10 +++++----- testbed/tests/widgets/test_textinput.py | 2 ++ testbed/tests/widgets/test_timeinput.py | 6 +++--- testbed/tests/widgets/test_tree.py | 10 +++++----- testbed/tests/widgets/test_webview.py | 8 +++++++- testbed/tests/window/test_dialogs.py | 5 +++++ 28 files changed, 105 insertions(+), 31 deletions(-) diff --git a/testbed/tests/app/test_dialogs.py b/testbed/tests/app/test_dialogs.py index 20c6cd68a8..9c4ffd2df9 100644 --- a/testbed/tests/app/test_dialogs.py +++ b/testbed/tests/app/test_dialogs.py @@ -6,9 +6,14 @@ import toga +from ..conftest import skip_on_backends + TESTS_DIR = Path(__file__).parent.parent +skip_on_backends("toga_winui3", allow_module_level=True) + + async def test_info_dialog(app, app_probe): """An app-level info dialog can be displayed and acknowledged.""" dialog = toga.InfoDialog("Info", "Some info") diff --git a/testbed/tests/test_images.py b/testbed/tests/test_images.py index 06d3b394ae..e19631ba7b 100644 --- a/testbed/tests/test_images.py +++ b/testbed/tests/test_images.py @@ -10,6 +10,10 @@ import toga +from .conftest import skip_on_backends + +skip_on_backends("toga_winui3", allow_module_level=True) + def image_probe(app, image): module = import_module("tests_backend.images") diff --git a/testbed/tests/test_keys.py b/testbed/tests/test_keys.py index ce8cc6c0cf..44fbfe50ee 100644 --- a/testbed/tests/test_keys.py +++ b/testbed/tests/test_keys.py @@ -2,6 +2,10 @@ from toga.keys import Key +from .conftest import skip_on_backends + +skip_on_backends("toga_winui3", allow_module_level=True) + @pytest.mark.parametrize( "key_combo, key_data", diff --git a/testbed/tests/widgets/canvas/conftest.py b/testbed/tests/widgets/canvas/conftest.py index 9ad843ff64..d9c1093ff3 100644 --- a/testbed/tests/widgets/canvas/conftest.py +++ b/testbed/tests/widgets/canvas/conftest.py @@ -5,6 +5,8 @@ import toga from toga.colors import WHITE +from ..conftest import skip_on_backends + @pytest.fixture def on_resize_handler(): @@ -57,6 +59,7 @@ async def widget( on_alt_release_handler, on_alt_drag_handler, ): + skip_on_backends("toga_winui3") return toga.Canvas( on_resize=on_resize_handler, on_press=on_press_handler, diff --git a/testbed/tests/widgets/canvas/test_canvas.py b/testbed/tests/widgets/canvas/test_canvas.py index 2e62c05c16..bbc29c56d3 100644 --- a/testbed/tests/widgets/canvas/test_canvas.py +++ b/testbed/tests/widgets/canvas/test_canvas.py @@ -34,7 +34,7 @@ test_focus_noop, ) -test_cleanup = build_cleanup_test(toga.Canvas) +test_cleanup = build_cleanup_test(toga.Canvas, skip_backends=("toga_winui3",)) async def test_resize(widget, probe, on_resize_handler): diff --git a/testbed/tests/widgets/conftest.py b/testbed/tests/widgets/conftest.py index 5b67845d4a..111b390e40 100644 --- a/testbed/tests/widgets/conftest.py +++ b/testbed/tests/widgets/conftest.py @@ -45,6 +45,7 @@ async def container_probe(widget): @pytest.fixture async def other(widget): """A separate widget that can take focus""" + skip_on_backends("toga_winui3", reason="TextInput is not implemented yet.") other = toga.TextInput() widget.parent.add(other) return other diff --git a/testbed/tests/widgets/test_activityindicator.py b/testbed/tests/widgets/test_activityindicator.py index 55bc725f2c..71435c02b6 100644 --- a/testbed/tests/widgets/test_activityindicator.py +++ b/testbed/tests/widgets/test_activityindicator.py @@ -4,7 +4,7 @@ import toga from toga.style import Pack -from .conftest import build_cleanup_test +from .conftest import build_cleanup_test, skip_on_backends from .probe import get_probe from .properties import ( # noqa: F401 test_enable_noop, @@ -14,10 +14,13 @@ @pytest.fixture async def widget(): + skip_on_backends("toga_winui3") return toga.ActivityIndicator() -test_cleanup = build_cleanup_test(toga.ActivityIndicator) +test_cleanup = build_cleanup_test( + toga.ActivityIndicator, skip_backends=("toga_winui3",) +) async def test_start_stop(widget, probe): diff --git a/testbed/tests/widgets/test_dateinput.py b/testbed/tests/widgets/test_dateinput.py index d669847b5f..84ef30bd69 100644 --- a/testbed/tests/widgets/test_dateinput.py +++ b/testbed/tests/widgets/test_dateinput.py @@ -5,6 +5,7 @@ import toga +from ..conftest import skip_on_backends from .conftest import build_cleanup_test from .properties import ( # noqa: F401 test_background_color, @@ -79,14 +80,16 @@ def assert_approx_now(actual): @fixture async def widget(): + skip_on_backends("toga_winui3") return toga.DateInput() -test_cleanup = build_cleanup_test(toga.DateInput) +test_cleanup = build_cleanup_test(toga.DateInput, skip_backends=("toga_winui3",)) async def test_init(): "Properties can be set in the constructor" + skip_on_backends("toga_winui3") value = date(1999, 12, 31) min = date(1999, 12, 30) diff --git a/testbed/tests/widgets/test_detailedlist.py b/testbed/tests/widgets/test_detailedlist.py index 30463a8c79..b13fcc110e 100644 --- a/testbed/tests/widgets/test_detailedlist.py +++ b/testbed/tests/widgets/test_detailedlist.py @@ -6,6 +6,7 @@ from toga.sources import ListListener, ListSource from toga.style.pack import Pack +from ..conftest import skip_on_backends from .conftest import build_cleanup_test from .properties import ( # noqa: F401 test_enable_noop, @@ -61,6 +62,7 @@ async def widget( on_primary_action_handler, on_secondary_action_handler, ): + skip_on_backends("toga_winui3") return toga.DetailedList( data=source, accessors=("a", "b", "c"), @@ -75,7 +77,7 @@ async def widget( ) -test_cleanup = build_cleanup_test(toga.DetailedList) +test_cleanup = build_cleanup_test(toga.DetailedList, skip_backends=("toga_winui3",)) async def test_scroll(widget, probe): diff --git a/testbed/tests/widgets/test_divider.py b/testbed/tests/widgets/test_divider.py index b02a43a4c2..88221b6bf7 100644 --- a/testbed/tests/widgets/test_divider.py +++ b/testbed/tests/widgets/test_divider.py @@ -4,6 +4,7 @@ from toga.constants import Direction from toga.style.pack import COLUMN, ROW +from ..conftest import skip_on_backends from .conftest import build_cleanup_test from .properties import ( # noqa: F401 test_enable_noop, @@ -13,10 +14,11 @@ @pytest.fixture async def widget(): + skip_on_backends("toga_winui3") return toga.Divider() -test_cleanup = build_cleanup_test(toga.Divider) +test_cleanup = build_cleanup_test(toga.Divider, skip_backends=("toga_winui3",)) async def test_directions(widget, probe): diff --git a/testbed/tests/widgets/test_imageview.py b/testbed/tests/widgets/test_imageview.py index b9431778a4..bd7612ac38 100644 --- a/testbed/tests/widgets/test_imageview.py +++ b/testbed/tests/widgets/test_imageview.py @@ -3,6 +3,7 @@ import toga from toga.style.pack import COLUMN, ROW +from ..conftest import skip_on_backends from .conftest import build_cleanup_test from .properties import ( # noqa: F401 test_background_color, @@ -15,11 +16,14 @@ @pytest.fixture async def widget(): + skip_on_backends("toga_winui3") return toga.ImageView(image="resources/sample.png") test_cleanup = build_cleanup_test( - toga.ImageView, kwargs={"image": "resources/sample.png"} + toga.ImageView, + kwargs={"image": "resources/sample.png"}, + skip_backends=("toga_winui3",), ) diff --git a/testbed/tests/widgets/test_mapview.py b/testbed/tests/widgets/test_mapview.py index 77a712f650..2248b32212 100644 --- a/testbed/tests/widgets/test_mapview.py +++ b/testbed/tests/widgets/test_mapview.py @@ -8,6 +8,7 @@ import toga from toga.style import Pack +from ..conftest import skip_on_backends from .conftest import build_cleanup_test, safe_create from .properties import ( # noqa: F401 test_flex_widget_size, @@ -32,6 +33,7 @@ async def on_select(): @pytest.fixture async def widget(on_select): + skip_on_backends("toga_winui3") with safe_create(): widget = toga.MapView(style=Pack(flex=1), on_select=on_select) @@ -59,7 +61,10 @@ async def widget(on_select): toga.App.app._gc_protector.append(widget) -test_cleanup = build_cleanup_test(toga.MapView) +test_cleanup = build_cleanup_test( + toga.MapView, + skip_backends=("toga_winui3",), +) # The next two tests fail about 75% of the time in the macOS x86_64 CI configuration. diff --git a/testbed/tests/widgets/test_multilinetextinput.py b/testbed/tests/widgets/test_multilinetextinput.py index 1cdfa34978..6aaa4f03bd 100644 --- a/testbed/tests/widgets/test_multilinetextinput.py +++ b/testbed/tests/widgets/test_multilinetextinput.py @@ -5,6 +5,7 @@ import toga from toga.style import Pack +from ..conftest import skip_on_backends from .conftest import build_cleanup_test from .properties import ( # noqa: F401 test_background_color, @@ -39,6 +40,7 @@ @pytest.fixture async def widget(): + skip_on_backends("toga_winui3") return toga.MultilineTextInput(value="Hello", style=Pack(flex=1)) @@ -48,7 +50,10 @@ def verify_font_sizes(): return False, False -test_cleanup = build_cleanup_test(toga.MultilineTextInput) +test_cleanup = build_cleanup_test( + toga.MultilineTextInput, + skip_backends=("toga_winui3",), +) async def test_scroll_position(widget, probe): diff --git a/testbed/tests/widgets/test_numberinput.py b/testbed/tests/widgets/test_numberinput.py index e8b4683b37..0de43ef0b1 100644 --- a/testbed/tests/widgets/test_numberinput.py +++ b/testbed/tests/widgets/test_numberinput.py @@ -5,7 +5,7 @@ import toga -from ..conftest import skip_on_platforms +from ..conftest import skip_on_backends, skip_on_platforms from .conftest import build_cleanup_test from .properties import ( # noqa: F401 test_background_color, @@ -28,6 +28,7 @@ @pytest.fixture async def widget(): + skip_on_backends("toga_winui3") return toga.NumberInput(value="1.23", step="0.01") @@ -42,7 +43,7 @@ def verify_focus_handlers(): return False -test_cleanup = build_cleanup_test(toga.NumberInput) +test_cleanup = build_cleanup_test(toga.NumberInput, skip_backends=("toga_winui3",)) async def test_on_change_handler(widget, probe): diff --git a/testbed/tests/widgets/test_optioncontainer.py b/testbed/tests/widgets/test_optioncontainer.py index d91eae3367..455ab34441 100644 --- a/testbed/tests/widgets/test_optioncontainer.py +++ b/testbed/tests/widgets/test_optioncontainer.py @@ -6,6 +6,7 @@ from toga.colors import CORNFLOWERBLUE, GOLDENROD, REBECCAPURPLE, SEAGREEN from toga.style.pack import Pack +from ..conftest import skip_on_backends from .conftest import build_cleanup_test, safe_create from .probe import get_probe from .properties import ( # noqa: F401 @@ -61,6 +62,7 @@ async def on_select_handler(): @pytest.fixture async def widget(content1, content2, content3, on_select_handler): + skip_on_backends("toga_winui3") with safe_create(): return toga.OptionContainer( content=[ @@ -81,6 +83,7 @@ async def widget(content1, content2, content3, on_select_handler): # Pass a function here to prevent init of toga.Box() in a different thread than # toga.OptionContainer. This would raise a runtime error on Windows. lambda: toga.OptionContainer(content=[("Tab 1", toga.Box())]), + skip_backends=("toga_winui3",), ) diff --git a/testbed/tests/widgets/test_passwordinput.py b/testbed/tests/widgets/test_passwordinput.py index db5bb7fe46..5a8098d431 100644 --- a/testbed/tests/widgets/test_passwordinput.py +++ b/testbed/tests/widgets/test_passwordinput.py @@ -2,6 +2,7 @@ import toga +from ..conftest import skip_on_backends from .conftest import build_cleanup_test from .properties import ( # noqa: F401 test_background_color, @@ -36,6 +37,7 @@ @pytest.fixture async def widget(): + skip_on_backends("toga_winui3") return toga.PasswordInput(value="sekrit") @@ -45,7 +47,7 @@ def verify_font_sizes(): return False, True -test_cleanup = build_cleanup_test(toga.PasswordInput) +test_cleanup = build_cleanup_test(toga.PasswordInput, skip_backends=("toga_winui3",)) async def test_value_hidden(widget, probe): diff --git a/testbed/tests/widgets/test_progressbar.py b/testbed/tests/widgets/test_progressbar.py index 931576891f..2ae50fa660 100644 --- a/testbed/tests/widgets/test_progressbar.py +++ b/testbed/tests/widgets/test_progressbar.py @@ -2,6 +2,7 @@ import toga +from ..conftest import skip_on_backends from .conftest import build_cleanup_test from .properties import ( # noqa: F401 test_enable_noop, @@ -17,10 +18,11 @@ @pytest.fixture async def widget(): + skip_on_backends("toga_winui3") return toga.ProgressBar(max=100, value=5) -test_cleanup = build_cleanup_test(toga.ProgressBar) +test_cleanup = build_cleanup_test(toga.ProgressBar, skip_backends=("toga_winui3",)) async def test_start_stop_determinate(widget, probe): diff --git a/testbed/tests/widgets/test_scrollcontainer.py b/testbed/tests/widgets/test_scrollcontainer.py index 0f8dcc292d..0ed788e349 100644 --- a/testbed/tests/widgets/test_scrollcontainer.py +++ b/testbed/tests/widgets/test_scrollcontainer.py @@ -7,6 +7,7 @@ from toga.colors import CORNFLOWERBLUE, REBECCAPURPLE, TRANSPARENT from toga.style.pack import COLUMN, ROW, Pack +from ..conftest import skip_on_backends from .conftest import build_cleanup_test from .properties import ( # noqa: F401 test_background_color, @@ -68,6 +69,7 @@ async def on_scroll(): @pytest.fixture async def widget(content, on_scroll): + skip_on_backends("toga_winui3") return toga.ScrollContainer( content=content, style=Pack(flex=1), on_scroll=on_scroll ) @@ -77,6 +79,7 @@ async def widget(content, on_scroll): # Pass a function here to prevent init of toga.Box() in a different thread than # toga.ScrollContainer. This would raise a runtime error on Windows. lambda: toga.ScrollContainer(content=toga.Box()), + skip_backends=("toga_winui3",), ) diff --git a/testbed/tests/widgets/test_selection.py b/testbed/tests/widgets/test_selection.py index 0bc5f1e530..f850e1f7aa 100644 --- a/testbed/tests/widgets/test_selection.py +++ b/testbed/tests/widgets/test_selection.py @@ -6,6 +6,7 @@ from toga.constants import CENTER from toga.sources import ListListener, ListSource +from ..conftest import skip_on_backends from .conftest import build_cleanup_test from .properties import ( # noqa: F401 test_background_color, @@ -38,6 +39,7 @@ @pytest.fixture async def widget(): + skip_on_backends("toga_winui3") return toga.Selection(items=["first", "second", "third"]) @@ -55,6 +57,7 @@ def verify_vertical_text_align(): test_cleanup = build_cleanup_test( toga.Selection, kwargs={"items": ["first", "second", "third"]}, + skip_backends=("toga_winui3",), ) diff --git a/testbed/tests/widgets/test_slider.py b/testbed/tests/widgets/test_slider.py index 394efc88ae..0387900c14 100644 --- a/testbed/tests/widgets/test_slider.py +++ b/testbed/tests/widgets/test_slider.py @@ -7,6 +7,7 @@ import toga from ..assertions import assert_set_get +from ..conftest import skip_on_backends from .conftest import build_cleanup_test from .properties import ( # noqa: F401 test_enabled, @@ -32,6 +33,7 @@ @fixture async def widget(): + skip_on_backends("toga_winui3") return toga.Slider() @@ -42,7 +44,7 @@ def on_change(widget): return handler -test_cleanup = build_cleanup_test(toga.Slider) +test_cleanup = build_cleanup_test(toga.Slider, skip_backends=("toga_winui3",)) async def test_init(widget, probe): @@ -54,6 +56,7 @@ async def test_init(widget, probe): async def test_init_handlers(): + skip_on_backends("toga_winui3") handlers = { name: Mock(name=name) for name in ["on_change", "on_press", "on_release"] } diff --git a/testbed/tests/widgets/test_splitcontainer.py b/testbed/tests/widgets/test_splitcontainer.py index 282340bc03..3ac4b8eaa2 100644 --- a/testbed/tests/widgets/test_splitcontainer.py +++ b/testbed/tests/widgets/test_splitcontainer.py @@ -6,7 +6,7 @@ from toga.constants import Direction from toga.style.pack import Pack -from ..conftest import skip_on_platforms +from ..conftest import skip_on_backends from .conftest import build_cleanup_test from .probe import get_probe from .properties import ( # noqa: F401 @@ -57,7 +57,7 @@ async def content3_probe(content3): @pytest.fixture async def widget(content1, content2): - skip_on_platforms("android", "iOS") + skip_on_backends("toga_android", "toga_iOS", "toga_winui3") return toga.SplitContainer(content=[content1, content2], style=Pack(flex=1)) @@ -65,7 +65,7 @@ async def widget(content1, content2): # Pass a function here to prevent init of toga.Box() in a different thread than # toga.SplitContainer. This would raise a runtime error on Windows. lambda: toga.SplitContainer(content=[toga.Box(), toga.Box()]), - skip_platforms=("android", "iOS"), + skip_backends=("toga_android", "toga_iOS", "toga_winui3"), ) diff --git a/testbed/tests/widgets/test_switch.py b/testbed/tests/widgets/test_switch.py index 2451d350d7..1d773cc0f5 100644 --- a/testbed/tests/widgets/test_switch.py +++ b/testbed/tests/widgets/test_switch.py @@ -4,6 +4,7 @@ import toga +from ..conftest import skip_on_backends from ..data import TEXTS from .conftest import build_cleanup_test from .properties import ( # noqa: F401 @@ -28,12 +29,14 @@ @fixture async def widget(): + skip_on_backends("toga_winui3") return toga.Switch("Hello") test_cleanup = build_cleanup_test( toga.Switch, args=("Hello",), + skip_backends=("toga_winui3",), ) diff --git a/testbed/tests/widgets/test_table.py b/testbed/tests/widgets/test_table.py index 2f02d56110..ca754de9b2 100644 --- a/testbed/tests/widgets/test_table.py +++ b/testbed/tests/widgets/test_table.py @@ -7,7 +7,7 @@ from toga.sources import AccessorColumn, ListListener, ListSource from toga.style.pack import Pack -from ..conftest import skip_on_platforms +from ..conftest import skip_on_backends from .conftest import build_cleanup_test from .probe import get_probe from .properties import ( # noqa: F401 @@ -54,7 +54,7 @@ def source(): @pytest.fixture async def widget(source, on_select_handler, on_activate_handler): - skip_on_platforms("iOS") + skip_on_backends("toga_iOS", "toga_winui3") return toga.Table( ["A", "B", "C"], data=source, @@ -67,7 +67,7 @@ async def widget(source, on_select_handler, on_activate_handler): @pytest.fixture async def headerless_widget(source, on_select_handler): - skip_on_platforms("iOS") + skip_on_backends("toga_iOS", "toga_winui3") return toga.Table( columns=[ AccessorColumn(None, "a"), @@ -98,7 +98,7 @@ async def headerless_probe(main_window, headerless_widget): @pytest.fixture async def multiselect_widget(source, on_select_handler): - skip_on_platforms("iOS") + skip_on_backends("toga_iOS", "toga_winui3") return toga.Table( ["A", "B", "C"], data=source, @@ -125,7 +125,7 @@ async def multiselect_probe(main_window, multiselect_widget): test_cleanup = build_cleanup_test( toga.Table, kwargs={"columns": ["A", "B", "C"]}, - skip_platforms=("iOS",), + skip_backends=("toga_iOS", "toga_winui3"), ) diff --git a/testbed/tests/widgets/test_textinput.py b/testbed/tests/widgets/test_textinput.py index 939f30d616..746bd72209 100644 --- a/testbed/tests/widgets/test_textinput.py +++ b/testbed/tests/widgets/test_textinput.py @@ -7,6 +7,7 @@ from toga.style import Pack from toga.style.pack import RIGHT, SERIF +from ..conftest import skip_on_backends from ..data import TEXTS from .conftest import build_cleanup_test from .properties import ( # noqa: F401 @@ -30,6 +31,7 @@ @pytest.fixture async def widget(): + skip_on_backends("toga_winui3") return toga.TextInput(value="Hello") diff --git a/testbed/tests/widgets/test_timeinput.py b/testbed/tests/widgets/test_timeinput.py index c6169bd1f0..c1ecd1e48b 100644 --- a/testbed/tests/widgets/test_timeinput.py +++ b/testbed/tests/widgets/test_timeinput.py @@ -75,19 +75,19 @@ def normalize_time(value): @fixture async def widget(): - skip_on_backends("toga_gtk") + skip_on_backends("toga_gtk", "toga_winui3") return toga.TimeInput() test_cleanup = build_cleanup_test( toga.TimeInput, - skip_backends=("toga_gtk",), + skip_backends=("toga_gtk", "toga_winui3"), ) async def test_init(normalize): "Properties can be set in the constructor" - skip_on_backends("toga_gtk") + skip_on_backends("toga_gtk", "toga_winui3") value = time(10, 10, 30) min = time(2, 3, 4) diff --git a/testbed/tests/widgets/test_tree.py b/testbed/tests/widgets/test_tree.py index 14f5648824..208b3e8963 100644 --- a/testbed/tests/widgets/test_tree.py +++ b/testbed/tests/widgets/test_tree.py @@ -7,7 +7,7 @@ from toga.sources import AccessorColumn, ListListener, TreeListener, TreeSource from toga.style.pack import Pack -from ..conftest import skip_on_platforms +from ..conftest import skip_on_backends from .conftest import build_cleanup_test from .probe import get_probe from .properties import ( # noqa: F401 @@ -107,7 +107,7 @@ def source(): @pytest.fixture async def widget(source, on_select_handler, on_activate_handler): - skip_on_platforms("iOS", "android") + skip_on_backends("toga_android", "toga_iOS", "toga_winui3") return toga.Tree( ["A", "B", "C"], data=source, @@ -120,7 +120,7 @@ async def widget(source, on_select_handler, on_activate_handler): @pytest.fixture async def headerless_widget(source, on_select_handler): - skip_on_platforms("iOS", "android") + skip_on_backends("toga_android", "toga_iOS", "toga_winui3") return toga.Tree( columns=[ AccessorColumn(None, "a"), @@ -152,7 +152,7 @@ async def headerless_probe(main_window, headerless_widget): @pytest.fixture async def multiselect_widget(source, on_select_handler): # Although Android *has* a table implementation, it needs to be rebuilt. - skip_on_platforms("iOS", "android") + skip_on_backends("toga_android", "toga_iOS", "toga_winui3") return toga.Tree( ["A", "B", "C"], data=source, @@ -179,7 +179,7 @@ async def multiselect_probe(main_window, multiselect_widget): test_cleanup = build_cleanup_test( toga.Tree, kwargs={"columns": ["A", "B", "C"]}, - skip_platforms=("iOS", "android"), + skip_backends=("toga_android", "toga_iOS", "toga_winui3"), ) diff --git a/testbed/tests/widgets/test_webview.py b/testbed/tests/widgets/test_webview.py index 57623b88e5..256c25ae5f 100644 --- a/testbed/tests/widgets/test_webview.py +++ b/testbed/tests/widgets/test_webview.py @@ -10,6 +10,7 @@ import toga from toga.style import Pack +from ..conftest import skip_on_backends from .conftest import build_cleanup_test, safe_create from .properties import ( # noqa: F401 test_flex_widget_size, @@ -80,6 +81,7 @@ async def on_load(): @pytest.fixture async def widget(on_load): + skip_on_backends("toga_winui3") with safe_create(): widget = toga.WebView(style=Pack(flex=1), on_webview_load=on_load) @@ -118,7 +120,11 @@ async def widget(on_load): toga.App.app._gc_protector.append(widget) -test_cleanup = build_cleanup_test(toga.WebView, xfail_backends=("toga_gtk",)) +test_cleanup = build_cleanup_test( + toga.WebView, + xfail_backends=("toga_gtk",), + skip_backends=("toga_winui3",), +) @pytest.mark.flaky(retries=5, delay=1) diff --git a/testbed/tests/window/test_dialogs.py b/testbed/tests/window/test_dialogs.py index aafbe23de7..a912f1e3dc 100644 --- a/testbed/tests/window/test_dialogs.py +++ b/testbed/tests/window/test_dialogs.py @@ -8,9 +8,14 @@ import toga +from ..conftest import skip_on_backends + TESTS_DIR = Path(__file__).parent.parent +skip_on_backends("toga_winui3", allow_module_level=True) + + @pytest.fixture async def wait_for_dialog_to_close(main_window): """Wait for any asyncio task that is responsible for closing the dialog. From d1db8c0e2d0ca5ce19f5c141d6184b60c9aa5818 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:43:40 +0200 Subject: [PATCH 026/110] Add skips to test_app.py and make menu tests async --- android/tests_backend/app.py | 8 +++---- cocoa/tests_backend/app.py | 8 +++---- gtk/tests_backend/app.py | 8 +++---- iOS/tests_backend/app.py | 8 +++---- qt/tests_backend/app.py | 8 +++---- testbed/tests/app/test_app.py | 44 +++++++++++++++++++++-------------- winforms/tests_backend/app.py | 8 +++---- 7 files changed, 50 insertions(+), 42 deletions(-) diff --git a/android/tests_backend/app.py b/android/tests_backend/app.py index 6b7c50e6a7..f7324f7520 100644 --- a/android/tests_backend/app.py +++ b/android/tests_backend/app.py @@ -84,13 +84,13 @@ async def close_about_dialog(self): assert about_dialog is not None, "No about dialog displayed" await self.press_dialog_button(about_dialog, "OK") - def activate_menu_visit_homepage(self): + async def activate_menu_visit_homepage(self): pytest.xfail("This backend doesn't have a visit homepage command") - def assert_menu_item(self, path, *, enabled=True): + async def assert_menu_item(self, path, *, enabled=True): assert self._menu_item(path).isEnabled() == enabled - def assert_menu_order(self, path, expected): + async def assert_menu_order(self, path, expected): item = self._menu_item(path) menu = item.getSubMenu() @@ -104,7 +104,7 @@ def assert_menu_order(self, path, expected): else: assert menu.getItem(i - separator_offset).getTitle() == title - def assert_system_menus(self): + async def assert_system_menus(self): self.assert_menu_item(["About Toga Testbed"]) def activate_menu_close_window(self): diff --git a/cocoa/tests_backend/app.py b/cocoa/tests_backend/app.py index 71a028c666..70ae3e0cde 100644 --- a/cocoa/tests_backend/app.py +++ b/cocoa/tests_backend/app.py @@ -159,10 +159,10 @@ async def close_about_dialog(self): if isinstance(about_dialog, NSPanel): about_dialog.close() - def activate_menu_visit_homepage(self): + async def activate_menu_visit_homepage(self): self._activate_menu_item(["Help", "Visit homepage"]) - def assert_system_menus(self): + async def assert_system_menus(self): self.assert_menu_item(["*", "About Toga Testbed"], enabled=True) self.assert_menu_item(["*", "Hide Toga Testbed"], enabled=True) self.assert_menu_item(["*", "Hide Others"], enabled=True) @@ -221,11 +221,11 @@ def assert_dialog_in_focus(self, dialog): "The dialog is not in focus" ) - def assert_menu_item(self, path, enabled): + async def assert_menu_item(self, path, enabled): item = self._menu_item(path) assert item.isEnabled() == enabled - def assert_menu_order(self, path, expected): + async def assert_menu_order(self, path, expected): menu = self._menu_item(path).submenu assert menu.numberOfItems == len(expected) diff --git a/gtk/tests_backend/app.py b/gtk/tests_backend/app.py index b5f71c831e..8404953af6 100644 --- a/gtk/tests_backend/app.py +++ b/gtk/tests_backend/app.py @@ -150,11 +150,11 @@ async def close_about_dialog(self): pytest.skip("GTK4 doesn't support system menus") self.app._impl._close_about(self.app._impl.native_about_dialog) - def activate_menu_visit_homepage(self): + async def activate_menu_visit_homepage(self): # Homepage is a link on the GTK about page. pytest.xfail("GTK doesn't have a visit homepage menu item") - def assert_system_menus(self): + async def assert_system_menus(self): if GTK_VERSION >= (4, 0, 0): pytest.skip("GTK4 doesn't support system menus") self.assert_menu_item(["*", "Preferences"], enabled=False) @@ -179,13 +179,13 @@ def activate_menu_close_all_windows(self): def activate_menu_minimize(self): pytest.xfail("GTK doesn't have a window management menu items") - def assert_menu_item(self, path, enabled): + async def assert_menu_item(self, path, enabled): if GTK_VERSION >= (4, 0, 0): pytest.skip("GTK4 doesn't support menu items") _, action = self._menu_item(path) assert action.get_enabled() == enabled - def assert_menu_order(self, path, expected): + async def assert_menu_order(self, path, expected): if GTK_VERSION >= (4, 0, 0): pytest.skip("GTK4 doesn't support menu items") item, action = self._menu_item(path) diff --git a/iOS/tests_backend/app.py b/iOS/tests_backend/app.py index 6c55e3bdb0..17095b956e 100644 --- a/iOS/tests_backend/app.py +++ b/iOS/tests_backend/app.py @@ -57,19 +57,19 @@ def assert_dialog_in_focus(self, dialog): "The dialog is not in focus" ) - def assert_system_menus(self): + async def assert_system_menus(self): pytest.skip("Menus not implemented on iOS") def activate_menu_about(self): pytest.skip("Menus not implemented on iOS") - def activate_menu_visit_homepage(self): + async def activate_menu_visit_homepage(self): pytest.skip("Menus not implemented on iOS") - def assert_menu_item(self, path, enabled): + async def assert_menu_item(self, path, enabled): pytest.skip("Menus not implemented on iOS") - def assert_menu_order(self, path, expected): + async def assert_menu_order(self, path, expected): pytest.skip("Menus not implemented on iOS") def enter_background(self): diff --git a/qt/tests_backend/app.py b/qt/tests_backend/app.py index 0003201fe6..217a70c33c 100644 --- a/qt/tests_backend/app.py +++ b/qt/tests_backend/app.py @@ -88,18 +88,18 @@ def activate_menu_about(self): async def close_about_dialog(self): self.impl._about_dialog.done(QDialog.DialogCode.Accepted) - def activate_menu_visit_homepage(self): + async 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): + async def assert_menu_item(self, path, *, enabled=True): item = self._menu_item(path) assert item.isEnabled() == enabled - def assert_menu_order(self, path, expected): + async def assert_menu_order(self, path, expected): menu = self._menu_item(path) actual_titles = [ action.text() if action.isSeparator() is False else "---" @@ -107,7 +107,7 @@ def assert_menu_order(self, path, expected): ] assert actual_titles == expected - def assert_system_menus(self): + async def assert_system_menus(self): self.assert_menu_item( ["Settings", "Configure Toga Testbed (Qt)"], enabled=False, diff --git a/testbed/tests/app/test_app.py b/testbed/tests/app/test_app.py index 8b05f0b47c..2011acafd9 100644 --- a/testbed/tests/app/test_app.py +++ b/testbed/tests/app/test_app.py @@ -5,6 +5,8 @@ import toga +from ..conftest import skip_on_backends + async def test_unsupported_widget(app): """If a widget isn't implemented, the factory raises NotImplementedError.""" @@ -20,6 +22,7 @@ async def test_unsupported_widget(app): async def test_main_window_toolbar(app, main_window, main_window_probe): """A toolbar can be added to a main window""" + skip_on_backends("toga_winui3") # Add some items to show the toolbar assert not main_window_probe.has_toolbar() main_window.toolbar.add(app.cmd1, app.cmd2) @@ -103,10 +106,11 @@ async def test_main_window_toolbar(app, main_window, main_window_probe): async def test_system_menus(app_probe): """System-specific menus behave as expected""" # Check that the system menus (which can be platform specific) exist. - app_probe.assert_system_menus() + await app_probe.assert_system_menus() async def test_menu_about(monkeypatch, app, app_probe): + skip_on_backends("toga_winui3", reason="Dialogs are not implemented yet.") """The about menu can be displayed""" app_probe.activate_menu_about() # When in CI, Cocoa needs a little time to guarantee the dialog is displayed. @@ -140,7 +144,7 @@ async def test_menu_visit_homepage(monkeypatch, app, app_probe): app.commands[toga.Command.VISIT_HOMEPAGE], "_action", app.visit_homepage ) - app_probe.activate_menu_visit_homepage() + await app_probe.activate_menu_visit_homepage() # Browser opened visit_homepage.assert_called_once_with() @@ -149,53 +153,57 @@ async def test_menu_visit_homepage(monkeypatch, app, app_probe): async def test_menu_items(app, app_probe): """Menu items can be created, disabled and invoked""" - app_probe.assert_menu_item( + await app_probe.assert_menu_item( ["Other", "Full command"], enabled=True, ) - app_probe.assert_menu_item( + + await app_probe.assert_menu_item( ["Other", "Submenu1", "Disabled"], enabled=False, ) - app_probe.assert_menu_item( + + await app_probe.assert_menu_item( ["Other", "Submenu1", "No Action"], enabled=False, ) - app_probe.assert_menu_item( + + await app_probe.assert_menu_item( ["Other", "Submenu1", "Submenu1 menu1", "Deep"], enabled=True, ) - app_probe.assert_menu_item( + + await app_probe.assert_menu_item( ["Other", "Wiggle"], enabled=True, ) - app_probe.assert_menu_order( + await app_probe.assert_menu_order( ["Other"], ["Full command", "---", "Submenu1", "Submenu2", "Wiggle"], ) - app_probe.assert_menu_order( + await app_probe.assert_menu_order( ["Other", "Submenu1"], ["Disabled", "No Action", "Submenu1 menu1"], ) - app_probe.assert_menu_order( + await app_probe.assert_menu_order( ["Other", "Submenu1", "Submenu1 menu1"], ["Deep"], ) - app_probe.assert_menu_order( + await app_probe.assert_menu_order( ["Other", "Submenu2"], ["Jiggle"], ) - app_probe.assert_menu_item( + await app_probe.assert_menu_item( ["Commands", "No Tooltip"], enabled=True, ) - app_probe.assert_menu_item( + await app_probe.assert_menu_item( ["Commands", "No Icon"], enabled=True, ) - app_probe.assert_menu_item( + await app_probe.assert_menu_item( ["Commands", "Sectioned"], enabled=True, ) @@ -205,12 +213,12 @@ async def test_menu_items(app, app_probe): app.no_action_cmd.enabled = True await app_probe.redraw("Menu items enabled") - app_probe.assert_menu_item( + await app_probe.assert_menu_item( ["Other", "Submenu1", "Disabled"], enabled=True, ) # Item has no action - it can't be enabled - app_probe.assert_menu_item( + await app_probe.assert_menu_item( ["Other", "Submenu1", "No Action"], enabled=False, ) @@ -220,11 +228,11 @@ async def test_menu_items(app, app_probe): app.no_action_cmd.enabled = False await app_probe.redraw("Menu item disabled again") - app_probe.assert_menu_item( + await app_probe.assert_menu_item( ["Other", "Submenu1", "Disabled"], enabled=False, ) - app_probe.assert_menu_item( + await app_probe.assert_menu_item( ["Other", "Submenu1", "No Action"], enabled=False, ) diff --git a/winforms/tests_backend/app.py b/winforms/tests_backend/app.py index eb0746ebbb..8975ec2fa7 100644 --- a/winforms/tests_backend/app.py +++ b/winforms/tests_backend/app.py @@ -150,7 +150,7 @@ def activate_menu_about(self): async def close_about_dialog(self): await self.type_character("\n") - def activate_menu_visit_homepage(self): + async def activate_menu_visit_homepage(self): self._activate_menu_item(["Help", "Visit homepage"]) def assert_dialog_in_focus(self, dialog): @@ -164,7 +164,7 @@ def assert_dialog_in_focus(self, dialog): "The dialog is not in focus" ) - def assert_menu_item(self, path, *, enabled=True): + async def assert_menu_item(self, path, *, enabled=True): item = self._menu_item(path) assert item.Enabled == enabled @@ -181,7 +181,7 @@ def assert_menu_item(self, path, *, enabled=True): else: assert item.ShortcutKeyDisplayString == shortcut - def assert_menu_order(self, path, expected): + async def assert_menu_order(self, path, expected): menu = self._menu_item(path) assert len(menu.DropDownItems) == len(expected) @@ -191,7 +191,7 @@ def assert_menu_order(self, path, expected): else: assert item.Text == title - def assert_system_menus(self): + async def assert_system_menus(self): 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) From ea4af2e383fbcc1c3dd4b8120d19f6c8818bd66d Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:14:41 +0200 Subject: [PATCH 027/110] Add await within app_probe.assert_system_menus() --- android/tests_backend/app.py | 2 +- cocoa/tests_backend/app.py | 54 +++++++++++++++++------------------ gtk/tests_backend/app.py | 24 ++++++++-------- qt/tests_backend/app.py | 34 +++++++++++----------- winforms/tests_backend/app.py | 22 +++++++------- 5 files changed, 68 insertions(+), 68 deletions(-) diff --git a/android/tests_backend/app.py b/android/tests_backend/app.py index f7324f7520..a73ada8f06 100644 --- a/android/tests_backend/app.py +++ b/android/tests_backend/app.py @@ -105,7 +105,7 @@ async def assert_menu_order(self, path, expected): assert menu.getItem(i - separator_offset).getTitle() == title async def assert_system_menus(self): - self.assert_menu_item(["About Toga Testbed"]) + await self.assert_menu_item(["About Toga Testbed"]) def activate_menu_close_window(self): pytest.xfail("This backend doesn't have a window management menu") diff --git a/cocoa/tests_backend/app.py b/cocoa/tests_backend/app.py index 70ae3e0cde..4a4f1db1f2 100644 --- a/cocoa/tests_backend/app.py +++ b/cocoa/tests_backend/app.py @@ -163,33 +163,33 @@ async def activate_menu_visit_homepage(self): self._activate_menu_item(["Help", "Visit homepage"]) async def assert_system_menus(self): - self.assert_menu_item(["*", "About Toga Testbed"], enabled=True) - self.assert_menu_item(["*", "Hide Toga Testbed"], enabled=True) - self.assert_menu_item(["*", "Hide Others"], enabled=True) - self.assert_menu_item(["*", "Show All"], enabled=True) - self.assert_menu_item(["*", "Quit Toga Testbed"], 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\u2026"], enabled=True) - self.assert_menu_item(["File", "Save"], enabled=True) - self.assert_menu_item(["File", "Save As\u2026"], enabled=True) - self.assert_menu_item(["File", "Save All"], enabled=True) - self.assert_menu_item(["File", "Close"], enabled=True) - self.assert_menu_item(["File", "Close All"], enabled=True) - - self.assert_menu_item(["Edit", "Undo"], enabled=True) - self.assert_menu_item(["Edit", "Redo"], enabled=True) - self.assert_menu_item(["Edit", "Cut"], enabled=True) - self.assert_menu_item(["Edit", "Copy"], enabled=True) - self.assert_menu_item(["Edit", "Paste"], enabled=True) - self.assert_menu_item(["Edit", "Paste and Match Style"], enabled=True) - self.assert_menu_item(["Edit", "Delete"], enabled=True) - self.assert_menu_item(["Edit", "Select All"], enabled=True) - - self.assert_menu_item(["Window", "Minimize"], enabled=True) - - self.assert_menu_item(["Help", "Visit homepage"], enabled=True) + await self.assert_menu_item(["*", "About Toga Testbed"], enabled=True) + await self.assert_menu_item(["*", "Hide Toga Testbed"], enabled=True) + await self.assert_menu_item(["*", "Hide Others"], enabled=True) + await self.assert_menu_item(["*", "Show All"], enabled=True) + await self.assert_menu_item(["*", "Quit Toga Testbed"], enabled=True) + + await self.assert_menu_item(["File", "New Example Document"], enabled=True) + await self.assert_menu_item(["File", "New Read-only Document"], enabled=True) + await self.assert_menu_item(["File", "Open\u2026"], enabled=True) + await self.assert_menu_item(["File", "Save"], enabled=True) + await self.assert_menu_item(["File", "Save As\u2026"], enabled=True) + await self.assert_menu_item(["File", "Save All"], enabled=True) + await self.assert_menu_item(["File", "Close"], enabled=True) + await self.assert_menu_item(["File", "Close All"], enabled=True) + + await self.assert_menu_item(["Edit", "Undo"], enabled=True) + await self.assert_menu_item(["Edit", "Redo"], enabled=True) + await self.assert_menu_item(["Edit", "Cut"], enabled=True) + await self.assert_menu_item(["Edit", "Copy"], enabled=True) + await self.assert_menu_item(["Edit", "Paste"], enabled=True) + await self.assert_menu_item(["Edit", "Paste and Match Style"], enabled=True) + await self.assert_menu_item(["Edit", "Delete"], enabled=True) + await self.assert_menu_item(["Edit", "Select All"], enabled=True) + + await self.assert_menu_item(["Window", "Minimize"], enabled=True) + + await self.assert_menu_item(["Help", "Visit homepage"], enabled=True) def _activate_menu_window_item(self, path): item = self._menu_item(path) diff --git a/gtk/tests_backend/app.py b/gtk/tests_backend/app.py index 8404953af6..446c322915 100644 --- a/gtk/tests_backend/app.py +++ b/gtk/tests_backend/app.py @@ -157,18 +157,18 @@ async def activate_menu_visit_homepage(self): async def assert_system_menus(self): if GTK_VERSION >= (4, 0, 0): pytest.skip("GTK4 doesn't support system menus") - self.assert_menu_item(["*", "Preferences"], enabled=False) - self.assert_menu_item(["*", "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", "Visit homepage"], enabled=True) - self.assert_menu_item(["Help", "About Toga Testbed"], enabled=True) + await self.assert_menu_item(["*", "Preferences"], enabled=False) + await self.assert_menu_item(["*", "Quit"], enabled=True) + + await self.assert_menu_item(["File", "New Example Document"], enabled=True) + await self.assert_menu_item(["File", "New Read-only Document"], enabled=True) + await self.assert_menu_item(["File", "Open..."], enabled=True) + await self.assert_menu_item(["File", "Save"], enabled=True) + await self.assert_menu_item(["File", "Save As..."], enabled=True) + await self.assert_menu_item(["File", "Save All"], enabled=True) + + await self.assert_menu_item(["Help", "Visit homepage"], enabled=True) + await self.assert_menu_item(["Help", "About Toga Testbed"], enabled=True) def activate_menu_close_window(self): pytest.xfail("GTK doesn't have a window management menu items") diff --git a/qt/tests_backend/app.py b/qt/tests_backend/app.py index 217a70c33c..8e94b7d465 100644 --- a/qt/tests_backend/app.py +++ b/qt/tests_backend/app.py @@ -108,26 +108,26 @@ async def assert_menu_order(self, path, expected): assert actual_titles == expected async def assert_system_menus(self): - self.assert_menu_item( + await self.assert_menu_item( ["Settings", "Configure Toga Testbed (Qt)"], 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 (Qt)"], 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"]) + await self.assert_menu_item(["File", "Quit"], enabled=True) + + await self.assert_menu_item(["File", "New Example Document"], enabled=True) + await self.assert_menu_item(["File", "New Read-only Document"], enabled=True) + await self.assert_menu_item(["File", "Open..."], enabled=True) + await self.assert_menu_item(["File", "Save"], enabled=True) + await self.assert_menu_item(["File", "Save As..."], enabled=True) + await self.assert_menu_item(["File", "Save All"], enabled=True) + + await self.assert_menu_item(["Help", "About Toga Testbed (Qt)"], enabled=True) + + await self.assert_menu_item(["Edit", "Undo"]) + await self.assert_menu_item(["Edit", "Redo"]) + await self.assert_menu_item(["Edit", "Cut"]) + await self.assert_menu_item(["Edit", "Copy"]) + await self.assert_menu_item(["Edit", "Paste"]) def activate_menu_close_window(self): pytest.xfail("KDE apps do not include Close in the menu bar") diff --git a/winforms/tests_backend/app.py b/winforms/tests_backend/app.py index 8975ec2fa7..2938054243 100644 --- a/winforms/tests_backend/app.py +++ b/winforms/tests_backend/app.py @@ -192,17 +192,17 @@ async def assert_menu_order(self, path, expected): assert item.Text == title async def assert_system_menus(self): - 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(["File", "Preferences"], enabled=False) - self.assert_menu_item(["File", "Exit"]) - - self.assert_menu_item(["Help", "Visit homepage"]) - self.assert_menu_item(["Help", "About Toga Testbed"]) + await self.assert_menu_item(["File", "New Example Document"], enabled=True) + await self.assert_menu_item(["File", "New Read-only Document"], enabled=True) + await self.assert_menu_item(["File", "Open..."], enabled=True) + await self.assert_menu_item(["File", "Save"], enabled=True) + await self.assert_menu_item(["File", "Save As..."], enabled=True) + await self.assert_menu_item(["File", "Save All"], enabled=True) + await self.assert_menu_item(["File", "Preferences"], enabled=False) + await self.assert_menu_item(["File", "Exit"]) + + await self.assert_menu_item(["Help", "Visit homepage"]) + await self.assert_menu_item(["Help", "About Toga Testbed"]) def activate_menu_close_window(self): pytest.xfail("This platform doesn't have a window management menu") From fbe9c52144c07ce329a82859ef68f203fc5f4f25 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:37:00 +0200 Subject: [PATCH 028/110] Add base, app and window probes - test_app passing --- winui3/tests_backend/app.py | 415 +++++++++++++++++++++++++++++++++ winui3/tests_backend/probe.py | 127 ++++++++++ winui3/tests_backend/window.py | 71 ++++++ 3 files changed, 613 insertions(+) create mode 100644 winui3/tests_backend/app.py create mode 100644 winui3/tests_backend/probe.py create mode 100644 winui3/tests_backend/window.py diff --git a/winui3/tests_backend/app.py b/winui3/tests_backend/app.py new file mode 100644 index 0000000000..163c370d99 --- /dev/null +++ b/winui3/tests_backend/app.py @@ -0,0 +1,415 @@ +import asyncio +from ctypes import byref, sizeof, windll +from ctypes import byref, sizeof, wintypes as wt +from pathlib import Path +from time import sleep + +import PIL.Image +import pytest + +from win32more.Microsoft.UI.Input import InputCursor +from win32more.Microsoft.UI.Interop import GetWindowFromWindowId +from win32more.Microsoft.UI.Xaml import FocusState, Window +from win32more.Microsoft.UI.Xaml.Controls import ( + MenuBarItem, + MenuFlyout, + MenuFlyoutItem, + MenuFlyoutSeparator, + MenuFlyoutSubItem, +) +from win32more.Windows.Win32.Foundation import POINT, RECT +from win32more.Windows.Win32.UI.Input.KeyboardAndMouse import ( + GetFocus, + VK_RETURN, + VK_RWIN, + VK_B, +) +from win32more.Windows.Win32.UI.WindowsAndMessaging import ( + CURSORINFO, + GetCursorInfo, + SendMessageW, + PostMessageW, + SetForegroundWindow, + WM_GETICON, + WM_KEYDOWN, + WM_KEYUP, + WM_SETCURSOR, + HTCLIENT, + WM_MOUSEMOVE, + WM_NCDESTROY, + GetWindowThreadProcessId, +) + +from win32more.Windows.Win32.UI.WindowsAndMessaging import ( + IDC_ARROW, + LoadCursorW, + SetCursor, +) + + +import toga + +import toga_winui3.libs.win32structures as ws +from toga_winui3.libs.winui3app import WinUI3App +from toga_winui3.libs.gdiplus import icon_pixels +from toga_winui3.libs.shell import Shell_NotifyIconGetRect + +from .probe import BaseProbe + +from toga_winui3.libs import win32constants as wc, win32structures as ws +from toga_winui3.libs.comctl32 import ( + DefSubclassProc, + RemoveWindowSubclass, + SetWindowSubclass, +) +from toga_winui3.libs.shell import Shell_NotifyIconW +from toga_winui3.libs.misc import loword, get_x_lparam, get_y_lparam + +from win32more.Windows.Win32.UI.WindowsAndMessaging import ( + SetForegroundWindow, + TITLEBARINFOEX, + WM_GETTITLEBARINFOEX +) + + +class AppProbe(BaseProbe): + formal_name = "Toga Testbed (WinUI 3)" + supports_key = True + supports_key_mod3 = False + supports_current_window_assignment = True + supports_dark_mode = True + edit_menu_noop_enabled = False + supports_psutil = True + + def __init__(self, app): + super().__init__() + self.app = app + self.main_window = app.main_window + + # The WinUI3App class is a child class of with Microsoft.UI.Xaml.Application + # class, which is a singleton instance. + assert self.app._impl.native == WinUI3App + assert isinstance(self.app._impl.native_instance, WinUI3App) + + @property + def _hwnd(self): + """The handle of the main window.""" + return GetWindowFromWindowId(self.main_window._impl.native.AppWindow.Id) + + #################################################################################### + # Paths + #################################################################################### + + @property + def config_path(self): + return Path.home() / "AppData/Local/Tiberius Yak/Toga Testbed (WinUI 3)/Config" + + @property + def data_path(self): + return Path.home() / "AppData/Local/Tiberius Yak/Toga Testbed (WinUI 3)/Data" + + @property + def cache_path(self): + return Path.home() / "AppData/Local/Tiberius Yak/Toga Testbed (WinUI 3)/Cache" + + @property + def logs_path(self): + return Path.home() / "AppData/Local/Tiberius Yak/Toga Testbed (WinUI 3)/Logs" + + #################################################################################### + # Menu tests + #################################################################################### + + def _menu_children(self, menu): + children = [ + self._menu_item_casted(child) + for child in menu.Items + ] + child_labels = [ + self._menu_item_label(child) + for child in children + ] + return children, child_labels + + async def _menu_item(self, path, open_menus=False): + """Select a menu item with the given path.""" + # Note that retrieving a submenu's items via menu.Items gives a list of + # MenuFlyoutItemBase objects. These need to be casted manually to the + # appropriate types. + + item = self.main_window._impl.menu_native + for i, label in enumerate(path): + children, child_labels = self._menu_children(item) + + try: + child_index = child_labels.index(label) + except ValueError: + raise AssertionError( + f"no item named {path[: i + 1]}; options are {child_labels}" + ) from None + + item = children[child_index] + + if open_menus: + item.Focus(FocusState.Programmatic) + await self._keyboard_select() + + # A selectable final menu item is always of type MenuFlyoutItem + return item + + def _menu_item_label(self, menu_item): + if isinstance(menu_item, MenuBarItem): + return menu_item.Title + + elif type(menu_item) in (MenuFlyoutItem, MenuFlyoutSubItem): + return menu_item.Text + + return "---" + + def _menu_item_casted(self, menu_item): + # Note that retrieving a submenu's items via menu.Items gives a list of + # MenuFlyoutItemBase objects. The actual type of this object could be one of: + # - MenuFlyoutSubItem: Has both the Items and the Text attributes. + # - MenuFlyoutItem: Has the Items attribute but not the Text attribute. + # - MenuFlyoutSeparator: Doesn't have the Items or the Text attributes. + + # Attempt to cast as MenuFlyoutSubItem + if isinstance(menu_item, MenuBarItem): + return menu_item + + try: + casted = MenuFlyoutSubItem(value=menu_item.value) + test = casted.Items + return casted + except OSError as e: + pass + + # Attempt to cast as MenuFlyoutItem + try: + casted = MenuFlyoutItem(value=menu_item.value) + test = casted.Text + return casted + except OSError as e: + pass + + # Fallback to MenuFlyoutSeparator + return MenuFlyoutSeparator(value=menu_item.value) + + async def _activate_menu_item(self, path): + await self._menu_item(path, open_menus=True) + + async def activate_menu_visit_homepage(self): + await self._activate_menu_item(["Help", "Visit homepage"]) + + async def assert_menu_item(self, path, *, enabled=True): + item = await self._menu_item(path) + assert item.IsEnabled == enabled + + async def assert_menu_order(self, path, expected): + menu = await self._menu_item(path) + _, child_labels = self._menu_children(menu) + + assert child_labels == expected + + async def assert_system_menus(self): + await self.assert_menu_item(["File", "New Example Document"], enabled=True) + await self.assert_menu_item(["File", "New Read-only Document"], enabled=True) + await self.assert_menu_item(["File", "Open..."], enabled=True) + await self.assert_menu_item(["File", "Save"], enabled=True) + await self.assert_menu_item(["File", "Save As..."], enabled=True) + await self.assert_menu_item(["File", "Save All"], enabled=True) + await self.assert_menu_item(["File", "Preferences"], enabled=False) + await self.assert_menu_item(["File", "Exit"]) + + await self.assert_menu_item(["Help", "Visit homepage"]) + await self.assert_menu_item(["Help", "About Toga Testbed (WinUI 3)"]) + + async def activate_menu_exit(self): + await self._activate_menu_item(["File", "Exit"]) + + def activate_menu_about(self): + self._activate_menu_item(["Help", "About Toga Testbed"]) + + #################################################################################### + # Cursor visablity + #################################################################################### + + @property + def _is_cursor_visible_non_client(self): + # This method used code from the toga_winforms probe which is based off: + # https://stackoverflow.com/a/12467292. + # + # The documentation recommends using GetCursorInfo to test the visibilty of + # cursors shown/hidden with ShowCursor. + # https://learn.microsoft.com/windows/win32/api/winuser/nf-winuser-showcursor + # https://learn.microsoft.com/windows/win32/api/winuser/nf-winuser-getcursorinfo + + # First, place the cursor in the non-client area. Use SendMessageW from windll + # to treat LPARAM as a pointer. + SendMessage = windll.user32.SendMessageW + + # Get the bounding rectangle of the close button. + title_bar_info = TITLEBARINFOEX() + title_bar_info.cbSize = sizeof(TITLEBARINFOEX) + SendMessage(self._hwnd, WM_GETTITLEBARINFOEX, 0, byref(title_bar_info)) + close_rect = title_bar_info.rgrect[5] + + self._set_cursor_position( + int((close_rect.left + close_rect.right)/2), + int((close_rect.top + close_rect.bottom)/2), + ) + + # A sleep to allow the window messages to propagate. + sleep(0.1) + + cursor_info = CURSORINFO() + cursor_info.cbSize = sizeof(CURSORINFO) + if not GetCursorInfo(byref(cursor_info)): + raise RuntimeError("GetCursorInfo failed") + + print(f"cursor_info.flags = {cursor_info.flags}") + + # Visibility *should* be exposed by CursorInfo.flags; but in CI, + # CursorInfo.flags returns 2 ("the system is not drawing the cursor + # because the user is providing input through touch or pen instead of + # the mouse"). In that case, we have to fall back to the backend's + # boolean representation, because there doesn't appear to be any + # more reliable mechanism for determining cursor state. + if cursor_info.flags == 2: + return self.app._impl._cursor_visible + else: + return cursor_info.flags == 1 + + @property + def is_cursor_visible(self): + # The cursor visibility if has two parts: + # 1. ShowCursor for the non-client area + # 2. ProtectedCursor for the client area. + + # Get the cursor visibility of the non-client area. + is_cursor_visible_non_client = self._is_cursor_visible_non_client + + # Confirm that the cursor visibilities of the client and non-client areas match. + protected_cursor = self.main_window._impl.native.Content.ProtectedCursor + if is_cursor_visible_non_client: + assert protected_cursor is None + else: + assert isinstance(protected_cursor, InputCursor) + + return is_cursor_visible_non_client + + #################################################################################### + # Miscellaneous + #################################################################################### + + async def restore_standard_app(self): + # No special handling needed to restore standard app. + await self.redraw("Restore to standard app") + + + def assert_app_icon(self, icon): + # Compare the pixels of `icon` using Pillow to those from the registered icon + # using GDI+. + path = toga.Icon(icon if icon else "")._impl.path + + with PIL.Image.open(path).convert('RGBA') as pil_image: + width_pil, height_pil = pil_image.size + pixels_pil = pil_image.load() + + for window in self.app.windows: + hwnd = GetWindowFromWindowId(window._impl.native.AppWindow.Id) + hicon = SendMessageW(hwnd, WM_GETICON, 0, 0) + pixels_gdip = icon_pixels(hicon) + + assert width_pil == len(pixels_gdip) + assert height_pil == len(pixels_gdip[0]) + + count = 0 + for x in range(width_pil): + for y in range(height_pil): + if pixels_pil[x,y] == pixels_gdip[x][y]: + count += 1 + + # There are some difference in how alpha is treated. Accept 97% match + assert count / (width_pil*height_pil) > 0.97 + + def unhide(self): + pytest.xfail("This platform doesn't have an app level unhide.") + + + async def open_initial_document(self, monkeypatch, document_path): + pytest.xfail("Winforms doesn't require initial document support") + + def open_document_by_drag(self, document_path): + pytest.xfail("Winforms doesn't support opening documents by drag") + + #################################################################################### + # Methods relating to StatusIcon + #################################################################################### + + def has_status_icon(self, status_icon): + return isinstance(status_icon._impl.native_window, Window) + + async def _click_status_icon(self, status_icon): + # `Winkey + B` then `Enter` opens the notification icon overflow tray. + await self._send_key(VK_RWIN, up=False) + await self._send_key(VK_B) + await self._send_key(VK_RWIN, down=False) + await self._send_key(VK_RETURN) + + notify_icon_identifier = ws.NOTIFYICONIDENTIFIER() + notify_icon_identifier.cbSize = sizeof(ws.NOTIFYICONIDENTIFIER()) + notify_icon_identifier.hWnd = status_icon._impl._hwnd + notify_icon_identifier.uID = 1 + + rect = wt.RECT() + Shell_NotifyIconGetRect(byref(notify_icon_identifier), byref(rect)) + + x = int((rect.left + rect.right)/2) + y =int((rect.top + rect.bottom)/2) + await self._send_click(x, y) + + def _get_status_menu_items(self, status_icon): + native_menu = getattr(status_icon._impl, "native_menu", None) + + if native_menu: + assert isinstance(native_menu, MenuFlyout) + return [self._menu_item_casted(child) for child in native_menu.Items] + + def status_menu_items(self, status_icon): + items = self._get_status_menu_items(status_icon) + + if items is None: + return + + def process_text(text): + return{ + "About Toga Testbed (WinUI 3)": "**ABOUT**", + "Exit": "**EXIT**", + }.get(text, text) + + return [ + "---" if isinstance(child, MenuFlyoutSeparator) + else process_text(child.Text) + for child in items + ] + + async def activate_status_icon_button(self, item_id): + # Click on the status icon. + status_icon = self.app.status_icons[item_id] + await self._click_status_icon(status_icon) + + await self._keyboard_escape() + + async def activate_status_menu_item(self, item_id, title): + # Click on the status icon. + status_icon = self.app.status_icons[item_id] + await self._click_status_icon(status_icon) + + items = self._get_status_menu_items(status_icon) + index = self.status_menu_items(status_icon).index(title) + + items[index].Focus(FocusState.Programmatic) + await self._keyboard_select() + + await self._keyboard_escape() diff --git a/winui3/tests_backend/probe.py b/winui3/tests_backend/probe.py new file mode 100644 index 0000000000..02eaa98d66 --- /dev/null +++ b/winui3/tests_backend/probe.py @@ -0,0 +1,127 @@ +import asyncio +from ctypes import byref, sizeof + +from win32more.Windows.Win32.Foundation import POINT +from win32more.Windows.Win32.UI.Input.KeyboardAndMouse import ( + INPUT, + INPUT_KEYBOARD, + INPUT_MOUSE, + KEYBDINPUT, + KEYEVENTF_KEYUP, + MOUSEEVENTF_LEFTDOWN, + MOUSEEVENTF_LEFTUP, + MOUSEINPUT, + VK_ESCAPE, + VK_RETURN, + SendInput, +) +from win32more.Windows.Win32.UI.WindowsAndMessaging import ( + GetCursorPos, + SetCursorPos, +) + +import toga + + +class BaseProbe: + def __init__(self, native=None): + self.native = native + self._click_count = 0 + + async def redraw(self, message=None, delay=0, wait_for=None): + """Request a redraw of the app, waiting until that redraw has completed.""" + # Make sure that any staged properties have sufficient time to completed the + # process. + widgets = toga.App.app.widgets.values() + staging_areas = {widget._impl.container.staging_area for widget in widgets} + + def staging_complete(): + for staging_area in staging_areas: + if len(staging_area._native_widgets) > 0: + return False + + return True + + for _ in range(1000): + if staging_complete(): + break + await asyncio.sleep(0) + + # If we're running slow, or we have a wait condition, + # wait for at least a second + if toga.App.app.run_slow or wait_for: + delay = max(1, delay) + + if delay or wait_for: + print("Waiting for redraw" if message is None else message) + if toga.App.app.run_slow or wait_for is None: + await asyncio.sleep(delay) + else: + delta = 0.1 + interval = 0.0 + while not wait_for() and interval < delay: + await asyncio.sleep(delta) + interval += delta + else: + # Sleep even if the delay is zero: this allows any pending callbacks on the + # event loop to run. + await asyncio.sleep(0) + + def _set_cursor_position(self, x, y): + # x and y are in screen coordinates. + point = POINT() + GetCursorPos(byref(point)) + + # Only move the cursor if necessary. + if x != point.x or y != point.y: + SetCursorPos(x, y) + + def _send_input(self, input): + return_value = SendInput(1, input, sizeof(input)) + if return_value != 1: + raise OSError("SendInput failed.") + + async def _send_click(self, x, y): + # x and y are in screen coordinates. + + # Move x to avoid double clicks. + x_shifted = x - 3 + 6 * self._click_count + self._click_count = (self._click_count + 1) % 2 + + self._set_cursor_position(x_shifted, y) + + mouse_input = INPUT() + mouse_input.type = INPUT_MOUSE + mouse_input.Anonymous.mi = MOUSEINPUT() + + message_list = [MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP] + + async def click(): + for message in message_list: + mouse_input.Anonymous.mi.dwFlags = message + self._send_input(mouse_input) + + await click() + + await asyncio.sleep(0.05) + + async def _send_key(self, key_code, down=True, up=True): + key_input = INPUT() + key_input.type = INPUT_KEYBOARD + key_input.Anonymous.ki = KEYBDINPUT() + key_input.Anonymous.ki.wVk = key_code + + if down: + self._send_input(key_input) + + if up: + key_input.Anonymous.ki.dwFlags = KEYEVENTF_KEYUP + self._send_input(key_input) + + await asyncio.sleep(0.1) + + async def _keyboard_select(self): + await self._send_key(VK_RETURN) + + async def _keyboard_escape(self): + await self._send_key(VK_ESCAPE) diff --git a/winui3/tests_backend/window.py b/winui3/tests_backend/window.py new file mode 100644 index 0000000000..1d6d0e11c0 --- /dev/null +++ b/winui3/tests_backend/window.py @@ -0,0 +1,71 @@ +from ctypes import byref, sizeof, windll +from typing import Literal +import asyncio + +from toga import Size + +from win32more.Microsoft.UI.Interop import GetWindowFromWindowId +from win32more.Microsoft.UI.Windowing import ( + AppWindowPresenterKind, + OverlappedPresenterState, +) +from win32more.Microsoft.UI.Xaml import Window as NativeWindow +from win32more.Windows.Win32.UI.WindowsAndMessaging import ( + SetForegroundWindow, + TITLEBARINFOEX, + WM_GETTITLEBARINFOEX +) + + +from .probe import BaseProbe + + +class WindowProbe(BaseProbe): + supports_closable = False # FIXME: Use Win32 + supports_minimizable = True + supports_move_while_hidden = True + supports_unminimize = True + supports_minimize = True + supports_placement = True + supports_as_image = True + supports_focus = True + fullscreen_presentation_equal_size = True + maximize_fullscreen_presentation_equal_size = False + + def __init__(self, app, window): + self.app = app + self.window = window + self.impl = window._impl + super().__init__(window._impl.native) + assert isinstance(self.native, NativeWindow) + + @property + def _hwnd(self): + return GetWindowFromWindowId(self.impl.native.AppWindow.Id) + + async def wait_for_window( + self, + message, + state=None, + ): + await self.redraw(message) + + 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) + continue + raise exception + + @property + def instantaneous_state(self): + return self.impl.get_window_state(in_progress_state=False) From fe2a5e57502f611f07a09cc235c60d08e0995b51 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:20:31 +0200 Subject: [PATCH 029/110] Improve app probe - test_statusicons passing --- winui3/tests_backend/app.py | 148 +++++++++++++----------------------- 1 file changed, 53 insertions(+), 95 deletions(-) diff --git a/winui3/tests_backend/app.py b/winui3/tests_backend/app.py index 163c370d99..80f1177c66 100644 --- a/winui3/tests_backend/app.py +++ b/winui3/tests_backend/app.py @@ -1,12 +1,13 @@ -import asyncio -from ctypes import byref, sizeof, windll -from ctypes import byref, sizeof, wintypes as wt +from ctypes import byref, sizeof, windll, wintypes as wt from pathlib import Path from time import sleep import PIL.Image import pytest - +import toga_winui3.libs.win32structures as ws +from toga_winui3.libs.gdiplus import icon_pixels +from toga_winui3.libs.shell import Shell_NotifyIconGetRect +from toga_winui3.libs.winui3app import WinUI3App from win32more.Microsoft.UI.Input import InputCursor from win32more.Microsoft.UI.Interop import GetWindowFromWindowId from win32more.Microsoft.UI.Xaml import FocusState, Window @@ -17,60 +18,24 @@ MenuFlyoutSeparator, MenuFlyoutSubItem, ) -from win32more.Windows.Win32.Foundation import POINT, RECT from win32more.Windows.Win32.UI.Input.KeyboardAndMouse import ( - GetFocus, + VK_B, VK_RETURN, VK_RWIN, - VK_B, ) from win32more.Windows.Win32.UI.WindowsAndMessaging import ( CURSORINFO, + TITLEBARINFOEX, + WM_GETICON, + WM_GETTITLEBARINFOEX, GetCursorInfo, SendMessageW, - PostMessageW, - SetForegroundWindow, - WM_GETICON, - WM_KEYDOWN, - WM_KEYUP, - WM_SETCURSOR, - HTCLIENT, - WM_MOUSEMOVE, - WM_NCDESTROY, - GetWindowThreadProcessId, -) - -from win32more.Windows.Win32.UI.WindowsAndMessaging import ( - IDC_ARROW, - LoadCursorW, - SetCursor, ) - import toga -import toga_winui3.libs.win32structures as ws -from toga_winui3.libs.winui3app import WinUI3App -from toga_winui3.libs.gdiplus import icon_pixels -from toga_winui3.libs.shell import Shell_NotifyIconGetRect - from .probe import BaseProbe -from toga_winui3.libs import win32constants as wc, win32structures as ws -from toga_winui3.libs.comctl32 import ( - DefSubclassProc, - RemoveWindowSubclass, - SetWindowSubclass, -) -from toga_winui3.libs.shell import Shell_NotifyIconW -from toga_winui3.libs.misc import loword, get_x_lparam, get_y_lparam - -from win32more.Windows.Win32.UI.WindowsAndMessaging import ( - SetForegroundWindow, - TITLEBARINFOEX, - WM_GETTITLEBARINFOEX -) - class AppProbe(BaseProbe): formal_name = "Toga Testbed (WinUI 3)" @@ -87,7 +52,7 @@ def __init__(self, app): self.main_window = app.main_window # The WinUI3App class is a child class of with Microsoft.UI.Xaml.Application - # class, which is a singleton instance. + # class, which is a singleton instance. assert self.app._impl.native == WinUI3App assert isinstance(self.app._impl.native_instance, WinUI3App) @@ -121,21 +86,15 @@ def logs_path(self): #################################################################################### def _menu_children(self, menu): - children = [ - self._menu_item_casted(child) - for child in menu.Items - ] - child_labels = [ - self._menu_item_label(child) - for child in children - ] + children = [self._menu_item_casted(child) for child in menu.Items] + child_labels = [self._menu_item_label(child) for child in children] return children, child_labels async def _menu_item(self, path, open_menus=False): """Select a menu item with the given path.""" - # Note that retrieving a submenu's items via menu.Items gives a list of + # Note that retrieving a submenu's items via menu.Items gives a list of # MenuFlyoutItemBase objects. These need to be casted manually to the - # appropriate types. + # appropriate types. item = self.main_window._impl.menu_native for i, label in enumerate(path): @@ -147,7 +106,7 @@ async def _menu_item(self, path, open_menus=False): raise AssertionError( f"no item named {path[: i + 1]}; options are {child_labels}" ) from None - + item = children[child_index] if open_menus: @@ -156,18 +115,18 @@ async def _menu_item(self, path, open_menus=False): # A selectable final menu item is always of type MenuFlyoutItem return item - + def _menu_item_label(self, menu_item): if isinstance(menu_item, MenuBarItem): return menu_item.Title - + elif type(menu_item) in (MenuFlyoutItem, MenuFlyoutSubItem): return menu_item.Text - + return "---" - + def _menu_item_casted(self, menu_item): - # Note that retrieving a submenu's items via menu.Items gives a list of + # Note that retrieving a submenu's items via menu.Items gives a list of # MenuFlyoutItemBase objects. The actual type of this object could be one of: # - MenuFlyoutSubItem: Has both the Items and the Text attributes. # - MenuFlyoutItem: Has the Items attribute but not the Text attribute. @@ -179,19 +138,19 @@ def _menu_item_casted(self, menu_item): try: casted = MenuFlyoutSubItem(value=menu_item.value) - test = casted.Items + casted.Items # noqa B018 return casted - except OSError as e: + except OSError: pass # Attempt to cast as MenuFlyoutItem try: casted = MenuFlyoutItem(value=menu_item.value) - test = casted.Text + casted.Text # noqa B018 return casted - except OSError as e: + except OSError: pass - + # Fallback to MenuFlyoutSeparator return MenuFlyoutSeparator(value=menu_item.value) @@ -239,8 +198,8 @@ def _is_cursor_visible_non_client(self): # This method used code from the toga_winforms probe which is based off: # https://stackoverflow.com/a/12467292. # - # The documentation recommends using GetCursorInfo to test the visibilty of - # cursors shown/hidden with ShowCursor. + # The documentation recommends using GetCursorInfo to test the visibility of + # cursors shown/hidden with ShowCursor. # https://learn.microsoft.com/windows/win32/api/winuser/nf-winuser-showcursor # https://learn.microsoft.com/windows/win32/api/winuser/nf-winuser-getcursorinfo @@ -255,13 +214,13 @@ def _is_cursor_visible_non_client(self): close_rect = title_bar_info.rgrect[5] self._set_cursor_position( - int((close_rect.left + close_rect.right)/2), - int((close_rect.top + close_rect.bottom)/2), + int((close_rect.left + close_rect.right) / 2), + int((close_rect.top + close_rect.bottom) / 2), ) - - # A sleep to allow the window messages to propagate. + + # A sleep to allow the window messages to propagate. sleep(0.1) - + cursor_info = CURSORINFO() cursor_info.cbSize = sizeof(CURSORINFO) if not GetCursorInfo(byref(cursor_info)): @@ -285,19 +244,19 @@ def is_cursor_visible(self): # The cursor visibility if has two parts: # 1. ShowCursor for the non-client area # 2. ProtectedCursor for the client area. - - # Get the cursor visibility of the non-client area. + + # Get the cursor visibility of the non-client area. is_cursor_visible_non_client = self._is_cursor_visible_non_client - + # Confirm that the cursor visibilities of the client and non-client areas match. protected_cursor = self.main_window._impl.native.Content.ProtectedCursor if is_cursor_visible_non_client: assert protected_cursor is None else: assert isinstance(protected_cursor, InputCursor) - + return is_cursor_visible_non_client - + #################################################################################### # Miscellaneous #################################################################################### @@ -305,14 +264,13 @@ def is_cursor_visible(self): async def restore_standard_app(self): # No special handling needed to restore standard app. await self.redraw("Restore to standard app") - def assert_app_icon(self, icon): # Compare the pixels of `icon` using Pillow to those from the registered icon - # using GDI+. + # using GDI+. path = toga.Icon(icon if icon else "")._impl.path - with PIL.Image.open(path).convert('RGBA') as pil_image: + with PIL.Image.open(path).convert("RGBA") as pil_image: width_pil, height_pil = pil_image.size pixels_pil = pil_image.load() @@ -320,23 +278,22 @@ def assert_app_icon(self, icon): hwnd = GetWindowFromWindowId(window._impl.native.AppWindow.Id) hicon = SendMessageW(hwnd, WM_GETICON, 0, 0) pixels_gdip = icon_pixels(hicon) - + assert width_pil == len(pixels_gdip) assert height_pil == len(pixels_gdip[0]) count = 0 for x in range(width_pil): for y in range(height_pil): - if pixels_pil[x,y] == pixels_gdip[x][y]: + if pixels_pil[x, y] == pixels_gdip[x][y]: count += 1 # There are some difference in how alpha is treated. Accept 97% match - assert count / (width_pil*height_pil) > 0.97 + assert count / (width_pil * height_pil) > 0.97 def unhide(self): pytest.xfail("This platform doesn't have an app level unhide.") - async def open_initial_document(self, monkeypatch, document_path): pytest.xfail("Winforms doesn't require initial document support") @@ -349,7 +306,7 @@ def open_document_by_drag(self, document_path): def has_status_icon(self, status_icon): return isinstance(status_icon._impl.native_window, Window) - + async def _click_status_icon(self, status_icon): # `Winkey + B` then `Enter` opens the notification icon overflow tray. await self._send_key(VK_RWIN, up=False) @@ -365,31 +322,32 @@ async def _click_status_icon(self, status_icon): rect = wt.RECT() Shell_NotifyIconGetRect(byref(notify_icon_identifier), byref(rect)) - x = int((rect.left + rect.right)/2) - y =int((rect.top + rect.bottom)/2) + x = int((rect.left + rect.right) / 2) + y = int((rect.top + rect.bottom) / 2) await self._send_click(x, y) def _get_status_menu_items(self, status_icon): native_menu = getattr(status_icon._impl, "native_menu", None) - + if native_menu: assert isinstance(native_menu, MenuFlyout) return [self._menu_item_casted(child) for child in native_menu.Items] def status_menu_items(self, status_icon): items = self._get_status_menu_items(status_icon) - + if items is None: return - + def process_text(text): - return{ - "About Toga Testbed (WinUI 3)": "**ABOUT**", + return { + "About Toga Testbed (WinUI 3)": "**ABOUT**", "Exit": "**EXIT**", }.get(text, text) return [ - "---" if isinstance(child, MenuFlyoutSeparator) + "---" + if isinstance(child, MenuFlyoutSeparator) else process_text(child.Text) for child in items ] @@ -398,7 +356,7 @@ async def activate_status_icon_button(self, item_id): # Click on the status icon. status_icon = self.app.status_icons[item_id] await self._click_status_icon(status_icon) - + await self._keyboard_escape() async def activate_status_menu_item(self, item_id, title): From e4a29111c3805676a2b80348cb540bb7fbb1a4a7 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:27:31 +0200 Subject: [PATCH 030/110] Make StatusIcon activation tests async --- android/tests_backend/app.py | 4 ++-- cocoa/tests_backend/app.py | 4 ++-- gtk/tests_backend/app.py | 4 ++-- iOS/tests_backend/app.py | 4 ++-- qt/tests_backend/app.py | 4 ++-- testbed/tests/test_statusicons.py | 4 ++-- winforms/tests_backend/app.py | 4 ++-- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/android/tests_backend/app.py b/android/tests_backend/app.py index a73ada8f06..3dacbe254a 100644 --- a/android/tests_backend/app.py +++ b/android/tests_backend/app.py @@ -139,8 +139,8 @@ def has_status_icon(self, status_icon): def status_menu_items(self, status_icon): pytest.xfail("Status icons not implemented on Android") - def activate_status_icon_button(self, item_id): + async def activate_status_icon_button(self, item_id): pytest.xfail("Status icons not implemented on Android") - def activate_status_menu_item(self, item_id, title): + async def activate_status_menu_item(self, item_id, title): pytest.xfail("Status icons not implemented on Android") diff --git a/cocoa/tests_backend/app.py b/cocoa/tests_backend/app.py index 4a4f1db1f2..687dc1f409 100644 --- a/cocoa/tests_backend/app.py +++ b/cocoa/tests_backend/app.py @@ -331,10 +331,10 @@ def status_menu_items(self, status_icon): # It's a button status item return None - def activate_status_icon_button(self, item_id): + async def activate_status_icon_button(self, item_id): self.app.status_icons[item_id]._impl.native.button.performClick(None) - def activate_status_menu_item(self, item_id, title): + async def activate_status_menu_item(self, item_id, title): item = self.app.status_icons[item_id]._impl.native.menu.itemWithTitle(title) send_message( self.app._impl.native.delegate, diff --git a/gtk/tests_backend/app.py b/gtk/tests_backend/app.py index 446c322915..4418fef20a 100644 --- a/gtk/tests_backend/app.py +++ b/gtk/tests_backend/app.py @@ -281,12 +281,12 @@ def status_menu_items(self, status_icon): # It's a button status item return None - def activate_status_icon_button(self, item_id): + async def activate_status_icon_button(self, item_id): if GTK_VERSION >= (4, 0, 0): pytest.skip("GTK4 doesn't support status icons") self.app.status_icons[item_id]._impl.native.emit("activate", 0, 0) - def activate_status_menu_item(self, item_id, title): + async def activate_status_menu_item(self, item_id, title): if GTK_VERSION >= (4, 0, 0): pytest.skip("GTK4 doesn't support status menu items") menu = self.app.status_icons[item_id]._impl.native.get_primary_menu() diff --git a/iOS/tests_backend/app.py b/iOS/tests_backend/app.py index 17095b956e..4cc5b2aa8f 100644 --- a/iOS/tests_backend/app.py +++ b/iOS/tests_backend/app.py @@ -91,8 +91,8 @@ def has_status_icon(self, status_icon): def status_menu_items(self, status_icon): pytest.xfail("Status icons not implemented on iOS") - def activate_status_icon_button(self, item_id): + async def activate_status_icon_button(self, item_id): pytest.xfail("Status icons not implemented on iOS") - def activate_status_menu_item(self, item_id, title): + async def activate_status_menu_item(self, item_id, title): pytest.xfail("Status icons not implemented on iOS") diff --git a/qt/tests_backend/app.py b/qt/tests_backend/app.py index 8e94b7d465..cd42edfd13 100644 --- a/qt/tests_backend/app.py +++ b/qt/tests_backend/app.py @@ -168,12 +168,12 @@ def status_menu_items(self, status_icon): for action in menu.actions() ] - def activate_status_icon_button(self, item_id): + async def activate_status_icon_button(self, item_id): self.app.status_icons[item_id]._impl.native.activated.emit( QSystemTrayIcon.ActivationReason.Trigger ) - def activate_status_menu_item(self, item_id, title): + async def activate_status_menu_item(self, item_id, title): menu = self.app.status_icons[item_id]._impl.native.contextMenu() item = {action.text(): action for action in menu.actions()}[title] item.triggered.emit() diff --git a/testbed/tests/test_statusicons.py b/testbed/tests/test_statusicons.py index 0e64b8393c..7860e9c74b 100644 --- a/testbed/tests/test_statusicons.py +++ b/testbed/tests/test_statusicons.py @@ -122,7 +122,7 @@ async def test_unknown_status_icon(app, app_probe): async def test_activate_button_icon(app, app_probe): """A button status icon can be activated.""" - app_probe.activate_status_icon_button("button") + await app_probe.activate_status_icon_button("button") await app_probe.redraw("Pressed status icon button") app.cmd_action.assert_called_once_with(app.status_icons["button"]) @@ -130,7 +130,7 @@ async def test_activate_button_icon(app, app_probe): async def test_activate_status_menu_item(app, app_probe): """A menu status item can be activated.""" - app_probe.activate_status_menu_item("second", "Action 5") + await app_probe.activate_status_menu_item("second", "Action 5") await app_probe.redraw("Pressed menu status item") app.cmd_action.assert_called_once_with(app.status_cmd5) diff --git a/winforms/tests_backend/app.py b/winforms/tests_backend/app.py index 2938054243..5800358d08 100644 --- a/winforms/tests_backend/app.py +++ b/winforms/tests_backend/app.py @@ -245,7 +245,7 @@ def status_menu_items(self, status_icon): # It's a button status item return None - def activate_status_icon_button(self, item_id): + async def activate_status_icon_button(self, item_id): # Winforms doesn't provide an OnClick to trigger clicks, so we have to fake it # at the level of the impl. self.app.status_icons[item_id]._impl.winforms_click( @@ -253,7 +253,7 @@ def activate_status_icon_button(self, item_id): EventArgs.Empty, ) - def activate_status_menu_item(self, item_id, title): + async def activate_status_menu_item(self, item_id, title): menu = getattr(self.app.status_icons[item_id]._impl.native, CONTEXT_MENU_ATTR) item = {item.Text: item for item in getattr(menu, MENU_ATTR)}[title] item.OnClick(EventArgs.Empty) From 13c953e77159cbbe8d84c9cd71998606dd0a25c7 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:49:20 +0200 Subject: [PATCH 031/110] test_desktop async changes --- cocoa/tests_backend/app.py | 2 +- cocoa/tests_backend/window.py | 2 +- gtk/tests_backend/app.py | 2 +- gtk/tests_backend/window.py | 2 +- qt/tests_backend/app.py | 2 +- qt/tests_backend/window.py | 2 +- testbed/tests/app/test_desktop.py | 33 ++++++++++++++++++++----------- winforms/tests_backend/app.py | 2 +- winforms/tests_backend/window.py | 2 +- 9 files changed, 29 insertions(+), 20 deletions(-) diff --git a/cocoa/tests_backend/app.py b/cocoa/tests_backend/app.py index 687dc1f409..544e9c971d 100644 --- a/cocoa/tests_backend/app.py +++ b/cocoa/tests_backend/app.py @@ -148,7 +148,7 @@ def activate_menu_hide(self): argtypes=[objc_id], ) - def activate_menu_exit(self): + async def activate_menu_exit(self): self._activate_menu_item(["*", "Quit Toga Testbed"]) def activate_menu_about(self): diff --git a/cocoa/tests_backend/window.py b/cocoa/tests_backend/window.py index 80cfa35f47..cd174f544b 100644 --- a/cocoa/tests_backend/window.py +++ b/cocoa/tests_backend/window.py @@ -70,7 +70,7 @@ async def cleanup(self): delay = 0.1 await self.redraw("Closing window", delay=delay) - def close(self): + async def close(self): self.native.performClose(None) @property diff --git a/gtk/tests_backend/app.py b/gtk/tests_backend/app.py index 4418fef20a..e846e26743 100644 --- a/gtk/tests_backend/app.py +++ b/gtk/tests_backend/app.py @@ -135,7 +135,7 @@ def _activate_menu_item(self, path): def activate_menu_hide(self): pytest.xfail("This platform doesn't present a app level hide option in menu.") - def activate_menu_exit(self): + async def activate_menu_exit(self): if GTK_VERSION >= (4, 0, 0): pytest.skip("GTK4 doesn't support system menus") self._activate_menu_item(["*", "Quit"]) diff --git a/gtk/tests_backend/window.py b/gtk/tests_backend/window.py index e444c423a0..54ce7d1dcb 100644 --- a/gtk/tests_backend/window.py +++ b/gtk/tests_backend/window.py @@ -95,7 +95,7 @@ async def cleanup(self): delay = 0.1 await self.redraw("Closing window", delay=delay) - def close(self): + async def close(self): if self.is_closable: # Trigger the OS-level window close event. if GTK_VERSION < (4, 0, 0): diff --git a/qt/tests_backend/app.py b/qt/tests_backend/app.py index cd42edfd13..6a04f0a79f 100644 --- a/qt/tests_backend/app.py +++ b/qt/tests_backend/app.py @@ -79,7 +79,7 @@ def assert_app_icon(self, icon): def activate_menu_hide(self): pytest.xfail("KDE apps do not include a Hide in the menu bar") - def activate_menu_exit(self): + async def activate_menu_exit(self): self._activate_menu_item(["File", "Quit"]) def activate_menu_about(self): diff --git a/qt/tests_backend/window.py b/qt/tests_backend/window.py index dc70627e03..c8e7f7f0e6 100644 --- a/qt/tests_backend/window.py +++ b/qt/tests_backend/window.py @@ -67,7 +67,7 @@ async def cleanup(self): self.window.close() await self.redraw("Closing window", delay=0.5) - def close(self): + async def close(self): if self.is_closable: self.native.close() diff --git a/testbed/tests/app/test_desktop.py b/testbed/tests/app/test_desktop.py index c599f3b39e..497af2b3f9 100644 --- a/testbed/tests/app/test_desktop.py +++ b/testbed/tests/app/test_desktop.py @@ -1,3 +1,4 @@ +import asyncio import itertools from functools import partial from unittest.mock import Mock @@ -11,6 +12,7 @@ from toga.style.pack import Pack from ..assertions import assert_window_on_hide, assert_window_on_show +from ..conftest import skip_on_backends from ..widgets.probe import get_probe from ..window.test_window import window_probe @@ -38,7 +40,7 @@ async def test_exit_on_close_main_window( monkeypatch.setattr(app, "on_exit", on_exit_handler) # Try to close the main window; rejected by window - main_window_probe.close() + await main_window_probe.close() await main_window_probe.redraw("Main window close requested; rejected by window") # on_close_handler was invoked, rejecting the close. @@ -54,7 +56,7 @@ async def test_exit_on_close_main_window( on_exit_handler.reset_mock() # Close the main window; rejected by app - main_window_probe.close() + await main_window_probe.close() await main_window_probe.redraw("Main window close requested; rejected by app") # on_close_handler was invoked, allowing the close @@ -70,7 +72,7 @@ async def test_exit_on_close_main_window( on_exit_handler.return_value = True # Close the main window; this will succeed - main_window_probe.close() + await main_window_probe.close() await main_window_probe.redraw("Main window close requested; accepted") # on_close_handler was invoked, allowing the close @@ -88,7 +90,7 @@ async def test_menu_exit(monkeypatch, app, app_probe, mock_app_exit): monkeypatch.setattr(app, "on_exit", on_exit_handler) # Close the main window - app_probe.activate_menu_exit() + await app_probe.activate_menu_exit() await app_probe.redraw("Exit selected from menu, but rejected") # on_exit_handler was invoked, rejecting the close; so the app won't be closed @@ -98,7 +100,7 @@ async def test_menu_exit(monkeypatch, app, app_probe, mock_app_exit): # Reset and try again, this time allowing the exit on_exit_handler.reset_mock() on_exit_handler.return_value = True - app_probe.activate_menu_exit() + await app_probe.activate_menu_exit() await app_probe.redraw("Exit selected from menu, and accepted") # on_exit_handler was invoked and accepted, so the mocked exit() was called. @@ -255,24 +257,27 @@ async def test_presentation_mode(app, app_probe, main_window, main_window_probe) window_widget = toga.Box(style=Pack(flex=1, background_color=next(color_cycle))) window.content = window_widget window.show() - window_information = {} window_information["window"] = window window_information["window_probe"] = window_probe(app, window) window_information["initial_screen"] = window_information["window"].screen window_information["paired_screen"] = app.screens[i] + window_information["widget_probe"] = get_probe(window_widget) + window_information_list.append(window_information) + screen_window_dict[window_information["paired_screen"]] = window_information[ + "window" + ] + + # The size properties for WinUI 3 are not immediately available + await asyncio.sleep(0.1) window_information["initial_content_size"] = window_information[ "window_probe" ].content_size - window_information["widget_probe"] = get_probe(window_widget) window_information["initial_widget_size"] = ( window_information["widget_probe"].width, window_information["widget_probe"].height, ) - window_information_list.append(window_information) - screen_window_dict[window_information["paired_screen"]] = window_information[ - "window" - ] + # Wait for window animation before assertion. await main_window_probe.wait_for_window("All Test Windows are visible") @@ -320,6 +325,9 @@ async def test_presentation_mode(app, app_probe, main_window, main_window_probe) "App is not in presentation mode", state=WindowState.NORMAL ) assert not app.in_presentation_mode + + # The size properties for WinUI 3 are not immediately available + await asyncio.sleep(0.1) assert ( window_information["window_probe"].instantaneous_state == WindowState.NORMAL ), f"{window_information['window'].title}:" @@ -528,6 +536,7 @@ async def test_show_hide_cursor(app, app_probe): async def test_current_window(app, app_probe, main_window, main_window_probe): """The current window can be retrieved""" + skip_on_backends("toga_winui3", reason="Dialogs are not implemented yet.") try: if app_probe.supports_current_window_assignment: assert app.current_window == main_window @@ -607,7 +616,7 @@ def test_current_window_in_presence_of_dialog(dialog): async def test_system_dpi_change( main_window, main_window_probe, event_path, mock_scale ): - if toga.platform.current_platform != "windows": + if toga.backend != "toga_winforms": pytest.xfail("This test is winforms backend specific") from toga_winforms.libs import shcore diff --git a/winforms/tests_backend/app.py b/winforms/tests_backend/app.py index 5800358d08..32f5a7d244 100644 --- a/winforms/tests_backend/app.py +++ b/winforms/tests_backend/app.py @@ -141,7 +141,7 @@ def _activate_menu_item(self, path): def activate_menu_hide(self): pytest.xfail("This platform doesn't present a app level hide option in menu.") - def activate_menu_exit(self): + async def activate_menu_exit(self): self._activate_menu_item(["File", "Exit"]) def activate_menu_about(self): diff --git a/winforms/tests_backend/window.py b/winforms/tests_backend/window.py index 013cb78a6a..096fc11b66 100644 --- a/winforms/tests_backend/window.py +++ b/winforms/tests_backend/window.py @@ -66,7 +66,7 @@ async def cleanup(self): self.window.close() await self.redraw("Closing window") - def close(self): + async def close(self): self.native.Close() @property From 6773cdfe2b4ba44e07621792b522c4eaa5d78741 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:06:20 +0200 Subject: [PATCH 032/110] Add lib files for StatusIcon tests --- winui3/src/toga_winui3/libs/shell.py | 10 ++++++++-- winui3/src/toga_winui3/libs/win32structures.py | 16 ++++++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/winui3/src/toga_winui3/libs/shell.py b/winui3/src/toga_winui3/libs/shell.py index e00679279a..18c82a7257 100644 --- a/winui3/src/toga_winui3/libs/shell.py +++ b/winui3/src/toga_winui3/libs/shell.py @@ -1,12 +1,18 @@ -from ctypes import POINTER, windll import ctypes.wintypes as wt +from ctypes import POINTER, windll from . import win32structures as ws shell32 = windll.shell32 -# https://learn.microsoft.com/windows/win32/api/shellapi/nf-shellapi-shell_notifyiconw +# learn.microsoft.com/windows/win32/api/shellapi/nf-shellapi-shell_notifyicongetrect +Shell_NotifyIconGetRect = shell32.Shell_NotifyIconGetRect +Shell_NotifyIconGetRect.restype = wt.HANDLE +Shell_NotifyIconGetRect.argtypes = [POINTER(ws.NOTIFYICONIDENTIFIER), POINTER(wt.RECT)] + + +# learn.microsoft.com/windows/win32/api/shellapi/nf-shellapi-shell_notifyiconw Shell_NotifyIconW = shell32.Shell_NotifyIconW Shell_NotifyIconW.restype = wt.BOOL Shell_NotifyIconW.argtypes = [wt.DWORD, POINTER(ws.NOTIFYICONDATAW)] diff --git a/winui3/src/toga_winui3/libs/win32structures.py b/winui3/src/toga_winui3/libs/win32structures.py index 86c0b5bdfa..aa32ca21ed 100644 --- a/winui3/src/toga_winui3/libs/win32structures.py +++ b/winui3/src/toga_winui3/libs/win32structures.py @@ -1,5 +1,5 @@ import ctypes.wintypes as wt -from ctypes import c_size_t, Structure as c_Structure, Union, WINFUNCTYPE +from ctypes import WINFUNCTYPE, Structure as c_Structure, Union, c_size_t from win32more import Guid @@ -9,13 +9,14 @@ LRESULT = wt.LPARAM UINT_PTR = c_size_t -DWORD_PTR = c_size_t +DWORD_PTR = c_size_t ######################################################################################## # Structures ######################################################################################## + # https://learn.microsoft.com/windows/win32/api/shellapi/ns-shellapi-notifyicondataw class _TIMEOUT_VERSION_UNION(Union): _fields_ = [ @@ -23,6 +24,7 @@ class _TIMEOUT_VERSION_UNION(Union): ("uVersion", wt.UINT), ] + class NOTIFYICONDATAW(c_Structure): _fields_ = [ ("cbSize", wt.DWORD), @@ -43,6 +45,16 @@ class NOTIFYICONDATAW(c_Structure): ] +# https://learn.microsoft.com/windows/win32/api/shellapi/ns-shellapi-notifyiconidentifier +class NOTIFYICONIDENTIFIER(c_Structure): + _fields_ = [ + ("cbSize", wt.DWORD), + ("hWnd", wt.HWND), + ("uID", wt.UINT), + ("guidItem", Guid), + ] + + # https://learn.microsoft.com/windows/win32/api/commctrl/nc-commctrl-subclassproc SUBCLASSPROC = WINFUNCTYPE( # Return type: From f24d7cbaea77c9ab4ff19a9fc0fd69e33dc00763 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:13:00 +0200 Subject: [PATCH 033/110] Change app and window probe - test_desktop passing --- winui3/tests_backend/app.py | 13 +++++- winui3/tests_backend/window.py | 77 ++++++++++++++++++++++++++++++---- 2 files changed, 81 insertions(+), 9 deletions(-) diff --git a/winui3/tests_backend/app.py b/winui3/tests_backend/app.py index 80f1177c66..8b0fb8ab30 100644 --- a/winui3/tests_backend/app.py +++ b/winui3/tests_backend/app.py @@ -186,8 +186,17 @@ async def assert_system_menus(self): async def activate_menu_exit(self): await self._activate_menu_item(["File", "Exit"]) - def activate_menu_about(self): - self._activate_menu_item(["Help", "About Toga Testbed"]) + async def activate_menu_about(self): + await self._activate_menu_item(["Help", "About Toga Testbed"]) + + def activate_menu_close_window(self): + pytest.xfail("This platform doesn't have a window management menu") + + def activate_menu_hide(self): + pytest.xfail("This platform doesn't present a app level hide option in menu.") + + def activate_menu_minimize(self): + pytest.xfail("This platform doesn't have a window management menu") #################################################################################### # Cursor visablity diff --git a/winui3/tests_backend/window.py b/winui3/tests_backend/window.py index 1d6d0e11c0..13ce0e7877 100644 --- a/winui3/tests_backend/window.py +++ b/winui3/tests_backend/window.py @@ -1,8 +1,6 @@ +import asyncio from ctypes import byref, sizeof, windll from typing import Literal -import asyncio - -from toga import Size from win32more.Microsoft.UI.Interop import GetWindowFromWindowId from win32more.Microsoft.UI.Windowing import ( @@ -11,17 +9,18 @@ ) from win32more.Microsoft.UI.Xaml import Window as NativeWindow from win32more.Windows.Win32.UI.WindowsAndMessaging import ( + TITLEBARINFOEX, + WM_GETTITLEBARINFOEX, SetForegroundWindow, - TITLEBARINFOEX, - WM_GETTITLEBARINFOEX ) +from toga import Size from .probe import BaseProbe class WindowProbe(BaseProbe): - supports_closable = False # FIXME: Use Win32 + supports_closable = False # FIXME: Use Win32 supports_minimizable = True supports_move_while_hidden = True supports_unminimize = True @@ -65,7 +64,71 @@ async def wait_for_window( await asyncio.sleep(polling_interval) continue raise exception - + + async def cleanup(self): + self.window.close() + await self.redraw("Closing window") + + def title_bar_object_midpoint(self, type: Literal["maximize", "minimize", "close"]): + type_dict = {"maximize": 3, "minimize": 2, "close": 5} + index = type_dict[type] + + info = TITLEBARINFOEX() + info.cbSize = sizeof(TITLEBARINFOEX) + windll.user32.SendMessageW(self._hwnd, WM_GETTITLEBARINFOEX, 0, byref(info)) + + rect = info.rgrect[index] + return (int((rect.left + rect.right) / 2), int((rect.top + rect.bottom) / 2)) + + async def close(self): + # The window Closing event is not triggered when self.native.Close() is + # called directly. So click on the close button instead. + midpoint = self.title_bar_object_midpoint("close") + SetForegroundWindow(self._hwnd) + await self._send_click(*midpoint) + + @property + def content_size(self): + actual_size = self.impl.container_native.ActualSize + + return Size(actual_size.X, actual_size.Y) + + @property + def is_resizable(self): + presenter, _ = self.impl._presenter + return presenter.IsResizable + + #################################################################################### + # State changing + #################################################################################### + @property def instantaneous_state(self): return self.impl.get_window_state(in_progress_state=False) + + async def maximize(self): + midpoint = self.title_bar_object_midpoint("minimize") + SetForegroundWindow(self._hwnd) + await self._send_click(*midpoint) + + async def minimize(self): + midpoint = self.title_bar_object_midpoint("minimize") + SetForegroundWindow(self._hwnd) + await self._send_click(*midpoint) + + @property + def is_minimizable(self): + presenter, _ = self.impl._presenter + return presenter.IsMinimizable + + @property + def is_minimized(self): + presenter, _ = self.impl._presenter + return ( + presenter.Kind == AppWindowPresenterKind.Overlapped + and presenter.State == OverlappedPresenterState.Minimized + ) + + def unminimize(self): + presenter, _ = self.impl._presenter + presenter.Restore() From 99bfb271764a8864b4aac158fb9ad6116948e640 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:37:41 +0200 Subject: [PATCH 034/110] test_document_app & test_screens skips - passing --- testbed/tests/app/test_document_app.py | 4 +++- testbed/tests/app/test_screens.py | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/testbed/tests/app/test_document_app.py b/testbed/tests/app/test_document_app.py index c9fe444b09..e243c833c7 100644 --- a/testbed/tests/app/test_document_app.py +++ b/testbed/tests/app/test_document_app.py @@ -5,6 +5,8 @@ import toga from testbed.app import ExampleDoc +from ..conftest import skip_on_backends + #################################################################################### # Document API tests #################################################################################### @@ -127,7 +129,7 @@ async def test_save_document(app, app_probe): async def test_save_as_document(monkeypatch, app, app_probe, tmp_path): """A document can be saved under a new filename.""" - + skip_on_backends("toga_winui3", reason="Dialogs are not implemented yet.") # A document can be opened document_path = Path(__file__).parent / "docs/example.testbed" document = app.documents.open(document_path) diff --git a/testbed/tests/app/test_screens.py b/testbed/tests/app/test_screens.py index 27c869b1b9..f687f2f8a7 100644 --- a/testbed/tests/app/test_screens.py +++ b/testbed/tests/app/test_screens.py @@ -4,6 +4,8 @@ from toga.images import Image as TogaImage +from ..conftest import skip_on_backends + def screen_probe(screen): module = import_module("tests_backend.screens") @@ -38,6 +40,7 @@ async def test_size(app): async def test_as_image(app): """A screen can be captured as an image""" + skip_on_backends("toga_winui3", reason="Screen.get_image_data is no implemented.") # Using a probe for test as the feature is not implemented on some platforms. for screen in app.screens: probe = screen_probe(screen) From 9d819bbf3f101beae419586c0eea617fe1b35c64 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:24:02 +0200 Subject: [PATCH 035/110] Icon probe & tests async - test_icons passing --- testbed/tests/test_icons.py | 15 ++++-- winui3/tests_backend/icons.py | 93 +++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 winui3/tests_backend/icons.py diff --git a/testbed/tests/test_icons.py b/testbed/tests/test_icons.py index 1f16731c20..1ed7bf2fe9 100644 --- a/testbed/tests/test_icons.py +++ b/testbed/tests/test_icons.py @@ -13,13 +13,15 @@ async def test_icon(app): icon = toga.Icon("resources/icons/green") probe = icon_probe(app, icon) - probe.assert_icon_content("resources/icons/green") + await probe.redraw("Icon probe is using a green icon") + await probe.assert_icon_content("resources/icons/green") # Create a second icon using an alternate (non-preferred) resource format. icon = toga.Icon(probe.alternate_resource) probe = icon_probe(app, icon) - probe.assert_icon_content(probe.alternate_resource) + await probe.redraw("Icon probe is using an alternate resource format") + await probe.assert_icon_content(probe.alternate_resource) async def test_app_icon(app): @@ -31,16 +33,19 @@ async def test_app_icon(app): async def test_system_icon(app): """The default icon can be obtained""" probe = icon_probe(app, toga.Icon.DEFAULT_ICON) - probe.assert_default_icon_content() + await probe.redraw("Icon probe is using the default icon") + await probe.assert_default_icon_content() async def test_platform_icon(app): """A platform-specific icon can be loaded""" probe = icon_probe(app, toga.Icon("resources/logo")) - probe.assert_platform_icon_content() + await probe.redraw("Icon probe is using a platform-specific icon") + await probe.assert_platform_icon_content() async def test_bad_icon_file(app): """If a file isn't a loadable icon, the default icon is used.""" probe = icon_probe(app, toga.Icon("resources/icons/bad")) - probe.assert_default_icon_content() + await probe.redraw("Icon probe is using a bad icon file") + await probe.assert_default_icon_content() diff --git a/winui3/tests_backend/icons.py b/winui3/tests_backend/icons.py new file mode 100644 index 0000000000..a00b3a7333 --- /dev/null +++ b/winui3/tests_backend/icons.py @@ -0,0 +1,93 @@ +import asyncio +from ctypes import byref +from pathlib import Path + +import PIL.Image +import pytest +import toga_winui3 +from toga_winui3.libs.gdiplus import icon_pixels +from win32more import UInt32 +from win32more.Microsoft.UI import IconId +from win32more.Microsoft.UI.Interop import GetWindowFromWindowId +from win32more.Microsoft.UI.Xaml.Controls import Button, ImageIcon +from win32more.Windows.Win32.UI.WindowsAndMessaging import ( + WM_GETICON, + SendMessageW, +) + +import toga + +from .probe import BaseProbe + + +class IconProbe(BaseProbe): + alternate_resource = "resources/icons/orange" + + def __init__(self, app, icon): + super().__init__() + self.app = app + self.icon = icon + + # The WinUI 3 ImageIcon won't load until it has been added to the visual tree. + self.container_children = app.main_window._impl.container.native.Children + self.button = Button() + image_icon = self.icon._impl.image_icon() + self.button.Content = image_icon + self.container_children.Append(self.button) + + assert isinstance(image_icon, ImageIcon) + assert isinstance(self.icon._impl.id, IconId) + + def __del__(self): + index = UInt32() + self.container_children.IndexOf(self.button, byref(index)) + self.container_children.RemoveAt(index) + + async def _assert_source(self, path: Path): + assert self.icon._impl.path == path + + await asyncio.sleep(0.1) + uri = f"file:///{self.icon._impl.path.as_posix()}" + assert self.icon._impl._bitmap_image.UriSource.ToString() == uri + + async def assert_icon_content(self, path): + if path == "resources/icons/green": + await self._assert_source(self.app.paths.app / "resources/icons/green.png") + elif path == "resources/icons/orange": + await self._assert_source(self.app.paths.app / "resources/icons/orange.ico") + else: + pytest.fail("Unknown icon resource") + + async def assert_default_icon_content(self): + await self._assert_source( + Path(toga_winui3.__file__).parent / "resources/toga.png" + ) + + async def assert_platform_icon_content(self): + await self._assert_source(self.app.paths.app / "resources/logo-windows.ico") + + def assert_app_icon_content(self): + # Compare the pixels of the default icon using Pillow to those from the + # registered app icon using GDI+. + path = toga.Icon.DEFAULT_ICON._impl.path + + with PIL.Image.open(path).convert("RGBA") as pil_image: + width_pil, height_pil = pil_image.size + pixels_pil = pil_image.load() + + for window in self.app.windows: + hwnd = GetWindowFromWindowId(window._impl.native.AppWindow.Id) + hicon = SendMessageW(hwnd, WM_GETICON, 0, 0) + pixels_gdip = icon_pixels(hicon) + + assert width_pil == len(pixels_gdip) + assert height_pil == len(pixels_gdip[0]) + + count = 0 + for x in range(width_pil): + for y in range(height_pil): + if pixels_pil[x, y] == pixels_gdip[x][y]: + count += 1 + + # There are some difference in how alpha is treated. Accept 97% match + assert count / (width_pil * height_pil) > 0.97 From 9ecbbe5bcca446f5d8299fc215b5fddb6eb793a0 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:34:18 +0200 Subject: [PATCH 036/110] Add skips & async close & minimize to test_window --- cocoa/tests_backend/window.py | 2 +- gtk/tests_backend/window.py | 2 +- qt/tests_backend/window.py | 2 +- testbed/tests/window/test_window.py | 15 +++++++++------ winforms/tests_backend/window.py | 2 +- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/cocoa/tests_backend/window.py b/cocoa/tests_backend/window.py index cd174f544b..ca936cd019 100644 --- a/cocoa/tests_backend/window.py +++ b/cocoa/tests_backend/window.py @@ -96,7 +96,7 @@ def is_minimizable(self): def is_minimized(self): return bool(self.native.isMiniaturized) - def minimize(self): + async def minimize(self): self.native.performMiniaturize(None) def unminimize(self): diff --git a/gtk/tests_backend/window.py b/gtk/tests_backend/window.py index 54ce7d1dcb..3729997cc6 100644 --- a/gtk/tests_backend/window.py +++ b/gtk/tests_backend/window.py @@ -125,7 +125,7 @@ def is_closable(self): def is_minimized(self): return self.impl._window_state_flags & Gdk.WindowState.ICONIFIED - def minimize(self): + async def minimize(self): if GTK_VERSION < (4, 0, 0): self.native.iconify() else: diff --git a/qt/tests_backend/window.py b/qt/tests_backend/window.py index c8e7f7f0e6..58b3d23713 100644 --- a/qt/tests_backend/window.py +++ b/qt/tests_backend/window.py @@ -91,7 +91,7 @@ def is_closable(self): def is_minimized(self): return self.native.isMinimized() - def minimize(self): + async def minimize(self): self.native.showMinimized() def unminimize(self): diff --git a/testbed/tests/window/test_window.py b/testbed/tests/window/test_window.py index 9ae9ac83cd..4fca063c71 100644 --- a/testbed/tests/window/test_window.py +++ b/testbed/tests/window/test_window.py @@ -18,6 +18,7 @@ assert_window_on_hide, assert_window_on_show, ) +from ..conftest import skip_on_backends def window_probe(app, window): @@ -386,7 +387,7 @@ async def test_secondary_window_with_args(app, second_window, second_window_prob if second_window_probe.supports_placement: assert second_window.position == (200, 300) - second_window_probe.close() + await second_window_probe.close() await second_window_probe.wait_for_window( "Attempt to close second window that is rejected" ) @@ -398,7 +399,7 @@ async def test_secondary_window_with_args(app, second_window, second_window_prob on_close_handler.reset_mock() on_close_handler.return_value = True - second_window_probe.close() + await second_window_probe.close() await second_window_probe.wait_for_window( "Attempt to close second window that succeeds" ) @@ -465,6 +466,7 @@ async def test_secondary_window_cleanup(app_probe): ) async def test_secondary_window_toolbar(app, second_window, second_window_probe): """A toolbar can be added to a secondary window""" + skip_on_backends("toga_winui3") second_window.toolbar.add(app.cmd1) # Window doesn't have content. This is intentional. @@ -516,7 +518,7 @@ async def test_non_closable(second_window, second_window_probe): assert not second_window_probe.is_closable # Do a UI close on the window - second_window_probe.close() + await second_window_probe.close() await second_window_probe.wait_for_window("Close request was ignored") on_close_handler.assert_not_called() assert second_window.visible @@ -548,7 +550,7 @@ async def test_non_minimizable(second_window, second_window_probe): assert second_window.visible assert not second_window_probe.is_minimizable - second_window_probe.minimize() + await second_window_probe.minimize() await second_window_probe.wait_for_window("Minimize request has been ignored") assert not second_window_probe.is_minimized @@ -617,7 +619,7 @@ async def test_visibility(app, second_window, second_window_probe): ): assert second_window.position == (300, 150) - second_window_probe.minimize() + await second_window_probe.minimize() # Wait for window animation before assertion. await second_window_probe.wait_for_window( "Window has been minimized", @@ -639,7 +641,7 @@ async def test_visibility(app, second_window, second_window_probe): # Window size hasn't changed as a result of min/unmin cycle assert second_window.size == approx((250, 200), abs=2) - second_window_probe.close() + await second_window_probe.close() await second_window_probe.wait_for_window("Secondary window has been closed") assert second_window not in app.windows @@ -1323,6 +1325,7 @@ async def test_screen(second_window, second_window_probe): async def test_as_image(main_window, main_window_probe): """The window can be captured as a screenshot""" + skip_on_backends("toga_winui3") if main_window_probe.supports_as_image: screenshot = main_window.as_image() diff --git a/winforms/tests_backend/window.py b/winforms/tests_backend/window.py index 096fc11b66..0af4618d6c 100644 --- a/winforms/tests_backend/window.py +++ b/winforms/tests_backend/window.py @@ -96,7 +96,7 @@ def is_minimizable(self): def is_minimized(self): return self.native.WindowState == FormWindowState.Minimized - def minimize(self): + async def minimize(self): if self.native.MinimizeBox: self.native.WindowState = FormWindowState.Minimized From b1b2f2fafe74de856abdb4c5ddb4ce7fef8a5755 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:49:49 +0200 Subject: [PATCH 037/110] SimpleProbe, FontsMixin & update update test_base --- testbed/tests/widgets/test_base.py | 10 ++ winui3/tests_backend/fonts.py | 96 ++++++++++++++++ winui3/tests_backend/widgets/base.py | 123 +++++++++++++++++++++ winui3/tests_backend/widgets/properties.py | 28 +++++ 4 files changed, 257 insertions(+) create mode 100644 winui3/tests_backend/fonts.py create mode 100644 winui3/tests_backend/widgets/base.py create mode 100644 winui3/tests_backend/widgets/properties.py diff --git a/testbed/tests/widgets/test_base.py b/testbed/tests/widgets/test_base.py index 4faa538f6a..4c45b457e3 100644 --- a/testbed/tests/widgets/test_base.py +++ b/testbed/tests/widgets/test_base.py @@ -169,6 +169,16 @@ async def test_tab_index(widget, probe, other): if toga.platform.current_platform not in {"windows"}: assert widget.tab_index is None assert other.tab_index is None + elif toga.backend == "toga_winui3": + # Unset WinUI 3 tab indices default to Int32_MaxValue. + Int32_MaxValue = 2**31 - 1 + assert widget.tab_index == Int32_MaxValue + assert other.tab_index == Int32_MaxValue + + widget.tab_index = 4 + other.tab_index = 2 + assert widget.tab_index == 4 + assert other.tab_index == 2 else: assert widget.tab_index == 1 assert other.tab_index == 2 diff --git a/winui3/tests_backend/fonts.py b/winui3/tests_backend/fonts.py new file mode 100644 index 0000000000..1bccde18e2 --- /dev/null +++ b/winui3/tests_backend/fonts.py @@ -0,0 +1,96 @@ +from toga_winui3.widgets.properties.native import get_attribute_base +from win32more.Windows.UI.Text import FontStyle, FontWeights + +from toga.fonts import ( + BOLD, + CURSIVE, + FANTASY, + ITALIC, + MESSAGE, + MONOSPACE, + NORMAL, + OBLIQUE, + SANS_SERIF, + SERIF, + SMALL_CAPS, + SYSTEM, + SYSTEM_DEFAULT_FONT_SIZE, +) + + +class FontMixin: + supports_custom_fonts = False + supports_custom_variable_fonts = True + + def preinstalled_font(self): + """A font known to be installed on the system.""" + return "Arial" + + @property + def font_family(self): + return self.native.FontFamily + + @property + def font_size(self): + return self.native.FontSize + + @property + def font_style(self): + return self.native.FontStyle + + @property + def font_weight(self): + return self.native.FontWeight + + @property + def native_cls(self): + return type(self.native) + + def assert_font_options(self, weight=NORMAL, style=NORMAL, variant=NORMAL): + # Font weight. + if weight == BOLD: + assert self.font_weight.Weight == FontWeights.get_Bold().Weight + else: + assert weight == NORMAL + assert self.font_weight.Weight == FontWeights.get_Normal().Weight + + # Font style + if style == OBLIQUE: + assert self.font_style == FontStyle.Oblique + elif style == ITALIC: + assert self.font_style == FontStyle.Italic + else: + assert style == NORMAL + assert self.font_style == FontStyle.Normal + + # Font variant + if variant == SMALL_CAPS: + print("Ignoring SMALL CAPS font test") + else: + assert variant == NORMAL + + def assert_font_size(self, expected): + if expected == SYSTEM_DEFAULT_FONT_SIZE: + # Store current size + current_size = self.font_size + + # Reset size to the default value + native_cls = self.native_cls + dependency_ancestor = get_attribute_base(native_cls, "FontSizeProperty") + dependency_attribute = dependency_ancestor.FontSizeProperty + self.native.ClearValue(dependency_attribute) + + assert self.font_size == current_size + else: + assert round(self.font_size, 2) == round(expected * 96 / 72, 2) + + def assert_font_family(self, expected): + assert str(self.font_family.Source) == { + CURSIVE: "Segoe Script", + FANTASY: "Impact", + MESSAGE: "Segoe UI Variable", + MONOSPACE: "Courier New", + SANS_SERIF: "Segoe UI", + SERIF: "Times New Roman", + SYSTEM: "Segoe UI Variable", + }.get(expected, expected) diff --git a/winui3/tests_backend/widgets/base.py b/winui3/tests_backend/widgets/base.py new file mode 100644 index 0000000000..b1a2649433 --- /dev/null +++ b/winui3/tests_backend/widgets/base.py @@ -0,0 +1,123 @@ +from pytest import approx +from win32more.Microsoft.UI.Xaml import FocusState, Visibility +from win32more.Windows.Foundation import Rect +from win32more.Windows.Win32.UI.Input.KeyboardAndMouse import GetFocus + +from ..fonts import FontMixin +from ..probe import BaseProbe +from .properties import brush_to_color + + +class SimpleProbe(BaseProbe, FontMixin): + invalid_size_while_hidden = False + + def __init__(self, widget): + self.app = widget.app + self.widget = widget + self.impl = widget._impl + super().__init__(self.impl.native) + assert isinstance(self.native, self.native_class) + + def assert_container(self, container): + assert self.widget._impl.container is container._impl.container + assert self.native.Parent is not None + + parent_1 = container._impl.container.native + parent_2_raw = self.native.Parent + parent_2 = type(parent_1)(value=parent_2_raw.value) + + # Confirm that parent_1 and parent_2 are the same WinUI 3 object. The python + # objects have different memory addresses, so change the Name property on one + # and confirm that the other has the same name. + parent_1.Name = "Parent Name" + assert parent_1.Name == parent_2.Name == "Parent Name" + + parent_2.Name = "New Parent Name" + assert parent_1.Name == parent_2.Name == "New Parent Name" + + def assert_not_contained(self): + assert self.widget._impl.container is None + assert self.native.Parent is None + + def assert_layout(self, size, position): + # Widget is contained and in a window. + assert self.widget._impl.container is not None + assert self.native.Parent is not None + + # size and position is as expected. + assert (self.width, self.height) == approx(size, abs=1) + assert (self.x, self.y) == approx(position, abs=1) + + def get_hwnd(self, native): + focus_set = native.Focus(FocusState.Programmatic) + if not focus_set: + return -1 + + return GetFocus() + + @property + def _hwnd(self): + return self.get_hwnd(self.impl.native) + + @property + def _bounds_screen_coords(self): + """The bounding Rect(X, Y, Width, Height) of self.native in screen coords.""" + # Get the top left point in coordinates with respect to the XamlRoot element + # learn.microsoft.com/windows/windows-app-sdk/api/winrt/microsoft.ui.xaml.uielement.transformtovisual # noqa E501 + transform = self.native.TransformToVisual(None) + bounds = transform.TransformBounds(Rect(0, 0, self.width, self.height)) + + # Note that self.native must be added to the visual tree for XamlRoot to exist. + converter = self.native.XamlRoot.CoordinateConverter + return converter.ConvertLocalToScreenWithRect(bounds) + + @property + def _midpoint_screen_coords(self): + bounds = self._bounds_screen_coords + return (int(bounds.X + bounds.Width / 2), int(bounds.Y + bounds.Height / 2)) + + @property + def width(self): + return self.native.ActualWidth + + def assert_width(self, min_width, max_width): + assert min_width <= self.width <= max_width, ( + f"Width ({self.width}) not in range ({min_width}, {max_width})" + ) + + @property + def height(self): + return self.native.ActualHeight + + def assert_height(self, min_height, max_height): + assert min_height <= self.height <= max_height, ( + f"Height ({self.height}) not in range ({min_height}, {max_height})" + ) + + @property + def x(self): + return self.native.ActualOffset.X + + @property + def y(self): + return self.native.ActualOffset.Y + + @property + def is_hidden(self): + return self.native.Visibility == Visibility.Collapsed + + @property + def color(self): + return brush_to_color(self.native.Foreground) + + @property + def background_color(self): + return brush_to_color(self.native.Background) + + @property + def enabled(self): + return self.native.IsEnabled + + @property + def shrink_on_resize(self): + return True diff --git a/winui3/tests_backend/widgets/properties.py b/winui3/tests_backend/widgets/properties.py new file mode 100644 index 0000000000..4fe1e19556 --- /dev/null +++ b/winui3/tests_backend/widgets/properties.py @@ -0,0 +1,28 @@ +from win32more.Microsoft.UI.Xaml import TextAlignment +from win32more.Microsoft.UI.Xaml.Media import Brush, SolidColorBrush + +from toga import rgba as TogaColor +from toga.constants import TRANSPARENT +from toga.style.pack import CENTER, JUSTIFY, LEFT, RIGHT + + +def brush_to_color(brush: Brush): + if not brush: + return + + color = SolidColorBrush(value=brush.value).Color + color_tuple = (color.R, color.G, color.B, color.A / 255) + + if color_tuple == (0, 0, 0, 0): + return TRANSPARENT + + return TogaColor(*color_tuple) + + +def toga_x_text_align(alignment): + return { + TextAlignment.Left: LEFT, + TextAlignment.Right: RIGHT, + TextAlignment.Center: CENTER, + TextAlignment.Justify: JUSTIFY, + }[alignment] From 50af05bc9605e8907e3078719b0eff6cab2dcacd Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:07:12 +0200 Subject: [PATCH 038/110] Add button probe --- winui3/tests_backend/widgets/button.py | 61 ++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 winui3/tests_backend/widgets/button.py diff --git a/winui3/tests_backend/widgets/button.py b/winui3/tests_backend/widgets/button.py new file mode 100644 index 0000000000..f83f51e876 --- /dev/null +++ b/winui3/tests_backend/widgets/button.py @@ -0,0 +1,61 @@ +import asyncio + +import pytest +from win32more import unbox_value +from win32more.Microsoft.UI.Xaml.Controls import Button as NativeButton, ImageIcon + +from .base import SimpleProbe + + +class ButtonProbe(SimpleProbe): + native_class = NativeButton + + @property + def content_is_text(self): + try: + content = unbox_value(self.native.Content) + return isinstance(content, str) + except TypeError: + return False + + @property + def text(self): + if not self.content_is_text: + return "" + + text = unbox_value(self.native.Content) + + # Normalize the zero width space to the empty string. + if text == "\u200b": + return "" + return text + + def assert_no_icon(self): + button_content = self.native.Content + if button_content: + # Try to cast the Button content as an icon + image_icon = ImageIcon(value=button_content.value) + + try: + image_icon.Width # noqa B018 + pytest.fail("Button has an icon.") + except OSError: + # There should be an OSError exception + pass + + def assert_icon_size(self): + button_content = self.native.Content + if button_content: + # Cast the Button content as an icon + image_icon = ImageIcon(value=button_content.value) + + assert image_icon.Width == 32 + assert image_icon.Height == 32 + else: + pytest.fail("Button has no content.") + + async def press(self): + # A small delay to ensure that the button is added to the visual tree. + await asyncio.sleep(0.05) + midpoint = self._midpoint_screen_coords + await self._send_click(*midpoint) From 982e7bab38cc2d2638af5df90ccebff58449d4cf Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:14:49 +0200 Subject: [PATCH 039/110] Add label probe --- testbed/tests/widgets/test_label.py | 9 ++++- winui3/tests_backend/widgets/label.py | 53 +++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 winui3/tests_backend/widgets/label.py diff --git a/testbed/tests/widgets/test_label.py b/testbed/tests/widgets/test_label.py index 17269dffd9..f8e79f68e1 100644 --- a/testbed/tests/widgets/test_label.py +++ b/testbed/tests/widgets/test_label.py @@ -9,7 +9,6 @@ test_background_color_transparent, test_color, test_color_reset, - test_enabled, test_flex_horizontal_widget_size, test_focus_noop, test_font, @@ -19,6 +18,12 @@ test_text_width_change, ) +# Label on WinUI 3 is always enabled. +if toga.backend in {"toga_winui3"}: + from .properties import test_enable_noop # noqa: F401 +else: + from .properties import test_enabled # noqa: F401 + @pytest.fixture async def widget(): @@ -54,7 +59,7 @@ def make_lines(n): # Empty text should not cause the widget to collapse. widget.text = "" await probe.redraw("Label text should be empty") - assert probe.height == line_height + assert probe.height == pytest.approx(line_height, rel=0.04) # Label should have almost 0 width assert probe.width < 10 diff --git a/winui3/tests_backend/widgets/label.py b/winui3/tests_backend/widgets/label.py new file mode 100644 index 0000000000..821b834c71 --- /dev/null +++ b/winui3/tests_backend/widgets/label.py @@ -0,0 +1,53 @@ +from win32more.Microsoft.UI.Xaml.Controls import Grid, TextBlock + +from .base import SimpleProbe +from .properties import brush_to_color, toga_x_text_align + + +class LabelProbe(SimpleProbe): + native_class = Grid + + def __init__(self, widget): + super().__init__(widget) + self.label_native = self.impl.label_text.native + assert isinstance(self.label_native, TextBlock) + + @property + def text(self): + return self.label_native.Text + + def assert_text_align(self, expected): + assert expected == toga_x_text_align(self.label_native.TextAlignment) + + def assert_vertical_text_align(self, expected): + # Vertical text alignment is not configurable for TextBlock. + pass + + @property + def color(self): + return brush_to_color(self.label_native.Foreground) + + @property + def enabled(self): + # Neither TextBlock or Grid has the IsEnabled property. + return True + + @property + def font_family(self): + return self.label_native.FontFamily + + @property + def font_size(self): + return self.label_native.FontSize + + @property + def font_style(self): + return self.label_native.FontStyle + + @property + def font_weight(self): + return self.label_native.FontWeight + + @property + def native_cls(self): + return type(self.label_native) From 7578edc297ae9c737dff4e6335b4132ab711d699 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:31:58 +0200 Subject: [PATCH 040/110] Box probe & format fixes - all existing tests pass --- winui3/src/toga_winui3/libs/comctl32.py | 2 +- winui3/src/toga_winui3/libs/win32constants.py | 2 -- winui3/tests_backend/widgets/box.py | 7 +++++++ 3 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 winui3/tests_backend/widgets/box.py diff --git a/winui3/src/toga_winui3/libs/comctl32.py b/winui3/src/toga_winui3/libs/comctl32.py index 26ce5c2048..6b3441eb47 100644 --- a/winui3/src/toga_winui3/libs/comctl32.py +++ b/winui3/src/toga_winui3/libs/comctl32.py @@ -1,5 +1,5 @@ -from ctypes import windll import ctypes.wintypes as wt +from ctypes import windll from . import win32structures as ws diff --git a/winui3/src/toga_winui3/libs/win32constants.py b/winui3/src/toga_winui3/libs/win32constants.py index 9b6c2400ac..e3472730b5 100644 --- a/winui3/src/toga_winui3/libs/win32constants.py +++ b/winui3/src/toga_winui3/libs/win32constants.py @@ -19,5 +19,3 @@ # NotifyIcon Versions NOTIFYICON_VERSION_4 = 4 - - diff --git a/winui3/tests_backend/widgets/box.py b/winui3/tests_backend/widgets/box.py new file mode 100644 index 0000000000..c513611798 --- /dev/null +++ b/winui3/tests_backend/widgets/box.py @@ -0,0 +1,7 @@ +from win32more.Microsoft.UI.Xaml.Controls import Canvas + +from .base import SimpleProbe + + +class BoxProbe(SimpleProbe): + native_class = Canvas From 0a86ff4f99b1d990695852462d7b711a02300695 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:32:10 +0200 Subject: [PATCH 041/110] Simplify toml and native application class --- winui3/pyproject.toml | 3 - winui3/src/toga_winui3/app.py | 8 +- winui3/src/toga_winui3/libs/nativeapp.py | 63 +++++++++ winui3/src/toga_winui3/libs/proactor.py | 2 +- winui3/src/toga_winui3/libs/winui3app.py | 164 ----------------------- winui3/tests_backend/app.py | 8 +- 6 files changed, 72 insertions(+), 176 deletions(-) create mode 100644 winui3/src/toga_winui3/libs/nativeapp.py delete mode 100644 winui3/src/toga_winui3/libs/winui3app.py diff --git a/winui3/pyproject.toml b/winui3/pyproject.toml index a663d93e79..8d9cdaf130 100644 --- a/winui3/pyproject.toml +++ b/winui3/pyproject.toml @@ -115,9 +115,6 @@ root = ".." dependencies = [ "toga-core == {version}", "win32more >= 0.8.1", - "winui3-Microsoft.UI.Interop", - "winui3-Microsoft.UI", - "winrt-runtime", ] [tool.coverage.run] diff --git a/winui3/src/toga_winui3/app.py b/winui3/src/toga_winui3/app.py index 4e9e8fea78..e6cadaf3f1 100644 --- a/winui3/src/toga_winui3/app.py +++ b/winui3/src/toga_winui3/app.py @@ -5,8 +5,8 @@ from win32more.Windows.Win32.Media.Audio import SND_ALIAS, SND_ASYNC, PlaySound from win32more.Windows.Win32.UI.WindowsAndMessaging import ShowCursor +from .libs.nativeapp import NativeApp from .libs.proactor import WinUI3ProactorEventLoop -from .libs.winui3app import WinUI3App from .screens import Screen as ScreenImpl @@ -26,10 +26,10 @@ def __init__(self, interface): self._cursor_visible = True self.loop = WinUI3ProactorEventLoop() - self.native_instance: WinUI3App + self.native_instance: NativeApp def create(self): - self.native = WinUI3App + self.native = NativeApp # TODO Ensure that TLS1.2 and TLS1.3 are enabled. See Winforms. @@ -101,7 +101,7 @@ def get_screens(self): #################################################################################### def get_dark_mode_state(self) -> bool: - """Returns True if the WinUI3App instance is in dark mode.""" + """Returns True if the NativeApp instance is in dark mode.""" return self.native_instance.RequestedTheme == ApplicationTheme.Dark #################################################################################### diff --git a/winui3/src/toga_winui3/libs/nativeapp.py b/winui3/src/toga_winui3/libs/nativeapp.py new file mode 100644 index 0000000000..5a3e611d04 --- /dev/null +++ b/winui3/src/toga_winui3/libs/nativeapp.py @@ -0,0 +1,63 @@ +######################################################################################## +# NativeApp is derived from Yukihiro Nakadaira's XamlApplication: +# github.com/ynkdir/py-win32more/blob/main/packages/appsdk/src/win32more/winui3/__init__.py # noqa: E501 +# +# ====================================================================================== +# +# MIT License +# +# Copyright (c) 2022 Yukihiro Nakadaira +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# ====================================================================================== +# +######################################################################################## + +from __future__ import annotations + +from win32more import FAILED, WinError +from win32more.Microsoft.UI.Xaml import Application, Window +from win32more.Windows.Win32.System.Com import ( + COINIT_APARTMENTTHREADED, + CoInitializeEx, + CoUninitialize, +) +from win32more.winui3 import XamlApplication + +from .nativeevents import events_handled + + +class NativeApp(XamlApplication): + def CreateWindow(self): + return events_handled(Window) + + @classmethod + def Start(cls): + + hr = CoInitializeEx(None, COINIT_APARTMENTTHREADED) + if FAILED(hr): + raise WinError(hr) + + def ApplicationInitializationCallback(*_args): + return cls() + + Application.Start(ApplicationInitializationCallback) + + CoUninitialize() diff --git a/winui3/src/toga_winui3/libs/proactor.py b/winui3/src/toga_winui3/libs/proactor.py index a165f68c5f..567dcb70de 100644 --- a/winui3/src/toga_winui3/libs/proactor.py +++ b/winui3/src/toga_winui3/libs/proactor.py @@ -269,7 +269,7 @@ def app_exiting(loop, winui3_app): # pragma: no cover loop._run_forever_cleanup() def native_app_launched(self, winui3_app, args): - """A function to be used as an override of the OnLauched method of WinUI3App.""" + """A function to be used as an override of the OnLauched method of NativeApp.""" dispatcher = DispatcherQueue.GetForCurrentThread() self.task_enqueuer = dispatcher.TryEnqueue diff --git a/winui3/src/toga_winui3/libs/winui3app.py b/winui3/src/toga_winui3/libs/winui3app.py deleted file mode 100644 index 8ed28161d4..0000000000 --- a/winui3/src/toga_winui3/libs/winui3app.py +++ /dev/null @@ -1,164 +0,0 @@ -######################################################################################## -# WinUI3App is derived from Yukihiro Nakadaira's XamlApplication: -# github.com/ynkdir/py-win32more/blob/main/packages/appsdk/src/win32more/winui3/__init__.py # noqa: E501 -# -# ====================================================================================== -# -# MIT License -# -# Copyright (c) 2022 Yukihiro Nakadaira -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# -# ====================================================================================== -# -######################################################################################## - -from __future__ import annotations - -import inspect -from pathlib import Path - -from win32more import FAILED, Char, ComClass, WinError -from win32more.Microsoft.UI.Xaml import Application, IApplicationOverrides, Window -from win32more.Microsoft.UI.Xaml.Markup import IXamlMetadataProvider -from win32more.Microsoft.UI.Xaml.XamlTypeInfo import XamlControlsXamlMetaDataProvider -from win32more.Microsoft.Windows.ApplicationModel.Resources import ( - ResourceCandidate, - ResourceCandidateKind, - ResourceManager, -) -from win32more.Windows.Foundation import Uri -from win32more.Windows.Win32.System.Com import ( - COINIT_APARTMENTTHREADED, - CoInitializeEx, - CoUninitialize, -) -from win32more.Windows.Win32.System.LibraryLoader import GetModuleFileName - -from .nativeevents import events_handled - -# TODO: Clean up code. -# TODO: Needs to be commented and explained. -# FIXME: Fix resources. - - -class WinUI3App(ComClass, Application, IApplicationOverrides, IXamlMetadataProvider): - def __init__(self): - WinUI3App.__current = self - self._provider = None - super().__init__(own=True) - self.InitializeComponent() - self.ResourceManagerRequested += self.OnResourceManagerRequested - - def InitializeComponent(self): - xaml_path = Path(__file__).parent.parent / "resources" / "winui3app.xaml" - resource_locator = Uri(f"ms-appx:///{xaml_path.as_posix()}") - Application.LoadComponent(self, resource_locator) - - def OnLaunched(self, args): ... - - def OnExited(self): ... - - # FIXME: Find a way to remove this method. - def CreateWindow(self): - return events_handled(Window) - - def GetXamlType(self, type): - return self.AppProvider().GetXamlType(type) - - # TODO: Is it needed to provide information for primitive or winui type? - def GetXamlTypeByFullName(self, fullName): - return self.AppProvider().GetXamlTypeByFullName(fullName) - - def GetXmlnsDefinitions(self): - return self.AppProvider().GetXmlnsDefinitions() - - def AppProvider(self): - if self._provider is None: - self._provider = XamlControlsXamlMetaDataProvider() - return self._provider - - # FIXME: When executing app execution alias, sys.executable points alias. - # sys.executable => $LOCALAPPDATA\Microsoft\WindowsApps\python.exe - # We need resolved path instead. - def AppExecutable(self) -> Path: - buf = (Char * 1024)() - r = GetModuleFileName(None, buf, 1024) - if r == 0: - raise WinError() - return Path(buf.value) - - # Application root path. This is used to convert "ms-appx:///" path. See - # OnResourceNotFound(). This does not affect to WindowsAppSDK and may not work - # in specific situation. Appsdk's default is directory of python.exe file. - def AppRoot(self) -> Path: - # return self.AppExecutable().parent - return Path(inspect.getfile(type(self))).parent - - def OnResourceManagerRequested(self, sender, e): - # Workaround to avoid FileNotFoundError with default constructor (file does - # not need to exist). https://github.com/microsoft/WindowsAppSDK/issues/5814 - manager = ResourceManager("resources.pri") - manager.ResourceNotFound += self.OnResourceNotFound - e.CustomResourceManager = manager - - def OnResourceNotFound(self, sender, e): - name = e.Name - print(f"OnResourceNotFound: {name}") - if name.startswith("Files/file:///") and name.endswith(".png"): - resource_candidate = ResourceCandidate( - ResourceCandidateKind.FilePath, str(name) - ) - e.SetResolvedCandidate(resource_candidate) - # ignore absolute path - pass - elif e.Name.startswith("Files/"): - # convert relative path from ms-appx:///path/to/file to - # AppRoot()/path/to/file - name = name.removeprefix("Files/") - filepath = self.__tmp_resource_file.pop(name, self.AppRoot() / name) - if filepath.exists(): - resource_candidate = ResourceCandidate( - ResourceCandidateKind.FilePath, str(filepath) - ) - e.SetResolvedCandidate(resource_candidate) - - __tmp_resource_file = {} - - __current = None - - @classmethod - def Start(cls): - - hr = CoInitializeEx(None, COINIT_APARTMENTTHREADED) - if FAILED(hr): - raise WinError(hr) - - def ApplicationInitializationCallback(*_args): - return cls() - - Application.Start(ApplicationInitializationCallback) - - # FIXME: force Release() to avoid exit with error code. - if WinUI3App.__current is not None: - WinUI3App.__current.OnExited() - WinUI3App.__current.Release() - - CoUninitialize() diff --git a/winui3/tests_backend/app.py b/winui3/tests_backend/app.py index 8b0fb8ab30..88a5742316 100644 --- a/winui3/tests_backend/app.py +++ b/winui3/tests_backend/app.py @@ -6,8 +6,8 @@ import pytest import toga_winui3.libs.win32structures as ws from toga_winui3.libs.gdiplus import icon_pixels +from toga_winui3.libs.nativeapp import NativeApp from toga_winui3.libs.shell import Shell_NotifyIconGetRect -from toga_winui3.libs.winui3app import WinUI3App from win32more.Microsoft.UI.Input import InputCursor from win32more.Microsoft.UI.Interop import GetWindowFromWindowId from win32more.Microsoft.UI.Xaml import FocusState, Window @@ -51,10 +51,10 @@ def __init__(self, app): self.app = app self.main_window = app.main_window - # The WinUI3App class is a child class of with Microsoft.UI.Xaml.Application + # The NativeApp class is a descendant class of the Microsoft.UI.Xaml.Application # class, which is a singleton instance. - assert self.app._impl.native == WinUI3App - assert isinstance(self.app._impl.native_instance, WinUI3App) + assert self.app._impl.native == NativeApp + assert isinstance(self.app._impl.native_instance, NativeApp) @property def _hwnd(self): From 69236de1573321a0ffbdfaab33b160f31bd96b96 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Sat, 18 Jul 2026 12:26:18 +0200 Subject: [PATCH 042/110] Make icon tests async --- android/tests_backend/icons.py | 6 +++--- cocoa/tests_backend/icons.py | 6 +++--- gtk/tests_backend/icons.py | 6 +++--- iOS/tests_backend/icons.py | 6 +++--- qt/tests_backend/icons.py | 6 +++--- winforms/tests_backend/icons.py | 6 +++--- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/android/tests_backend/icons.py b/android/tests_backend/icons.py index 722195a0ec..676aea7904 100644 --- a/android/tests_backend/icons.py +++ b/android/tests_backend/icons.py @@ -17,7 +17,7 @@ def __init__(self, app, icon): self.icon = icon assert isinstance(self.icon._impl.native, Bitmap) - def assert_icon_content(self, path): + async def assert_icon_content(self, path): if path == "resources/icons/green": assert ( self.icon._impl.path == self.app.paths.app / "resources/icons/green.png" @@ -29,13 +29,13 @@ def assert_icon_content(self, path): else: pytest.fail("Unknown icon resource") - def assert_default_icon_content(self): + async def assert_default_icon_content(self): assert ( self.icon._impl.path == Path(toga_android.__file__).parent / "resources/toga.png" ) - def assert_platform_icon_content(self): + async def assert_platform_icon_content(self): assert self.icon._impl.path == self.app.paths.app / "resources/logo-android.png" def assert_app_icon_content(self): diff --git a/cocoa/tests_backend/icons.py b/cocoa/tests_backend/icons.py index 00533921bb..a172e362cd 100644 --- a/cocoa/tests_backend/icons.py +++ b/cocoa/tests_backend/icons.py @@ -19,7 +19,7 @@ def __init__(self, app, icon): self.icon = icon assert isinstance(self.icon._impl.native, NSImage) - def assert_icon_content(self, path): + async def assert_icon_content(self, path): if path == "resources/icons/green": assert ( self.icon._impl.path @@ -32,13 +32,13 @@ def assert_icon_content(self, path): else: pytest.fail("Unknown icon resource") - def assert_default_icon_content(self): + async def assert_default_icon_content(self): assert ( self.icon._impl.path == Path(toga_cocoa.__file__).parent / "resources/toga.icns" ) - def assert_platform_icon_content(self): + async def assert_platform_icon_content(self): assert self.icon._impl.path == self.app.paths.app / "resources/logo-macOS.icns" def assert_app_icon_content(self): diff --git a/gtk/tests_backend/icons.py b/gtk/tests_backend/icons.py index cfe977ad15..253e0f235d 100644 --- a/gtk/tests_backend/icons.py +++ b/gtk/tests_backend/icons.py @@ -31,7 +31,7 @@ def __init__(self, app, icon): # The following only checks for the paths detected, which does not # require GTK 3/4 differentiation. - def assert_icon_content(self, path): + async def assert_icon_content(self, path): if path == "resources/icons/green": # Three icons given with size; others sizes match the generic name assert self.icon._impl.paths == { @@ -52,13 +52,13 @@ def assert_icon_content(self, path): else: pytest.fail("Unknown icon resource") - def assert_default_icon_content(self): + async def assert_default_icon_content(self): assert self.icon._impl.paths == { size: Path(toga_gtk.__file__).parent / "resources/toga.png" for size in [16, 32, 64, 72, 128, 256, 512] } - def assert_platform_icon_content(self): + async def assert_platform_icon_content(self): # Only 32 and 72 pixel forms are available assert self.icon._impl.paths == { 32: self.app.paths.app / "resources/logo-linux-32.png", diff --git a/iOS/tests_backend/icons.py b/iOS/tests_backend/icons.py index a3bb0d291f..010762903c 100644 --- a/iOS/tests_backend/icons.py +++ b/iOS/tests_backend/icons.py @@ -17,7 +17,7 @@ def __init__(self, app, icon): self.icon = icon assert isinstance(self.icon._impl.native, UIImage) - def assert_icon_content(self, path): + async def assert_icon_content(self, path): if path == "resources/icons/green": assert ( self.icon._impl.path @@ -30,13 +30,13 @@ def assert_icon_content(self, path): else: pytest.fail("Unknown icon resource") - def assert_default_icon_content(self): + async def assert_default_icon_content(self): assert ( self.icon._impl.path == Path(toga_iOS.__file__).parent / "resources/toga.icns" ) - def assert_platform_icon_content(self): + async def assert_platform_icon_content(self): assert self.icon._impl.path == self.app.paths.app / "resources/logo-iOS.icns" def assert_app_icon_content(self): diff --git a/qt/tests_backend/icons.py b/qt/tests_backend/icons.py index 7bb1f00da2..274b89c041 100644 --- a/qt/tests_backend/icons.py +++ b/qt/tests_backend/icons.py @@ -18,7 +18,7 @@ def __init__(self, app, icon): self.app = app assert isinstance(self.icon._impl.native, QIcon) - def assert_icon_content(self, path): + async def assert_icon_content(self, path): if path == "resources/icons/green": assert ( self.icon._impl.path == self.app.paths.app / "resources/icons/green.png" @@ -35,12 +35,12 @@ def assert_icon_content(self, path): else: pytest.fail("Unknown icon resource") - def assert_default_icon_content(self): + async 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): + async def assert_platform_icon_content(self): pytest.xfail("Qt does not use sized icons") def assert_app_icon_content(self): diff --git a/winforms/tests_backend/icons.py b/winforms/tests_backend/icons.py index 9175f71642..580a2bbfd9 100644 --- a/winforms/tests_backend/icons.py +++ b/winforms/tests_backend/icons.py @@ -19,7 +19,7 @@ def __init__(self, app, icon): self.icon = icon assert isinstance(self.icon._impl.native, WinIcon) - def assert_icon_content(self, path): + async def assert_icon_content(self, path): if path == "resources/icons/green": assert ( self.icon._impl.path == self.app.paths.app / "resources/icons/green.ico" @@ -31,13 +31,13 @@ def assert_icon_content(self, path): else: pytest.fail("Unknown icon resource") - def assert_default_icon_content(self): + async def assert_default_icon_content(self): assert ( self.icon._impl.path == Path(toga_winforms.__file__).parent / "resources/toga.ico" ) - def assert_platform_icon_content(self): + async def assert_platform_icon_content(self): assert self.icon._impl.path == self.app.paths.app / "resources/logo-windows.ico" def assert_app_icon_content(self): From 9f3de597709115f6638327a2558eddb33c105c64 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Sat, 18 Jul 2026 12:33:20 +0200 Subject: [PATCH 043/110] Simplify color setting code --- winui3/src/toga_winui3/colors.py | 6 +++--- winui3/src/toga_winui3/widgets/base.py | 22 ++++------------------ winui3/src/toga_winui3/widgets/label.py | 15 +++------------ 3 files changed, 10 insertions(+), 33 deletions(-) diff --git a/winui3/src/toga_winui3/colors.py b/winui3/src/toga_winui3/colors.py index eda052fc64..3dd49205de 100644 --- a/winui3/src/toga_winui3/colors.py +++ b/winui3/src/toga_winui3/colors.py @@ -30,11 +30,11 @@ def native_color(toga_color): def native_brush(toga_color): - if not toga_color: - return None - color = native_color(toga_color) + if not color: + return None + try: brush = BRUSH_CACHE[toga_color] except KeyError: diff --git a/winui3/src/toga_winui3/widgets/base.py b/winui3/src/toga_winui3/widgets/base.py index c22573241b..ba0160b71b 100644 --- a/winui3/src/toga_winui3/widgets/base.py +++ b/winui3/src/toga_winui3/widgets/base.py @@ -2,13 +2,11 @@ from travertino.size import at_least from win32more.Microsoft.UI.Xaml import FocusState, Visibility -from win32more.Microsoft.UI.Xaml.Controls import Canvas, Panel - -from toga.constants import TRANSPARENT +from win32more.Microsoft.UI.Xaml.Controls import Canvas from ..colors import native_brush from ..libs.nativeevents import EventsHandledMixin -from .properties.native import NativeProperties, is_based_on +from .properties.native import NativeProperties from .properties.staged import StagedProperties @@ -84,14 +82,7 @@ def remove_child(self, child): #################################################################################### def set_background_color(self, color): - if color is not None: - brush = native_brush(color) - elif is_based_on(type(self.native), Panel): - brush = native_brush(TRANSPARENT) - else: - brush = None - - self._native_properties.Background = brush + self._native_properties.Background = native_brush(color) def set_bounds(self, x, y, width, height): self.native.Width = width @@ -100,12 +91,7 @@ def set_bounds(self, x, y, width, height): Canvas.SetTop(self.native, y) def set_color(self, color): - if color is not None: - brush = native_brush(color) - else: - brush = None - - self._native_properties.Foreground = brush + self._native_properties.Foreground = native_brush(color) def set_font(self, font): native_font = font._impl.native diff --git a/winui3/src/toga_winui3/widgets/label.py b/winui3/src/toga_winui3/widgets/label.py index 5aa2a70a3e..0dd50fc2ab 100644 --- a/winui3/src/toga_winui3/widgets/label.py +++ b/winui3/src/toga_winui3/widgets/label.py @@ -6,15 +6,13 @@ TextAlignment, VerticalAlignment, ) -from win32more.Microsoft.UI.Xaml.Controls import Grid, Panel, TextBlock - -from toga.constants import TRANSPARENT +from win32more.Microsoft.UI.Xaml.Controls import Grid, TextBlock from ..colors import native_brush from ..libs.misc import column_definition_star, row_definition_auto from ..libs.nativeevents import EventsHandledMixin from .base import Widget -from .properties.native import NativeProperties, is_based_on +from .properties.native import NativeProperties from .properties.staged import StagedProperties @@ -92,14 +90,7 @@ def text(self): #################################################################################### def set_background_color(self, color): - if color is not None: - brush = native_brush(color) - elif is_based_on(type(self.native), Panel): - brush = native_brush(TRANSPARENT) - else: - brush = None - - self._background_properties.Background = brush + self._background_properties.Background = native_brush(color) def set_text_align(self, alignment): property_dict = { From 404fa9e74fb20c9cd8505f031683d0de01f77ee7 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Sat, 18 Jul 2026 12:36:35 +0200 Subject: [PATCH 044/110] Added two cases of pragma: no cover to __init__.py --- winui3/src/toga_winui3/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/winui3/src/toga_winui3/__init__.py b/winui3/src/toga_winui3/__init__.py index d7623404f3..fe8545e40f 100644 --- a/winui3/src/toga_winui3/__init__.py +++ b/winui3/src/toga_winui3/__init__.py @@ -9,7 +9,7 @@ SetProcessDpiAwarenessContext, ) -if getwindowsversion().build < 17763: +if getwindowsversion().build < 17763: # pragma: no cover # https://learn.microsoft.com/en-us/windows/apps/winui/winui3/ raise WinError( descr="WinUI 3 only runs on Windows 10, version 1809 (build 17763) and later." @@ -22,7 +22,7 @@ # According to the Microsoft documentation, if SetProcessDpiAwarenessContext fails with # ERROR_ACCESS_DENIED, then the ProcessDpiAwarenessContext has already been set. -if not success: +if not success: # pragma: no cover dpi_error = GetLastError() if dpi_error == ERROR_ACCESS_DENIED: warn( From 6424b4eabd8484aa7844a9cb6bae2f0c1ef19579 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:15:50 +0200 Subject: [PATCH 045/110] StatusIcons: Fix flaky test & add icon change test --- testbed/tests/test_statusicons.py | 15 +++++++++++++++ winui3/src/toga_winui3/__init__.py | 4 ++-- winui3/tests_backend/app.py | 2 ++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/testbed/tests/test_statusicons.py b/testbed/tests/test_statusicons.py index 7860e9c74b..bc1b5c73bc 100644 --- a/testbed/tests/test_statusicons.py +++ b/testbed/tests/test_statusicons.py @@ -120,6 +120,21 @@ async def test_unknown_status_icon(app, app_probe): app.status_icons.commands.remove(bad_cmd) +async def test_change_icon(app, app_probe): + """A button status icon can be activated.""" + status_icon = app_probe.app.status_icons["button"] + old_icon = status_icon.icon + new_icon = toga.Icon("resources/alt-icon") + + status_icon.icon = new_icon + await app_probe.redraw("Status icon changed to a snake icon.") + assert status_icon.icon == new_icon + + status_icon.icon = old_icon + await app_probe.redraw("Status icon restored to blue disk.") + assert status_icon.icon == old_icon + + async def test_activate_button_icon(app, app_probe): """A button status icon can be activated.""" await app_probe.activate_status_icon_button("button") diff --git a/winui3/src/toga_winui3/__init__.py b/winui3/src/toga_winui3/__init__.py index fe8545e40f..a028119506 100644 --- a/winui3/src/toga_winui3/__init__.py +++ b/winui3/src/toga_winui3/__init__.py @@ -9,7 +9,7 @@ SetProcessDpiAwarenessContext, ) -if getwindowsversion().build < 17763: # pragma: no cover +if getwindowsversion().build < 17763: # pragma: no cover # https://learn.microsoft.com/en-us/windows/apps/winui/winui3/ raise WinError( descr="WinUI 3 only runs on Windows 10, version 1809 (build 17763) and later." @@ -22,7 +22,7 @@ # According to the Microsoft documentation, if SetProcessDpiAwarenessContext fails with # ERROR_ACCESS_DENIED, then the ProcessDpiAwarenessContext has already been set. -if not success: # pragma: no cover +if not success: # pragma: no cover dpi_error = GetLastError() if dpi_error == ERROR_ACCESS_DENIED: warn( diff --git a/winui3/tests_backend/app.py b/winui3/tests_backend/app.py index 88a5742316..01884b1ab2 100644 --- a/winui3/tests_backend/app.py +++ b/winui3/tests_backend/app.py @@ -328,6 +328,8 @@ async def _click_status_icon(self, status_icon): notify_icon_identifier.hWnd = status_icon._impl._hwnd notify_icon_identifier.uID = 1 + await self.redraw("The system tray overflow has been open", delay=0.1) + rect = wt.RECT() Shell_NotifyIconGetRect(byref(notify_icon_identifier), byref(rect)) From 78821c0400aab0835f453d509e981841178ff039 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:51:47 +0200 Subject: [PATCH 046/110] Icons: Add test for a bad icon of another type --- android/tests_backend/icons.py | 1 + cocoa/tests_backend/icons.py | 1 + gtk/tests_backend/icons.py | 1 + iOS/tests_backend/icons.py | 1 + qt/tests_backend/icons.py | 1 + testbed/src/testbed/resources/icons/bad_ico.ico | 1 + testbed/src/testbed/resources/icons/bad_png.png | 1 + testbed/tests/test_icons.py | 5 +++++ winforms/tests_backend/icons.py | 1 + winui3/tests_backend/icons.py | 1 + 10 files changed, 14 insertions(+) create mode 100644 testbed/src/testbed/resources/icons/bad_ico.ico create mode 100644 testbed/src/testbed/resources/icons/bad_png.png diff --git a/android/tests_backend/icons.py b/android/tests_backend/icons.py index 676aea7904..94a4439845 100644 --- a/android/tests_backend/icons.py +++ b/android/tests_backend/icons.py @@ -11,6 +11,7 @@ class IconProbe(BaseProbe): # Android only supports 1 format, so the alternate is the same as the primary. alternate_resource = "resources/icons/blue" + alternate_bad = "resources/icons/bad_png" def __init__(self, app, icon): super().__init__(app) diff --git a/cocoa/tests_backend/icons.py b/cocoa/tests_backend/icons.py index a172e362cd..a2be0ec343 100644 --- a/cocoa/tests_backend/icons.py +++ b/cocoa/tests_backend/icons.py @@ -12,6 +12,7 @@ class IconProbe(BaseProbe): alternate_resource = "resources/icons/blue" + alternate_bad = "resources/icons/bad_png" def __init__(self, app, icon): super().__init__() diff --git a/gtk/tests_backend/icons.py b/gtk/tests_backend/icons.py index 253e0f235d..7565ff95c2 100644 --- a/gtk/tests_backend/icons.py +++ b/gtk/tests_backend/icons.py @@ -12,6 +12,7 @@ class IconProbe(BaseProbe): alternate_resource = "resources/icons/orange" + alternate_bad = "resources/icons/bad_ico" def __init__(self, app, icon): super().__init__() diff --git a/iOS/tests_backend/icons.py b/iOS/tests_backend/icons.py index 010762903c..2fea49f70c 100644 --- a/iOS/tests_backend/icons.py +++ b/iOS/tests_backend/icons.py @@ -10,6 +10,7 @@ class IconProbe(BaseProbe): alternate_resource = "resources/icons/blue" + alternate_bad = "resources/icons/bad_png" def __init__(self, app, icon): super().__init__() diff --git a/qt/tests_backend/icons.py b/qt/tests_backend/icons.py index 274b89c041..7d12503953 100644 --- a/qt/tests_backend/icons.py +++ b/qt/tests_backend/icons.py @@ -12,6 +12,7 @@ class IconProbe(BaseProbe): alternate_resource = "resources/icons/orange" + alternate_bad = "resources/icons/bad_ico" def __init__(self, app, icon): self.icon = icon diff --git a/testbed/src/testbed/resources/icons/bad_ico.ico b/testbed/src/testbed/resources/icons/bad_ico.ico new file mode 100644 index 0000000000..0c57ab9323 --- /dev/null +++ b/testbed/src/testbed/resources/icons/bad_ico.ico @@ -0,0 +1 @@ +This is not an ico file. diff --git a/testbed/src/testbed/resources/icons/bad_png.png b/testbed/src/testbed/resources/icons/bad_png.png new file mode 100644 index 0000000000..9d7eda0507 --- /dev/null +++ b/testbed/src/testbed/resources/icons/bad_png.png @@ -0,0 +1 @@ +This is not a png file. diff --git a/testbed/tests/test_icons.py b/testbed/tests/test_icons.py index 1ed7bf2fe9..d422d6965f 100644 --- a/testbed/tests/test_icons.py +++ b/testbed/tests/test_icons.py @@ -49,3 +49,8 @@ async def test_bad_icon_file(app): probe = icon_probe(app, toga.Icon("resources/icons/bad")) await probe.redraw("Icon probe is using a bad icon file") await probe.assert_default_icon_content() + + # Attempt to create another probe with an alternate (non-preferred) resource format. + probe = icon_probe(app, toga.Icon(probe.alternate_bad)) + await probe.redraw("Icon probe is using an alternate bad icon file") + await probe.assert_default_icon_content() diff --git a/winforms/tests_backend/icons.py b/winforms/tests_backend/icons.py index 580a2bbfd9..766153e881 100644 --- a/winforms/tests_backend/icons.py +++ b/winforms/tests_backend/icons.py @@ -12,6 +12,7 @@ class IconProbe(BaseProbe): alternate_resource = "resources/icons/blue" + alternate_bad = "resources/icons/bad_png" def __init__(self, app, icon): super().__init__() diff --git a/winui3/tests_backend/icons.py b/winui3/tests_backend/icons.py index a00b3a7333..bb7a9a30d5 100644 --- a/winui3/tests_backend/icons.py +++ b/winui3/tests_backend/icons.py @@ -22,6 +22,7 @@ class IconProbe(BaseProbe): alternate_resource = "resources/icons/orange" + alternate_bad = "resources/icons/bad_ico" def __init__(self, app, icon): super().__init__() From e4928f61b4fda6d43d2a7d245f21395d3e2c08fa Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:35:01 +0200 Subject: [PATCH 047/110] StatusIcons: Improve flaky selection tests --- winui3/tests_backend/app.py | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/winui3/tests_backend/app.py b/winui3/tests_backend/app.py index 01884b1ab2..745a1e0d4d 100644 --- a/winui3/tests_backend/app.py +++ b/winui3/tests_backend/app.py @@ -1,3 +1,4 @@ +import asyncio from ctypes import byref, sizeof, windll, wintypes as wt from pathlib import Path from time import sleep @@ -328,14 +329,26 @@ async def _click_status_icon(self, status_icon): notify_icon_identifier.hWnd = status_icon._impl._hwnd notify_icon_identifier.uID = 1 - await self.redraw("The system tray overflow has been open", delay=0.1) + def get_midpoint(): + rect = wt.RECT() + Shell_NotifyIconGetRect(byref(notify_icon_identifier), byref(rect)) - rect = wt.RECT() - Shell_NotifyIconGetRect(byref(notify_icon_identifier), byref(rect)) + x = int((rect.left + rect.right) / 2) + y = int((rect.top + rect.bottom) / 2) + return (x, y) - x = int((rect.left + rect.right) / 2) - y = int((rect.top + rect.bottom) / 2) - await self._send_click(x, y) + # Make sure the overflow tray is fully open by tracking when the midpoint stops + # moving. + mid_point = get_midpoint() + for _ in range(10): + await asyncio.sleep(0.05) + + new_mid_point = get_midpoint() + if mid_point == new_mid_point: + break + mid_point = new_mid_point + + await self._send_click(*mid_point) def _get_status_menu_items(self, status_icon): native_menu = getattr(status_icon._impl, "native_menu", None) @@ -378,7 +391,13 @@ async def activate_status_menu_item(self, item_id, title): items = self._get_status_menu_items(status_icon) index = self.status_menu_items(status_icon).index(title) - items[index].Focus(FocusState.Programmatic) + # Make sure that the menu item is selected before sending select command. + for _ in range(100): + items[index].Focus(FocusState.Programmatic) + if items[index].FocusState != 0: + break + await asyncio.sleep(0.01) + await self._keyboard_select() await self._keyboard_escape() From df533e76ef258dea5629078befc2f140e060ea17 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:21:02 +0200 Subject: [PATCH 048/110] Fix menu visibility and re-creation --- winui3/src/toga_winui3/command.py | 6 ++-- winui3/src/toga_winui3/statusicons.py | 2 +- winui3/src/toga_winui3/window.py | 40 ++++++++++++++++++++------- 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/winui3/src/toga_winui3/command.py b/winui3/src/toga_winui3/command.py index ccd67c8ad3..5f4e456d46 100644 --- a/winui3/src/toga_winui3/command.py +++ b/winui3/src/toga_winui3/command.py @@ -8,7 +8,7 @@ class Command: def __init__(self, interface): self.interface = interface - self.native = [] + self.native = {} @classmethod def standard(self, app, id): @@ -91,7 +91,7 @@ def set_enabled(self, value): for item in self.native: item.IsEnabled = is_enabled - def create_menu_item(self, NativeClass): + def create_menu_item(self, window_id, NativeClass): item = events_handled(NativeClass) item.Text = self.interface.text item.event_handler.Click += self.native_event_Click @@ -101,6 +101,6 @@ def create_menu_item(self, NativeClass): item.IsEnabled = self.interface.enabled - self.native.append(item) + self.native[window_id] = item return item diff --git a/winui3/src/toga_winui3/statusicons.py b/winui3/src/toga_winui3/statusicons.py index e8d302f12b..81dc4521a7 100644 --- a/winui3/src/toga_winui3/statusicons.py +++ b/winui3/src/toga_winui3/statusicons.py @@ -233,6 +233,6 @@ def create(self): if isinstance(cmd, Separator): menu_item = MenuFlyoutSeparator() else: - menu_item = cmd._impl.create_menu_item(MenuFlyoutItem) + menu_item = cmd._impl.create_menu_item(0, MenuFlyoutItem) submenu.Items.Append(menu_item) diff --git a/winui3/src/toga_winui3/window.py b/winui3/src/toga_winui3/window.py index 014ddd9826..29a0c34931 100644 --- a/winui3/src/toga_winui3/window.py +++ b/winui3/src/toga_winui3/window.py @@ -15,6 +15,7 @@ from win32more.Microsoft.UI.Xaml import ( HorizontalAlignment, VerticalAlignment, + Visibility, WindowActivationState, ) from win32more.Microsoft.UI.Xaml.Controls import ( @@ -464,18 +465,18 @@ def set_window_state(self, state: WindowState): if state == WindowState.PRESENTATION: self._in_presentation_mode = True if hasattr(self, "menu_native"): - self.menu_native.Visible = False + self.menu_native.Visibility = Visibility.Collapsed if hasattr(self, "toolbar_native"): - self.toolbar_native.Visible = False + self.menu_native.Visibility = Visibility.Collapsed else: self._in_presentation_mode = False if hasattr(self, "menu_native"): - self.menu_native.Visible = True + self.menu_native.Visibility = Visibility.Visible if hasattr(self, "toolbar_native"): - self.toolbar_native.Visible = True + self.menu_native.Visibility = Visibility.Visible match state: case WindowState.NORMAL: @@ -531,6 +532,18 @@ def create_content(self): # Attach the content to the window. self.native.Content = self.content_native + def __del__(self): + window_id = id(self) + for cmd in self.interface.app.commands: + try: + impl = cmd._impl + try: + del impl.native[window_id] + except KeyError: + pass + except AttributeError: + pass + def _submenu(self, group, group_cache): try: return group_cache[group] @@ -554,10 +567,16 @@ def _submenu(self, group, group_cache): return submenu def create_menus(self): - self.menu_native = MenuBar() - self.menu_native.VerticalAlignment = VerticalAlignment.Top - Grid.SetRow(self.menu_native, 0) - Grid.SetColumn(self.menu_native, 0) + window_id = id(self) + menu_exists = hasattr(self, "menu_native") + + if not menu_exists: + self.menu_native = MenuBar() + self.menu_native.VerticalAlignment = VerticalAlignment.Top + Grid.SetRow(self.menu_native, 0) + Grid.SetColumn(self.menu_native, 0) + else: + self.menu_native.Items.Clear() group_cache = {None: self.menu_native} @@ -567,11 +586,12 @@ def create_menus(self): if isinstance(cmd, Separator): item = MenuFlyoutSeparator() else: - item = cmd._impl.create_menu_item(MenuFlyoutItem) + item = cmd._impl.create_menu_item(window_id, MenuFlyoutItem) submenu.Items.Append(item) - self.content_native.Children.Append(self.menu_native) + if not menu_exists: + self.content_native.Children.Append(self.menu_native) def create_toolbar(self): if not self.interface.toolbar: From 05edecb6e46e5e6f6b71c33748bd89d313c5f37d Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:36:48 +0200 Subject: [PATCH 049/110] Remove unnecessary code and add no covers/branches --- winui3/src/toga_winui3/container.py | 21 ++----------------- winui3/src/toga_winui3/libs/gdiplus.py | 3 --- winui3/src/toga_winui3/libs/nativeapp.py | 5 +++-- winui3/src/toga_winui3/libs/proactor.py | 6 +++--- winui3/src/toga_winui3/screens.py | 15 +++++-------- .../toga_winui3/widgets/properties/native.py | 3 ++- .../toga_winui3/widgets/properties/staged.py | 6 ------ winui3/src/toga_winui3/window.py | 3 ++- 8 files changed, 17 insertions(+), 45 deletions(-) diff --git a/winui3/src/toga_winui3/container.py b/winui3/src/toga_winui3/container.py index 964b424603..af0b9b025a 100644 --- a/winui3/src/toga_winui3/container.py +++ b/winui3/src/toga_winui3/container.py @@ -86,22 +86,6 @@ def width(self): def height(self): return ceil(self.native.ActualSize.Y) - @property - def min_width(self): - return self.native.MinWidth - - @min_width.setter - def min_width(self, width): - self.native.MinWidth = width - - @property - def min_height(self): - return self.native.MinHeight - - @min_height.setter - def min_height(self, height): - self.native.MinHeight = height - #################################################################################### # Container content #################################################################################### @@ -124,7 +108,8 @@ def content(self, widget): self._content.container = None self._content = widget - if widget: + # FIXME: Remove the 'no branch' when ScrollContainer is implemented. + if widget: # pragma: no branch widget.container = self #################################################################################### @@ -136,6 +121,4 @@ def native_event_size_changed(self, sender, args): self.content.interface.refresh() def refreshed(self): - self.min_width = self.content.interface.layout.min_width - self.min_height = self.content.interface.layout.min_height self._on_refresh() diff --git a/winui3/src/toga_winui3/libs/gdiplus.py b/winui3/src/toga_winui3/libs/gdiplus.py index a0cfe3bf00..0abd58a1c6 100644 --- a/winui3/src/toga_winui3/libs/gdiplus.py +++ b/winui3/src/toga_winui3/libs/gdiplus.py @@ -93,9 +93,6 @@ def __enter__(self): def __exit__(self, exc_type, exc_value, traceback): GdiplusShutdown(self._token) - def __del__(self): - pass - gdi_plus_context = GdiPlusContext() diff --git a/winui3/src/toga_winui3/libs/nativeapp.py b/winui3/src/toga_winui3/libs/nativeapp.py index 5a3e611d04..152df86580 100644 --- a/winui3/src/toga_winui3/libs/nativeapp.py +++ b/winui3/src/toga_winui3/libs/nativeapp.py @@ -52,7 +52,7 @@ def CreateWindow(self): def Start(cls): hr = CoInitializeEx(None, COINIT_APARTMENTTHREADED) - if FAILED(hr): + if FAILED(hr): # pragma: no cover raise WinError(hr) def ApplicationInitializationCallback(*_args): @@ -60,4 +60,5 @@ def ApplicationInitializationCallback(*_args): Application.Start(ApplicationInitializationCallback) - CoUninitialize() + # This line occurs after shutdown, which can't be covered by the testbed. + CoUninitialize() # pragma: no cover diff --git a/winui3/src/toga_winui3/libs/proactor.py b/winui3/src/toga_winui3/libs/proactor.py index 567dcb70de..f0b886e436 100644 --- a/winui3/src/toga_winui3/libs/proactor.py +++ b/winui3/src/toga_winui3/libs/proactor.py @@ -72,9 +72,6 @@ def _iocp_listener(self): task_enqueuer = self._loop.task_enqueuer GetQueuedCompletionStatus = _overlapped.GetQueuedCompletionStatus - def exit_native(): - app.native.Exit(app.native_instance) - # The listener lock forces the close method to wait until the listener # loop is closed. with self._listener_lock: @@ -103,6 +100,9 @@ def iocp_action(status=status): # Exit the application. Call here to avoid dispatcher calls after # app.native is exited. + def exit_native(): # pragma: no cover + app.native.Exit(app.native_instance) + task_enqueuer(exit_native) # pragma: no cover #################################################################################### diff --git a/winui3/src/toga_winui3/screens.py b/winui3/src/toga_winui3/screens.py index 184e3c7fcb..e6ef86ebb2 100644 --- a/winui3/src/toga_winui3/screens.py +++ b/winui3/src/toga_winui3/screens.py @@ -1,7 +1,6 @@ from ctypes import byref from decimal import ROUND_HALF_EVEN, Decimal -from travertino.size import at_least from win32more.Microsoft.UI.Interop import GetMonitorFromDisplayId from win32more.Windows.Win32.Graphics.Gdi import HMONITOR from win32more.Windows.Win32.UI.Shell import GetScaleFactorForMonitor @@ -12,10 +11,8 @@ from toga.types import Position, Size -def round_pixels(value, rounding=ROUND_HALF_EVEN) -> int: - if rounding is None: - return value - return int(Decimal(value).to_integral(rounding)) +def round_pixels(value) -> int: + return int(Decimal(value).to_integral(ROUND_HALF_EVEN)) class Screen: @@ -57,10 +54,7 @@ def css_to_physical(self, value): return round_pixels(value * self.dpi_scale) def physical_to_css(self, value): - if isinstance(value, at_least): - return at_least(self.physical_to_css(value.value)) - else: - return round_pixels(value / self.dpi_scale) + return round_pixels(value / self.dpi_scale) #################################################################################### # Size and position @@ -89,5 +83,6 @@ def get_size(self) -> Size: # Screen capabilities #################################################################################### - def get_image_data(self): + def get_image_data(self): # pragma: no cover + # FIXME: Remove 'no cover' when implemented. print("Not yet implemented on WinUI3 - Screen.get_image_data") diff --git a/winui3/src/toga_winui3/widgets/properties/native.py b/winui3/src/toga_winui3/widgets/properties/native.py index 9ec9f3dc8f..a7bd8daefb 100644 --- a/winui3/src/toga_winui3/widgets/properties/native.py +++ b/winui3/src/toga_winui3/widgets/properties/native.py @@ -60,7 +60,8 @@ def __setattr__(self, name, value): def set_native_property(self, name, value): native_instance = self._widget.native - if not hasattr(native_instance, name): + # This codeblock shouldn't be accessed under normal operations, so use no cover. + if not hasattr(native_instance, name): # pragma: no cover raise AttributeError(f"{native_instance} has no attribute named {name}.") # For non-None values, set the property as normal. diff --git a/winui3/src/toga_winui3/widgets/properties/staged.py b/winui3/src/toga_winui3/widgets/properties/staged.py index b265a519a7..cab0b4440a 100644 --- a/winui3/src/toga_winui3/widgets/properties/staged.py +++ b/winui3/src/toga_winui3/widgets/properties/staged.py @@ -82,12 +82,6 @@ def __setattr__(self, name, value): super().__setattr__(name, value) return - if not callable(value): - raise ValueError( - "The 'value' of a staged property must be callable i.e. a " - + "'value creator'." - ) - # Set and cache the native property. setattr(self._widget._native_properties, name, value()) self._staged_properties[name] = value diff --git a/winui3/src/toga_winui3/window.py b/winui3/src/toga_winui3/window.py index 29a0c34931..7f39f28c44 100644 --- a/winui3/src/toga_winui3/window.py +++ b/winui3/src/toga_winui3/window.py @@ -397,7 +397,8 @@ def _presenter(self): elif raw_presenter.Kind == AppWindowPresenterKind.FullScreen: # Cast presenter as an instance of FullScreenPresenter. return FullScreenPresenter(value=raw_presenter.value), raw_presenter - else: + else: # pragma: no cover + # This codeblock should not be accessed under normal operations. raise ValueError("CompactOverlay is not a supported presenter type.") def get_window_state(self, in_progress_state=False) -> WindowState: From c87657231b1e8856ee640bc6b33975b43ab63265 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:58:07 +0200 Subject: [PATCH 050/110] Simplify staged property refreshes --- .../src/toga_winui3/widgets/properties/staged.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/winui3/src/toga_winui3/widgets/properties/staged.py b/winui3/src/toga_winui3/widgets/properties/staged.py index cab0b4440a..430b82d575 100644 --- a/winui3/src/toga_winui3/widgets/properties/staged.py +++ b/winui3/src/toga_winui3/widgets/properties/staged.py @@ -49,19 +49,16 @@ def add(self, native_widget): self.native.Children.Append(native_widget) def remove(self, native_widget): - """Removes a widget and triggers a layout refresh when the widget list empties. - - The refresh mechanism here is to avoid excessive refresh calls. - """ - non_empty_initial = len(self._native_widgets) > 0 + """Removes a widget and triggers a layout refresh.""" index = self._native_widgets.index(native_widget) self._native_widgets.remove(native_widget) self.native.Children.RemoveAt(index) - empty_final = len(self._native_widgets) == 0 - if non_empty_initial and empty_final: - if self._container._content: - self._container._content.interface.refresh() + # It is possible that self._container._content was removed during the staging + # process. It is difficult to reliably create this scenario during testing, so + # use no branch here. + if self._container._content: # pragma: no branch + self._container._content.interface.refresh() class StagedProperties: From f4f26eabfde3d06fb12010795cad0bc4cc78497b Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:03:02 +0200 Subject: [PATCH 051/110] Minor changes to BaseProbe and WindowProbe timing --- winui3/tests_backend/probe.py | 2 +- winui3/tests_backend/window.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/winui3/tests_backend/probe.py b/winui3/tests_backend/probe.py index 02eaa98d66..6de45f869e 100644 --- a/winui3/tests_backend/probe.py +++ b/winui3/tests_backend/probe.py @@ -45,7 +45,7 @@ def staging_complete(): for _ in range(1000): if staging_complete(): break - await asyncio.sleep(0) + await asyncio.sleep(0.01) # If we're running slow, or we have a wait condition, # wait for at least a second diff --git a/winui3/tests_backend/window.py b/winui3/tests_backend/window.py index 13ce0e7877..6539701afc 100644 --- a/winui3/tests_backend/window.py +++ b/winui3/tests_backend/window.py @@ -47,7 +47,8 @@ async def wait_for_window( message, state=None, ): - await self.redraw(message) + # A small delay to allow the window to resize. + await self.redraw(message, delay=0.1) if state: timeout = 5 From 62c83b6cc1241fe15a622d4a35f80179b4e67432 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:13:15 +0200 Subject: [PATCH 052/110] Fixes to widget input focus --- winui3/src/toga_winui3/widgets/base.py | 3 ++- winui3/src/toga_winui3/widgets/box.py | 2 ++ winui3/src/toga_winui3/widgets/label.py | 20 ++++---------------- winui3/tests_backend/widgets/base.py | 4 ++++ 4 files changed, 12 insertions(+), 17 deletions(-) diff --git a/winui3/src/toga_winui3/widgets/base.py b/winui3/src/toga_winui3/widgets/base.py index ba0160b71b..2ce99a6609 100644 --- a/winui3/src/toga_winui3/widgets/base.py +++ b/winui3/src/toga_winui3/widgets/base.py @@ -125,7 +125,8 @@ def has_focus(self): return self.native.FocusState != FocusState.Unfocused def focus(self): - self.native.Focus(FocusState.Programmatic) + if not self.has_focus: + self.native.Focus(FocusState.Programmatic) def get_tab_index(self): return self.native.TabIndex diff --git a/winui3/src/toga_winui3/widgets/box.py b/winui3/src/toga_winui3/widgets/box.py index fa1b5ef1f8..877ff51c05 100644 --- a/winui3/src/toga_winui3/widgets/box.py +++ b/winui3/src/toga_winui3/widgets/box.py @@ -6,6 +6,8 @@ class Box(Widget): def create(self): self.native_cls = Canvas + # Box cannot receive input focus, so remove it from the tab sequence. + self.native.IsTabStop = False #################################################################################### # Overrides of methods called by the Toga style applicator. diff --git a/winui3/src/toga_winui3/widgets/label.py b/winui3/src/toga_winui3/widgets/label.py index 0dd50fc2ab..3c41f46b4c 100644 --- a/winui3/src/toga_winui3/widgets/label.py +++ b/winui3/src/toga_winui3/widgets/label.py @@ -1,7 +1,6 @@ from travertino.constants import CENTER, JUSTIFY, LEFT, RIGHT from travertino.size import at_least from win32more.Microsoft.UI.Xaml import ( - FocusState, HorizontalAlignment, TextAlignment, VerticalAlignment, @@ -21,6 +20,8 @@ def __init__(self, label): self._label = label self.native_cls = TextBlock + # LabelText cannot receive input focus, so remove it from the tab sequence. + self.native.IsTabStop = False self._native_properties = NativeProperties(self) self._staged_properties = StagedProperties(self) @@ -59,6 +60,8 @@ def rehint(self): class Label(Widget): def create(self): self.native_cls = Grid + # Label cannot receive input focus, so remove it from the tab sequence. + self.native.IsTabStop = False self._background_properties = self._native_properties @@ -116,21 +119,6 @@ def set_enabled(self, value): # Neither TextBlock or Grid has the IsEnabled property. pass - @property - def has_focus(self): - grid_has_focus = self.native.FocusState != FocusState.Unfocused - text_has_focus = self.label_text.native.FocusState != FocusState.Unfocused - return grid_has_focus or text_has_focus - - def focus(self): - self.label_text.native.Focus(FocusState.Programmatic) - - def get_tab_index(self): - return self.label_text.native.TabIndex - - def set_tab_index(self, tab_index): - self.label_text.native.TabIndex = tab_index - def rehint(self): self.interface.intrinsic.width = at_least(self._min_width) self.interface.intrinsic.height = self._min_height diff --git a/winui3/tests_backend/widgets/base.py b/winui3/tests_backend/widgets/base.py index b1a2649433..7a26e77958 100644 --- a/winui3/tests_backend/widgets/base.py +++ b/winui3/tests_backend/widgets/base.py @@ -121,3 +121,7 @@ def enabled(self): @property def shrink_on_resize(self): return True + + @property + def has_focus(self): + return self.native.FocusState != FocusState.Unfocused From 17170683ed613568147988440b203f0f9d80f02b Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:09:35 +0200 Subject: [PATCH 053/110] Minor changes/fixex to window states and menus --- winui3/src/toga_winui3/command.py | 2 +- winui3/src/toga_winui3/window.py | 24 ++++++++++-------------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/winui3/src/toga_winui3/command.py b/winui3/src/toga_winui3/command.py index 5f4e456d46..17aadb5c82 100644 --- a/winui3/src/toga_winui3/command.py +++ b/winui3/src/toga_winui3/command.py @@ -88,7 +88,7 @@ def native_event_Click(self, sender, args): def set_enabled(self, value): is_enabled = self.interface.enabled - for item in self.native: + for item in self.native.values(): item.IsEnabled = is_enabled def create_menu_item(self, window_id, NativeClass): diff --git a/winui3/src/toga_winui3/window.py b/winui3/src/toga_winui3/window.py index 7f39f28c44..96d7e135bc 100644 --- a/winui3/src/toga_winui3/window.py +++ b/winui3/src/toga_winui3/window.py @@ -445,11 +445,7 @@ def set_window_state(self, state: WindowState): ): self.interface.app.exit_presentation_mode() - from_state = self.get_window_state() - if from_state == state: - return - - from_overlapped = from_state not in { + from_overlapped = self.get_window_state() not in { WindowState.FULLSCREEN, WindowState.PRESENTATION, } @@ -463,21 +459,24 @@ def set_window_state(self, state: WindowState): # Change from fullscreen presenter to overlapped presenter. self.native.AppWindow.SetPresenterByKind(AppWindowPresenterKind.Overlapped) + # The core interface filters out the case state == self.get_window_state(). if state == WindowState.PRESENTATION: self._in_presentation_mode = True if hasattr(self, "menu_native"): self.menu_native.Visibility = Visibility.Collapsed - if hasattr(self, "toolbar_native"): - self.menu_native.Visibility = Visibility.Collapsed + # TODO: Implement toolbars. + # if hasattr(self, "toolbar_native"): + # self.menu_native.Visibility = Visibility.Collapsed else: self._in_presentation_mode = False if hasattr(self, "menu_native"): self.menu_native.Visibility = Visibility.Visible - if hasattr(self, "toolbar_native"): - self.menu_native.Visibility = Visibility.Visible + # TODO: Implement toolbars. + # if hasattr(self, "toolbar_native"): + # self.menu_native.Visibility = Visibility.Visible match state: case WindowState.NORMAL: @@ -538,11 +537,8 @@ def __del__(self): for cmd in self.interface.app.commands: try: impl = cmd._impl - try: - del impl.native[window_id] - except KeyError: - pass - except AttributeError: + del impl.native[window_id] + except (AttributeError, KeyError): pass def _submenu(self, group, group_cache): From 1fb0cea34a28f77694b82602cd7c2771925be921 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:13:46 +0200 Subject: [PATCH 054/110] Simplify Label widget --- winui3/src/toga_winui3/widgets/label.py | 32 +++++++------------------ 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/winui3/src/toga_winui3/widgets/label.py b/winui3/src/toga_winui3/widgets/label.py index 3c41f46b4c..d829c07182 100644 --- a/winui3/src/toga_winui3/widgets/label.py +++ b/winui3/src/toga_winui3/widgets/label.py @@ -26,6 +26,10 @@ def __init__(self, label): self._native_properties = NativeProperties(self) self._staged_properties = StagedProperties(self) + # Initial minimum sizes are 0 so that the staged properties are sized up. + self._min_width = 0 + self._min_height = 0 + Grid.SetRow(self.native, 0) Grid.SetColumn(self.native, 0) label.native.Children.Append(self.native) @@ -37,22 +41,6 @@ def __init__(self, label): def container(self): return self._label.container - @property - def _min_width(self): - return self._label._min_width - - @_min_width.setter - def _min_width(self, value): - self._label._min_width = value - - @property - def _min_height(self): - return self._label._min_height - - @_min_height.setter - def _min_height(self, value): - self._label._min_height = value - def rehint(self): self._label.rehint() @@ -74,10 +62,6 @@ def create(self): self._text = "" - # Initial minimum sizes are 0 so that the staged properties are sized up. - self._min_width = 0 - self._min_height = 0 - def get_text(self): return self._text @@ -112,13 +96,13 @@ def set_text_align(self, alignment): #################################################################################### def get_enabled(self): - # Neither TextBlock or Grid has the IsEnabled property. + # Neither TextBlock nor Grid has the IsEnabled property. return True def set_enabled(self, value): - # Neither TextBlock or Grid has the IsEnabled property. + # Neither TextBlock nor Grid has the IsEnabled property. pass def rehint(self): - self.interface.intrinsic.width = at_least(self._min_width) - self.interface.intrinsic.height = self._min_height + self.interface.intrinsic.width = at_least(self.label_text._min_width) + self.interface.intrinsic.height = self.label_text._min_height From a35c0a27a0dae6910f92c4e717e4a15fc93b818f Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:18:39 +0200 Subject: [PATCH 055/110] Improve native event handling --- winui3/src/toga_winui3/libs/nativeevents.py | 15 +++++++++++++-- winui3/tests_backend/widgets/base.py | 5 ++++- winui3/tests_backend/widgets/button.py | 7 +++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/winui3/src/toga_winui3/libs/nativeevents.py b/winui3/src/toga_winui3/libs/nativeevents.py index 302fc8a933..89db1479f3 100644 --- a/winui3/src/toga_winui3/libs/nativeevents.py +++ b/winui3/src/toga_winui3/libs/nativeevents.py @@ -26,6 +26,8 @@ def __iadd__(self, callback): # Keep a local reference to the callback. self._registry[id(token)] = (token, callback) + return self + def clear(self): event_remover = getattr(self._owner, "remove_" + self._name) for token, callback in self._registry.values(): @@ -70,6 +72,13 @@ def __getattr__(self, name): return self._event_registry[name] + def __setattr__(self, name, value): + if not name[0].isupper(): + super().__setattr__(name, value) + return + + self._event_registry[name] = value + def clear(self): for event in self._event_registry.values(): event.clear() @@ -86,7 +95,8 @@ def __del__(self): if getattr(self, "_event_handler", None): self.event_handler.clear() - if hasattr(self.native_class, "__del__"): + # This is a safety catch for future changes in the native backend. + if hasattr(self.native_class, "__del__"): # pragma: no cover super().__del__() @property @@ -107,8 +117,9 @@ def events_handled(native_cls): class EventsHandledMixin: @property def native_cls(self): - return type(self.native) + return self._native_cls if hasattr(self, "_native_cls") else None @native_cls.setter def native_cls(self, cls): + self._native_cls = cls self.native = events_handled(cls) diff --git a/winui3/tests_backend/widgets/base.py b/winui3/tests_backend/widgets/base.py index 7a26e77958..ae85603641 100644 --- a/winui3/tests_backend/widgets/base.py +++ b/winui3/tests_backend/widgets/base.py @@ -16,7 +16,10 @@ def __init__(self, widget): self.widget = widget self.impl = widget._impl super().__init__(self.impl.native) - assert isinstance(self.native, self.native_class) + + # Check that the native class has been instantiated using events_handled() + assert self.impl.native_cls == self.native_class + assert type(self.native).__name__ == self.native_class.__name__ + "Handled" def assert_container(self, container): assert self.widget._impl.container is container._impl.container diff --git a/winui3/tests_backend/widgets/button.py b/winui3/tests_backend/widgets/button.py index f83f51e876..f16d0ca4e8 100644 --- a/winui3/tests_backend/widgets/button.py +++ b/winui3/tests_backend/widgets/button.py @@ -1,6 +1,7 @@ import asyncio import pytest +from toga_winui3.libs.nativeevents import NativeEvent from win32more import unbox_value from win32more.Microsoft.UI.Xaml.Controls import Button as NativeButton, ImageIcon @@ -10,6 +11,12 @@ class ButtonProbe(SimpleProbe): native_class = NativeButton + def __init__(self, widget): + super().__init__(widget) + + # Check the Click event is being properly handled. + assert isinstance(self.native.event_handler.Click, NativeEvent) + @property def content_is_text(self): try: From 80559b6bddd90823360ffb74ba528a9f66962668 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:37:55 +0200 Subject: [PATCH 056/110] Ensure StatusIcons are closed on app exit --- winui3/src/toga_winui3/libs/proactor.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/winui3/src/toga_winui3/libs/proactor.py b/winui3/src/toga_winui3/libs/proactor.py index f0b886e436..7f554a652f 100644 --- a/winui3/src/toga_winui3/libs/proactor.py +++ b/winui3/src/toga_winui3/libs/proactor.py @@ -92,15 +92,19 @@ def iocp_action(status=status): # Queue/run the actions to run synchronously on the main thread. task_enqueuer(iocp_action) - ######################################################################## - # From here onward is part of the app shutdown procedure, which can't - # have test coverage. So use no cover. - ######################################################################## + ############################################################################ + # From here onward is part of the app shutdown procedure, which can't have + # test coverage. So use no cover. + ############################################################################ # Exit the application. Call here to avoid dispatcher calls after # app.native is exited. def exit_native(): # pragma: no cover + # Make sure that the Win32-based StatusIcons are closed correctly. + for status_icon in app.interface.status_icons: + status_icon._impl.remove() + app.native.Exit(app.native_instance) task_enqueuer(exit_native) # pragma: no cover From 1b14357b4bb673a5090041df49936fb80094f256 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:44:11 +0200 Subject: [PATCH 057/110] Minor testbed changes for code coverage --- testbed/tests/app/test_desktop.py | 4 +++- testbed/tests/test_fonts.py | 15 ++++++++++++--- testbed/tests/widgets/conftest.py | 8 ++++++-- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/testbed/tests/app/test_desktop.py b/testbed/tests/app/test_desktop.py index 497af2b3f9..dd8c9ca630 100644 --- a/testbed/tests/app/test_desktop.py +++ b/testbed/tests/app/test_desktop.py @@ -536,7 +536,6 @@ async def test_show_hide_cursor(app, app_probe): async def test_current_window(app, app_probe, main_window, main_window_probe): """The current window can be retrieved""" - skip_on_backends("toga_winui3", reason="Dialogs are not implemented yet.") try: if app_probe.supports_current_window_assignment: assert app.current_window == main_window @@ -577,6 +576,9 @@ async def test_current_window(app, app_probe, main_window, main_window_probe): if app_probe.supports_current_window_assignment: assert app.current_window == window3 + # Defer the WinUI 3 skip until here so that the above code is exercised. + skip_on_backends("toga_winui3", reason="Dialogs are not implemented yet.") + # When a dialog is in focus, app.current_window should return the # previously active window. def test_current_window_in_presence_of_dialog(dialog): diff --git a/testbed/tests/test_fonts.py b/testbed/tests/test_fonts.py index ac6ad16d03..4f01441e68 100644 --- a/testbed/tests/test_fonts.py +++ b/testbed/tests/test_fonts.py @@ -75,10 +75,19 @@ async def test_use_first_valid_font( ): """The widget should get the first valid font.""" if custom: - if not font_probe.supports_custom_fonts: - pytest.skip("Platform doesn't support registering and loading custom fonts") + custom_name = "Endor" + Font.register(custom_name, path=app.paths.app / "resources/fonts/ENDOR___.ttf") + + # If user registered fonts are not implement and the expected font is a custom + # font, then a ValueError is raised. + if not font_probe.supports_custom_fonts and result == custom_name: + with pytest.raises( + ValueError, + match=r"Couldn't load .*. User registered fonts are not implemented.", + ): + widget.style.font_family = family - Font.register("Endor", path=app.paths.app / "resources/fonts/ENDOR___.ttf") + pytest.skip("Platform doesn't support registering and loading custom fonts") widget.style.font_family = family await font_probe.redraw(f"Font family should be {result}") diff --git a/testbed/tests/widgets/conftest.py b/testbed/tests/widgets/conftest.py index 111b390e40..3523aeeef8 100644 --- a/testbed/tests/widgets/conftest.py +++ b/testbed/tests/widgets/conftest.py @@ -45,8 +45,12 @@ async def container_probe(widget): @pytest.fixture async def other(widget): """A separate widget that can take focus""" - skip_on_backends("toga_winui3", reason="TextInput is not implemented yet.") - other = toga.TextInput() + if toga.backend in {"toga_winui3"}: + # FIXME: Remove this block when TextInput is implemented on WinUI 3. + other = toga.Button() + else: + other = toga.TextInput() + widget.parent.add(other) return other From 50f6b563b8a6db6e5130a203a39cac0433e6992d Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:18:02 +0200 Subject: [PATCH 058/110] Remove dead code and fix flaky testbed resizing --- .../toga_winui3/widgets/properties/native.py | 17 --------- winui3/tests_backend/probe.py | 36 +++++++++++++++---- 2 files changed, 30 insertions(+), 23 deletions(-) diff --git a/winui3/src/toga_winui3/widgets/properties/native.py b/winui3/src/toga_winui3/widgets/properties/native.py index a7bd8daefb..c1d6a0eb12 100644 --- a/winui3/src/toga_winui3/widgets/properties/native.py +++ b/winui3/src/toga_winui3/widgets/properties/native.py @@ -1,20 +1,3 @@ -def is_based_on_recursive(cls, ancestor): - for parent in cls.__bases__: - if parent == ancestor: - return True - elif is_based_on_recursive(parent, ancestor): - return True - - return False - - -def is_based_on(cls, ancestor): - if cls == ancestor: - return True - else: - return is_based_on_recursive(cls, ancestor) - - def get_attribute_base_recursive(cls, attribute): for parent in cls.__bases__: if hasattr(parent, attribute): diff --git a/winui3/tests_backend/probe.py b/winui3/tests_backend/probe.py index 6de45f869e..67fcdd8a1a 100644 --- a/winui3/tests_backend/probe.py +++ b/winui3/tests_backend/probe.py @@ -28,10 +28,8 @@ def __init__(self, native=None): self.native = native self._click_count = 0 - async def redraw(self, message=None, delay=0, wait_for=None): - """Request a redraw of the app, waiting until that redraw has completed.""" - # Make sure that any staged properties have sufficient time to completed the - # process. + async def redraw_staging(self): + """Wait until any property staging is finished.""" widgets = toga.App.app.widgets.values() staging_areas = {widget._impl.container.staging_area for widget in widgets} @@ -42,10 +40,36 @@ def staging_complete(): return True - for _ in range(1000): + for _ in range(50): if staging_complete(): break - await asyncio.sleep(0.01) + await asyncio.sleep(0.02) + + async def redraw_resizing(self): + """Wait until any resizing is finished.""" + try: + width = self.native.Width + height = self.native.Height + except AttributeError: + return + + def resizing_complete(): + return ( + width - 1 < self.native.ActualWidth < width + 1 + and height - 1 < self.native.ActualHeight < height + 1 + ) + + for _ in range(50): + if resizing_complete(): + break + await asyncio.sleep(0.02) + + async def redraw(self, message=None, delay=0, wait_for=None): + """Request a redraw of the app, waiting until that redraw has completed.""" + + await self.redraw_staging() + + await self.redraw_resizing() # If we're running slow, or we have a wait condition, # wait for at least a second From 5c5a4af576d448361ab9feecba04bcb0e69df088 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:31:38 +0200 Subject: [PATCH 059/110] Add native property test --- android/tests_backend/widgets/base.py | 3 +++ cocoa/tests_backend/widgets/base.py | 4 ++++ gtk/tests_backend/widgets/base.py | 3 +++ qt/tests_backend/widgets/base.py | 3 +++ testbed/tests/widgets/test_base.py | 4 ++++ winforms/tests_backend/widgets/base.py | 3 +++ winui3/tests_backend/widgets/base.py | 26 ++++++++++++++++++++++++++ 7 files changed, 46 insertions(+) diff --git a/android/tests_backend/widgets/base.py b/android/tests_backend/widgets/base.py index a61c371a0b..d85855abd6 100644 --- a/android/tests_backend/widgets/base.py +++ b/android/tests_backend/widgets/base.py @@ -190,6 +190,9 @@ async def undo(self): async def redo(self): pytest.skip("Redo not supported on this platform") + async def test_native_properties(self): + pytest.skip("Test not implemented for this platform") + def find_view_by_type(root, cls): assert isinstance(root, View) diff --git a/cocoa/tests_backend/widgets/base.py b/cocoa/tests_backend/widgets/base.py index 4537aba6a7..60dc1014d1 100644 --- a/cocoa/tests_backend/widgets/base.py +++ b/cocoa/tests_backend/widgets/base.py @@ -1,3 +1,4 @@ +from pytest import skip from rubicon.objc import NSPoint from toga.colors import TRANSPARENT @@ -223,3 +224,6 @@ async def undo(self): async def redo(self): await self.type_character("z", alt=True, shift=True) + + async def test_native_properties(self): + skip("Test not implemented for this platform") diff --git a/gtk/tests_backend/widgets/base.py b/gtk/tests_backend/widgets/base.py index 2b308d5ced..873b8bf960 100644 --- a/gtk/tests_backend/widgets/base.py +++ b/gtk/tests_backend/widgets/base.py @@ -240,3 +240,6 @@ async def undo(self): async def redo(self): pytest.skip("Redo not supported on this platform") + + async def test_native_properties(self): + pytest.skip("Test not implemented for this platform") diff --git a/qt/tests_backend/widgets/base.py b/qt/tests_backend/widgets/base.py index 0c2854e432..46dfa2148c 100644 --- a/qt/tests_backend/widgets/base.py +++ b/qt/tests_backend/widgets/base.py @@ -103,3 +103,6 @@ async def undo(self): async def redo(self): await self.type_character("z", ctrl=True, shift=True) + + async def test_native_properties(self): + pytest.skip("Test not implemented for this platform") diff --git a/testbed/tests/widgets/test_base.py b/testbed/tests/widgets/test_base.py index 4c45b457e3..51fad65905 100644 --- a/testbed/tests/widgets/test_base.py +++ b/testbed/tests/widgets/test_base.py @@ -187,3 +187,7 @@ async def test_tab_index(widget, probe, other): other.tab_index = 2 assert widget.tab_index == 4 assert other.tab_index == 2 + + +async def test_native_properties(widget, probe): + probe.assert_native_properties() diff --git a/winforms/tests_backend/widgets/base.py b/winforms/tests_backend/widgets/base.py index 6c2c4031e4..2c6ac7f6ab 100644 --- a/winforms/tests_backend/widgets/base.py +++ b/winforms/tests_backend/widgets/base.py @@ -103,3 +103,6 @@ async def undo(self): async def redo(self): pytest.skip("Redo not supported on this platform") + + async def test_native_properties(self): + pytest.skip("Test not implemented for this platform") diff --git a/winui3/tests_backend/widgets/base.py b/winui3/tests_backend/widgets/base.py index ae85603641..2b64c6342c 100644 --- a/winui3/tests_backend/widgets/base.py +++ b/winui3/tests_backend/widgets/base.py @@ -128,3 +128,29 @@ def shrink_on_resize(self): @property def has_focus(self): return self.native.FocusState != FocusState.Unfocused + + def assert_native_properties(self): + """Test whether native properties are reset correctly.""" + + # Create a local alias for the native property handler. + native_properties = self.impl._native_properties + + # Set an unused native dependency property. + old_value = self.native.Opacity + native_properties.Opacity = 0.5 + + assert self.native.Opacity != old_value + + # Test that the property is reset by setting None. + native_properties.Opacity = None + + assert self.native.Opacity == old_value + + # Test a native non-dependency property. + assert self.native.Resources is not None + + native_properties.Resources = None + + # Setting a non-dependency native property to None should result in the property + # being None. + assert self.native.Resources is None From 30b2200958728077c354c8240170be17c89fa439 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:59:41 +0200 Subject: [PATCH 060/110] Commenting and minor changes --- winui3/src/toga_winui3/icons.py | 26 +++++++++- winui3/src/toga_winui3/libs/misc.py | 29 ++++++----- winui3/src/toga_winui3/libs/nativeevents.py | 48 +++++++++++++++++-- .../src/toga_winui3/resources/winui3app.xaml | 12 ----- winui3/src/toga_winui3/statusicons.py | 13 +++++ winui3/src/toga_winui3/widgets/label.py | 10 ++++ .../toga_winui3/widgets/properties/native.py | 4 +- winui3/src/toga_winui3/window.py | 13 ++++- 8 files changed, 117 insertions(+), 38 deletions(-) delete mode 100644 winui3/src/toga_winui3/resources/winui3app.xaml diff --git a/winui3/src/toga_winui3/icons.py b/winui3/src/toga_winui3/icons.py index f3632e0151..1766df8bca 100644 --- a/winui3/src/toga_winui3/icons.py +++ b/winui3/src/toga_winui3/icons.py @@ -5,14 +5,36 @@ from win32more.Microsoft.UI.Xaml.Controls import ImageIcon from win32more.Microsoft.UI.Xaml.Media.Imaging import BitmapImage from win32more.Windows.Foundation import Uri -from win32more.Windows.Win32.UI.WindowsAndMessaging import HICON +from win32more.Windows.Win32.Foundation import PWSTR +from win32more.Windows.Win32.UI.WindowsAndMessaging import ( + HICON, + IMAGE_ICON, + LR_LOADFROMFILE, + LoadImageW, +) from .libs.gdiplus import create_icon -from .libs.misc import load_icon from .libs.nativeevents import events_handled +def load_icon(path: str) -> HICON: + """Creates an icon resource from an .ico file.""" + hwnd = LoadImageW(None, PWSTR(path), IMAGE_ICON, 0, 0, LR_LOADFROMFILE) + if hwnd is None: + raise OSError(f"LoadImageW failed to load {path}.") + return HICON(hwnd) + + class Icon: + """The Icon implementation for the WinUI 3 backend. + + The WinUI 3 backend needs two type of icon: + - Native WinUI 3 - to be used with most native WinUI 3 classes such as button. + - Win32 - to be used with StatusIcons the title bar. + + To avoid loading unnecessary resources, the needed icon resources are lazy loaded. + """ + EXTENSIONS = [".png", ".ico", ".bmp", ".jpg", ".jpeg", ".gif", ".tif", ".tiff"] SIZES = None diff --git a/winui3/src/toga_winui3/libs/misc.py b/winui3/src/toga_winui3/libs/misc.py index 968f93dd20..e40c4b2cd9 100644 --- a/winui3/src/toga_winui3/libs/misc.py +++ b/winui3/src/toga_winui3/libs/misc.py @@ -5,16 +5,14 @@ ColumnDefinition, RowDefinition, ) -from win32more.Windows.Win32.Foundation import PWSTR -from win32more.Windows.Win32.UI.WindowsAndMessaging import ( - HICON, - IMAGE_ICON, - LR_LOADFROMFILE, - LoadImageW, -) + +######################################################################################## +# Properties to be used by the WinUI 3 Grid class. +######################################################################################## def grid_length_auto(): + """The grid length will size to fit the content.""" grid_length = GridLength() grid_length.GridUnitType = GridUnitType.Auto @@ -22,6 +20,7 @@ def grid_length_auto(): def grid_length_star(value: int = 1): + """The grid length will be a weighted division of the remaining space.""" grid_length = GridLength() grid_length.GridUnitType = GridUnitType.Star grid_length.Value = value @@ -30,6 +29,7 @@ def grid_length_star(value: int = 1): def column_definition_star(value: int = 1): + """The grid column will be a weighted division of the remaining horizontal space.""" column_definition = ColumnDefinition() column_definition.Width = grid_length_star(value) @@ -37,6 +37,7 @@ def column_definition_star(value: int = 1): def row_definition_auto(): + """The grid row will size to fit the height of its content.""" row_definition = RowDefinition() row_definition.Height = grid_length_auto() @@ -44,12 +45,18 @@ def row_definition_auto(): def row_definition_star(value: int = 1): + """The grid row will be a weighted division of the remaining vertical space.""" row_definition = RowDefinition() row_definition.Height = grid_length_star(value) return row_definition +######################################################################################## +# Functions for the upper and lower 16 bits of a 32 bit value. +######################################################################################## + + # https://learn.microsoft.com/en-us/windows/win32/winmsg/loword def loword(lparam: int) -> int: """Keeps the lower 16 bits of a value with at least 16 bits.""" @@ -70,11 +77,3 @@ def get_x_lparam(lparam: int) -> int: # https://learn.microsoft.com/en-us/windows/win32/api/windowsx/nf-windowsx-get_y_lparam def get_y_lparam(lparam: int) -> int: return SHORT(hiword(lparam)).value - - -def load_icon(path: str) -> HICON: - """Creates an icon resource from an .ico file.""" - hwnd = LoadImageW(None, PWSTR(path), IMAGE_ICON, 0, 0, LR_LOADFROMFILE) - if hwnd is None: - raise OSError(f"LoadImageW failed to load {path}.") - return HICON(hwnd) diff --git a/winui3/src/toga_winui3/libs/nativeevents.py b/winui3/src/toga_winui3/libs/nativeevents.py index 89db1479f3..559d935d4d 100644 --- a/winui3/src/toga_winui3/libs/nativeevents.py +++ b/winui3/src/toga_winui3/libs/nativeevents.py @@ -3,11 +3,32 @@ from toga import App from toga.handlers import WeakrefCallable +"""A handler to be used with WinUI 3 native events. + +The need for this module arises from the requirements of `build_cleanup_test` from the +testbed. In particular, the callback needs to be assigned with a weak reference since +the native process will hold onto it reference after cleanup. + +Assigning the callback with a weak reference leads to another issue: The underlying +native process may still have a callback scheduled after the python callback function +has been garbage collect. This lead to the second purpose of this module, which is to +cleanup and avoid any dangling pointers. +""" + class NativeEvent: _cleared_callbacks = {} def __init__(self, owner, name: str): + """Manages the adding and clearing of callbacks of a native instance event. + + :param owner: The native instance that is triggering the event callback e.g. an + instance of `Microsoft.UI.Xaml.Window`. + :param name: The name of the event as a property of the owner e.g. Activated. + Note that recursive properties of sub-properties can be accessed by + replacing `.` with `_`. For example, `instance.AppWindow.Changed` is + accessed using the the name `AppWindow_Changed`. + """ split_name = name.split("_") self._owner = owner @@ -18,6 +39,7 @@ def __init__(self, owner, name: str): self._registry = {} def __iadd__(self, callback): + """Add a callback for the event.""" event_adder = getattr(self._owner, "add_" + self._name) # Don't allow the external process to keep a reference to the callback. @@ -29,6 +51,7 @@ def __iadd__(self, callback): return self def clear(self): + """Clear all callbacks for the event.""" event_remover = getattr(self._owner, "remove_" + self._name) for token, callback in self._registry.values(): try: @@ -62,24 +85,34 @@ def clear_callback_task(cls=cls, callback_id=callback_id): class NativeEventsHandler: def __init__(self, owner): + """A handler that interfaces with the NativeEvent objects of a native instance. + + :param owner: The native instance that is triggering the event callbacks e.g. an + instance of `Microsoft.UI.Xaml.Window`. + """ self._owner = owner self._event_registry = {} - def __getattr__(self, name): - """Gets the native event for a name with a capital first character.""" - if name not in self._event_registry.keys(): - self._event_registry[name] = NativeEvent(self._owner, name) + def __getattr__(self, event_name): + """Get (or creates, registers and gets) the NativeEvent object for an event.""" + if not event_name[0].isupper(): # pragma: no cover + raise ValueError("Native events use the PascalCase naming convention.") - return self._event_registry[name] + if event_name not in self._event_registry.keys(): + self._event_registry[event_name] = NativeEvent(self._owner, event_name) + + return self._event_registry[event_name] def __setattr__(self, name, value): if not name[0].isupper(): super().__setattr__(name, value) return + # If the name has a capital first letter, assume it is an event name. self._event_registry[name] = value def clear(self): + """Clears all the registered NativeEvent objects.""" for event in self._event_registry.values(): event.clear() @@ -87,6 +120,8 @@ def clear(self): class NativeEventsMixin: + """Methods used to manage and clean-up the events for a native instance.""" + @property def native_class(self): return type(self).__bases__[1] @@ -109,12 +144,15 @@ def event_handler(self): def events_handled(native_cls): + """Dynamically creates a native class with handled events.""" cls_name = native_cls.__name__ + "Handled" bases = (NativeEventsMixin, native_cls) return type(cls_name, bases, {})() class EventsHandledMixin: + """Methods to allow the easy instantiation of a native class with handled events.""" + @property def native_cls(self): return self._native_cls if hasattr(self, "_native_cls") else None diff --git a/winui3/src/toga_winui3/resources/winui3app.xaml b/winui3/src/toga_winui3/resources/winui3app.xaml deleted file mode 100644 index bfa2d9feb7..0000000000 --- a/winui3/src/toga_winui3/resources/winui3app.xaml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - diff --git a/winui3/src/toga_winui3/statusicons.py b/winui3/src/toga_winui3/statusicons.py index 81dc4521a7..2ef33ea590 100644 --- a/winui3/src/toga_winui3/statusicons.py +++ b/winui3/src/toga_winui3/statusicons.py @@ -37,6 +37,16 @@ class StatusIcon: + """The WinUI 3 backend implementation of the StatusIcon class. + + The WinUI 3 API does not provide a class that could be used as a StatusIcon (see + for example https://github.com/microsoft/microsoft-ui-xaml/issues/2020), so a + Win32 approach is used. + Moreover, the needed subclassing functionality is not provided in the Win32 + metadata, so it does not appear in win32more. Hence the need to load it directly + using ctypes. + """ + def __init__(self, interface): self.interface = interface self.native_window = None @@ -58,6 +68,8 @@ def create(self): presenter = self.native_window.AppWindow.Presenter overlapped_presenter = OverlappedPresenter(value=presenter.value) overlapped_presenter.SetBorderAndTitleBar(False, False) + + # Setting "always on top" is needed for any menus will appear at the top. overlapped_presenter.IsAlwaysOnTop = True # Subclass the native_window to receive the WM_COMMAND messages. @@ -155,6 +167,7 @@ def native_menu(self, native_menu_instance: MenuFlyout): self._native_menu = native_menu_instance def native_event_closing(self, sender, args): + # Hide the parent window immediately after a menu item is selected. self.native_window.AppWindow.Hide() def native_event_click(self, x, y): diff --git a/winui3/src/toga_winui3/widgets/label.py b/winui3/src/toga_winui3/widgets/label.py index d829c07182..c92e37d39f 100644 --- a/winui3/src/toga_winui3/widgets/label.py +++ b/winui3/src/toga_winui3/widgets/label.py @@ -17,6 +17,10 @@ class LabelText(EventsHandledMixin): def __init__(self, label): + """A class the handles the text part of the `Label` widget. + + :param label: The `Label` widget itself. + """ self._label = label self.native_cls = TextBlock @@ -46,6 +50,12 @@ def rehint(self): class Label(Widget): + """The WinUI 3 `Label` widget implementation. + + This widget is necessarily split into two parts because the WinUI 3 class the widget + is based on, `Microsoft.UI.Xaml.Controls.TextBlock`, doesn't have a background. + """ + def create(self): self.native_cls = Grid # Label cannot receive input focus, so remove it from the tab sequence. diff --git a/winui3/src/toga_winui3/widgets/properties/native.py b/winui3/src/toga_winui3/widgets/properties/native.py index c1d6a0eb12..d5066b1301 100644 --- a/winui3/src/toga_winui3/widgets/properties/native.py +++ b/winui3/src/toga_winui3/widgets/properties/native.py @@ -25,8 +25,8 @@ class NativeProperties: which can change e.g. DPI, darkmode theme. When a dependency property is manually set to a value, it can lose the ability to listen to these changes. - Using this class to set a dependency property to None reset the property to the - default value and restore ability to listen to changes. + Using this class to set a dependency property to None resets the property to the + default value and restores the ability to listen to changes. """ def __init__(self, widget): diff --git a/winui3/src/toga_winui3/window.py b/winui3/src/toga_winui3/window.py index 96d7e135bc..ec51a4bace 100644 --- a/winui3/src/toga_winui3/window.py +++ b/winui3/src/toga_winui3/window.py @@ -511,8 +511,17 @@ def get_image_data(self): class MainWindow(Window): def create_content(self): - # Row 0 is allocated for the menu - # Row 1 is allocated for toolbar + # Create a Grid with the following layout: + # + # col 0 - fills the available horizontal space. + # +-----------+ + # | menu | row 0 - fits to the vertical size of the menu. + # +-----------+ + # | toolbar | row 1 - fits to the vertical size of the toolbar. + # +-----------+ + # | content | row 2 - fills the available vertical space. + # +-----------+ + # self.content_native = Grid() self.content_native.ColumnDefinitions.Append(column_definition_star(1)) self.content_native.RowDefinitions.Append(row_definition_auto()) From 27e85c60f11f94662b41579e39e8fd403ce68619 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:01:31 +0200 Subject: [PATCH 061/110] Add CI workflow and win32more version pin to toml --- .github/workflows/ci.yml | 28 ++++++++++++++++++++++++++++ winui3/pyproject.toml | 2 ++ 2 files changed, 30 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b85b2fecf0..8a7216bb3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -326,6 +326,8 @@ jobs: - "windows-netfx-x86_64" - "windows-netcore-x86_64" - "windows-netcore-arm64" + - "windows-winui3-x86_64" + - "windows-winui3-arm64" - "linux-x11-gtk3" - "linux-wayland-gtk3" - "linux-wayland-gtk4" @@ -580,6 +582,32 @@ jobs: runs-on: "windows-11-arm" app-user-data-path: '$HOME\AppData\Local\Tiberius Yak\Toga Testbed\Data' + - backend: "windows-winui3-x86_64" + platform: "windows" + runs-on: "windows-latest" + testbed-app: "testbed-winui3" + app-user-data-path: '$HOME\AppData\Local\Tiberius Yak\Toga Testbed (WinUI 3)\Data' + pre-command: | + Invoke-WebRequest ` + -Uri "https://aka.ms/windowsappsdk/2.3/2.3.1/windowsappruntimeinstall-x64.exe" ` + -OutFile "windows_app_sdk_installer.exe" + Start-Process ` + -FilePath ".\windows_app_sdk_installer.exe" ` + -Wait + + - backend: "windows-winui3-arm64" + platform: "windows" + runs-on: "windows-11-arm" + testbed-app: "testbed-winui3" + app-user-data-path: '$HOME\AppData\Local\Tiberius Yak\Toga Testbed (WinUI 3)\Data' + pre-command: | + Invoke-WebRequest ` + -Uri "https://aka.ms/windowsappsdk/2.3/2.3.1/windowsappruntimeinstall-arm64.exe" ` + -OutFile "windows_app_sdk_installer.exe" + Start-Process ` + -FilePath ".\windows_app_sdk_installer.exe" ` + -Wait + - backend: "iOS" platform: "iOS" runs-on: "macos-latest" diff --git a/winui3/pyproject.toml b/winui3/pyproject.toml index 8d9cdaf130..1d8358de17 100644 --- a/winui3/pyproject.toml +++ b/winui3/pyproject.toml @@ -114,6 +114,8 @@ root = ".." [tool.setuptools_dynamic_dependencies] dependencies = [ "toga-core == {version}", + # Specify the version of the Windows App SDK to be used. + "win32more-Microsoft.WindowsAppSDK == 0.8.2.3.1", "win32more >= 0.8.1", ] From a9001a8b5374164dd519c6aa8352c5c904f3a294 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:04:23 +0200 Subject: [PATCH 062/110] Add change notes --- changes/2574.feature.1.md | 1 + changes/2574.feature.2.md | 1 + changes/2574.feature.3.md | 1 + changes/2574.feature.4.md | 1 + changes/2574.feature.5.md | 1 + 5 files changed, 5 insertions(+) create mode 100644 changes/2574.feature.1.md create mode 100644 changes/2574.feature.2.md create mode 100644 changes/2574.feature.3.md create mode 100644 changes/2574.feature.4.md create mode 100644 changes/2574.feature.5.md diff --git a/changes/2574.feature.1.md b/changes/2574.feature.1.md new file mode 100644 index 0000000000..6c56e02479 --- /dev/null +++ b/changes/2574.feature.1.md @@ -0,0 +1 @@ +Toga now provides a WinUI 3 backend for Windows desktops. diff --git a/changes/2574.feature.2.md b/changes/2574.feature.2.md new file mode 100644 index 0000000000..a553d9cc0a --- /dev/null +++ b/changes/2574.feature.2.md @@ -0,0 +1 @@ +The Toga WinUI 3 backend for Windows desktops now implements StatusIcons. diff --git a/changes/2574.feature.3.md b/changes/2574.feature.3.md new file mode 100644 index 0000000000..ed1919f713 --- /dev/null +++ b/changes/2574.feature.3.md @@ -0,0 +1 @@ +The Toga WinUI 3 backend for Windows desktops now implements the Box widget. diff --git a/changes/2574.feature.4.md b/changes/2574.feature.4.md new file mode 100644 index 0000000000..1e024ef57b --- /dev/null +++ b/changes/2574.feature.4.md @@ -0,0 +1 @@ +The Toga WinUI 3 backend for Windows desktops now implements the Button widget. diff --git a/changes/2574.feature.5.md b/changes/2574.feature.5.md new file mode 100644 index 0000000000..4be121aa56 --- /dev/null +++ b/changes/2574.feature.5.md @@ -0,0 +1 @@ +The Toga WinUI 3 backend for Windows desktops now implements the Label widget. From fb8a77eb3de3d77ae0a3f9f8d1c84ee68eb62170 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:53:31 +0200 Subject: [PATCH 063/110] Fixes from merge with 'main' --- testbed/tests/widgets/canvas/test_canvas.py | 1 - testbed/tests/widgets/test_base.py | 8 +------- testbed/tests/widgets/test_splitcontainer.py | 2 +- testbed/tests/widgets/test_table.py | 2 +- testbed/tests/widgets/test_textinput.py | 1 + testbed/tests/widgets/test_tree.py | 2 +- winforms/tests_backend/widgets/base.py | 9 +++++++++ winui3/src/toga_winui3/__init__.py | 5 ++--- winui3/tests_backend/app.py | 17 +++++++++++++++++ winui3/tests_backend/probe.py | 7 +++++++ winui3/tests_backend/widgets/base.py | 12 ++++++++++++ 11 files changed, 52 insertions(+), 14 deletions(-) diff --git a/testbed/tests/widgets/canvas/test_canvas.py b/testbed/tests/widgets/canvas/test_canvas.py index 460999f938..4e2dce3a89 100644 --- a/testbed/tests/widgets/canvas/test_canvas.py +++ b/testbed/tests/widgets/canvas/test_canvas.py @@ -36,7 +36,6 @@ test_focus_noop, ) - skip_on_backends( "toga_textual", reason="Canvas is not implemented on Textual.", diff --git a/testbed/tests/widgets/test_base.py b/testbed/tests/widgets/test_base.py index a20491b17c..6e7b677f32 100644 --- a/testbed/tests/widgets/test_base.py +++ b/testbed/tests/widgets/test_base.py @@ -167,13 +167,7 @@ async def test_parenting(widget, probe): async def test_tab_index(widget, probe, other): if probe.supports_tab_index: - assert widget.tab_index == 1 - assert other.tab_index == 2 - - widget.tab_index = 4 - other.tab_index = 2 - assert widget.tab_index == 4 - assert other.tab_index == 2 + probe.assert_tab_index(widget, other) else: assert widget.tab_index is None assert other.tab_index is None diff --git a/testbed/tests/widgets/test_splitcontainer.py b/testbed/tests/widgets/test_splitcontainer.py index ba5bc12bb9..661802d149 100644 --- a/testbed/tests/widgets/test_splitcontainer.py +++ b/testbed/tests/widgets/test_splitcontainer.py @@ -6,7 +6,7 @@ from toga.constants import Direction from toga.style.pack import Pack -from ..conftest import skip_on_backends, skip_on_platforms +from ..conftest import skip_on_backends from .conftest import build_cleanup_test from .probe import get_probe from .properties import ( # noqa: F401 diff --git a/testbed/tests/widgets/test_table.py b/testbed/tests/widgets/test_table.py index 030d7b9a05..11f0438b79 100644 --- a/testbed/tests/widgets/test_table.py +++ b/testbed/tests/widgets/test_table.py @@ -7,7 +7,7 @@ from toga.sources import AccessorColumn, ListListener, ListSource from toga.style.pack import Pack -from ..conftest import skip_on_backends, skip_on_platforms +from ..conftest import skip_on_backends from .conftest import build_cleanup_test from .probe import get_probe from .properties import ( # noqa: F401 diff --git a/testbed/tests/widgets/test_textinput.py b/testbed/tests/widgets/test_textinput.py index 4d6fe1a030..80dfd68af6 100644 --- a/testbed/tests/widgets/test_textinput.py +++ b/testbed/tests/widgets/test_textinput.py @@ -114,6 +114,7 @@ async def test_on_change_user(widget, probe, on_change): async def test_on_change_user_after_initial_value(main_window): "User input triggers on_change after setting the initial value before mounting." + skip_on_backends("toga_winui3") old_content = main_window.content widget = toga.TextInput(value="Hello") on_change = Mock() diff --git a/testbed/tests/widgets/test_tree.py b/testbed/tests/widgets/test_tree.py index d293926594..e5eaa7fe13 100644 --- a/testbed/tests/widgets/test_tree.py +++ b/testbed/tests/widgets/test_tree.py @@ -7,7 +7,7 @@ from toga.sources import AccessorColumn, ListListener, TreeListener, TreeSource from toga.style.pack import Pack -from ..conftest import skip_on_backends, skip_on_platforms +from ..conftest import skip_on_backends from .conftest import build_cleanup_test from .probe import get_probe from .properties import ( # noqa: F401 diff --git a/winforms/tests_backend/widgets/base.py b/winforms/tests_backend/widgets/base.py index 591402ca89..3d35e8f0c3 100644 --- a/winforms/tests_backend/widgets/base.py +++ b/winforms/tests_backend/widgets/base.py @@ -107,3 +107,12 @@ async def redo(self): async def test_native_properties(self): pytest.skip("Test not implemented for this platform") + + def assert_tab_index(self, widget, other): + assert widget.tab_index == 1 + assert other.tab_index == 2 + + widget.tab_index = 4 + other.tab_index = 2 + assert widget.tab_index == 4 + assert other.tab_index == 2 diff --git a/winui3/src/toga_winui3/__init__.py b/winui3/src/toga_winui3/__init__.py index a028119506..c7503ec515 100644 --- a/winui3/src/toga_winui3/__init__.py +++ b/winui3/src/toga_winui3/__init__.py @@ -1,8 +1,8 @@ from ctypes import WinError +from importlib.metadata import version from sys import getwindowsversion from warnings import warn -from travertino import _package_version from win32more.Windows.Win32.Foundation import ERROR_ACCESS_DENIED, GetLastError from win32more.Windows.Win32.UI.HiDpi import ( DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, @@ -36,5 +36,4 @@ ) -# Travertino package_version. -__version__ = _package_version(__file__, __name__) +__version__ = version("toga-winui3") diff --git a/winui3/tests_backend/app.py b/winui3/tests_backend/app.py index 745a1e0d4d..11ea4e515f 100644 --- a/winui3/tests_backend/app.py +++ b/winui3/tests_backend/app.py @@ -1,3 +1,4 @@ +import _overlapped import asyncio from ctypes import byref, sizeof, windll, wintypes as wt from pathlib import Path @@ -46,6 +47,7 @@ class AppProbe(BaseProbe): supports_dark_mode = True edit_menu_noop_enabled = False supports_psutil = True + beep_delay = 0.1 def __init__(self, app): super().__init__() @@ -271,6 +273,21 @@ def is_cursor_visible(self): # Miscellaneous #################################################################################### + async def assert_event_loop_unregistering(self, loop): + """Test that events can be unregistered.""" + event = _overlapped.CreateEvent(None, True, False, None) + fut = loop._proactor.wait_for_handle(event, 10) + fut.cancel() + + # Wait for the future to be removed from the unregistered list. + await asyncio.sleep(0.2) + assert len(loop._proactor._unregistered) == 0 + + async def assert_event_loop(self): + loop = self.app.loop + + await self.assert_event_loop_unregistering(loop) + async def restore_standard_app(self): # No special handling needed to restore standard app. await self.redraw("Restore to standard app") diff --git a/winui3/tests_backend/probe.py b/winui3/tests_backend/probe.py index 67fcdd8a1a..43eecab039 100644 --- a/winui3/tests_backend/probe.py +++ b/winui3/tests_backend/probe.py @@ -1,6 +1,7 @@ import asyncio from ctypes import byref, sizeof +from pytest import approx from win32more.Windows.Win32.Foundation import POINT from win32more.Windows.Win32.UI.Input.KeyboardAndMouse import ( INPUT, @@ -28,6 +29,12 @@ def __init__(self, native=None): self.native = native self._click_count = 0 + def approx_width(self, width): + return approx(width, rel=0.01) + + def approx_height(self, height): + return approx(height, rel=0.01) + async def redraw_staging(self): """Wait until any property staging is finished.""" widgets = toga.App.app.widgets.values() diff --git a/winui3/tests_backend/widgets/base.py b/winui3/tests_backend/widgets/base.py index 2b64c6342c..5e7e000a93 100644 --- a/winui3/tests_backend/widgets/base.py +++ b/winui3/tests_backend/widgets/base.py @@ -10,6 +10,7 @@ class SimpleProbe(BaseProbe, FontMixin): invalid_size_while_hidden = False + supports_tab_index = True def __init__(self, widget): self.app = widget.app @@ -154,3 +155,14 @@ def assert_native_properties(self): # Setting a non-dependency native property to None should result in the property # being None. assert self.native.Resources is None + + def assert_tab_index(self, widget, other): + # Unset WinUI 3 tab indices default to Int32_MaxValue. + Int32_MaxValue = 2**31 - 1 + assert widget.tab_index == Int32_MaxValue + assert other.tab_index == Int32_MaxValue + + widget.tab_index = 4 + other.tab_index = 2 + assert widget.tab_index == 4 + assert other.tab_index == 2 From 2727d8796af67a9198abd1ee668e5218e051dac8 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:59:07 +0200 Subject: [PATCH 064/110] Changes to CI workflow --- .github/workflows/ci.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c304ae50d..5be1cf01fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -598,7 +598,7 @@ jobs: runs-on: "windows-latest" testbed-app: "testbed-winui3" app-user-data-path: '$HOME\AppData\Local\Tiberius Yak\Toga Testbed (WinUI 3)\Data' - pre-command: | + pre-command-pwsh: | Invoke-WebRequest ` -Uri "https://aka.ms/windowsappsdk/2.3/2.3.1/windowsappruntimeinstall-x64.exe" ` -OutFile "windows_app_sdk_installer.exe" @@ -611,7 +611,7 @@ jobs: runs-on: "windows-11-arm" testbed-app: "testbed-winui3" app-user-data-path: '$HOME\AppData\Local\Tiberius Yak\Toga Testbed (WinUI 3)\Data' - pre-command: | + pre-command-pwsh: | Invoke-WebRequest ` -Uri "https://aka.ms/windowsappsdk/2.3/2.3.1/windowsappruntimeinstall-arm64.exe" ` -OutFile "windows_app_sdk_installer.exe" @@ -661,7 +661,12 @@ jobs: with: python-version: "3.12" - - name: Install Dependencies + - name: Install Dependencies (pwsh) + if: ${{ matrix.platform == "windows" }} + shell: pwsh + run: ${{ matrix.pre-command-pwsh }} + + - name: Install Dependencies (bash) env: PIP_BREAK_SYSTEM_PACKAGES: "1" run: | From 2c28c425014ac1c6fd2e829549a085254188d6c6 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:15:16 +0200 Subject: [PATCH 065/110] Remove evaluation from CI workflow conditional --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5be1cf01fe..e39786d02e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -662,7 +662,7 @@ jobs: python-version: "3.12" - name: Install Dependencies (pwsh) - if: ${{ matrix.platform == "windows" }} + if: matrix.platform == 'windows' shell: pwsh run: ${{ matrix.pre-command-pwsh }} From eceb8460d4d483befaf78b821be25e03fb431b17 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:43:56 +0200 Subject: [PATCH 066/110] Fix native properties test --- android/tests_backend/widgets/base.py | 2 +- cocoa/tests_backend/widgets/base.py | 2 +- gtk/tests_backend/widgets/base.py | 2 +- qt/tests_backend/widgets/base.py | 2 +- textual/tests_backend/widgets/base.py | 3 +++ winforms/tests_backend/widgets/base.py | 2 +- 6 files changed, 8 insertions(+), 5 deletions(-) diff --git a/android/tests_backend/widgets/base.py b/android/tests_backend/widgets/base.py index 952f28a99a..8687547d0d 100644 --- a/android/tests_backend/widgets/base.py +++ b/android/tests_backend/widgets/base.py @@ -192,7 +192,7 @@ async def undo(self): async def redo(self): pytest.skip("Redo not supported on this platform") - async def test_native_properties(self): + def assert_native_properties(self): pytest.skip("Test not implemented for this platform") diff --git a/cocoa/tests_backend/widgets/base.py b/cocoa/tests_backend/widgets/base.py index 3bf6d7b74c..103d579cdc 100644 --- a/cocoa/tests_backend/widgets/base.py +++ b/cocoa/tests_backend/widgets/base.py @@ -226,5 +226,5 @@ async def undo(self): async def redo(self): await self.type_character("z", alt=True, shift=True) - async def test_native_properties(self): + def assert_native_properties(self): skip("Test not implemented for this platform") diff --git a/gtk/tests_backend/widgets/base.py b/gtk/tests_backend/widgets/base.py index 6f6fbd8693..25d5f08d0c 100644 --- a/gtk/tests_backend/widgets/base.py +++ b/gtk/tests_backend/widgets/base.py @@ -242,5 +242,5 @@ async def undo(self): async def redo(self): pytest.skip("Redo not supported on this platform") - async def test_native_properties(self): + def assert_native_properties(self): pytest.skip("Test not implemented for this platform") diff --git a/qt/tests_backend/widgets/base.py b/qt/tests_backend/widgets/base.py index 51dffe6dd6..9f4227439d 100644 --- a/qt/tests_backend/widgets/base.py +++ b/qt/tests_backend/widgets/base.py @@ -105,5 +105,5 @@ async def undo(self): async def redo(self): await self.type_character("z", ctrl=True, shift=True) - async def test_native_properties(self): + def assert_native_properties(self): pytest.skip("Test not implemented for this platform") diff --git a/textual/tests_backend/widgets/base.py b/textual/tests_backend/widgets/base.py index bc9e5447aa..2397ef68c5 100644 --- a/textual/tests_backend/widgets/base.py +++ b/textual/tests_backend/widgets/base.py @@ -123,6 +123,9 @@ async def undo(self): async def redo(self): pytest.skip("Redo is not implemented on Textual probes.") + def assert_native_properties(self): + pytest.skip("Test not implemented for this platform") + class TextualWidgetProbe(SimpleProbe): native_class = TextualWidget diff --git a/winforms/tests_backend/widgets/base.py b/winforms/tests_backend/widgets/base.py index 3d35e8f0c3..e25b6a659d 100644 --- a/winforms/tests_backend/widgets/base.py +++ b/winforms/tests_backend/widgets/base.py @@ -105,7 +105,7 @@ async def undo(self): async def redo(self): pytest.skip("Redo not supported on this platform") - async def test_native_properties(self): + def assert_native_properties(self): pytest.skip("Test not implemented for this platform") def assert_tab_index(self, widget, other): From e4819b024a66a659c7327e032cde767e42e0adbd Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:57:03 +0200 Subject: [PATCH 067/110] Minor fixes to tests --- iOS/tests_backend/widgets/base.py | 3 +++ testbed/tests/test_statusicons.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/iOS/tests_backend/widgets/base.py b/iOS/tests_backend/widgets/base.py index 6506b9f03b..326ff289bc 100644 --- a/iOS/tests_backend/widgets/base.py +++ b/iOS/tests_backend/widgets/base.py @@ -177,3 +177,6 @@ async def undo(self): async def redo(self): pytest.skip("Redo not supported on this platform") + + def assert_native_properties(self): + pytest.skip("Test not implemented for this platform") diff --git a/testbed/tests/test_statusicons.py b/testbed/tests/test_statusicons.py index 68d92de69c..7ea682c7fb 100644 --- a/testbed/tests/test_statusicons.py +++ b/testbed/tests/test_statusicons.py @@ -129,7 +129,7 @@ async def test_unknown_status_icon(app, app_probe): async def test_change_icon(app, app_probe): - """A button status icon can be activated.""" + """The icon of a status icon can be changed.""" status_icon = app_probe.app.status_icons["button"] old_icon = status_icon.icon new_icon = toga.Icon("resources/alt-icon") From 55c867ca9ddf0f59953951290e8b75c6e4847e65 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:00:24 +0200 Subject: [PATCH 068/110] Add handling for native exceptions --- winui3/src/toga_winui3/libs/nativeapp.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/winui3/src/toga_winui3/libs/nativeapp.py b/winui3/src/toga_winui3/libs/nativeapp.py index 152df86580..85505ea2d0 100644 --- a/winui3/src/toga_winui3/libs/nativeapp.py +++ b/winui3/src/toga_winui3/libs/nativeapp.py @@ -45,6 +45,15 @@ class NativeApp(XamlApplication): + def __init__(self): + super().__init__() + self.UnhandledException += self.native_event_unhandled_exception + + def native_event_unhandled_exception(self, sender, args): + message = args.Message + code = args.Exception + raise OSError(code, message) + def CreateWindow(self): return events_handled(Window) From 06984a1f5050ac3624ebc6bcd82b6ef31f294bc8 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:32:48 +0200 Subject: [PATCH 069/110] Trial changes to widget staging --- winui3/src/toga_winui3/widgets/properties/staged.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/winui3/src/toga_winui3/widgets/properties/staged.py b/winui3/src/toga_winui3/widgets/properties/staged.py index 430b82d575..97f3dc6fab 100644 --- a/winui3/src/toga_winui3/widgets/properties/staged.py +++ b/winui3/src/toga_winui3/widgets/properties/staged.py @@ -97,9 +97,10 @@ def refresh(self): widget = self._widget duplicate = type(widget.native)() self._latest = duplicate + staging_area = widget.container.staging_area - def size_changed(sender, args, duplicate=duplicate): - self.native_event_size_changed(sender, args, duplicate) + def size_changed(sender, args, duplicate=duplicate, staging_area=staging_area): + self.native_event_size_changed(sender, args, duplicate, staging_area) duplicate.event_handler.SizeChanged += size_changed @@ -108,9 +109,9 @@ def size_changed(sender, args, duplicate=duplicate): if value is not None: setattr(duplicate, attribute, value) - widget.container.staging_area.add(duplicate) + staging_area.add(duplicate) - def native_event_size_changed(self, sender, args, duplicate): + def native_event_size_changed(self, sender, args, duplicate, staging_area): if duplicate == self._latest: self._widget._min_width = self._adjusted_width(duplicate) self._widget._min_height = duplicate.ActualSize.Y @@ -118,7 +119,7 @@ def native_event_size_changed(self, sender, args, duplicate): self._latest = None - self._widget.container.staging_area.remove(duplicate) + staging_area.remove(duplicate) def _adjusted_width(self, duplicate): # FIXME: The staging method doesn't calculate a large enough width for italic From 12bf420a9932ab9e754ca8c8011ed7961677eadd Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 29 Jul 2026 08:06:24 +0200 Subject: [PATCH 070/110] Use weakref in widget staging --- .../toga_winui3/widgets/properties/staged.py | 49 ++++++++++++------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/winui3/src/toga_winui3/widgets/properties/staged.py b/winui3/src/toga_winui3/widgets/properties/staged.py index 97f3dc6fab..d710117c43 100644 --- a/winui3/src/toga_winui3/widgets/properties/staged.py +++ b/winui3/src/toga_winui3/widgets/properties/staged.py @@ -1,3 +1,5 @@ +import weakref + from win32more.Microsoft.UI.Xaml.Controls import RelativePanel from win32more.Windows.UI.Text import FontStyle @@ -95,37 +97,50 @@ def refresh(self): return widget = self._widget - duplicate = type(widget.native)() - self._latest = duplicate + clone = type(widget.native)() staging_area = widget.container.staging_area + self._latest = clone + + # Use a weak reference so that the external process doesn't prevent garbage + # collection. + clone_weak = weakref.ref(clone) + area_weak = weakref.ref(staging_area) - def size_changed(sender, args, duplicate=duplicate, staging_area=staging_area): - self.native_event_size_changed(sender, args, duplicate, staging_area) + def size_changed(sender, args, clone_weak=clone_weak, area_weak=area_weak): + self.native_event_size_changed(sender, args, clone_weak, area_weak) - duplicate.event_handler.SizeChanged += size_changed + clone.event_handler.SizeChanged += size_changed for attribute, value_creator in self._staged_properties.items(): value = value_creator() if value is not None: - setattr(duplicate, attribute, value) + setattr(clone, attribute, value) + + staging_area.add(clone) - staging_area.add(duplicate) + def native_event_size_changed(self, sender, args, clone_weak, area_weak): + clone = clone_weak() + staging_area = area_weak() + + # If the clone or staging area no longer exist then do nothing. This is not + # reliably hit during testing to use no over. + if not clone or not staging_area: # pragma: no cover + return - def native_event_size_changed(self, sender, args, duplicate, staging_area): - if duplicate == self._latest: - self._widget._min_width = self._adjusted_width(duplicate) - self._widget._min_height = duplicate.ActualSize.Y + if clone == self._latest: + self._widget._min_width = self._adjusted_width(clone) + self._widget._min_height = clone.ActualSize.Y self._widget.rehint() self._latest = None - staging_area.remove(duplicate) + staging_area.remove(clone) - def _adjusted_width(self, duplicate): + def _adjusted_width(self, clone): # FIXME: The staging method doesn't calculate a large enough width for italic # and oblique font styles. Add 0.25em for each of these. - if duplicate.FontStyle in {FontStyle.Oblique, FontStyle.Italic}: - font_size = duplicate.FontSize - return duplicate.ActualSize.X + round(font_size * 96 / 72 / 4, 0) + if clone.FontStyle in {FontStyle.Oblique, FontStyle.Italic}: + font_size = clone.FontSize + return clone.ActualSize.X + round(font_size * 96 / 72 / 4, 0) - return duplicate.ActualSize.X + return clone.ActualSize.X From 98b12932e682b1261c25ebe8226293ffdeb53647 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 29 Jul 2026 08:52:12 +0200 Subject: [PATCH 071/110] Fix test_cleanup skip for TextInput --- testbed/tests/widgets/test_textinput.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testbed/tests/widgets/test_textinput.py b/testbed/tests/widgets/test_textinput.py index 80dfd68af6..1c24c5b9e3 100644 --- a/testbed/tests/widgets/test_textinput.py +++ b/testbed/tests/widgets/test_textinput.py @@ -57,7 +57,7 @@ async def placeholder(request, widget): widget.placeholder = request.param -test_cleanup = build_cleanup_test(toga.TextInput) +test_cleanup = build_cleanup_test(toga.TextInput, skip_backends=("toga_winui3",)) async def test_value_not_hidden(widget, probe): From 00b456fe2be27c9cb05f985c65d3ae83e6183fb0 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:49:21 +0200 Subject: [PATCH 072/110] Debug - StatusIcon testbed click, I --- winui3/tests_backend/app.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/winui3/tests_backend/app.py b/winui3/tests_backend/app.py index 11ea4e515f..9f4dba5b1e 100644 --- a/winui3/tests_backend/app.py +++ b/winui3/tests_backend/app.py @@ -356,16 +356,20 @@ def get_midpoint(): # Make sure the overflow tray is fully open by tracking when the midpoint stops # moving. - mid_point = get_midpoint() + midpoint = get_midpoint() for _ in range(10): await asyncio.sleep(0.05) - new_mid_point = get_midpoint() - if mid_point == new_mid_point: + new_midpoint = get_midpoint() + print(f"StatusIcon - old_midpoint={midpoint}") + print(f"StatusIcon - new_midpoint={new_midpoint}") + if midpoint == new_midpoint: break - mid_point = new_mid_point + midpoint = new_midpoint - await self._send_click(*mid_point) + await self._send_click(*midpoint) + + print(f"StatusIcon - midpoint after click = {get_midpoint()}") def _get_status_menu_items(self, status_icon): native_menu = getattr(status_icon._impl, "native_menu", None) @@ -398,6 +402,7 @@ async def activate_status_icon_button(self, item_id): status_icon = self.app.status_icons[item_id] await self._click_status_icon(status_icon) + # Close the overflow tray await self._keyboard_escape() async def activate_status_menu_item(self, item_id, title): @@ -417,4 +422,5 @@ async def activate_status_menu_item(self, item_id, title): await self._keyboard_select() + # Close the overflow tray await self._keyboard_escape() From 9803ccc798b13ae29a1deac770a25db0375cb3b9 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:04:44 +0200 Subject: [PATCH 073/110] Fix to Icon probe __del__ method --- winui3/tests_backend/icons.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/winui3/tests_backend/icons.py b/winui3/tests_backend/icons.py index bb7a9a30d5..e8943aadc8 100644 --- a/winui3/tests_backend/icons.py +++ b/winui3/tests_backend/icons.py @@ -30,19 +30,19 @@ def __init__(self, app, icon): self.icon = icon # The WinUI 3 ImageIcon won't load until it has been added to the visual tree. - self.container_children = app.main_window._impl.container.native.Children + self.container_native = app.main_window._impl.container.native self.button = Button() image_icon = self.icon._impl.image_icon() self.button.Content = image_icon - self.container_children.Append(self.button) + self.container_native.Children.Append(self.button) assert isinstance(image_icon, ImageIcon) assert isinstance(self.icon._impl.id, IconId) def __del__(self): index = UInt32() - self.container_children.IndexOf(self.button, byref(index)) - self.container_children.RemoveAt(index) + self.container_native.Children.IndexOf(self.button, byref(index)) + self.container_native.Children.RemoveAt(index) async def _assert_source(self, path: Path): assert self.icon._impl.path == path From acff47cbba4eca2f92466cead212225adc50c365 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:20:49 +0200 Subject: [PATCH 074/110] Debug - StatusIcon testbed click, II --- winui3/tests_backend/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/winui3/tests_backend/app.py b/winui3/tests_backend/app.py index 9f4dba5b1e..4e3015628f 100644 --- a/winui3/tests_backend/app.py +++ b/winui3/tests_backend/app.py @@ -358,7 +358,7 @@ def get_midpoint(): # moving. midpoint = get_midpoint() for _ in range(10): - await asyncio.sleep(0.05) + await asyncio.sleep(0.1) new_midpoint = get_midpoint() print(f"StatusIcon - old_midpoint={midpoint}") From 9f07186d28b1c50304a1b78aa7a3f248e1617523 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:41:10 +0200 Subject: [PATCH 075/110] Debug - ARM64 send input, I --- winui3/tests_backend/probe.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/winui3/tests_backend/probe.py b/winui3/tests_backend/probe.py index 43eecab039..4493fdcab4 100644 --- a/winui3/tests_backend/probe.py +++ b/winui3/tests_backend/probe.py @@ -136,6 +136,9 @@ async def click(): await asyncio.sleep(0.05) + # DEBUG ARM64 + await asyncio.sleep(1) + async def _send_key(self, key_code, down=True, up=True): key_input = INPUT() key_input.type = INPUT_KEYBOARD @@ -151,6 +154,9 @@ async def _send_key(self, key_code, down=True, up=True): await asyncio.sleep(0.1) + # DEBUG ARM64 + await asyncio.sleep(1) + async def _keyboard_select(self): await self._send_key(VK_RETURN) From 71efe33a62748a5be241c545c5868d967eaace48 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:10:30 +0200 Subject: [PATCH 076/110] Trial skip for SendInput on ARM64 CI --- winui3/tests_backend/probe.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/winui3/tests_backend/probe.py b/winui3/tests_backend/probe.py index 4493fdcab4..c7c45b730b 100644 --- a/winui3/tests_backend/probe.py +++ b/winui3/tests_backend/probe.py @@ -1,7 +1,9 @@ import asyncio +import os +import platform from ctypes import byref, sizeof -from pytest import approx +from pytest import approx, skip from win32more.Windows.Win32.Foundation import POINT from win32more.Windows.Win32.UI.Input.KeyboardAndMouse import ( INPUT, @@ -108,6 +110,11 @@ def _set_cursor_position(self, x, y): SetCursorPos(x, y) def _send_input(self, input): + # On GitHub Actions, Windows ARM64 runners don't seem to support SendInput. + # See https://github.com/actions/partner-runner-images/issues/174 + if platform.machine() == "ARM64" and os.environ["RUNNING_IN_CI"] == "true": + skip("SendInput not supported.") + return_value = SendInput(1, input, sizeof(input)) if return_value != 1: raise OSError("SendInput failed.") @@ -136,9 +143,6 @@ async def click(): await asyncio.sleep(0.05) - # DEBUG ARM64 - await asyncio.sleep(1) - async def _send_key(self, key_code, down=True, up=True): key_input = INPUT() key_input.type = INPUT_KEYBOARD @@ -154,9 +158,6 @@ async def _send_key(self, key_code, down=True, up=True): await asyncio.sleep(0.1) - # DEBUG ARM64 - await asyncio.sleep(1) - async def _keyboard_select(self): await self._send_key(VK_RETURN) From 737e7405c7afdcc6afa8009f19f7053a22df88e0 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:13:18 +0200 Subject: [PATCH 077/110] Trial second system tray opening --- winui3/tests_backend/app.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/winui3/tests_backend/app.py b/winui3/tests_backend/app.py index 4e3015628f..d65fa10b32 100644 --- a/winui3/tests_backend/app.py +++ b/winui3/tests_backend/app.py @@ -334,7 +334,7 @@ def open_document_by_drag(self, document_path): def has_status_icon(self, status_icon): return isinstance(status_icon._impl.native_window, Window) - async def _click_status_icon(self, status_icon): + async def _get_status_icon_midpoint(self, status_icon) -> tuple[int, int]: # `Winkey + B` then `Enter` opens the notification icon overflow tray. await self._send_key(VK_RWIN, up=False) await self._send_key(VK_B) @@ -367,9 +367,7 @@ def get_midpoint(): break midpoint = new_midpoint - await self._send_click(*midpoint) - - print(f"StatusIcon - midpoint after click = {get_midpoint()}") + return midpoint def _get_status_menu_items(self, status_icon): native_menu = getattr(status_icon._impl, "native_menu", None) @@ -400,7 +398,12 @@ def process_text(text): async def activate_status_icon_button(self, item_id): # Click on the status icon. status_icon = self.app.status_icons[item_id] - await self._click_status_icon(status_icon) + + midpoint = await self._get_status_icon_midpoint(status_icon) + await self._keyboard_escape() + + midpoint = await self._get_status_icon_midpoint(status_icon) + await self._send_click(*midpoint) # Close the overflow tray await self._keyboard_escape() @@ -408,7 +411,8 @@ async def activate_status_icon_button(self, item_id): async def activate_status_menu_item(self, item_id, title): # Click on the status icon. status_icon = self.app.status_icons[item_id] - await self._click_status_icon(status_icon) + midpoint = await self._get_status_icon_midpoint(status_icon) + await self._send_click(*midpoint) items = self._get_status_menu_items(status_icon) index = self.status_menu_items(status_icon).index(title) From 0a3c117b2006f9d0a619a76c7d9be948ef159a77 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:04:48 +0200 Subject: [PATCH 078/110] Trial longer wait_for_window delay --- winui3/tests_backend/window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/winui3/tests_backend/window.py b/winui3/tests_backend/window.py index 6539701afc..2c63156565 100644 --- a/winui3/tests_backend/window.py +++ b/winui3/tests_backend/window.py @@ -48,7 +48,7 @@ async def wait_for_window( state=None, ): # A small delay to allow the window to resize. - await self.redraw(message, delay=0.1) + await self.redraw(message, delay=0.15) if state: timeout = 5 From eab84a7bb86b307aa0f025677eeb28ee3384d4a8 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:40:04 +0200 Subject: [PATCH 079/110] Debug - Hide window ARM64, I --- testbed/tests/app/test_desktop.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/testbed/tests/app/test_desktop.py b/testbed/tests/app/test_desktop.py index 0d43523121..9642df9956 100644 --- a/testbed/tests/app/test_desktop.py +++ b/testbed/tests/app/test_desktop.py @@ -547,7 +547,11 @@ async def test_current_window(app, app_probe, main_window, main_window_probe): if app_probe.supports_current_window_assignment: assert app.current_window == main_window + visibility = main_window._impl.native.Visible + print(f"pre-hide main_window._impl.native.Visible={visibility}") main_window.hide() + visibility = main_window._impl.native.Visible + print(f"post-hide main_window._impl.native.Visible={visibility}") await main_window_probe.wait_for_window("Hiding main window") assert app.current_window is None From bb62e35c1e6151e8caac1cce3a185ca3739e7a8c Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:43:33 +0200 Subject: [PATCH 080/110] Revert handling for native exceptions --- winui3/src/toga_winui3/libs/nativeapp.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/winui3/src/toga_winui3/libs/nativeapp.py b/winui3/src/toga_winui3/libs/nativeapp.py index 85505ea2d0..152df86580 100644 --- a/winui3/src/toga_winui3/libs/nativeapp.py +++ b/winui3/src/toga_winui3/libs/nativeapp.py @@ -45,15 +45,6 @@ class NativeApp(XamlApplication): - def __init__(self): - super().__init__() - self.UnhandledException += self.native_event_unhandled_exception - - def native_event_unhandled_exception(self, sender, args): - message = args.Message - code = args.Exception - raise OSError(code, message) - def CreateWindow(self): return events_handled(Window) From fd02221986b991fca0139a5c275c398a37ef78df Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:40:50 +0200 Subject: [PATCH 081/110] Changes to Window.is_activated --- winui3/src/toga_winui3/window.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/winui3/src/toga_winui3/window.py b/winui3/src/toga_winui3/window.py index ec51a4bace..8cd773238f 100644 --- a/winui3/src/toga_winui3/window.py +++ b/winui3/src/toga_winui3/window.py @@ -36,6 +36,7 @@ MF_GRAYED, SC_CLOSE, EnableMenuItem, + GetForegroundWindow, GetSystemMenu, ) @@ -56,8 +57,6 @@ class Window: def __init__(self, interface, title, position, size): self.interface = interface - - self.is_activated = False self.create() # From a native WinUI 3 point of view, presentation mode is indistinguishable @@ -141,10 +140,8 @@ def native_event_activated(self, sender, args): """Event that fires when the window is activated or deactivated.""" # learn.microsoft.com/windows/windows-app-sdk/api/winrt/microsoft.ui.xaml.window.activated # noqa: E501 if args.WindowActivationState == WindowActivationState.Deactivated: - self.is_activated = False self.interface.on_lose_focus() else: - self.is_activated = True self.interface.on_gain_focus() def native_event_changed(self, sender, args): @@ -371,7 +368,7 @@ def set_position(self, position: PositionT): ) #################################################################################### - # Window visibility. + # Window visibility and focus #################################################################################### def get_visible(self) -> bool: @@ -383,6 +380,10 @@ def hide(self): self._visible = False self.native.AppWindow.Hide() + @property + def is_activated(self): + return self._hwnd == GetForegroundWindow() + #################################################################################### # Window state. #################################################################################### @@ -411,9 +412,8 @@ def get_window_state(self, in_progress_state=False) -> WindowState: presenter, _ = self._presenter if presenter.Kind == AppWindowPresenterKind.FullScreen: - # Fullscreen here corresponds to Toga 'PRESENTATION' window state. From the - # Microsoft documentation: 'The window does not have a border or title bar, - # and hides the system task bar.' + # From the Microsoft documentation: 'The window does not have a border + # or title bar, and hides the system task bar.' # learn.microsoft.com/en-us/windows/apps/develop/ui/manage-app-windows if self._in_presentation_mode: return WindowState.PRESENTATION From 3b351478fe9e1981df4e44db9d27884c239bdfd0 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:00:24 +0200 Subject: [PATCH 082/110] Revert debug changes and add comments --- testbed/tests/app/test_desktop.py | 4 ---- winui3/tests_backend/app.py | 5 ++++- winui3/tests_backend/window.py | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/testbed/tests/app/test_desktop.py b/testbed/tests/app/test_desktop.py index 9642df9956..0d43523121 100644 --- a/testbed/tests/app/test_desktop.py +++ b/testbed/tests/app/test_desktop.py @@ -547,11 +547,7 @@ async def test_current_window(app, app_probe, main_window, main_window_probe): if app_probe.supports_current_window_assignment: assert app.current_window == main_window - visibility = main_window._impl.native.Visible - print(f"pre-hide main_window._impl.native.Visible={visibility}") main_window.hide() - visibility = main_window._impl.native.Visible - print(f"post-hide main_window._impl.native.Visible={visibility}") await main_window_probe.wait_for_window("Hiding main window") assert app.current_window is None diff --git a/winui3/tests_backend/app.py b/winui3/tests_backend/app.py index d65fa10b32..4cf077afdd 100644 --- a/winui3/tests_backend/app.py +++ b/winui3/tests_backend/app.py @@ -358,7 +358,7 @@ def get_midpoint(): # moving. midpoint = get_midpoint() for _ in range(10): - await asyncio.sleep(0.1) + await asyncio.sleep(0.05) new_midpoint = get_midpoint() print(f"StatusIcon - old_midpoint={midpoint}") @@ -399,6 +399,9 @@ async def activate_status_icon_button(self, item_id): # Click on the status icon. status_icon = self.app.status_icons[item_id] + # There is an issue on the x86_64 CI runner where the method used here to open + # the system tray overflow menu doesn't work properly the first time. So, open + # it twice. midpoint = await self._get_status_icon_midpoint(status_icon) await self._keyboard_escape() diff --git a/winui3/tests_backend/window.py b/winui3/tests_backend/window.py index 2c63156565..6539701afc 100644 --- a/winui3/tests_backend/window.py +++ b/winui3/tests_backend/window.py @@ -48,7 +48,7 @@ async def wait_for_window( state=None, ): # A small delay to allow the window to resize. - await self.redraw(message, delay=0.15) + await self.redraw(message, delay=0.1) if state: timeout = 5 From 5060a47204baa6ef27118e7486bb891d79061af0 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:08:00 +0200 Subject: [PATCH 083/110] Revert changes to Window.is_activated --- winui3/src/toga_winui3/window.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/winui3/src/toga_winui3/window.py b/winui3/src/toga_winui3/window.py index 8cd773238f..bf02a3d9fd 100644 --- a/winui3/src/toga_winui3/window.py +++ b/winui3/src/toga_winui3/window.py @@ -36,7 +36,6 @@ MF_GRAYED, SC_CLOSE, EnableMenuItem, - GetForegroundWindow, GetSystemMenu, ) @@ -57,6 +56,8 @@ class Window: def __init__(self, interface, title, position, size): self.interface = interface + + self.is_activated = False self.create() # From a native WinUI 3 point of view, presentation mode is indistinguishable @@ -140,8 +141,10 @@ def native_event_activated(self, sender, args): """Event that fires when the window is activated or deactivated.""" # learn.microsoft.com/windows/windows-app-sdk/api/winrt/microsoft.ui.xaml.window.activated # noqa: E501 if args.WindowActivationState == WindowActivationState.Deactivated: + self.is_activated = False self.interface.on_lose_focus() else: + self.is_activated = True self.interface.on_gain_focus() def native_event_changed(self, sender, args): @@ -368,7 +371,7 @@ def set_position(self, position: PositionT): ) #################################################################################### - # Window visibility and focus + # Window visibility. #################################################################################### def get_visible(self) -> bool: @@ -380,10 +383,6 @@ def hide(self): self._visible = False self.native.AppWindow.Hide() - @property - def is_activated(self): - return self._hwnd == GetForegroundWindow() - #################################################################################### # Window state. #################################################################################### From 41a16b9800d9f6c19de7f205ca9ad0546ba14be9 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:40:11 +0200 Subject: [PATCH 084/110] Trial wait_for_window with is_activated argument --- android/tests_backend/window.py | 6 +----- cocoa/tests_backend/window.py | 6 +----- gtk/tests_backend/window.py | 6 +----- iOS/tests_backend/window.py | 2 +- qt/tests_backend/window.py | 2 +- testbed/tests/app/test_desktop.py | 10 ++++++++-- textual/tests_backend/window.py | 2 +- winforms/tests_backend/window.py | 6 +----- winui3/tests_backend/window.py | 13 ++++++------- 9 files changed, 21 insertions(+), 32 deletions(-) diff --git a/android/tests_backend/window.py b/android/tests_backend/window.py index 64980323dd..844f532432 100644 --- a/android/tests_backend/window.py +++ b/android/tests_backend/window.py @@ -21,11 +21,7 @@ def __init__(self, app, window): self.window = window self.impl = self.window._impl - async def wait_for_window( - self, - message, - state=None, - ): + async def wait_for_window(self, message, state=None, is_activated=None): await self.redraw(message, delay=0.1) if state: timeout = 5 diff --git a/cocoa/tests_backend/window.py b/cocoa/tests_backend/window.py index 034b3f020d..b42825844e 100644 --- a/cocoa/tests_backend/window.py +++ b/cocoa/tests_backend/window.py @@ -29,11 +29,7 @@ def __init__(self, app, window): self.native = window._impl.native assert isinstance(self.native, NSWindow) - async def wait_for_window( - self, - message, - state=None, - ): + async def wait_for_window(self, message, state=None, is_activated=None): await self.redraw(message, delay=0.1) if state: diff --git a/gtk/tests_backend/window.py b/gtk/tests_backend/window.py index 3729997cc6..f9970f9fc4 100644 --- a/gtk/tests_backend/window.py +++ b/gtk/tests_backend/window.py @@ -52,11 +52,7 @@ def __init__(self, app, window): self.native, Adw.ApplicationWindow ) - async def wait_for_window( - self, - message, - state=None, - ): + async def wait_for_window(self, message, state=None, is_activated=None): await self.redraw(message, delay=0.1) if state: timeout = 5 diff --git a/iOS/tests_backend/window.py b/iOS/tests_backend/window.py index a49d212b99..40b491ea80 100644 --- a/iOS/tests_backend/window.py +++ b/iOS/tests_backend/window.py @@ -53,7 +53,7 @@ def _state_assertion(): return _state_assertion - async def wait_for_window(self, message, state=None): + async def wait_for_window(self, message, state=None, is_activated=None): await self.redraw(message) # There may be some internal rendering delays that mean the container's content diff --git a/qt/tests_backend/window.py b/qt/tests_backend/window.py index 58b3d23713..c958aae785 100644 --- a/qt/tests_backend/window.py +++ b/qt/tests_backend/window.py @@ -41,7 +41,7 @@ def __init__(self, app, window): self.supports_unminimize = False self.supports_minimize = False - async def wait_for_window(self, message, state=None): + async def wait_for_window(self, message, state=None, is_activated=None): # 0.15 seconds to allow window size operations to propagate # events. await self.redraw(message, 0.15) diff --git a/testbed/tests/app/test_desktop.py b/testbed/tests/app/test_desktop.py index 0d43523121..a71885e212 100644 --- a/testbed/tests/app/test_desktop.py +++ b/testbed/tests/app/test_desktop.py @@ -548,11 +548,17 @@ async def test_current_window(app, app_probe, main_window, main_window_probe): assert app.current_window == main_window main_window.hide() - await main_window_probe.wait_for_window("Hiding main window") + await main_window_probe.wait_for_window( + "Hiding main window", + is_activated=False, + ) assert app.current_window is None main_window.show() - await main_window_probe.wait_for_window("Showing main window") + await main_window_probe.wait_for_window( + "Showing main window", + is_activated=True, + ) assert app.current_window == main_window finally: main_window.show() diff --git a/textual/tests_backend/window.py b/textual/tests_backend/window.py index 0f6911f0b8..cdf4ff3d6d 100644 --- a/textual/tests_backend/window.py +++ b/textual/tests_backend/window.py @@ -26,7 +26,7 @@ def __init__(self, app, window): self.native = window._impl.native assert isinstance(self.native, TextualScreen) - async def wait_for_window(self, message, state=None): + async def wait_for_window(self, message, state=None, is_activated=None): await self.redraw(message) if state: assert self.instantaneous_state == state diff --git a/winforms/tests_backend/window.py b/winforms/tests_backend/window.py index 0af4618d6c..9fd4b21dd7 100644 --- a/winforms/tests_backend/window.py +++ b/winforms/tests_backend/window.py @@ -39,11 +39,7 @@ def __init__(self, app, window): super().__init__(window._impl.native) assert isinstance(self.native, Form) - async def wait_for_window( - self, - message, - state=None, - ): + async def wait_for_window(self, message, state=None, is_activated=None): await self.redraw(message) if state: diff --git a/winui3/tests_backend/window.py b/winui3/tests_backend/window.py index 6539701afc..8a85afffea 100644 --- a/winui3/tests_backend/window.py +++ b/winui3/tests_backend/window.py @@ -42,15 +42,11 @@ def __init__(self, app, window): def _hwnd(self): return GetWindowFromWindowId(self.impl.native.AppWindow.Id) - async def wait_for_window( - self, - message, - state=None, - ): + async def wait_for_window(self, message, state=None, is_activated=None): # A small delay to allow the window to resize. await self.redraw(message, delay=0.1) - if state: + if state or is_activated: timeout = 5 polling_interval = 0.1 exception = None @@ -58,7 +54,10 @@ async def wait_for_window( start_time = loop.time() while (loop.time() - start_time) < timeout: try: - assert self.instantaneous_state == state + if state: + assert self.instantaneous_state == state + if is_activated: + assert self.impl.is_activated == is_activated return except AssertionError as e: exception = e From 66fbfc7be9050387c108b0cba1198c2419f41bd0 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:22:55 +0200 Subject: [PATCH 085/110] Revert wait_for_window and add ARM64 CI skip --- android/tests_backend/window.py | 2 +- cocoa/tests_backend/window.py | 2 +- gtk/tests_backend/window.py | 2 +- iOS/tests_backend/window.py | 2 +- qt/tests_backend/window.py | 2 +- testbed/tests/app/test_desktop.py | 17 +++++++++-------- textual/tests_backend/window.py | 2 +- winforms/tests_backend/window.py | 2 +- winui3/tests_backend/window.py | 9 +++------ 9 files changed, 19 insertions(+), 21 deletions(-) diff --git a/android/tests_backend/window.py b/android/tests_backend/window.py index 844f532432..ce809a22e8 100644 --- a/android/tests_backend/window.py +++ b/android/tests_backend/window.py @@ -21,7 +21,7 @@ def __init__(self, app, window): self.window = window self.impl = self.window._impl - async def wait_for_window(self, message, state=None, is_activated=None): + async def wait_for_window(self, message, state=None): await self.redraw(message, delay=0.1) if state: timeout = 5 diff --git a/cocoa/tests_backend/window.py b/cocoa/tests_backend/window.py index b42825844e..2739f4ff4e 100644 --- a/cocoa/tests_backend/window.py +++ b/cocoa/tests_backend/window.py @@ -29,7 +29,7 @@ def __init__(self, app, window): self.native = window._impl.native assert isinstance(self.native, NSWindow) - async def wait_for_window(self, message, state=None, is_activated=None): + async def wait_for_window(self, message, state=None): await self.redraw(message, delay=0.1) if state: diff --git a/gtk/tests_backend/window.py b/gtk/tests_backend/window.py index f9970f9fc4..1d203e7d16 100644 --- a/gtk/tests_backend/window.py +++ b/gtk/tests_backend/window.py @@ -52,7 +52,7 @@ def __init__(self, app, window): self.native, Adw.ApplicationWindow ) - async def wait_for_window(self, message, state=None, is_activated=None): + async def wait_for_window(self, message, state=None): await self.redraw(message, delay=0.1) if state: timeout = 5 diff --git a/iOS/tests_backend/window.py b/iOS/tests_backend/window.py index 40b491ea80..a49d212b99 100644 --- a/iOS/tests_backend/window.py +++ b/iOS/tests_backend/window.py @@ -53,7 +53,7 @@ def _state_assertion(): return _state_assertion - async def wait_for_window(self, message, state=None, is_activated=None): + async def wait_for_window(self, message, state=None): await self.redraw(message) # There may be some internal rendering delays that mean the container's content diff --git a/qt/tests_backend/window.py b/qt/tests_backend/window.py index c958aae785..58b3d23713 100644 --- a/qt/tests_backend/window.py +++ b/qt/tests_backend/window.py @@ -41,7 +41,7 @@ def __init__(self, app, window): self.supports_unminimize = False self.supports_minimize = False - async def wait_for_window(self, message, state=None, is_activated=None): + async def wait_for_window(self, message, state=None): # 0.15 seconds to allow window size operations to propagate # events. await self.redraw(message, 0.15) diff --git a/testbed/tests/app/test_desktop.py b/testbed/tests/app/test_desktop.py index a71885e212..a8cf0dbbc7 100644 --- a/testbed/tests/app/test_desktop.py +++ b/testbed/tests/app/test_desktop.py @@ -1,5 +1,7 @@ import asyncio import itertools +import os +import platform from functools import partial from unittest.mock import Mock @@ -548,18 +550,17 @@ async def test_current_window(app, app_probe, main_window, main_window_probe): assert app.current_window == main_window main_window.hide() - await main_window_probe.wait_for_window( - "Hiding main window", - is_activated=False, - ) + await main_window_probe.wait_for_window("Hiding main window") assert app.current_window is None main_window.show() - await main_window_probe.wait_for_window( - "Showing main window", - is_activated=True, - ) + await main_window_probe.wait_for_window("Showing main window") assert app.current_window == main_window + except AssertionError as e: + # GitHub Windows ARM64 runners don't seem to be able to accept input focus. + # See https://github.com/actions/partner-runner-images/issues/174 + if platform.machine() != "ARM64" or os.environ["RUNNING_IN_CI"] != "true": + raise AssertionError from e finally: main_window.show() diff --git a/textual/tests_backend/window.py b/textual/tests_backend/window.py index cdf4ff3d6d..0f6911f0b8 100644 --- a/textual/tests_backend/window.py +++ b/textual/tests_backend/window.py @@ -26,7 +26,7 @@ def __init__(self, app, window): self.native = window._impl.native assert isinstance(self.native, TextualScreen) - async def wait_for_window(self, message, state=None, is_activated=None): + async def wait_for_window(self, message, state=None): await self.redraw(message) if state: assert self.instantaneous_state == state diff --git a/winforms/tests_backend/window.py b/winforms/tests_backend/window.py index 9fd4b21dd7..1fe36aea19 100644 --- a/winforms/tests_backend/window.py +++ b/winforms/tests_backend/window.py @@ -39,7 +39,7 @@ def __init__(self, app, window): super().__init__(window._impl.native) assert isinstance(self.native, Form) - async def wait_for_window(self, message, state=None, is_activated=None): + async def wait_for_window(self, message, state=None): await self.redraw(message) if state: diff --git a/winui3/tests_backend/window.py b/winui3/tests_backend/window.py index 8a85afffea..163789ff66 100644 --- a/winui3/tests_backend/window.py +++ b/winui3/tests_backend/window.py @@ -42,11 +42,11 @@ def __init__(self, app, window): def _hwnd(self): return GetWindowFromWindowId(self.impl.native.AppWindow.Id) - async def wait_for_window(self, message, state=None, is_activated=None): + async def wait_for_window(self, message, state=None): # A small delay to allow the window to resize. await self.redraw(message, delay=0.1) - if state or is_activated: + if state: timeout = 5 polling_interval = 0.1 exception = None @@ -54,10 +54,7 @@ async def wait_for_window(self, message, state=None, is_activated=None): start_time = loop.time() while (loop.time() - start_time) < timeout: try: - if state: - assert self.instantaneous_state == state - if is_activated: - assert self.impl.is_activated == is_activated + assert self.instantaneous_state == state return except AssertionError as e: exception = e From 105e3955a9e904d53b3271d973943d7a3b5e7573 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:43:06 +0200 Subject: [PATCH 086/110] Allow incomplete coverage on ARM64 CI --- testbed/tests/testbed.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/testbed/tests/testbed.py b/testbed/tests/testbed.py index 261d0c20b8..0959cb3ce7 100644 --- a/testbed/tests/testbed.py +++ b/testbed/tests/testbed.py @@ -104,6 +104,19 @@ def run_tests(app, cov, args, report_coverage, run_slow, running_in_ci): print( "Incomplete test coverage is expected on Textual (for now!)" ) + elif ( + toga.backend == "toga_winui3" + and platform.machine() == "ARM64" + and running_in_ci + ): + # GitHub Windows ARM64 runners don't seem to be able to accept + # input focus. So some tests are skipped and incomplete coverage + # is expected. See + # https://github.com/actions/partner-runner-images/issues/174 + print( + "Incomplete test coverage is expected on WinUI 3 with the" + + " ARM64 CI (for now!)" + ) else: print("Test coverage is incomplete") app.returncode = 1 From 5dd8b60e9ab40d321cb630fc308840b07b9e4d26 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:11:23 +0200 Subject: [PATCH 087/110] Trial native error testbed debug --- testbed/tests/testbed.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/testbed/tests/testbed.py b/testbed/tests/testbed.py index 0959cb3ce7..5f6267d56b 100644 --- a/testbed/tests/testbed.py +++ b/testbed/tests/testbed.py @@ -61,6 +61,22 @@ def run_tests(app, cov, args, report_coverage, run_slow, running_in_ci): os.environ["RUNNING_IN_CI"] = "true" if running_in_ci else "" + # Make a mutable container for the error message. + native_error = [""] + + if toga.backend == "toga_winui3": + + def native_unhandled_exception(sender, args, native_error=native_error): + native_error += "=============WinUI 3 Unhandled Exception============\n" + native_error += f"Exception: {args.Exception}\n" + native_error += f"Message: {args.Message}\n" + native_error += "====================================================\n" + + def add_callback(app=app, callback=native_unhandled_exception): + app._impl.native_instance.add_UnhandledException(callback) + + app.loop.call_soon_threadsafe(add_callback) + app.returncode = pytest.main( [ # Output formatting @@ -129,6 +145,9 @@ def run_tests(app, cov, args, report_coverage, run_slow, running_in_ci): print("Can we remove the special case in the testbed?") app.returncode = 1 + if toga.backend == "toga_winui3" and native_error[0] != "": + print(native_error[0]) + except BaseException: traceback.print_exc() app.returncode = 1 From 305f81881bf41aa2428885978c7e54342af6bf1f Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:46:41 +0200 Subject: [PATCH 088/110] Trial using windows-11-vs2026-arm for CI --- .github/workflows/ci.yml | 2 +- winui3/tests_backend/probe.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d65e959b8b..5f740bf4ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -536,7 +536,7 @@ jobs: - backend: "windows-winui3-arm64" platform: "windows" - runs-on: "windows-11-arm" + runs-on: "windows-11-vs2026-arm" testbed-app: "testbed-winui3" app-user-data-path: '$HOME\AppData\Local\Tiberius Yak\Toga Testbed (WinUI 3)\Data' pre-command-pwsh: | diff --git a/winui3/tests_backend/probe.py b/winui3/tests_backend/probe.py index c7c45b730b..5ca3495563 100644 --- a/winui3/tests_backend/probe.py +++ b/winui3/tests_backend/probe.py @@ -112,8 +112,8 @@ def _set_cursor_position(self, x, y): def _send_input(self, input): # On GitHub Actions, Windows ARM64 runners don't seem to support SendInput. # See https://github.com/actions/partner-runner-images/issues/174 - if platform.machine() == "ARM64" and os.environ["RUNNING_IN_CI"] == "true": - skip("SendInput not supported.") + #if platform.machine() == "ARM64" and os.environ["RUNNING_IN_CI"] == "true": + # skip("SendInput not supported.") return_value = SendInput(1, input, sizeof(input)) if return_value != 1: From 1fc4a84c2abae58afd25e4689676ba8415f1b61e Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:52:13 +0200 Subject: [PATCH 089/110] Fix formatting --- winui3/tests_backend/probe.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/winui3/tests_backend/probe.py b/winui3/tests_backend/probe.py index 5ca3495563..1564d81683 100644 --- a/winui3/tests_backend/probe.py +++ b/winui3/tests_backend/probe.py @@ -1,9 +1,7 @@ import asyncio -import os -import platform from ctypes import byref, sizeof -from pytest import approx, skip +from pytest import approx from win32more.Windows.Win32.Foundation import POINT from win32more.Windows.Win32.UI.Input.KeyboardAndMouse import ( INPUT, @@ -112,7 +110,7 @@ def _set_cursor_position(self, x, y): def _send_input(self, input): # On GitHub Actions, Windows ARM64 runners don't seem to support SendInput. # See https://github.com/actions/partner-runner-images/issues/174 - #if platform.machine() == "ARM64" and os.environ["RUNNING_IN_CI"] == "true": + # if platform.machine() == "ARM64" and os.environ["RUNNING_IN_CI"] == "true": # skip("SendInput not supported.") return_value = SendInput(1, input, sizeof(input)) From 734db786c8dedd820fc7b6a15735770e2237dbf4 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:54:07 +0200 Subject: [PATCH 090/110] Ruff 0.16.0 fixes --- pyproject.toml | 1 + winui3/src/toga_winui3/factory.py | 40 ++++----------------- winui3/src/toga_winui3/icons.py | 12 ++++++- winui3/src/toga_winui3/libs/gdiplus.py | 5 ++- winui3/src/toga_winui3/libs/nativeevents.py | 6 ++-- winui3/src/toga_winui3/screens.py | 3 +- winui3/src/toga_winui3/widgets/base.py | 6 ++-- 7 files changed, 29 insertions(+), 44 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index db27b15447..021f42bc77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,6 +100,7 @@ ignore = [ "iOS/tests_backend/widgets/scrollcontainer.py" = ["RUF018"] "testbed/tests/widgets/test_optioncontainer.py" = ["PT031"] "winforms/src/toga_winforms/libs/win32structures.py" = ["RUF012"] +"winui3/src/toga_winui3/libs/win32structures.py" = ["RUF012"] [tool.ruff.lint.isort] combine-as-imports = true diff --git a/winui3/src/toga_winui3/factory.py b/winui3/src/toga_winui3/factory.py index bef68e295d..deb3254b32 100644 --- a/winui3/src/toga_winui3/factory.py +++ b/winui3/src/toga_winui3/factory.py @@ -53,48 +53,20 @@ def not_implemented(feature): # pragma: no cover __all__ = [ - "not_implemented", "App", + "Box", + "Button", "Command", - # Resources "Font", "Icon", - # "Image", - "Paths", - # "dialogs", - # Status Icons + "Label", + "MainWindow", "MenuStatusIcon", + "Paths", "SimpleStatusIcon", "StatusIconSet", - # Widgets - # "ActivityIndicator", - "Box", - "Button", - # "Canvas", - # "DateInput", - # "DetailedList", - # "Divider", - # "ImageView", - "Label", - # "MapView", - # "MultilineTextInput", - # "NumberInput", - # "OptionContainer", - # "PasswordInput", - # "ProgressBar", - # "ScrollContainer", - # "Selection", - # "Slider", - # "SplitContainer", - # "Switch", - # "Table", - # "TextInput", - # "TimeInput", - # "Tree", - # "WebView", - # Windows "Window", - "MainWindow", + "not_implemented", ] diff --git a/winui3/src/toga_winui3/icons.py b/winui3/src/toga_winui3/icons.py index 1766df8bca..76ea4f1c58 100644 --- a/winui3/src/toga_winui3/icons.py +++ b/winui3/src/toga_winui3/icons.py @@ -1,4 +1,5 @@ from pathlib import Path +from typing import ClassVar from win32more.Microsoft.UI import IconId from win32more.Microsoft.UI.Interop import GetIconIdFromIcon @@ -35,7 +36,16 @@ class Icon: To avoid loading unnecessary resources, the needed icon resources are lazy loaded. """ - EXTENSIONS = [".png", ".ico", ".bmp", ".jpg", ".jpeg", ".gif", ".tif", ".tiff"] + EXTENSIONS: ClassVar[list[str]] = [ + ".png", + ".ico", + ".bmp", + ".jpg", + ".jpeg", + ".gif", + ".tif", + ".tiff", + ] SIZES = None def __init__(self, interface, path): diff --git a/winui3/src/toga_winui3/libs/gdiplus.py b/winui3/src/toga_winui3/libs/gdiplus.py index 0abd58a1c6..8488971922 100644 --- a/winui3/src/toga_winui3/libs/gdiplus.py +++ b/winui3/src/toga_winui3/libs/gdiplus.py @@ -25,7 +25,7 @@ # https://learn.microsoft.com/en-us/windows/win32/gdiplus/-gdiplus-flatapi-flat # https://learn.microsoft.com/windows/win32/api/Gdiplustypes/ne-gdiplustypes-status -status_dict = { +STATUS_DICT = { 0: "Ok", 1: "GenericError", 2: "InvalidParameter", @@ -55,8 +55,7 @@ def gdi_plus_function(function): def wrapper(*args, **kwargs): status_code = function(*args, **kwargs) if status_code != 0 and status_code is not None: - global status_dict - error = str(status_dict[status_code]) + error = str(STATUS_DICT[status_code]) function_name = str(function._prototype.__name__) message = f"The GDI+ function {function_name} exit with status {error}." diff --git a/winui3/src/toga_winui3/libs/nativeevents.py b/winui3/src/toga_winui3/libs/nativeevents.py index 559d935d4d..7ab55fcdeb 100644 --- a/winui3/src/toga_winui3/libs/nativeevents.py +++ b/winui3/src/toga_winui3/libs/nativeevents.py @@ -1,3 +1,5 @@ +from typing import ClassVar + from win32more import ComError from toga import App @@ -17,7 +19,7 @@ class NativeEvent: - _cleared_callbacks = {} + _cleared_callbacks: ClassVar[dict] = {} def __init__(self, owner, name: str): """Manages the adding and clearing of callbacks of a native instance event. @@ -98,7 +100,7 @@ def __getattr__(self, event_name): if not event_name[0].isupper(): # pragma: no cover raise ValueError("Native events use the PascalCase naming convention.") - if event_name not in self._event_registry.keys(): + if event_name not in self._event_registry: self._event_registry[event_name] = NativeEvent(self._owner, event_name) return self._event_registry[event_name] diff --git a/winui3/src/toga_winui3/screens.py b/winui3/src/toga_winui3/screens.py index e6ef86ebb2..407fe07ef5 100644 --- a/winui3/src/toga_winui3/screens.py +++ b/winui3/src/toga_winui3/screens.py @@ -1,5 +1,6 @@ from ctypes import byref from decimal import ROUND_HALF_EVEN, Decimal +from typing import ClassVar from win32more.Microsoft.UI.Interop import GetMonitorFromDisplayId from win32more.Windows.Win32.Graphics.Gdi import HMONITOR @@ -16,7 +17,7 @@ def round_pixels(value) -> int: class Screen: - _instances = {} + _instances: ClassVar[dict] = {} def __new__(cls, native): native_id = str(native.DisplayId.Value) diff --git a/winui3/src/toga_winui3/widgets/base.py b/winui3/src/toga_winui3/widgets/base.py index 2ce99a6609..7b25cb3f05 100644 --- a/winui3/src/toga_winui3/widgets/base.py +++ b/winui3/src/toga_winui3/widgets/base.py @@ -33,11 +33,11 @@ def __init__(self, interface): @abstractmethod def create(self): ... - def set_app(self, app): # noqa B027 + def set_app(self, app): # Everything is already handled by the Toga core interface. pass - def set_window(self, window): # noqa B027 + def set_window(self, window): # Everything is already handled by the Toga core interface. pass @@ -106,7 +106,7 @@ def set_hidden(self, hidden): state = Visibility.Collapsed if hidden else Visibility.Visible self.native.Visibility = state - def set_text_align(self, alignment): # noqa B027 + def set_text_align(self, alignment): # Where appropriate, this is implement on a widget by widget basis. pass From b9e9cbc91ab9ac35a139a96fda1c4bc3cfecfaec Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:21:38 +0200 Subject: [PATCH 091/110] Revert "Trial using windows-11-vs2026-arm for CI" --- .github/workflows/ci.yml | 2 +- winui3/tests_backend/probe.py | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73baafa24c..80da0e14be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -536,7 +536,7 @@ jobs: - backend: "windows-winui3-arm64" platform: "windows" - runs-on: "windows-11-vs2026-arm" + runs-on: "windows-11-arm" testbed-app: "testbed-winui3" app-user-data-path: '$HOME\AppData\Local\Tiberius Yak\Toga Testbed (WinUI 3)\Data' pre-command-pwsh: | diff --git a/winui3/tests_backend/probe.py b/winui3/tests_backend/probe.py index 1564d81683..c7c45b730b 100644 --- a/winui3/tests_backend/probe.py +++ b/winui3/tests_backend/probe.py @@ -1,7 +1,9 @@ import asyncio +import os +import platform from ctypes import byref, sizeof -from pytest import approx +from pytest import approx, skip from win32more.Windows.Win32.Foundation import POINT from win32more.Windows.Win32.UI.Input.KeyboardAndMouse import ( INPUT, @@ -110,8 +112,8 @@ def _set_cursor_position(self, x, y): def _send_input(self, input): # On GitHub Actions, Windows ARM64 runners don't seem to support SendInput. # See https://github.com/actions/partner-runner-images/issues/174 - # if platform.machine() == "ARM64" and os.environ["RUNNING_IN_CI"] == "true": - # skip("SendInput not supported.") + if platform.machine() == "ARM64" and os.environ["RUNNING_IN_CI"] == "true": + skip("SendInput not supported.") return_value = SendInput(1, input, sizeof(input)) if return_value != 1: From fdd4097686fdffcb99b2dd448fb7f11c1be753c6 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:22:59 +0200 Subject: [PATCH 092/110] Improve DPI handling --- winui3/src/toga_winui3/window.py | 55 +++++++++++++++++++++----------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/winui3/src/toga_winui3/window.py b/winui3/src/toga_winui3/window.py index bf02a3d9fd..bda8963bd7 100644 --- a/winui3/src/toga_winui3/window.py +++ b/winui3/src/toga_winui3/window.py @@ -1,5 +1,6 @@ from __future__ import annotations +from ctypes import byref from typing import TYPE_CHECKING from win32more.Microsoft.UI.Interop import GetWindowFromWindowId @@ -29,7 +30,11 @@ ) from win32more.Microsoft.UI.Xaml.Media import MicaBackdrop from win32more.Windows.Graphics import PointInt32, SizeInt32 +from win32more.Windows.Win32.Foundation import RECT +from win32more.Windows.Win32.UI.HiDpi import AdjustWindowRectExForDpi, GetDpiForWindow from win32more.Windows.Win32.UI.WindowsAndMessaging import ( + GWL_EXSTYLE, + GWL_STYLE, MF_BYCOMMAND, MF_DISABLED, MF_ENABLED, @@ -37,6 +42,7 @@ SC_CLOSE, EnableMenuItem, GetSystemMenu, + GetWindowLongW, ) from toga import App @@ -271,6 +277,21 @@ def set_content(self, widget): # Example: For a 200% scale factor 1 css pixel is a 2x2 block of physical pixels. #################################################################################### + def _window_frame_size(self, dpi): + """The difference between `Bounds` and `AppWindow.Size` in physical pixels.""" + rect = RECT() + style = GetWindowLongW(self._hwnd, GWL_STYLE) + ex_style = GetWindowLongW(self._hwnd, GWL_EXSTYLE) + + AdjustWindowRectExForDpi(byref(rect), style, False, ex_style, dpi) + + return (rect.right - rect.left, rect.bottom - rect.top) + + @property + def _dpi(self): + """DPI is returned as 96 multiplied by the scale factor.""" + return GetDpiForWindow(self._hwnd) + def get_size(self) -> Size: """Gets the size of the window in CSS pixels (effective pixels).""" # If the window is minimized from a maxmimized state, then toga expects the size @@ -287,29 +308,24 @@ def get_size(self) -> Size: def set_size(self, size: SizeT): """Sets the size of the window in CSS pixels (effective pixels).""" - css_to_physical = self.get_current_screen().css_to_physical - - current_bounds = self.native.Bounds - current_size = self.native.AppWindow.Size + dpi = self._dpi - diff_width = current_size.Width - css_to_physical(current_bounds.Width) - diff_height = current_size.Height - css_to_physical(current_bounds.Height) + frame_size_physical = self._window_frame_size(dpi) + width_physical = round_pixels(size[0] * dpi / 96) + height_physical = round_pixels(size[1] * dpi / 96) self.native.AppWindow.Resize( SizeInt32( - css_to_physical(size[0]) + diff_width, - css_to_physical(size[1]) + diff_height, + width_physical + frame_size_physical[0], + height_physical + frame_size_physical[1], ) ) @property def min_size(self): """The minimum size of the window in physical pixels (device pixels).""" - css_to_physical = self.get_current_screen().css_to_physical - - # Window and client sizes are in physical pixels. - window_size = self.native.AppWindow.Size - client_size = self.native.AppWindow.ClientSize + dpi = self._dpi + frame_size_physical = self._window_frame_size(dpi) # Menu, toolbar and layout values are in CSS pixels. menu_native = getattr(self, "menu_native", None) @@ -320,19 +336,20 @@ def min_size(self): layout = self.interface.content.layout - # Compute the minimum values for the client area. - client_min_width = css_to_physical(layout.min_width) - client_min_height = css_to_physical( - layout.min_height + menu_height + toolbar_height + # Compute the minimum values for the client area in physical pixels. + client_min_width = round_pixels(layout.min_width * dpi / 96) + client_min_height = round_pixels( + (layout.min_height + menu_height + toolbar_height) * dpi / 96 ) return Size( - window_size.Width - client_size.Width + client_min_width, - window_size.Height - client_size.Height + client_min_height, + client_min_width + frame_size_physical[0], + client_min_height + frame_size_physical[1], ) @property def _normal_size(self): + """The size of the window when it was last in the `Normal` state.""" if self._cached_state == WindowState.NORMAL: return self.get_size() From 002a18ef3cd95d2fd90a2e6f3ad1291b56b5d19b Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:49:54 +0200 Subject: [PATCH 093/110] Minor changes to tests --- testbed/tests/app/test_screens.py | 3 --- testbed/tests/window/test_window.py | 1 - winui3/tests_backend/screens.py | 18 ++++++++++++++++++ winui3/tests_backend/window.py | 4 ++++ 4 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 winui3/tests_backend/screens.py diff --git a/testbed/tests/app/test_screens.py b/testbed/tests/app/test_screens.py index f687f2f8a7..27c869b1b9 100644 --- a/testbed/tests/app/test_screens.py +++ b/testbed/tests/app/test_screens.py @@ -4,8 +4,6 @@ from toga.images import Image as TogaImage -from ..conftest import skip_on_backends - def screen_probe(screen): module = import_module("tests_backend.screens") @@ -40,7 +38,6 @@ async def test_size(app): async def test_as_image(app): """A screen can be captured as an image""" - skip_on_backends("toga_winui3", reason="Screen.get_image_data is no implemented.") # Using a probe for test as the feature is not implemented on some platforms. for screen in app.screens: probe = screen_probe(screen) diff --git a/testbed/tests/window/test_window.py b/testbed/tests/window/test_window.py index 238296451b..c2ddceb3f9 100644 --- a/testbed/tests/window/test_window.py +++ b/testbed/tests/window/test_window.py @@ -479,7 +479,6 @@ async def test_secondary_window_cleanup(app_probe): ) async def test_secondary_window_toolbar(app, second_window, second_window_probe): """A toolbar can be added to a secondary window""" - skip_on_backends("toga_winui3") second_window.toolbar.add(app.cmd1) # Window doesn't have content. This is intentional. diff --git a/winui3/tests_backend/screens.py b/winui3/tests_backend/screens.py new file mode 100644 index 0000000000..7e4f188d58 --- /dev/null +++ b/winui3/tests_backend/screens.py @@ -0,0 +1,18 @@ +from pytest import skip +from win32more.Microsoft.UI.Windowing import DisplayArea + +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 + assert isinstance(self.native, DisplayArea) + + def get_screenshot(self, format=TogaImage): + skip("Screen.get_image_data is not implemented on toga_winui3 yet.") diff --git a/winui3/tests_backend/window.py b/winui3/tests_backend/window.py index 163789ff66..9708d80cf8 100644 --- a/winui3/tests_backend/window.py +++ b/winui3/tests_backend/window.py @@ -2,6 +2,7 @@ from ctypes import byref, sizeof, windll from typing import Literal +from pytest import skip from win32more.Microsoft.UI.Interop import GetWindowFromWindowId from win32more.Microsoft.UI.Windowing import ( AppWindowPresenterKind, @@ -129,3 +130,6 @@ def is_minimized(self): def unminimize(self): presenter, _ = self.impl._presenter presenter.Restore() + + def has_toolbar(self): + skip("Toolbars are not implemented on on toga_winui3 yet.") From 912819c204502941aa7f002a284d7c4d3869453c Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:03:30 +0200 Subject: [PATCH 094/110] Minor fixes and changes from review --- winui3/src/toga_winui3/app.py | 1 - winui3/src/toga_winui3/colors.py | 6 +----- winui3/src/toga_winui3/libs/proactor.py | 2 +- winui3/src/toga_winui3/screens.py | 5 ++--- winui3/src/toga_winui3/widgets/box.py | 3 +++ winui3/src/toga_winui3/widgets/button.py | 6 +++++- winui3/src/toga_winui3/widgets/label.py | 9 ++++++++- winui3/src/toga_winui3/widgets/properties/staged.py | 2 +- winui3/src/toga_winui3/window.py | 2 -- 9 files changed, 21 insertions(+), 15 deletions(-) diff --git a/winui3/src/toga_winui3/app.py b/winui3/src/toga_winui3/app.py index e6cadaf3f1..250d854f5e 100644 --- a/winui3/src/toga_winui3/app.py +++ b/winui3/src/toga_winui3/app.py @@ -48,7 +48,6 @@ def create_standard_commands(self): def create_menus(self): """Creates menu bars for the windows with the 'create_menus' attribute.""" for window in self.interface.windows: - # From toga_winforms: # It's difficult to trigger this on a simple window, because we can't easily # modify the set of app-level commands that are registered, and a simple # window doesn't exist when the app starts up. Therefore, no-branch the else diff --git a/winui3/src/toga_winui3/colors.py b/winui3/src/toga_winui3/colors.py index 3dd49205de..945625d5f3 100644 --- a/winui3/src/toga_winui3/colors.py +++ b/winui3/src/toga_winui3/colors.py @@ -1,10 +1,9 @@ from win32more.Microsoft.UI.Xaml.Media import SolidColorBrush from win32more.Windows.UI import Color as NativeColor -from toga import rgba as TogaColor from toga.constants import TRANSPARENT -COLOR_CACHE = {} +COLOR_CACHE = {TRANSPARENT: NativeColor(R=0, G=0, B=0, A=0)} BRUSH_CACHE = {} @@ -12,9 +11,6 @@ def native_color(toga_color): if not toga_color: return None - if toga_color == TRANSPARENT: - toga_color = TogaColor(0, 0, 0, 0) - try: color = COLOR_CACHE[toga_color] except KeyError: diff --git a/winui3/src/toga_winui3/libs/proactor.py b/winui3/src/toga_winui3/libs/proactor.py index 7f554a652f..d25d39c20f 100644 --- a/winui3/src/toga_winui3/libs/proactor.py +++ b/winui3/src/toga_winui3/libs/proactor.py @@ -15,7 +15,7 @@ class ReadyDeque(deque): - """A deque that enqueues a WinForms event tick when a value is appended.""" + """A deque that enqueues a WinUI3 event tick when a value is appended.""" def __init__(self, loop): self._loop = loop diff --git a/winui3/src/toga_winui3/screens.py b/winui3/src/toga_winui3/screens.py index 407fe07ef5..7bc18ce089 100644 --- a/winui3/src/toga_winui3/screens.py +++ b/winui3/src/toga_winui3/screens.py @@ -84,6 +84,5 @@ def get_size(self) -> Size: # Screen capabilities #################################################################################### - def get_image_data(self): # pragma: no cover - # FIXME: Remove 'no cover' when implemented. - print("Not yet implemented on WinUI3 - Screen.get_image_data") + def get_image_data(self): + self.interface.factory.not_implemented("Screen.get_image_data()") diff --git a/winui3/src/toga_winui3/widgets/box.py b/winui3/src/toga_winui3/widgets/box.py index 877ff51c05..c210730666 100644 --- a/winui3/src/toga_winui3/widgets/box.py +++ b/winui3/src/toga_winui3/widgets/box.py @@ -5,7 +5,10 @@ class Box(Widget): def create(self): + # Setting native_cls defines self.native and means that events are managed by + # the nativeevents module. self.native_cls = Canvas + # Box cannot receive input focus, so remove it from the tab sequence. self.native.IsTabStop = False diff --git a/winui3/src/toga_winui3/widgets/button.py b/winui3/src/toga_winui3/widgets/button.py index 040b17d2fa..52dff4d1c1 100644 --- a/winui3/src/toga_winui3/widgets/button.py +++ b/winui3/src/toga_winui3/widgets/button.py @@ -8,11 +8,15 @@ class Button(Widget): def create(self): + # Setting native_cls defines self.native and means that events are managed by + # the nativeevents module. self.native_cls = NativeButton + self._icon = None self._text = "" - # Initial minimum sizes are 0 so that the staged properties are sized up. + # Initial minimum sizes are 0 because the staged properties are delayed, and + # this allows to the widget to be sized up. self._min_width = 0 self._min_height = 0 diff --git a/winui3/src/toga_winui3/widgets/label.py b/winui3/src/toga_winui3/widgets/label.py index c92e37d39f..2d2f3aa338 100644 --- a/winui3/src/toga_winui3/widgets/label.py +++ b/winui3/src/toga_winui3/widgets/label.py @@ -23,14 +23,18 @@ def __init__(self, label): """ self._label = label + # Setting native_cls defines self.native and means that events are managed by + # the nativeevents module. self.native_cls = TextBlock + # LabelText cannot receive input focus, so remove it from the tab sequence. self.native.IsTabStop = False self._native_properties = NativeProperties(self) self._staged_properties = StagedProperties(self) - # Initial minimum sizes are 0 so that the staged properties are sized up. + # Initial minimum sizes are 0 because the staged properties are delayed, and + # this allows to the widget to be sized up. self._min_width = 0 self._min_height = 0 @@ -57,7 +61,10 @@ class Label(Widget): """ def create(self): + # Setting native_cls defines self.native and means that events are managed by + # the nativeevents module. self.native_cls = Grid + # Label cannot receive input focus, so remove it from the tab sequence. self.native.IsTabStop = False diff --git a/winui3/src/toga_winui3/widgets/properties/staged.py b/winui3/src/toga_winui3/widgets/properties/staged.py index d710117c43..50c5f9606f 100644 --- a/winui3/src/toga_winui3/widgets/properties/staged.py +++ b/winui3/src/toga_winui3/widgets/properties/staged.py @@ -123,7 +123,7 @@ def native_event_size_changed(self, sender, args, clone_weak, area_weak): staging_area = area_weak() # If the clone or staging area no longer exist then do nothing. This is not - # reliably hit during testing to use no over. + # reliably hit during testing, so use no cover. if not clone or not staging_area: # pragma: no cover return diff --git a/winui3/src/toga_winui3/window.py b/winui3/src/toga_winui3/window.py index bda8963bd7..2949ebdeaf 100644 --- a/winui3/src/toga_winui3/window.py +++ b/winui3/src/toga_winui3/window.py @@ -173,11 +173,9 @@ def native_event_changed(self, sender, args): if old_state != new_state: if old_state == WindowState.MINIMIZED: - print("Showing after minimize") self.interface.on_show() elif new_state == WindowState.MINIMIZED: - print("Hiding via minimize") self.interface.on_hide() if args.DidVisibilityChange: From 6c298c533902ac95180b1177c29686a2781d6093 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:38:22 +0200 Subject: [PATCH 095/110] Consolidate test skips into module level skips --- testbed/tests/app/test_dialogs.py | 6 ++---- testbed/tests/test_images.py | 9 ++------- testbed/tests/test_keys.py | 6 +++++- testbed/tests/widgets/canvas/conftest.py | 3 --- testbed/tests/widgets/canvas/test_canvas.py | 5 +++-- testbed/tests/widgets/canvas/test_deprecated_code.py | 3 ++- testbed/tests/widgets/test_activityindicator.py | 8 +++----- testbed/tests/widgets/test_dateinput.py | 7 +++---- testbed/tests/widgets/test_detailedlist.py | 6 +++--- testbed/tests/widgets/test_divider.py | 6 +++--- testbed/tests/widgets/test_imageview.py | 5 ++--- testbed/tests/widgets/test_mapview.py | 9 +++------ testbed/tests/widgets/test_multilinetextinput.py | 4 ++-- testbed/tests/widgets/test_numberinput.py | 6 +++--- testbed/tests/widgets/test_optioncontainer.py | 5 ++--- testbed/tests/widgets/test_passwordinput.py | 6 +++--- testbed/tests/widgets/test_progressbar.py | 6 +++--- testbed/tests/widgets/test_scrollcontainer.py | 5 ++--- testbed/tests/widgets/test_selection.py | 5 ++--- testbed/tests/widgets/test_slider.py | 7 +++---- testbed/tests/widgets/test_splitcontainer.py | 7 ++++--- testbed/tests/widgets/test_switch.py | 8 ++++++-- testbed/tests/widgets/test_table.py | 8 +++----- testbed/tests/widgets/test_textinput.py | 10 +++++++--- testbed/tests/widgets/test_timeinput.py | 11 ++++------- testbed/tests/widgets/test_tree.py | 9 ++++----- testbed/tests/widgets/test_webview.py | 5 ++--- testbed/tests/window/test_dialogs.py | 6 ++---- 28 files changed, 83 insertions(+), 98 deletions(-) diff --git a/testbed/tests/app/test_dialogs.py b/testbed/tests/app/test_dialogs.py index 7e9f66f640..e4ec0d3e74 100644 --- a/testbed/tests/app/test_dialogs.py +++ b/testbed/tests/app/test_dialogs.py @@ -12,14 +12,12 @@ skip_on_backends( "toga_textual", - reason="Dialogs are not implemented on Textual.", + "toga_winui3", + reason="Dialogs are not implemented on this backend.", allow_module_level=True, ) -skip_on_backends("toga_winui3", allow_module_level=True) - - async def test_info_dialog(app, app_probe): """An app-level info dialog can be displayed and acknowledged.""" dialog = toga.InfoDialog("Info", "Some info") diff --git a/testbed/tests/test_images.py b/testbed/tests/test_images.py index d01cd67274..7a00864135 100644 --- a/testbed/tests/test_images.py +++ b/testbed/tests/test_images.py @@ -12,15 +12,10 @@ from .conftest import skip_on_backends -skip_on_backends( - "toga_winui3", - reason="Images are not implemented yet on WinUI 3.", - allow_module_level=True, -) - skip_on_backends( "toga_textual", - reason="Images are not implemented on Textual.", + "toga_winui3", + reason="Images are not implemented on this backend.", allow_module_level=True, ) diff --git a/testbed/tests/test_keys.py b/testbed/tests/test_keys.py index 1dd367cf0e..04829dceba 100644 --- a/testbed/tests/test_keys.py +++ b/testbed/tests/test_keys.py @@ -4,7 +4,11 @@ from .conftest import skip_on_backends -skip_on_backends("toga_winui3", allow_module_level=True) +skip_on_backends( + "toga_winui3", + reason="Keys are not implemented on this backend.", + allow_module_level=True, +) @pytest.mark.parametrize( diff --git a/testbed/tests/widgets/canvas/conftest.py b/testbed/tests/widgets/canvas/conftest.py index d9c1093ff3..9ad843ff64 100644 --- a/testbed/tests/widgets/canvas/conftest.py +++ b/testbed/tests/widgets/canvas/conftest.py @@ -5,8 +5,6 @@ import toga from toga.colors import WHITE -from ..conftest import skip_on_backends - @pytest.fixture def on_resize_handler(): @@ -59,7 +57,6 @@ async def widget( on_alt_release_handler, on_alt_drag_handler, ): - skip_on_backends("toga_winui3") return toga.Canvas( on_resize=on_resize_handler, on_press=on_press_handler, diff --git a/testbed/tests/widgets/canvas/test_canvas.py b/testbed/tests/widgets/canvas/test_canvas.py index e5cb05887b..84645e582d 100644 --- a/testbed/tests/widgets/canvas/test_canvas.py +++ b/testbed/tests/widgets/canvas/test_canvas.py @@ -38,11 +38,12 @@ skip_on_backends( "toga_textual", - reason="Canvas is not implemented on Textual.", + "toga_winui3", + reason="Canvas is not implemented on this backend.", allow_module_level=True, ) -test_cleanup = build_cleanup_test(toga.Canvas, skip_backends=("toga_winui3",)) +test_cleanup = build_cleanup_test(toga.Canvas) async def test_resize(widget, probe, on_resize_handler): diff --git a/testbed/tests/widgets/canvas/test_deprecated_code.py b/testbed/tests/widgets/canvas/test_deprecated_code.py index cc0c6eddac..c92636f017 100644 --- a/testbed/tests/widgets/canvas/test_deprecated_code.py +++ b/testbed/tests/widgets/canvas/test_deprecated_code.py @@ -11,7 +11,8 @@ skip_on_backends( "toga_textual", - reason="Canvas is not implemented on Textual.", + "toga_winui3", + reason="Canvas is not implemented on this backend.", allow_module_level=True, ) diff --git a/testbed/tests/widgets/test_activityindicator.py b/testbed/tests/widgets/test_activityindicator.py index ec691a8f91..ea3bf4787d 100644 --- a/testbed/tests/widgets/test_activityindicator.py +++ b/testbed/tests/widgets/test_activityindicator.py @@ -14,20 +14,18 @@ skip_on_backends( "toga_textual", - reason="ActivityIndicator is not implemented on Textual.", + "toga_winui3", + reason="ActivityIndicator is not implemented on this backend.", allow_module_level=True, ) @pytest.fixture async def widget(): - skip_on_backends("toga_winui3") return toga.ActivityIndicator() -test_cleanup = build_cleanup_test( - toga.ActivityIndicator, skip_backends=("toga_winui3",) -) +test_cleanup = build_cleanup_test(toga.ActivityIndicator) async def test_start_stop(widget, probe): diff --git a/testbed/tests/widgets/test_dateinput.py b/testbed/tests/widgets/test_dateinput.py index 7d132c8d44..7f7cc42d81 100644 --- a/testbed/tests/widgets/test_dateinput.py +++ b/testbed/tests/widgets/test_dateinput.py @@ -19,7 +19,8 @@ skip_on_backends( "toga_textual", - reason="DateInput is not implemented on Textual.", + "toga_winui3", + reason="DateInput is not implemented on this backend.", allow_module_level=True, ) @@ -87,16 +88,14 @@ def assert_approx_now(actual): @fixture async def widget(): - skip_on_backends("toga_winui3") return toga.DateInput() -test_cleanup = build_cleanup_test(toga.DateInput, skip_backends=("toga_winui3",)) +test_cleanup = build_cleanup_test(toga.DateInput) async def test_init(): "Properties can be set in the constructor" - skip_on_backends("toga_winui3") value = date(1999, 12, 31) min = date(1999, 12, 30) diff --git a/testbed/tests/widgets/test_detailedlist.py b/testbed/tests/widgets/test_detailedlist.py index 8c2447965b..8601210ec0 100644 --- a/testbed/tests/widgets/test_detailedlist.py +++ b/testbed/tests/widgets/test_detailedlist.py @@ -19,7 +19,8 @@ skip_on_backends( "toga_textual", - reason="DetailedList is not implemented on Textual.", + "toga_winui3", + reason="DetailedList is not implemented on this backend.", allow_module_level=True, ) @@ -71,7 +72,6 @@ async def widget( on_primary_action_handler, on_secondary_action_handler, ): - skip_on_backends("toga_winui3") return toga.DetailedList( data=source, accessors=("a", "b", "c"), @@ -138,7 +138,7 @@ async def test_color_reset(widget, probe): await check_color_reset(widget, probe) -test_cleanup = build_cleanup_test(toga.DetailedList, skip_backends=("toga_winui3",)) +test_cleanup = build_cleanup_test(toga.DetailedList) async def test_scroll(widget, probe): diff --git a/testbed/tests/widgets/test_divider.py b/testbed/tests/widgets/test_divider.py index ec147c40f2..5a862ca34f 100644 --- a/testbed/tests/widgets/test_divider.py +++ b/testbed/tests/widgets/test_divider.py @@ -13,18 +13,18 @@ skip_on_backends( "toga_textual", - reason="Divider is not implemented on Textual.", + "toga_winui3", + reason="Divider is not implemented on this backend.", allow_module_level=True, ) @pytest.fixture async def widget(): - skip_on_backends("toga_winui3") return toga.Divider() -test_cleanup = build_cleanup_test(toga.Divider, skip_backends=("toga_winui3",)) +test_cleanup = build_cleanup_test(toga.Divider) async def test_directions(widget, probe): diff --git a/testbed/tests/widgets/test_imageview.py b/testbed/tests/widgets/test_imageview.py index 31bc2a28df..e3e1d766a8 100644 --- a/testbed/tests/widgets/test_imageview.py +++ b/testbed/tests/widgets/test_imageview.py @@ -15,21 +15,20 @@ skip_on_backends( "toga_textual", - reason="ImageView is not implemented on Textual.", + "toga_winui3", + reason="ImageView is not implemented on this backend.", allow_module_level=True, ) @pytest.fixture async def widget(): - skip_on_backends("toga_winui3") return toga.ImageView(image="resources/sample.png") test_cleanup = build_cleanup_test( toga.ImageView, kwargs={"image": "resources/sample.png"}, - skip_backends=("toga_winui3",), ) diff --git a/testbed/tests/widgets/test_mapview.py b/testbed/tests/widgets/test_mapview.py index e07bf85dc4..e2278edc1b 100644 --- a/testbed/tests/widgets/test_mapview.py +++ b/testbed/tests/widgets/test_mapview.py @@ -16,7 +16,8 @@ skip_on_backends( "toga_textual", - reason="MapView is not implemented on Textual.", + "toga_winui3", + reason="MapView is not implemented on this backend.", allow_module_level=True, ) @@ -39,7 +40,6 @@ async def on_select(): @pytest.fixture async def widget(on_select): - skip_on_backends("toga_winui3") with safe_create(): widget = toga.MapView(style=Pack(flex=1), on_select=on_select) @@ -67,10 +67,7 @@ async def widget(on_select): toga.App.app._gc_protector.append(widget) -test_cleanup = build_cleanup_test( - toga.MapView, - skip_backends=("toga_winui3",), -) +test_cleanup = build_cleanup_test(toga.MapView) # The next two tests fail about 75% of the time in the macOS x86_64 CI configuration. diff --git a/testbed/tests/widgets/test_multilinetextinput.py b/testbed/tests/widgets/test_multilinetextinput.py index c4fc4cb5e1..b3c6ec7b74 100644 --- a/testbed/tests/widgets/test_multilinetextinput.py +++ b/testbed/tests/widgets/test_multilinetextinput.py @@ -39,14 +39,14 @@ skip_on_backends( "toga_textual", - reason="MultilineTextInput is not implemented on Textual.", + "toga_winui3", + reason="MultilineTextInput is not implemented on this backend.", allow_module_level=True, ) @pytest.fixture async def widget(): - skip_on_backends("toga_winui3") return toga.MultilineTextInput(value="Hello", style=Pack(flex=1)) diff --git a/testbed/tests/widgets/test_numberinput.py b/testbed/tests/widgets/test_numberinput.py index b4ea7b986f..8502ed4417 100644 --- a/testbed/tests/widgets/test_numberinput.py +++ b/testbed/tests/widgets/test_numberinput.py @@ -27,14 +27,14 @@ skip_on_backends( "toga_textual", - reason="NumberInput is not implemented on Textual.", + "toga_winui3", + reason="NumberInput is not implemented on this backend.", allow_module_level=True, ) @pytest.fixture async def widget(): - skip_on_backends("toga_winui3") return toga.NumberInput(value="1.23", step="0.01") @@ -49,7 +49,7 @@ def verify_focus_handlers(): return False -test_cleanup = build_cleanup_test(toga.NumberInput, skip_backends=("toga_winui3",)) +test_cleanup = build_cleanup_test(toga.NumberInput) async def test_on_change_handler(widget, probe): diff --git a/testbed/tests/widgets/test_optioncontainer.py b/testbed/tests/widgets/test_optioncontainer.py index 9dec8148ae..9edcda602a 100644 --- a/testbed/tests/widgets/test_optioncontainer.py +++ b/testbed/tests/widgets/test_optioncontainer.py @@ -17,7 +17,8 @@ skip_on_backends( "toga_textual", - reason="OptionContainer is not implemented on Textual.", + "toga_winui3", + reason="OptionContainer is not implemented on this backend.", allow_module_level=True, ) @@ -68,7 +69,6 @@ async def on_select_handler(): @pytest.fixture async def widget(content1, content2, content3, on_select_handler): - skip_on_backends("toga_winui3") with safe_create(): return toga.OptionContainer( content=[ @@ -89,7 +89,6 @@ async def widget(content1, content2, content3, on_select_handler): # Pass a function here to prevent init of toga.Box() in a different thread than # toga.OptionContainer. This would raise a runtime error on Windows. lambda: toga.OptionContainer(content=[("Tab 1", toga.Box())]), - skip_backends=("toga_winui3",), ) diff --git a/testbed/tests/widgets/test_passwordinput.py b/testbed/tests/widgets/test_passwordinput.py index 6eb6dbd023..e20a276fb1 100644 --- a/testbed/tests/widgets/test_passwordinput.py +++ b/testbed/tests/widgets/test_passwordinput.py @@ -36,14 +36,14 @@ skip_on_backends( "toga_textual", - reason="PasswordInput is not implemented on Textual.", + "toga_winui3", + reason="PasswordInput is not implemented on this backend.", allow_module_level=True, ) @pytest.fixture async def widget(): - skip_on_backends("toga_winui3") return toga.PasswordInput(value="sekrit") @@ -53,7 +53,7 @@ def verify_font_sizes(): return False, True -test_cleanup = build_cleanup_test(toga.PasswordInput, skip_backends=("toga_winui3",)) +test_cleanup = build_cleanup_test(toga.PasswordInput) async def test_value_hidden(widget, probe): diff --git a/testbed/tests/widgets/test_progressbar.py b/testbed/tests/widgets/test_progressbar.py index 942d3fd4c4..a5777e45ce 100644 --- a/testbed/tests/widgets/test_progressbar.py +++ b/testbed/tests/widgets/test_progressbar.py @@ -11,7 +11,8 @@ skip_on_backends( "toga_textual", - reason="ProgressBar is not implemented on Textual.", + "toga_winui3", + reason="ProgressBar is not implemented on this backend.", allow_module_level=True, ) @@ -24,11 +25,10 @@ @pytest.fixture async def widget(): - skip_on_backends("toga_winui3") return toga.ProgressBar(max=100, value=5) -test_cleanup = build_cleanup_test(toga.ProgressBar, skip_backends=("toga_winui3",)) +test_cleanup = build_cleanup_test(toga.ProgressBar) async def test_start_stop_determinate(widget, probe): diff --git a/testbed/tests/widgets/test_scrollcontainer.py b/testbed/tests/widgets/test_scrollcontainer.py index 939272a44e..48ce76b4b5 100644 --- a/testbed/tests/widgets/test_scrollcontainer.py +++ b/testbed/tests/widgets/test_scrollcontainer.py @@ -20,7 +20,8 @@ skip_on_backends( "toga_textual", - reason="ScrollContainer is not implemented on Textual.", + "toga_winui3", + reason="ScrollContainer is not implemented on this backend.", allow_module_level=True, ) @@ -75,7 +76,6 @@ async def on_scroll(): @pytest.fixture async def widget(content, on_scroll): - skip_on_backends("toga_winui3") return toga.ScrollContainer( content=content, style=Pack(flex=1), on_scroll=on_scroll ) @@ -85,7 +85,6 @@ async def widget(content, on_scroll): # Pass a function here to prevent init of toga.Box() in a different thread than # toga.ScrollContainer. This would raise a runtime error on Windows. lambda: toga.ScrollContainer(content=toga.Box()), - skip_backends=("toga_winui3",), ) diff --git a/testbed/tests/widgets/test_selection.py b/testbed/tests/widgets/test_selection.py index 8f9eded008..a202b98eaf 100644 --- a/testbed/tests/widgets/test_selection.py +++ b/testbed/tests/widgets/test_selection.py @@ -23,7 +23,8 @@ skip_on_backends( "toga_textual", - reason="Selection is not implemented on Textual.", + "toga_winui3", + reason="Selection is not implemented on this backend.", allow_module_level=True, ) @@ -45,7 +46,6 @@ @pytest.fixture async def widget(): - skip_on_backends("toga_winui3") return toga.Selection(items=["first", "second", "third"]) @@ -63,7 +63,6 @@ def verify_vertical_text_align(): test_cleanup = build_cleanup_test( toga.Selection, kwargs={"items": ["first", "second", "third"]}, - skip_backends=("toga_winui3",), ) diff --git a/testbed/tests/widgets/test_slider.py b/testbed/tests/widgets/test_slider.py index 7cd467a291..b93a74de3c 100644 --- a/testbed/tests/widgets/test_slider.py +++ b/testbed/tests/widgets/test_slider.py @@ -16,7 +16,8 @@ skip_on_backends( "toga_textual", - reason="Slider is not implemented on Textual.", + "toga_winui3", + reason="Slider is not implemented on this backend.", allow_module_level=True, ) @@ -39,7 +40,6 @@ @fixture async def widget(): - skip_on_backends("toga_winui3") return toga.Slider() @@ -50,7 +50,7 @@ def on_change(widget): return handler -test_cleanup = build_cleanup_test(toga.Slider, skip_backends=("toga_winui3",)) +test_cleanup = build_cleanup_test(toga.Slider) async def test_init(widget, probe): @@ -62,7 +62,6 @@ async def test_init(widget, probe): async def test_init_handlers(): - skip_on_backends("toga_winui3") handlers = { name: Mock(name=name) for name in ["on_change", "on_press", "on_release"] } diff --git a/testbed/tests/widgets/test_splitcontainer.py b/testbed/tests/widgets/test_splitcontainer.py index 661802d149..0aa5f24fd4 100644 --- a/testbed/tests/widgets/test_splitcontainer.py +++ b/testbed/tests/widgets/test_splitcontainer.py @@ -16,8 +16,11 @@ ) skip_on_backends( + "toga_android", + "toga_iOS", "toga_textual", - reason="SplitContainer is not implemented on Textual.", + "toga_winui3", + reason="SplitContainer is not implemented on this backend.", allow_module_level=True, ) @@ -63,7 +66,6 @@ async def content3_probe(content3): @pytest.fixture async def widget(content1, content2): - skip_on_backends("toga_android", "toga_iOS", "toga_winui3") return toga.SplitContainer(content=[content1, content2], style=Pack(flex=1)) @@ -71,7 +73,6 @@ async def widget(content1, content2): # Pass a function here to prevent init of toga.Box() in a different thread than # toga.SplitContainer. This would raise a runtime error on Windows. lambda: toga.SplitContainer(content=[toga.Box(), toga.Box()]), - skip_backends=("toga_android", "toga_iOS", "toga_winui3"), ) diff --git a/testbed/tests/widgets/test_switch.py b/testbed/tests/widgets/test_switch.py index 1d773cc0f5..57a346e772 100644 --- a/testbed/tests/widgets/test_switch.py +++ b/testbed/tests/widgets/test_switch.py @@ -26,17 +26,21 @@ else: from .properties import test_focus # noqa: F401 +skip_on_backends( + "toga_winui3", + reason="Switch is not implemented on this backend.", + allow_module_level=True, +) + @fixture async def widget(): - skip_on_backends("toga_winui3") return toga.Switch("Hello") test_cleanup = build_cleanup_test( toga.Switch, args=("Hello",), - skip_backends=("toga_winui3",), ) diff --git a/testbed/tests/widgets/test_table.py b/testbed/tests/widgets/test_table.py index 11f0438b79..d2c5a7e754 100644 --- a/testbed/tests/widgets/test_table.py +++ b/testbed/tests/widgets/test_table.py @@ -19,8 +19,10 @@ ) skip_on_backends( + "toga_iOS", "toga_textual", - reason="Table is not implemented on Textual.", + "toga_winui3", + reason="Table is not implemented on this backend.", allow_module_level=True, ) @@ -60,7 +62,6 @@ def source(): @pytest.fixture async def widget(source, on_select_handler, on_activate_handler): - skip_on_backends("toga_iOS", "toga_winui3") return toga.Table( ["A", "B", "C"], data=source, @@ -73,7 +74,6 @@ async def widget(source, on_select_handler, on_activate_handler): @pytest.fixture async def headerless_widget(source, on_select_handler): - skip_on_backends("toga_iOS", "toga_winui3") return toga.Table( columns=[ AccessorColumn(None, "a"), @@ -104,7 +104,6 @@ async def headerless_probe(main_window, headerless_widget): @pytest.fixture async def multiselect_widget(source, on_select_handler): - skip_on_backends("toga_iOS", "toga_winui3") return toga.Table( ["A", "B", "C"], data=source, @@ -131,7 +130,6 @@ async def multiselect_probe(main_window, multiselect_widget): test_cleanup = build_cleanup_test( toga.Table, kwargs={"columns": ["A", "B", "C"]}, - skip_backends=("toga_iOS", "toga_winui3"), ) diff --git a/testbed/tests/widgets/test_textinput.py b/testbed/tests/widgets/test_textinput.py index 1c24c5b9e3..e4a550e21a 100644 --- a/testbed/tests/widgets/test_textinput.py +++ b/testbed/tests/widgets/test_textinput.py @@ -29,10 +29,15 @@ test_text_align, ) +skip_on_backends( + "toga_winui3", + reason="TextInput is not implemented on this backend.", + allow_module_level=True, +) + @pytest.fixture async def widget(): - skip_on_backends("toga_winui3") return toga.TextInput(value="Hello") @@ -57,7 +62,7 @@ async def placeholder(request, widget): widget.placeholder = request.param -test_cleanup = build_cleanup_test(toga.TextInput, skip_backends=("toga_winui3",)) +test_cleanup = build_cleanup_test(toga.TextInput) async def test_value_not_hidden(widget, probe): @@ -114,7 +119,6 @@ async def test_on_change_user(widget, probe, on_change): async def test_on_change_user_after_initial_value(main_window): "User input triggers on_change after setting the initial value before mounting." - skip_on_backends("toga_winui3") old_content = main_window.content widget = toga.TextInput(value="Hello") on_change = Mock() diff --git a/testbed/tests/widgets/test_timeinput.py b/testbed/tests/widgets/test_timeinput.py index c99532a766..48aa9d3d15 100644 --- a/testbed/tests/widgets/test_timeinput.py +++ b/testbed/tests/widgets/test_timeinput.py @@ -25,8 +25,10 @@ ) skip_on_backends( + "toga_gtk", "toga_textual", - reason="TimeInput is not implemented on Textual.", + "toga_winui3", + reason="TimeInput is not implemented on this backend.", allow_module_level=True, ) @@ -82,19 +84,14 @@ def normalize_time(value): @fixture async def widget(): - skip_on_backends("toga_gtk", "toga_winui3") return toga.TimeInput() -test_cleanup = build_cleanup_test( - toga.TimeInput, - skip_backends=("toga_gtk", "toga_winui3"), -) +test_cleanup = build_cleanup_test(toga.TimeInput) async def test_init(normalize): "Properties can be set in the constructor" - skip_on_backends("toga_gtk", "toga_winui3") value = time(10, 10, 30) min = time(2, 3, 4) diff --git a/testbed/tests/widgets/test_tree.py b/testbed/tests/widgets/test_tree.py index e5eaa7fe13..71f47b678f 100644 --- a/testbed/tests/widgets/test_tree.py +++ b/testbed/tests/widgets/test_tree.py @@ -20,8 +20,11 @@ ) skip_on_backends( + "toga_android", + "toga_iOS", "toga_textual", - reason="Tree is not implemented on Textual.", + "toga_winui3", + reason="Tree is not implemented on this backend.", allow_module_level=True, ) @@ -113,7 +116,6 @@ def source(): @pytest.fixture async def widget(source, on_select_handler, on_activate_handler): - skip_on_backends("toga_android", "toga_iOS", "toga_winui3") return toga.Tree( ["A", "B", "C"], data=source, @@ -126,7 +128,6 @@ async def widget(source, on_select_handler, on_activate_handler): @pytest.fixture async def headerless_widget(source, on_select_handler): - skip_on_backends("toga_android", "toga_iOS", "toga_winui3") return toga.Tree( columns=[ AccessorColumn(None, "a"), @@ -158,7 +159,6 @@ async def headerless_probe(main_window, headerless_widget): @pytest.fixture async def multiselect_widget(source, on_select_handler): # Although Android *has* a table implementation, it needs to be rebuilt. - skip_on_backends("toga_android", "toga_iOS", "toga_winui3") return toga.Tree( ["A", "B", "C"], data=source, @@ -185,7 +185,6 @@ async def multiselect_probe(main_window, multiselect_widget): test_cleanup = build_cleanup_test( toga.Tree, kwargs={"columns": ["A", "B", "C"]}, - skip_backends=("toga_android", "toga_iOS", "toga_winui3"), ) diff --git a/testbed/tests/widgets/test_webview.py b/testbed/tests/widgets/test_webview.py index 84b426f738..08bcd5989c 100644 --- a/testbed/tests/widgets/test_webview.py +++ b/testbed/tests/widgets/test_webview.py @@ -19,7 +19,8 @@ skip_on_backends( "toga_textual", - reason="WebView is not implemented on Textual.", + "toga_winui3", + reason="WebView is not implemented on this backend.", allow_module_level=True, ) @@ -97,7 +98,6 @@ async def on_load(): @pytest.fixture async def widget(on_load): - skip_on_backends("toga_winui3") with safe_create(): widget = toga.WebView(style=Pack(flex=1), on_webview_load=on_load) @@ -139,7 +139,6 @@ async def widget(on_load): test_cleanup = build_cleanup_test( toga.WebView, xfail_backends=("toga_gtk",), - skip_backends=("toga_winui3",), ) diff --git a/testbed/tests/window/test_dialogs.py b/testbed/tests/window/test_dialogs.py index 0f461594db..d5f77d2075 100644 --- a/testbed/tests/window/test_dialogs.py +++ b/testbed/tests/window/test_dialogs.py @@ -14,14 +14,12 @@ skip_on_backends( "toga_textual", - reason="Dialogs are not implemented on Textual.", + "toga_winui3", + reason="Dialogs are not implemented on this backend.", allow_module_level=True, ) -skip_on_backends("toga_winui3", allow_module_level=True) - - @pytest.fixture async def wait_for_dialog_to_close(main_window): """Wait for any asyncio task that is responsible for closing the dialog. From 2c2367ea3d794d42ba1af8d51299b9b409097af0 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:18:28 +0200 Subject: [PATCH 096/110] Property staging revamp and other minor changes --- .../tests/widgets/test_multilinetextinput.py | 5 +- winui3/src/toga_winui3/widgets/base.py | 8 +- .../toga_winui3/widgets/properties/staged.py | 179 +++++++++++------- 3 files changed, 115 insertions(+), 77 deletions(-) diff --git a/testbed/tests/widgets/test_multilinetextinput.py b/testbed/tests/widgets/test_multilinetextinput.py index b3c6ec7b74..9e76ae167a 100644 --- a/testbed/tests/widgets/test_multilinetextinput.py +++ b/testbed/tests/widgets/test_multilinetextinput.py @@ -56,10 +56,7 @@ def verify_font_sizes(): return False, False -test_cleanup = build_cleanup_test( - toga.MultilineTextInput, - skip_backends=("toga_winui3",), -) +test_cleanup = build_cleanup_test(toga.MultilineTextInput) async def test_scroll_position(widget, probe): diff --git a/winui3/src/toga_winui3/widgets/base.py b/winui3/src/toga_winui3/widgets/base.py index 7b25cb3f05..1404b2b627 100644 --- a/winui3/src/toga_winui3/widgets/base.py +++ b/winui3/src/toga_winui3/widgets/base.py @@ -31,7 +31,10 @@ def __init__(self, interface): self.create() @abstractmethod - def create(self): ... + def create(self): + ... + # Note: Use self.native_cls = NativeClass. This will instantiate self.native and + # means that events are managed by the nativeevents module. def set_app(self, app): # Everything is already handled by the Toga core interface. @@ -52,12 +55,13 @@ def container(self): @container.setter def container(self, container): if self._container: + self._staged_properties.deactivate() self._container.widgets.remove(self) self._container = container if container: container.widgets.add(self) - self._staged_properties.refresh() + self._staged_properties.activate() for child in self.interface.children: child._impl.container = container diff --git a/winui3/src/toga_winui3/widgets/properties/staged.py b/winui3/src/toga_winui3/widgets/properties/staged.py index 50c5f9606f..e6e27c4f08 100644 --- a/winui3/src/toga_winui3/widgets/properties/staged.py +++ b/winui3/src/toga_winui3/widgets/properties/staged.py @@ -1,8 +1,10 @@ -import weakref +from typing import ClassVar from win32more.Microsoft.UI.Xaml.Controls import RelativePanel from win32more.Windows.UI.Text import FontStyle +from .native import NativeProperties + """ Overview of content staging @@ -40,107 +42,142 @@ def __init__(self, container): self.native = RelativePanel() self.native.Opacity = 0 - self._native_widgets = [] + self._staging_clones = [] # Add the container self._container = container self._container.widgets.add(self) - def add(self, native_widget): - self._native_widgets.append(native_widget) - self.native.Children.Append(native_widget) + def add(self, staging_clone): + self._staging_clones.append(staging_clone) + self.native.Children.Append(staging_clone.native) - def remove(self, native_widget): + def remove(self, staging_clone): """Removes a widget and triggers a layout refresh.""" - index = self._native_widgets.index(native_widget) - self._native_widgets.remove(native_widget) + index = self._staging_clones.index(staging_clone) + self._staging_clones.remove(staging_clone) self.native.Children.RemoveAt(index) - # It is possible that self._container._content was removed during the staging - # process. It is difficult to reliably create this scenario during testing, so - # use no branch here. - if self._container._content: # pragma: no branch - self._container._content.interface.refresh() +class StagingClone: + """A facsimile of a widget that resizes to fit its content and reports its size. -class StagedProperties: - def __init__(self, widget): + Note that a new SizeChanged callback is created when a property is updated during + an incomplete staging process. This is because an event callback could already be in + the queue when a property is updated. + """ + + def __init__(self, widget, properties): self._widget = widget - self._staged_properties = {} - self._latest = None + self._removed = False + self._latest_callback_id = 0 - self._font_keys = {"FontFamily", "FontSize", "FontStyle", "FontWeight"} + self.native = type(self._widget.native)() + self.native.event_handler.SizeChanged += self.create_size_changed_callback() + self._native_properties = NativeProperties(self) - def __setattr__(self, name, value): - """Sets the native property value for a name with a capital first character. + for property, value_creator in properties.items(): + value = value_creator() + if value is not None: + setattr(self.native, property, value) - Note that the 'value' of a staged property must be a 'value creator' callable - that creates a new instance of the desired content. + self._widget.container.staging_area.add(self) + + def stage_property(self, name, value): + self.native.event_handler.SizeChanged.clear() + self.native.event_handler.SizeChanged += self.create_size_changed_callback() + setattr(self._native_properties, name, value()) + + def remove(self): + """Remove the clone from the staging process. + + This method is called by the SizeChanged event and when the associated widget is + removed from its container. """ - if not name[0].isupper(): - super().__setattr__(name, value) - return + self._widget._staged_properties._clone = None + self._widget.container.staging_area.remove(self) + self._removed = True - # Set and cache the native property. - setattr(self._widget._native_properties, name, value()) - self._staged_properties[name] = value + def create_size_changed_callback(self): + self._latest_callback_id += 1 - self.refresh() + def size_changed_callback(sender, args, callback_id=self._latest_callback_id): + if callback_id != self._latest_callback_id: + return - def refresh(self): - if not self._widget.container: - return + if self._removed: + return - # The properties in self._font_keys are only staged if other content such as - # text is being staged as well. - if set(self._staged_properties.keys()) - self._font_keys == set(): - return + self._widget._min_width = self._adjusted_width(self.native) + self._widget._min_height = self.native.ActualSize.Y + self._widget.rehint() + self._widget.container._content.interface.refresh() + + self.remove() - widget = self._widget - clone = type(widget.native)() - staging_area = widget.container.staging_area - self._latest = clone + return size_changed_callback - # Use a weak reference so that the external process doesn't prevent garbage - # collection. - clone_weak = weakref.ref(clone) - area_weak = weakref.ref(staging_area) + def _adjusted_width(self, native): + # FIXME: The staging method doesn't calculate a large enough width for italic + # and oblique font styles. Add 0.25em for each of these. + if native.FontStyle in {FontStyle.Oblique, FontStyle.Italic}: + font_size = native.FontSize + return native.ActualSize.X + round(font_size * 96 / 72 / 4, 0) - def size_changed(sender, args, clone_weak=clone_weak, area_weak=area_weak): - self.native_event_size_changed(sender, args, clone_weak, area_weak) + return native.ActualSize.X - clone.event_handler.SizeChanged += size_changed - for attribute, value_creator in self._staged_properties.items(): - value = value_creator() - if value is not None: - setattr(clone, attribute, value) +class StagedProperties: + _font_properties: ClassVar = {"FontFamily", "FontSize", "FontStyle", "FontWeight"} - staging_area.add(clone) + def __init__(self, widget): + self._widget = widget + self._clone = None + self._properties_dict = {} + + self._initialized = False + self._active = False + + def __setattr__(self, name, value): + """Sets the native property value for a name with a capital first character. - def native_event_size_changed(self, sender, args, clone_weak, area_weak): - clone = clone_weak() - staging_area = area_weak() + Note that the 'value' of a staged property must be a 'value creator' callable + that creates a new instance of the desired content. + """ + if not name[0].isupper(): + super().__setattr__(name, value) + return - # If the clone or staging area no longer exist then do nothing. This is not - # reliably hit during testing, so use no cover. - if not clone or not staging_area: # pragma: no cover + # Set the property for the widget and add it to the properties dict. + setattr(self._widget._native_properties, name, value()) + self._properties_dict[name] = value + + # Font properties in the widget base are set using this class, but the widget + # may not require staging. So, only initialize the staging process if another + # property has been explicitly staged. + if not self._initialized: + if name in self._font_properties: + return + else: + self._initialized = True + + if not self._active: return - if clone == self._latest: - self._widget._min_width = self._adjusted_width(clone) - self._widget._min_height = clone.ActualSize.Y - self._widget.rehint() + # Only one clone of the widget exists at any given time. + if not self._clone: + self._clone = StagingClone(self._widget, self._properties_dict) - self._latest = None + self._clone.stage_property(name, value) - staging_area.remove(clone) + def activate(self): + self._active = True - def _adjusted_width(self, clone): - # FIXME: The staging method doesn't calculate a large enough width for italic - # and oblique font styles. Add 0.25em for each of these. - if clone.FontStyle in {FontStyle.Oblique, FontStyle.Italic}: - font_size = clone.FontSize - return clone.ActualSize.X + round(font_size * 96 / 72 / 4, 0) + if self._initialized: + self._clone = StagingClone(self._widget, self._properties_dict) + + def deactivate(self): + self._active = False - return clone.ActualSize.X + if self._clone: + self._clone.remove() From 98cb359a4c09904a236352bce368d362017ea0bd Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:21:58 +0200 Subject: [PATCH 097/110] Improve the testing of property staging --- android/tests_backend/widgets/base.py | 2 +- cocoa/tests_backend/widgets/base.py | 2 +- gtk/tests_backend/widgets/base.py | 2 +- iOS/tests_backend/widgets/base.py | 2 +- qt/tests_backend/widgets/base.py | 2 +- testbed/tests/test_fonts.py | 6 +- testbed/tests/widgets/test_base.py | 4 +- textual/tests_backend/widgets/base.py | 2 +- winforms/tests_backend/widgets/base.py | 2 +- winui3/tests_backend/probe.py | 10 +- winui3/tests_backend/widgets/base.py | 130 +++++++++++++++++++++++++ 11 files changed, 153 insertions(+), 11 deletions(-) diff --git a/android/tests_backend/widgets/base.py b/android/tests_backend/widgets/base.py index 8687547d0d..a895595edc 100644 --- a/android/tests_backend/widgets/base.py +++ b/android/tests_backend/widgets/base.py @@ -192,7 +192,7 @@ async def undo(self): async def redo(self): pytest.skip("Redo not supported on this platform") - def assert_native_properties(self): + async def assert_backend_specific_properties(self): pytest.skip("Test not implemented for this platform") diff --git a/cocoa/tests_backend/widgets/base.py b/cocoa/tests_backend/widgets/base.py index 103d579cdc..7b9c697f19 100644 --- a/cocoa/tests_backend/widgets/base.py +++ b/cocoa/tests_backend/widgets/base.py @@ -226,5 +226,5 @@ async def undo(self): async def redo(self): await self.type_character("z", alt=True, shift=True) - def assert_native_properties(self): + async def assert_backend_specific_properties(self): skip("Test not implemented for this platform") diff --git a/gtk/tests_backend/widgets/base.py b/gtk/tests_backend/widgets/base.py index 25d5f08d0c..1b78084fd4 100644 --- a/gtk/tests_backend/widgets/base.py +++ b/gtk/tests_backend/widgets/base.py @@ -242,5 +242,5 @@ async def undo(self): async def redo(self): pytest.skip("Redo not supported on this platform") - def assert_native_properties(self): + async def assert_backend_specific_properties(self): pytest.skip("Test not implemented for this platform") diff --git a/iOS/tests_backend/widgets/base.py b/iOS/tests_backend/widgets/base.py index 326ff289bc..546b21e142 100644 --- a/iOS/tests_backend/widgets/base.py +++ b/iOS/tests_backend/widgets/base.py @@ -178,5 +178,5 @@ async def undo(self): async def redo(self): pytest.skip("Redo not supported on this platform") - def assert_native_properties(self): + async def assert_backend_specific_properties(self): pytest.skip("Test not implemented for this platform") diff --git a/qt/tests_backend/widgets/base.py b/qt/tests_backend/widgets/base.py index 9f4227439d..929a96b17e 100644 --- a/qt/tests_backend/widgets/base.py +++ b/qt/tests_backend/widgets/base.py @@ -105,5 +105,5 @@ async def undo(self): async def redo(self): await self.type_character("z", ctrl=True, shift=True) - def assert_native_properties(self): + async def assert_backend_specific_properties(self): pytest.skip("Test not implemented for this platform") diff --git a/testbed/tests/test_fonts.py b/testbed/tests/test_fonts.py index f1ba5c3831..7512deedac 100644 --- a/testbed/tests/test_fonts.py +++ b/testbed/tests/test_fonts.py @@ -3,6 +3,7 @@ import pytest import toga +from toga.colors import AQUAMARINE from toga.fonts import ( BOLD, FONT_STYLES, @@ -30,7 +31,10 @@ # Fully testing fonts requires a manifested widget. @pytest.fixture async def widget(): - return toga.Label("This is a font test") + label = toga.Label("This is a font test") + # Add a background color to see if the label is resized correctly. + label.style.background_color = AQUAMARINE + return label @pytest.fixture diff --git a/testbed/tests/widgets/test_base.py b/testbed/tests/widgets/test_base.py index 6e7b677f32..fbec17cf6c 100644 --- a/testbed/tests/widgets/test_base.py +++ b/testbed/tests/widgets/test_base.py @@ -173,5 +173,5 @@ async def test_tab_index(widget, probe, other): assert other.tab_index is None -async def test_native_properties(widget, probe): - probe.assert_native_properties() +async def test_backend_specific_properties(widget, probe): + await probe.assert_backend_specific_properties() diff --git a/textual/tests_backend/widgets/base.py b/textual/tests_backend/widgets/base.py index 2397ef68c5..f175b21732 100644 --- a/textual/tests_backend/widgets/base.py +++ b/textual/tests_backend/widgets/base.py @@ -123,7 +123,7 @@ async def undo(self): async def redo(self): pytest.skip("Redo is not implemented on Textual probes.") - def assert_native_properties(self): + async def assert_backend_specific_properties(self): pytest.skip("Test not implemented for this platform") diff --git a/winforms/tests_backend/widgets/base.py b/winforms/tests_backend/widgets/base.py index e25b6a659d..598c6e6cf8 100644 --- a/winforms/tests_backend/widgets/base.py +++ b/winforms/tests_backend/widgets/base.py @@ -105,7 +105,7 @@ async def undo(self): async def redo(self): pytest.skip("Redo not supported on this platform") - def assert_native_properties(self): + async def assert_backend_specific_properties(self): pytest.skip("Test not implemented for this platform") def assert_tab_index(self, widget, other): diff --git a/winui3/tests_backend/probe.py b/winui3/tests_backend/probe.py index c7c45b730b..660ac2242c 100644 --- a/winui3/tests_backend/probe.py +++ b/winui3/tests_backend/probe.py @@ -44,7 +44,7 @@ async def redraw_staging(self): def staging_complete(): for staging_area in staging_areas: - if len(staging_area._native_widgets) > 0: + if len(staging_area._staging_clones) > 0: return False return True @@ -53,6 +53,14 @@ def staging_complete(): if staging_complete(): break await asyncio.sleep(0.02) + else: + message = "Non-empty StagingArea:\n" + for staging_area in staging_areas: + if len(staging_area._staging_clones) > 0: + message += str(staging_area) + "\n" + message += str(staging_area._staging_clones) + "\n" + + raise ValueError(message) async def redraw_resizing(self): """Wait until any resizing is finished.""" diff --git a/winui3/tests_backend/widgets/base.py b/winui3/tests_backend/widgets/base.py index 5e7e000a93..ecfedca06c 100644 --- a/winui3/tests_backend/widgets/base.py +++ b/winui3/tests_backend/widgets/base.py @@ -1,8 +1,12 @@ +from unittest.mock import Mock + from pytest import approx from win32more.Microsoft.UI.Xaml import FocusState, Visibility from win32more.Windows.Foundation import Rect from win32more.Windows.Win32.UI.Input.KeyboardAndMouse import GetFocus +import toga + from ..fonts import FontMixin from ..probe import BaseProbe from .properties import brush_to_color @@ -156,6 +160,132 @@ def assert_native_properties(self): # being None. assert self.native.Resources is None + async def assert_staged_properties_containerless(self, staging_area): + """Test that there is no staging for a widget with no container.""" + mock = Mock() + + def callback_mock(sender, args): + mock() + + await self.redraw("Creating Label widget.") + + label = toga.Label("Label text") + staged_properties = label._impl._staged_properties + + # After creating label, but not adding it to a container, there should be no + # properties being staged. + assert len(staging_area._staging_clones) == 0 + assert staged_properties._clone is None + + # Adding the label as a child should initiate the label properties being staged. + self.widget.add(label) + label_clone = staging_area._staging_clones[0] + label_clone.native.event_handler.SizeChanged += callback_mock + + assert len(staging_area._staging_clones) == 1 + assert staged_properties._clone == label_clone + + # Immediately remove the label from the widget. The staging process should be + # removed. + self.widget.remove(label) + assert len(staging_area._staging_clones) == 0 + assert staged_properties._clone is None + + # Since the label_clone has been removed from the visual tree, the native + # SizeChanged event should not fire. + await self.redraw("Label added to and removed from a container.", delay=0.1) + mock.assert_not_called() + mock.reset_mock() + + async def assert_staged_properties_same_value(self, staging_area): + """Staging property with the same value is a no-op or triggers SizeChanged.""" + # Create a widget and set some style properties. + properties = { + "text": "Label text", + "font_family": "serif", + "font_size": 20, + "font_style": "italic", + "font_weight": "bold", + } + + def set_property(label, name): + if name == "text": + setattr(label, name, properties[name]) + else: + setattr(label.style, name, properties[name]) + + label = toga.Label(text="") + for name in properties: + set_property(label, name) + + self.widget.add(label) + + await self.redraw("Label widget created and added to a container.") + # Staging should be complete. + assert len(staging_area._staging_clones) == 0 + + for name in properties: + set_property(label, name) + + if name == "text": + # For `text` the staging process starts and completes. + assert len(staging_area._staging_clones) == 1 + + await self.redraw(f"Label.{name} has re-set to the same value.") + assert len(staging_area._staging_clones) == 0 + else: + # For font style attributes the staging process is a no-op. + assert len(staging_area._staging_clones) == 0 + + async def assert_staged_properties_events(self, staging_area): + await self.redraw("Creating Label widget.") + label = toga.Label("Label text") + self.widget.add(label) + + # Get the widget clone from the staging process, and assert that only one + # SizeChanged callback has been created. + label_clone = staging_area._staging_clones[0] + assert label_clone._latest_callback_id == 1 + + # Save the current SizeChanged callback. + native_event = label_clone.native.event_handler.SizeChanged + _, callback = next(iter(native_event._registry.values())) + + # Staging another property creates a new callback and clears the old. + label.style.font_weight = "bold" + _, new_callback = next(iter(native_event._registry.values())) + assert len(staging_area._staging_clones) == 1 + assert label_clone._latest_callback_id > 1 + assert new_callback != callback + + # Simluate the old callback being called. The could occur if it was already in + # the queue when the new property was staged. Assert that the staging process + # is not completed by this call. + callback(sender=None, args=None) + assert len(staging_area._staging_clones) == 1 + + # Assert that the staging process is finished after a small wait. + await self.redraw("Staging process completed with extra callback.", delay=0.1) + assert len(staging_area._staging_clones) == 0 + + # Simulate a callback after the staging process is finished. This can occur if + # a callback was already in the queue when the widget was removed from its + # container. This call should not result in any errors. + new_callback(sender=None, args=None) + + async def assert_staged_properties(self): + """Test whether staged properties are created and deleted correctly.""" + staging_area = self.widget._impl.container.staging_area + + await self.assert_staged_properties_containerless(staging_area) + await self.assert_staged_properties_same_value(staging_area) + await self.assert_staged_properties_events(staging_area) + + async def assert_backend_specific_properties(self): + self.assert_native_properties() + + await self.assert_staged_properties() + def assert_tab_index(self, widget, other): # Unset WinUI 3 tab indices default to Int32_MaxValue. Int32_MaxValue = 2**31 - 1 From 816112bbabdba99562c27a90cfd98a4a60aa6a56 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:54:07 +0200 Subject: [PATCH 098/110] Improve backend for `test_menu_visit_homepage` --- winui3/tests_backend/app.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/winui3/tests_backend/app.py b/winui3/tests_backend/app.py index 4cf077afdd..40e1423e5b 100644 --- a/winui3/tests_backend/app.py +++ b/winui3/tests_backend/app.py @@ -101,6 +101,14 @@ async def _menu_item(self, path, open_menus=False): item = self.main_window._impl.menu_native for i, label in enumerate(path): + if open_menus: + for _ in range(50): + if item.IsLoaded: + break + await asyncio.sleep(0.02) + else: + raise ValueError(f"The menu item {item} was never loaded.") + children, child_labels = self._menu_children(item) try: From d36dff80d5433ad21f5be71cca687f1fad048beb Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Thu, 6 Aug 2026 08:47:58 +0200 Subject: [PATCH 099/110] Improve backend for `test_menu_visit_homepage`, II --- winui3/tests_backend/app.py | 54 +++++++++++++++++++++++++---------- winui3/tests_backend/probe.py | 2 +- 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/winui3/tests_backend/app.py b/winui3/tests_backend/app.py index 40e1423e5b..aaceb8c5a3 100644 --- a/winui3/tests_backend/app.py +++ b/winui3/tests_backend/app.py @@ -93,6 +93,43 @@ def _menu_children(self, menu): child_labels = [self._menu_item_label(child) for child in children] return children, child_labels + async def _menu_item_open_or_select(self, item, select: bool): + # Wait to enure that the item can receive the input focus. + for _ in range(50): + item.Focus(FocusState.Programmatic) + + if item.FocusState != FocusState.Unfocused: + break + await asyncio.sleep(0.01) + else: + raise ValueError(f"{item} was never given the input focus.") + + if not select: + await self._keyboard_select() + return + + # Make a mutable boolean. + item_selected = [False] + + def callback(sender, args, item_selected=item_selected): + item_selected[0] = True + + # A selectable final menu item is always of type MenuFlyoutItem + selectable_item: MenuFlyoutItem = self._menu_item_casted(item) + token = selectable_item.add_Click(callback) + + await self._keyboard_select() + + # Wait to enure that the item has been selected. + for _ in range(50): + if item_selected[0]: + selectable_item.remove_Click(token) + break + await asyncio.sleep(0.01) + else: + selectable_item.remove_Click(token) + raise ValueError(f"{item} was never selected.") + async def _menu_item(self, path, open_menus=False): """Select a menu item with the given path.""" # Note that retrieving a submenu's items via menu.Items gives a list of @@ -101,14 +138,6 @@ async def _menu_item(self, path, open_menus=False): item = self.main_window._impl.menu_native for i, label in enumerate(path): - if open_menus: - for _ in range(50): - if item.IsLoaded: - break - await asyncio.sleep(0.02) - else: - raise ValueError(f"The menu item {item} was never loaded.") - children, child_labels = self._menu_children(item) try: @@ -121,10 +150,8 @@ async def _menu_item(self, path, open_menus=False): item = children[child_index] if open_menus: - item.Focus(FocusState.Programmatic) - await self._keyboard_select() + await self._menu_item_open_or_select(item, len(path) == i + 1) - # A selectable final menu item is always of type MenuFlyoutItem return item def _menu_item_label(self, menu_item): @@ -246,8 +273,6 @@ def _is_cursor_visible_non_client(self): if not GetCursorInfo(byref(cursor_info)): raise RuntimeError("GetCursorInfo failed") - print(f"cursor_info.flags = {cursor_info.flags}") - # Visibility *should* be exposed by CursorInfo.flags; but in CI, # CursorInfo.flags returns 2 ("the system is not drawing the cursor # because the user is providing input through touch or pen instead of @@ -369,8 +394,7 @@ def get_midpoint(): await asyncio.sleep(0.05) new_midpoint = get_midpoint() - print(f"StatusIcon - old_midpoint={midpoint}") - print(f"StatusIcon - new_midpoint={new_midpoint}") + if midpoint == new_midpoint: break midpoint = new_midpoint diff --git a/winui3/tests_backend/probe.py b/winui3/tests_backend/probe.py index 660ac2242c..3c21ca32f7 100644 --- a/winui3/tests_backend/probe.py +++ b/winui3/tests_backend/probe.py @@ -164,7 +164,7 @@ async def _send_key(self, key_code, down=True, up=True): key_input.Anonymous.ki.dwFlags = KEYEVENTF_KEYUP self._send_input(key_input) - await asyncio.sleep(0.1) + await asyncio.sleep(0.05) async def _keyboard_select(self): await self._send_key(VK_RETURN) From 9889c9359a932de655688b6b90af2a2da11e1b96 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:45:22 +0200 Subject: [PATCH 100/110] Improve menu item selection on testbed backend --- winui3/tests_backend/app.py | 44 +++++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/winui3/tests_backend/app.py b/winui3/tests_backend/app.py index aaceb8c5a3..b78ec91a05 100644 --- a/winui3/tests_backend/app.py +++ b/winui3/tests_backend/app.py @@ -93,18 +93,22 @@ def _menu_children(self, menu): child_labels = [self._menu_item_label(child) for child in children] return children, child_labels - async def _menu_item_open_or_select(self, item, select: bool): - # Wait to enure that the item can receive the input focus. - for _ in range(50): - item.Focus(FocusState.Programmatic) + async def _menu_item_open_or_select(self, path, item, final_select: bool): + # Wait to enure that the item has received the input focus. + count = 0 + focus_state = FocusState.Unfocused - if item.FocusState != FocusState.Unfocused: - break + while focus_state == FocusState.Unfocused and count < 50: + item.Focus(FocusState.Programmatic) + count += 1 + focus_state = item.FocusState await asyncio.sleep(0.01) - else: - raise ValueError(f"{item} was never given the input focus.") - if not select: + if focus_state == FocusState.Unfocused: + raise ValueError(f"Menu item {path} was never given the input focus.") + + # If a final menu items is not being selected then open the next submenu. + if not final_select: await self._keyboard_select() return @@ -121,13 +125,15 @@ def callback(sender, args, item_selected=item_selected): await self._keyboard_select() # Wait to enure that the item has been selected. - for _ in range(50): - if item_selected[0]: - selectable_item.remove_Click(token) - break + count = 0 + + while not item_selected[0] and count < 50: + count += 1 await asyncio.sleep(0.01) - else: - selectable_item.remove_Click(token) + + selectable_item.remove_Click(token) + + if not item_selected[0]: raise ValueError(f"{item} was never selected.") async def _menu_item(self, path, open_menus=False): @@ -144,13 +150,17 @@ async def _menu_item(self, path, open_menus=False): child_index = child_labels.index(label) except ValueError: raise AssertionError( - f"no item named {path[: i + 1]}; options are {child_labels}" + f"No item named {path[: i + 1]}; options are {child_labels}" ) from None item = children[child_index] if open_menus: - await self._menu_item_open_or_select(item, len(path) == i + 1) + await self._menu_item_open_or_select( + path[: i + 1], + item, + len(path) == i + 1, + ) return item From 8c25963cc28694f770696e75af42b2a9e06262ee Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:24:23 +0200 Subject: [PATCH 101/110] Debug testbed instability, I --- testbed/tests/testbed.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/testbed/tests/testbed.py b/testbed/tests/testbed.py index 5f6267d56b..4af9e9f941 100644 --- a/testbed/tests/testbed.py +++ b/testbed/tests/testbed.py @@ -16,6 +16,8 @@ def run_tests(app, cov, args, report_coverage, run_slow, running_in_ci): try: + import toga + # Wait for the app's main window to be visible. The visibility property # is set by the app in an on_running handler; this is required because # visibility is a GUI property, and accessing that property from a @@ -30,6 +32,16 @@ def run_tests(app, cov, args, report_coverage, run_slow, running_in_ci): if not ready: print("\nApp didn't display a main window.") + if toga.backend == "toga_winui3": + ready_append = app.loop._ready.append + + def append(value): + ready_append(value) + print(app.loop._ready) + + app.loop._ready.append = append + app.loop.call_soon_threadsafe(lambda: print("\nDEBUG - Loop running\n")) + app.returncode = 1 return @@ -37,7 +49,9 @@ def run_tests(app, cov, args, report_coverage, run_slow, running_in_ci): # Some backends and platforms do not support interactive GUI testing. # On those platforms, perform a basic app start test. - import toga + + if toga.backend == "toga_winui3": + print(f"toga_winui3 startup time = {0.05 * i}s") if ( # On GitHub Actions, Windows/ARM64 runners don't have an interactive From 961684d958f3ae74f6622f5f6e0fc15201c20fb1 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:41:57 +0200 Subject: [PATCH 102/110] Debug testbed instability, II --- testbed/tests/testbed.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/testbed/tests/testbed.py b/testbed/tests/testbed.py index 4af9e9f941..88634e2c97 100644 --- a/testbed/tests/testbed.py +++ b/testbed/tests/testbed.py @@ -25,7 +25,7 @@ def run_tests(app, cov, args, report_coverage, run_slow, running_in_ci): print("Waiting for app to be ready for testing... ", end="", flush=True) i = 0 ready = False - while i < 100 and not ready: + while i < 200 and not ready: time.sleep(0.05) ready = getattr(app, "is_visible", False) i += 1 @@ -42,6 +42,8 @@ def append(value): app.loop._ready.append = append app.loop.call_soon_threadsafe(lambda: print("\nDEBUG - Loop running\n")) + time.sleep(1) + app.returncode = 1 return @@ -53,6 +55,9 @@ def append(value): if toga.backend == "toga_winui3": print(f"toga_winui3 startup time = {0.05 * i}s") + else: + raise ValueError("DEBUG - only running WinUI 3 tested.") + if ( # On GitHub Actions, Windows/ARM64 runners don't have an interactive # logon session, so you can't run most of the GUI tests. For details, From 92eb3fb8cf1578f4f02d05d338b222690ba8e94c Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:52:46 +0200 Subject: [PATCH 103/110] Debug testbed instability, III --- core/src/toga/app.py | 17 +++++++++-------- testbed/src/testbed/app.py | 1 + testbed/tests/testbed.py | 2 ++ winui3/src/toga_winui3/window.py | 3 +++ 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/core/src/toga/app.py b/core/src/toga/app.py index 616afeb36d..91145ac3ed 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -641,21 +641,22 @@ def _create_initial_windows(self): ) def _startup(self) -> None: + print("app._startup() - start") # Wrap the platform's event loop's task factory for task tracking self._install_task_factory_wrapper() - + print("app._startup() - factory wrapper installed") # Install the standard commands. This is done *before* startup so the user's # code has the opportunity to remove/change the default commands. self._create_standard_commands() self._impl.create_standard_commands() - + print("app._startup() - standard commands created (app)") # Install the standard status icon commands. Again, this is done *before* # startup so that the user's code can remove/change the defaults. self.status_icons._create_standard_commands() - + print("app._startup() - standard commands created (status icons)") # Invoke the user's startup method (or the default implementation) self.startup() - + print("app._startup() - app.startup() finished") # Validate that the startup requirements have been met. # Accessing the main window attribute will raise an exception if the app hasn't # defined a main window. @@ -663,18 +664,18 @@ def _startup(self) -> None: # Create any initial windows self._create_initial_windows() - + print("app._startup() - initial windows created") # Manifest the initial state of the menus. This will cascade down to all # open windows if the platform has window-based menus. Then install the # on-change handler for menus to respond to any future changes. self._impl.create_menus() self.commands.on_change = self._impl.create_menus - + print("app._startup() - menus created") # Manifest the initial state of the status icons, then install an on-change # handler so that any future changes will be reflected in the GUI. self.status_icons._impl.create() self.status_icons.commands.on_change = self.status_icons._impl.create - + print("app._startup() - status icons created") # Manifest the initial state of toolbars (on the windows that have # them), then install a change listener so that any future changes to # the toolbar cause a change in toolbar items. @@ -682,7 +683,7 @@ def _startup(self) -> None: if hasattr(window, "toolbar"): window._impl.create_toolbar() window.toolbar.on_change = window._impl.create_toolbar - + print("app._startup() - toolbars created") # Queue a task to run as soon as the event loop starts. self.loop.call_soon_threadsafe(wrapped_handler(self, self.on_running)) diff --git a/testbed/src/testbed/app.py b/testbed/src/testbed/app.py index 81e41a9a81..eb5da19576 100644 --- a/testbed/src/testbed/app.py +++ b/testbed/src/testbed/app.py @@ -228,6 +228,7 @@ async def on_running(self): # The NoQA is warning about using sleep in a loop, which would be good advice # if there was an underlying Event that we could await - but there isn't. try: + print("\napp.on_running()\n") async with asyncio.timeout(10): while not self.main_window.visible: # noqa: ASYNC110 await asyncio.sleep(0.05) diff --git a/testbed/tests/testbed.py b/testbed/tests/testbed.py index 88634e2c97..2cbbf25b6b 100644 --- a/testbed/tests/testbed.py +++ b/testbed/tests/testbed.py @@ -26,6 +26,8 @@ def run_tests(app, cov, args, report_coverage, run_slow, running_in_ci): i = 0 ready = False while i < 200 and not ready: + if i % 5 == 0: + print(f"i:{i}") time.sleep(0.05) ready = getattr(app, "is_visible", False) i += 1 diff --git a/winui3/src/toga_winui3/window.py b/winui3/src/toga_winui3/window.py index 2949ebdeaf..413c9b9b23 100644 --- a/winui3/src/toga_winui3/window.py +++ b/winui3/src/toga_winui3/window.py @@ -79,6 +79,7 @@ def __init__(self, interface, title, position, size): # In WinUI 3 a minimized window is not considered visible. This variable keeps # track of this property. self._visible = self.native.Visible + print(f"\ninitial - self._visible:{self._visible} {App.app.loop.time()}") self._set_restrictions() self.set_title(title) @@ -182,9 +183,11 @@ def native_event_changed(self, sender, args): # Minimize is not considered visible but it also doesn't trigger this event. if self.native.AppWindow.IsVisible: self._visible = True + print(f"\nEvent - self._visible:{self._visible} {App.app.loop.time()}") self.interface.on_show() else: self._visible = False + print(f"\nEvent - self._visible:{self._visible} {App.app.loop.time()}") self.interface.on_hide() if args.DidPresenterChange: From 413318633fd8fd8997a5dd470ddd3c0d00c64348 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Fri, 7 Aug 2026 07:50:59 +0200 Subject: [PATCH 104/110] Use while loops over for loops when test waiting --- winui3/tests_backend/app.py | 34 ++++++++++------------------------ winui3/tests_backend/probe.py | 31 ++++++++++++++++++++++++------- 2 files changed, 34 insertions(+), 31 deletions(-) diff --git a/winui3/tests_backend/app.py b/winui3/tests_backend/app.py index b78ec91a05..06a8bdcf49 100644 --- a/winui3/tests_backend/app.py +++ b/winui3/tests_backend/app.py @@ -12,7 +12,7 @@ from toga_winui3.libs.shell import Shell_NotifyIconGetRect from win32more.Microsoft.UI.Input import InputCursor from win32more.Microsoft.UI.Interop import GetWindowFromWindowId -from win32more.Microsoft.UI.Xaml import FocusState, Window +from win32more.Microsoft.UI.Xaml import Window from win32more.Microsoft.UI.Xaml.Controls import ( MenuBarItem, MenuFlyout, @@ -95,17 +95,7 @@ def _menu_children(self, menu): async def _menu_item_open_or_select(self, path, item, final_select: bool): # Wait to enure that the item has received the input focus. - count = 0 - focus_state = FocusState.Unfocused - - while focus_state == FocusState.Unfocused and count < 50: - item.Focus(FocusState.Programmatic) - count += 1 - focus_state = item.FocusState - await asyncio.sleep(0.01) - - if focus_state == FocusState.Unfocused: - raise ValueError(f"Menu item {path} was never given the input focus.") + await self._wait_for_focus(item) # If a final menu items is not being selected then open the next submenu. if not final_select: @@ -399,15 +389,16 @@ def get_midpoint(): # Make sure the overflow tray is fully open by tracking when the midpoint stops # moving. + count = 0 + old_midpoint = None midpoint = get_midpoint() - for _ in range(10): + while midpoint != old_midpoint and count < 10: await asyncio.sleep(0.05) + old_midpoint = midpoint + midpoint = get_midpoint() - new_midpoint = get_midpoint() - - if midpoint == new_midpoint: - break - midpoint = new_midpoint + if midpoint != old_midpoint: + raise ValueError("System icon overflow tray never stabilized.") return midpoint @@ -463,12 +454,7 @@ async def activate_status_menu_item(self, item_id, title): index = self.status_menu_items(status_icon).index(title) # Make sure that the menu item is selected before sending select command. - for _ in range(100): - items[index].Focus(FocusState.Programmatic) - if items[index].FocusState != 0: - break - await asyncio.sleep(0.01) - + await self._wait_for_focus(items[index]) await self._keyboard_select() # Close the overflow tray diff --git a/winui3/tests_backend/probe.py b/winui3/tests_backend/probe.py index 3c21ca32f7..7680304c3f 100644 --- a/winui3/tests_backend/probe.py +++ b/winui3/tests_backend/probe.py @@ -4,6 +4,7 @@ from ctypes import byref, sizeof from pytest import approx, skip +from win32more.Microsoft.UI.Xaml import FocusState from win32more.Windows.Win32.Foundation import POINT from win32more.Windows.Win32.UI.Input.KeyboardAndMouse import ( INPUT, @@ -49,11 +50,12 @@ def staging_complete(): return True - for _ in range(50): - if staging_complete(): - break + count = 0 + while not staging_complete() and count < 50: + count += 1 await asyncio.sleep(0.02) - else: + + if not staging_complete(): message = "Non-empty StagingArea:\n" for staging_area in staging_areas: if len(staging_area._staging_clones) > 0: @@ -76,9 +78,9 @@ def resizing_complete(): and height - 1 < self.native.ActualHeight < height + 1 ) - for _ in range(50): - if resizing_complete(): - break + count = 0 + while not resizing_complete() and count < 50: + count += 1 await asyncio.sleep(0.02) async def redraw(self, message=None, delay=0, wait_for=None): @@ -171,3 +173,18 @@ async def _keyboard_select(self): async def _keyboard_escape(self): await self._send_key(VK_ESCAPE) + + async def _wait_for_focus(self, native_object): + """Attempts to set the input focus on a WinUI 3 object for 2 seconds.""" + # Make sure that the menu item is selected before sending select command. + count = 0 + focus_state = FocusState.Unfocused + + while focus_state == FocusState.Unfocused and count < 50: + native_object.Focus(FocusState.Programmatic) + count += 1 + focus_state = native_object.FocusState + await asyncio.sleep(0.01) + + if focus_state == FocusState.Unfocused: + raise ValueError(f"{native_object} was never given the input focus.") From bc09a338fe33a3eb92ab72041689011ee495b6ba Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Sat, 8 Aug 2026 06:00:13 +0200 Subject: [PATCH 105/110] Fixes to window state changes --- winui3/src/toga_winui3/window.py | 102 +++++++++++++++---------------- 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/winui3/src/toga_winui3/window.py b/winui3/src/toga_winui3/window.py index 413c9b9b23..f94fd82ce6 100644 --- a/winui3/src/toga_winui3/window.py +++ b/winui3/src/toga_winui3/window.py @@ -66,9 +66,11 @@ def __init__(self, interface, title, position, size): self.is_activated = False self.create() + self._presenter_changing = False + # From a native WinUI 3 point of view, presentation mode is indistinguishable # from fullscreen mode. Use this variable to distinguish between them. - self._in_presentation_mode = False + self._fullscreen_presenter = None # Keep a record of the current state to access after state changes. self._cached_state = WindowState.NORMAL @@ -155,29 +157,33 @@ def native_event_activated(self, sender, args): self.interface.on_gain_focus() def native_event_changed(self, sender, args): + """An event that fires *synchronously* when window properties change.""" + + # DidPresenterChange fires for every window state transition except: + # 1. WindowState.MINIMIZED => WindowState.NORMAL + # 2. WindowState.MAXIMIZED => WindowState.NORMAL + # 3. WindowState.PRESENTATION <=> WindowState.FULLSCREEN + # Number 3 is programmatic only, so it is triggered in set_window_state(). + # Numbers 1 and 2 are extracted from the DidSizeChange event. + if args.DidPresenterChange: + self._set_restrictions() + self._state_change_event(self.get_window_state()) - if args.DidPositionChange: - pass - - if args.DidSizeChange: + # The self._presenter_changing boolean is needed since the presenter type is + # only changed after DidSizeChange fires. This would lead to get_window_state() + # giving incorrect values. + if args.DidSizeChange and not self._presenter_changing: old_state = self._cached_state new_state = self.get_window_state() - # DidSizeChange is triggered by entering and leaving a Minimized state. - self._cached_state = new_state - - # Update the cached size. - self._cached_size = self._normal_size - - if {old_state, new_state} != {WindowState.MINIMIZED, WindowState.NORMAL}: - self.interface.on_resize() + if new_state == WindowState.NORMAL: + if old_state in {WindowState.MINIMIZED, WindowState.MAXIMIZED}: + self._state_change_event(new_state) - if old_state != new_state: - if old_state == WindowState.MINIMIZED: - self.interface.on_show() - - elif new_state == WindowState.MINIMIZED: - self.interface.on_hide() + if old_state == new_state: + # Update the cached normal window size. + self._cached_size = self.get_size() + self.interface.on_resize() if args.DidVisibilityChange: # Minimize is not considered visible but it also doesn't trigger this event. @@ -190,14 +196,6 @@ def native_event_changed(self, sender, args): print(f"\nEvent - self._visible:{self._visible} {App.app.loop.time()}") self.interface.on_hide() - if args.DidPresenterChange: - self._set_restrictions() - - # Notes: - # - DidSizeChange occurs before DidPresenterChange. - # - DidPresenterChange is not triggered by Minimized -> Normal. - self._cached_state = self.get_window_state() - def native_event_closing(self, sender, args): # Note: This event is raised when clicking on the close button, but not when # self.native.Close() is called. @@ -348,14 +346,6 @@ def min_size(self): client_min_height + frame_size_physical[1], ) - @property - def _normal_size(self): - """The size of the window when it was last in the `Normal` state.""" - if self._cached_state == WindowState.NORMAL: - return self.get_size() - - return self._cached_size - #################################################################################### # Window position (CSS pixels, see window size for terminology). #################################################################################### @@ -426,17 +416,10 @@ def get_window_state(self, in_progress_state=False) -> WindowState: :return: A WindowState constant determined by NORMAL, MAXIMIZED, MINIMIZED, FULLSCREEN or PRESENTATION. """ - presenter, _ = self._presenter - - if presenter.Kind == AppWindowPresenterKind.FullScreen: - # From the Microsoft documentation: 'The window does not have a border - # or title bar, and hides the system task bar.' - # learn.microsoft.com/en-us/windows/apps/develop/ui/manage-app-windows - if self._in_presentation_mode: - return WindowState.PRESENTATION - else: - return WindowState.FULLSCREEN + if self._fullscreen_presenter: + return self._fullscreen_presenter else: + presenter, _ = self._presenter # Assume presenter.Kind == AppWindowPresenterKind.Overlapped, since the # third alternative 'CompactOverlay' is not implemented by Toga. if presenter.State == OverlappedPresenterState.Maximized: @@ -462,12 +445,15 @@ def set_window_state(self, state: WindowState): ): self.interface.app.exit_presentation_mode() - from_overlapped = self.get_window_state() not in { - WindowState.FULLSCREEN, - WindowState.PRESENTATION, - } + from_overlapped = self._fullscreen_presenter is None to_overlapped = state not in {WindowState.FULLSCREEN, WindowState.PRESENTATION} + self._fullscreen_presenter = None if to_overlapped else state + + if from_overlapped != to_overlapped: + # Presenter is changing. Block size_caching until the presenter has changed. + self._presenter_changing = True + if from_overlapped and not to_overlapped: # Change from overlapped presenter to fullscreen presenter. self.native.AppWindow.SetPresenterByKind(AppWindowPresenterKind.FullScreen) @@ -476,9 +462,10 @@ def set_window_state(self, state: WindowState): # Change from fullscreen presenter to overlapped presenter. self.native.AppWindow.SetPresenterByKind(AppWindowPresenterKind.Overlapped) + self._presenter_changing = False + # The core interface filters out the case state == self.get_window_state(). if state == WindowState.PRESENTATION: - self._in_presentation_mode = True if hasattr(self, "menu_native"): self.menu_native.Visibility = Visibility.Collapsed @@ -487,7 +474,6 @@ def set_window_state(self, state: WindowState): # self.menu_native.Visibility = Visibility.Collapsed else: - self._in_presentation_mode = False if hasattr(self, "menu_native"): self.menu_native.Visibility = Visibility.Visible @@ -517,6 +503,20 @@ def set_window_state(self, state: WindowState): # this is not a native event so trigger it manually. self.interface.on_resize() + def _state_change_event(self, new_state): + old_state = self._cached_state + self._cached_state = new_state + + if {old_state, new_state} != {WindowState.MINIMIZED, WindowState.NORMAL}: + self.interface.on_resize() + + if old_state != new_state: + if old_state == WindowState.MINIMIZED: + self.interface.on_show() + + elif new_state == WindowState.MINIMIZED: + self.interface.on_hide() + #################################################################################### # Window capabilities #################################################################################### From fa0adc11000a9c0bfa2766ca9533344d9dab3faa Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:32:36 +0200 Subject: [PATCH 106/110] Minor updates to loop and fixes to loop shutdown --- winui3/src/toga_winui3/app.py | 8 ++ winui3/src/toga_winui3/libs/nativeevents.py | 21 ++- winui3/src/toga_winui3/libs/proactor.py | 150 ++++++++++---------- 3 files changed, 98 insertions(+), 81 deletions(-) diff --git a/winui3/src/toga_winui3/app.py b/winui3/src/toga_winui3/app.py index 250d854f5e..060ee09f7e 100644 --- a/winui3/src/toga_winui3/app.py +++ b/winui3/src/toga_winui3/app.py @@ -62,6 +62,14 @@ def create_menus(self): def exit(self): # pragma: no cover self._is_exiting = True + def _exiting(self): # pragma: no cover + """Final cleanup task to be called right before app exits.""" + # Make sure that the Win32-based StatusIcons are closed correctly. This needs to + # be the final task in the `_exiting()` method since this may trigger the native + # application to exit. + for status_icon in self.interface.status_icons: + status_icon._impl.remove() + def main_loop(self): self.create() self.loop.run_forever(self) diff --git a/winui3/src/toga_winui3/libs/nativeevents.py b/winui3/src/toga_winui3/libs/nativeevents.py index 7ab55fcdeb..407a034391 100644 --- a/winui3/src/toga_winui3/libs/nativeevents.py +++ b/winui3/src/toga_winui3/libs/nativeevents.py @@ -72,17 +72,24 @@ def clear(self): @classmethod def _clear_callback(cls, callback): + loop = App.app.loop # There is potentially still a call to the callback in the message queue # after the event has been deregistered. So the task to clear the callback # is placed at the back of the queue, and only deletes the # reference to the callback after any calls have been made. - callback_id = id(callback) - cls._cleared_callbacks[callback_id] = callback - - def clear_callback_task(cls=cls, callback_id=callback_id): - del cls._cleared_callbacks[callback_id] - - App.app.loop.call_soon_threadsafe(clear_callback_task) + if not loop.is_closed(): + callback_id = id(callback) + cls._cleared_callbacks[callback_id] = callback + + def clear_callback_task(cls=cls, callback_id=callback_id): + del cls._cleared_callbacks[callback_id] + + App.app.loop.call_soon_threadsafe(clear_callback_task) + # If the loop is closed then there is no need to wait for the event to be + # deregistered. This branch is part of the shutdown procedure so it is marked + # as no cover. + else: # pragma: no cover + callback = None class NativeEventsHandler: diff --git a/winui3/src/toga_winui3/libs/proactor.py b/winui3/src/toga_winui3/libs/proactor.py index d25d39c20f..07d4d87c38 100644 --- a/winui3/src/toga_winui3/libs/proactor.py +++ b/winui3/src/toga_winui3/libs/proactor.py @@ -101,10 +101,7 @@ def iocp_action(status=status): # app.native is exited. def exit_native(): # pragma: no cover - # Make sure that the Win32-based StatusIcons are closed correctly. - for status_icon in app.interface.status_icons: - status_icon._impl.remove() - + app._exiting() app.native.Exit(app.native_instance) task_enqueuer(exit_native) # pragma: no cover @@ -120,58 +117,64 @@ def start_iocp_listener(self): self._iocp_thread.start() def _iocp_action(self, status): - # The following codeblock is the part of asyncio.IocpProactor._poll(timeout) - # that processes the received IOCP messages. - # - # Use no cover for the KeyError and OSError codeblocks since these should not be - # accessed under normal operations. - # - # Use no cover obj in self._stopped_serving since this list is only populated - # by the self._stop_serving method, which is only called in the loop.close - # method. The loop.close method is part of the shutdown procedure, so no cover. - # - # Use no branch for f.done() since it is not consistently hit during normal - # operations. - # - # fmt: off - # ruff: disable[UP031] - # =================================== BEGIN =================================== - err, transferred, key, address = status - try: - f, ov, obj, callback = self._cache.pop(address) - except KeyError: # pragma: no cover - if self._loop.get_debug(): - self._loop.call_exception_handler({ - 'message': ('GetQueuedCompletionStatus() returned an ' - 'unexpected event'), - 'status': ('err=%s transferred=%s key=%#x address=%#x' - % (err, transferred, key, address)), - }) - - # key is either zero, or it is used to return a pipe - # handle which should be closed to avoid a leak. - if key not in (0, _overlapped.INVALID_HANDLE_VALUE): - _winapi.CloseHandle(key) - return - - if obj in self._stopped_serving: # pragma: no cover - f.cancel() - # Don't call the callback if _register() already read the result or - # if the overlapped has been cancelled - elif not f.done(): # pragma: no branch + # The testbed runs on Python 3.12. + if sys.version_info >= (3, 16): # pragma: no cover + self._process_completion_status(status) + else: + # The following codeblock is the part of asyncio.IocpProactor._poll(timeout) + # that processes the received IOCP messages. Since Python 3.16 it has been + # refactored into `_process_completion_status()`. + # + # Use no cover for the KeyError and OSError codeblocks since these should + # not be accessed under normal operations. + # + # Use no cover obj in self._stopped_serving since this list is only + # populated by the self._stop_serving method, which is only called in the + # loop.close method. The loop.close method is part of the shutdown + # procedure, so no cover. + # + # Use no branch for f.done() since it is not consistently hit during normal + # operations. + # + # fmt: off + # ruff: disable[UP031] + # =================================== BEGIN ================================ + err, transferred, key, address = status try: - value = callback(transferred, key, ov) - except OSError as e: # pragma: no cover - f.set_exception(e) - self._results.append(f) - else: - f.set_result(value) - self._results.append(f) - finally: - f = None - # ==================================== END ==================================== - # ruff: enable[UP031] - # fmt: on + f, ov, obj, callback = self._cache.pop(address) + except KeyError: # pragma: no cover + if self._loop.get_debug(): + self._loop.call_exception_handler({ + 'message': ('GetQueuedCompletionStatus() returned an ' + 'unexpected event'), + 'status': ('err=%s transferred=%s key=%#x address=%#x' + % (err, transferred, key, address)), + }) + + # key is either zero, or it is used to return a pipe + # handle which should be closed to avoid a leak. + if key not in (0, _overlapped.INVALID_HANDLE_VALUE): + _winapi.CloseHandle(key) + return + + if obj in self._stopped_serving: # pragma: no cover + f.cancel() + # Don't call the callback if _register() already read the result or + # if the overlapped has been cancelled + elif not f.done(): # pragma: no branch + try: + value = callback(transferred, key, ov) + except OSError as e: # pragma: no cover + f.set_exception(e) + self._results.append(f) + else: + f.set_result(value) + self._results.append(f) + finally: + f = None + # ==================================== END ================================= + # ruff: enable[UP031] + # fmt: on def _remove_unregistered_futures(self): # Remove unregistered futures @@ -238,6 +241,9 @@ def on_lauched(winui3_app, args): # Start the native event loop. app.native.Start() + # Cleanup tasks. Use no cover since this is part of the shutdown procedure. + self._on_exit() # pragma: no cover + def time(self): """A timer that is accurate to 100 nanoseconds. @@ -250,7 +256,7 @@ def time(self): return precise_time.value / 10000000 # Can't get coverage for app shutdown, so this handler must be no-cover. - def app_exiting(loop, winui3_app): # pragma: no cover + def _on_exit(self): # pragma: no cover """Perform cleanup that needs to occur when the app exits. This largely duplicates the "finally" behavior of the default Proactor @@ -260,17 +266,18 @@ def app_exiting(loop, winui3_app): # pragma: no cover # If we're stopping, we can do the "finally" handling from # the BaseEventLoop run_forever(). In Python 3.13.0a2, this # was refactored into the `_run_forever_cleanup()` helper. - # We run testbed on Py3.12, so the else branch is marked - # nocover. # === START BaseEventLoop.run_forever() finally handling === - loop._stopping = False - loop._thread_id = None + self._stopping = False + self._thread_id = None events._set_running_loop(None) - loop._set_coroutine_origin_tracking(False) - sys.set_asyncgen_hooks(*loop._old_agen_hooks) + self._set_coroutine_origin_tracking(False) + sys.set_asyncgen_hooks(*self._old_agen_hooks) # === END BaseEventLoop.run_forever() finally handling === - else: # pragma: no cover - loop._run_forever_cleanup() + else: + self._run_forever_cleanup() + + # Ensure the event loop is fully closed. + self.close() def native_app_launched(self, winui3_app, args): """A function to be used as an override of the OnLauched method of NativeApp.""" @@ -311,16 +318,11 @@ def run_once_recurring(self): return try: - # If the app is exiting, stop the asyncio event loop. - # Otherwise, perform one more tick of the event loop. - # We can't get coverage of app shutdown, so that branch - # is marked no cover - if self.app._is_exiting: - self.stop() # pragma: no cover - else: - self._idle = False - self._run_once() - self._idle = True + # Run one iteration of the event loop. The `_idle` flag stops the `_ready` + # deque from enqueuing tasks until the iteration is complete. + self._idle = False + self._run_once() + self._idle = True # Enqueue the next tick. Determine the delay of the tick by checking if # there are events in the ready list, otherwise then calculating a delay From 06241ab1332d0c558c3ee1c7a7219a0678150d6f Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:23:29 +0200 Subject: [PATCH 107/110] Fix to window min_size for DPI changes --- winui3/src/toga_winui3/window.py | 64 +++++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 9 deletions(-) diff --git a/winui3/src/toga_winui3/window.py b/winui3/src/toga_winui3/window.py index f94fd82ce6..336cf7bb15 100644 --- a/winui3/src/toga_winui3/window.py +++ b/winui3/src/toga_winui3/window.py @@ -78,6 +78,9 @@ def __init__(self, interface, title, position, size): # Keep a record of the window size in the NORMAL state. self._cached_size = size + # Keep a record of the window DPI to be able to detect changes. + self._cached_dpi = self._dpi + # In WinUI 3 a minimized window is not considered visible. This variable keeps # track of this property. self._visible = self.native.Visible @@ -93,6 +96,7 @@ def __init__(self, interface, title, position, size): # Create the window content and attach it. self.create_content() + self.container_native.event_handler.Loaded += self.native_event_loaded def create(self): self.native = App.app._impl.native_instance.CreateWindow() @@ -157,7 +161,11 @@ def native_event_activated(self, sender, args): self.interface.on_gain_focus() def native_event_changed(self, sender, args): - """An event that fires *synchronously* when window properties change.""" + """An event that fires when window properties change. + + Note that this fires *synchronously* when the presenter changes, but not when + a normal size change event occurs. + """ # DidPresenterChange fires for every window state transition except: # 1. WindowState.MINIMIZED => WindowState.NORMAL @@ -181,9 +189,11 @@ def native_event_changed(self, sender, args): self._state_change_event(new_state) if old_state == new_state: - # Update the cached normal window size. - self._cached_size = self.get_size() - self.interface.on_resize() + # Update the cached normal window size. Only update this value if + # the DidSizeChange event wasn't triggered by a DPI-change event. + if self._cached_dpi == self._dpi: + self._cached_size = self.get_size() + self.interface.on_resize() if args.DidVisibilityChange: # Minimize is not considered visible but it also doesn't trigger this event. @@ -213,6 +223,34 @@ def native_event_closing(self, sender, args): # triggered in test conditions, so it is as marked no-cover. pass + def native_event_loaded(self, sender, args): + # Only add the `XamlRoot.Changed` event if the window is not already closed. The + # branch where the window is closed is not reliably hit during testing, so use + # no branch. + if not self.interface.closed: # pragma: no branch + self.container_native.event_handler.XamlRoot_Changed += ( + self.native_event_xaml_root_changed + ) + + def native_event_xaml_root_changed(self, sender, args): + """Update the window minimum size after a DPI change.""" + dpi = self._dpi + + if self._cached_dpi != dpi: + # The minimum size of the window is set in physical pixels, so needs to be + # updated after the DPI changes. + self.content_refreshed() + + # Ensure that the window is the correct size. + if self._cached_state == WindowState.NORMAL: + self.set_size(self._cached_size) + + # Update the cached DPI. Note that the window `Changed` event with + # `DidSizeChange == True` is called synchronously after the `set_size` call. + # Since the cached DPI value is updated after this call the `on_resize` call + # is not triggered in this case (as desired). + self._cached_dpi = self._dpi + #################################################################################### # Window properties #################################################################################### @@ -230,6 +268,14 @@ def set_title(self, title: str): #################################################################################### def close(self): + # The XamlRoot event needs to be manually cleared to avoid memory access issues. + try: + self.container_native.event_handler.XamlRoot_Changed.clear() + except AttributeError: + # If the window is closed before the `Loaded` event, then XamlRoot will be + # None, and consequently will not have the `Changed` property. + del self.container_native.event_handler._event_registry["XamlRoot_Changed"] + # The native event `Closing` is not called when the Close() method is called # programmatically. self.native.Close() @@ -504,18 +550,18 @@ def set_window_state(self, state: WindowState): self.interface.on_resize() def _state_change_event(self, new_state): + # Note that this method should only be called when the window state has changed. old_state = self._cached_state self._cached_state = new_state if {old_state, new_state} != {WindowState.MINIMIZED, WindowState.NORMAL}: self.interface.on_resize() - if old_state != new_state: - if old_state == WindowState.MINIMIZED: - self.interface.on_show() + if old_state == WindowState.MINIMIZED: + self.interface.on_show() - elif new_state == WindowState.MINIMIZED: - self.interface.on_hide() + elif new_state == WindowState.MINIMIZED: + self.interface.on_hide() #################################################################################### # Window capabilities From 2748010a8c1437c53c31eff6e850dd8b5da34c72 Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:38:45 +0200 Subject: [PATCH 108/110] Add DPI change test --- cocoa/tests_backend/window.py | 4 + gtk/tests_backend/window.py | 3 + qt/tests_backend/window.py | 4 + testbed/tests/app/test_desktop.py | 241 +---------------------------- testbed/tests/testbed.py | 3 - winforms/tests_backend/window.py | 248 +++++++++++++++++++++++++++++- winui3/tests_backend/window.py | 79 +++++++++- 7 files changed, 339 insertions(+), 243 deletions(-) diff --git a/cocoa/tests_backend/window.py b/cocoa/tests_backend/window.py index 2739f4ff4e..5d44cfc912 100644 --- a/cocoa/tests_backend/window.py +++ b/cocoa/tests_backend/window.py @@ -1,5 +1,6 @@ import asyncio +import pytest from rubicon.objc import objc_id, send_message from toga.constants import WindowState @@ -172,3 +173,6 @@ def automated_show(host_window, future): def _setup_file_dialog_result(self, dialog, result): # Closing a window modal file dialog is the same as alerts. self._setup_alert_dialog_result(dialog, result) + + async def assert_system_dpi_change(self, get_probe, mock_scale): + pytest.skip("Test not implemented for this platform") diff --git a/gtk/tests_backend/window.py b/gtk/tests_backend/window.py index 15fba69012..6b66706630 100644 --- a/gtk/tests_backend/window.py +++ b/gtk/tests_backend/window.py @@ -160,3 +160,6 @@ def assert_toolbar_item(self, index, label, tooltip, has_icon, enabled): def press_toolbar_button(self, index): item = self.impl.native_toolbar.get_nth_item(index) item.emit("clicked") + + async def assert_system_dpi_change(self, get_probe, mock_scale): + pytest.skip("Test not implemented for this platform") diff --git a/qt/tests_backend/window.py b/qt/tests_backend/window.py index e9e377b866..a170193507 100644 --- a/qt/tests_backend/window.py +++ b/qt/tests_backend/window.py @@ -1,5 +1,6 @@ import asyncio +import pytest from PySide6.QtCore import Qt from toga_qt.libs import IS_WAYLAND @@ -118,3 +119,6 @@ def assert_toolbar_item(self, index, label, tooltip, has_icon, enabled): def press_toolbar_button(self, index): self.window._impl.toolbar_native.actions()[index].trigger() + + async def assert_system_dpi_change(self, get_probe, mock_scale): + pytest.skip("Test not implemented for this platform") diff --git a/testbed/tests/app/test_desktop.py b/testbed/tests/app/test_desktop.py index a8cf0dbbc7..7906daec80 100644 --- a/testbed/tests/app/test_desktop.py +++ b/testbed/tests/app/test_desktop.py @@ -2,13 +2,11 @@ import itertools import os import platform -from functools import partial from unittest.mock import Mock import pytest import toga -from toga import Position, Size from toga.colors import CORNFLOWERBLUE, FIREBRICK, GOLDENROD, REBECCAPURPLE from toga.constants import WindowState from toga.style.pack import Pack @@ -621,242 +619,9 @@ def test_current_window_in_presence_of_dialog(dialog): @pytest.mark.parametrize("mock_scale", [1.0, 1.25, 1.5, 1.75, 2.0]) -async def test_system_dpi_change(main_window, main_window_probe, mock_scale): - if toga.backend != "toga_winforms": - pytest.xfail("This test is winforms backend specific") - - from ctypes import byref, c_void_p, cast - from ctypes.wintypes import RECT - - from toga_winforms.libs import user32, win32constants as wc - - real_scale = main_window_probe.scale_factor - if real_scale == mock_scale: - pytest.skip("mock scale and real scale are the same") - scale_change = mock_scale / real_scale - client_size = main_window_probe.client_size - - original_content = main_window.content - AdjustWindowRectExForDpi_original = user32.AdjustWindowRectExForDpi - - # During our testing, we mock DPICHANGED events, but the system does not actually - # change the DPI of the titlebar decors. Thus, we need to be able to keep proper - # track of those ourselves. - def AdjustWindowRectExForDpi_mock(lpRect, dwStyle, bMenu, dwExStyle, dpi): - return AdjustWindowRectExForDpi_original( - lpRect, dwStyle, bMenu, dwExStyle, real_scale * 96 - ) - - user32.AdjustWindowRectExForDpi = AdjustWindowRectExForDpi_mock - - native_window = main_window._impl.native - bounds = native_window.Bounds - new_width, new_height = ( - int(bounds.Width * scale_change), - int(bounds.Height * scale_change), - ) - original_window_rect = RECT( - bounds.X, bounds.Y, bounds.X + bounds.Width, bounds.Y + bounds.Height - ) - scaled_window_rect = RECT( - bounds.X, - bounds.Y, - bounds.X + new_width, - bounds.Y + new_height, - ) - - try: - main_window.toolbar.add(toga.Command(None, "Test command")) - - # Include widgets which are sized in different ways, with margin and fixed - # sizes in both dimensions. - main_window.content = toga.Box( - style=Pack(direction="row"), - children=[ - toga.Label( - "fixed", - id="fixed", - style=Pack(background_color="yellow", margin_left=20, width=100), - ), - toga.Label( - "minimal", # Shrink to fit content - id="minimal", - style=Pack(background_color="cyan", font_size=16), - ), - toga.Label( - "flex", - id="flex", - style=Pack( - background_color="pink", flex=1, margin_top=15, height=50 - ), - ), - ], - ) - await main_window_probe.redraw("main_window is ready for testing") - - widget_ids = ["fixed", "minimal", "flex"] - probes = {id: get_probe(main_window.widgets[id]) for id in widget_ids} - - decor_ids = ["menubar", "toolbar", "container"] - probes.update( - {id: getattr(main_window_probe, f"{id}_probe") for id in decor_ids} - ) - ids = widget_ids + decor_ids - - def get_metrics(): - return ( - {id: Position(probes[id].x, probes[id].y) for id in ids}, - {id: Size(probes[id].width, probes[id].height) for id in ids}, - {id: probes[id].font_size for id in ids}, - ) - - positions, sizes, font_sizes = get_metrics() - - # Because of hinting, font size changes can have non-linear effects on pixel - # sizes. - approx_fixed = partial(pytest.approx, abs=1) - approx_font = partial(pytest.approx, rel=0.25) - - # Positions of the menubar, toolbar and top-level container are relative to the - # window client area. - assert font_sizes["menubar"] == 9 - assert positions["menubar"] == approx_fixed((0, 0)) - assert sizes["menubar"].width == approx_fixed(client_size.width) - - assert font_sizes["toolbar"] == 9 - assert positions["toolbar"] == approx_fixed((0, sizes["menubar"].height)) - assert sizes["toolbar"].width == approx_fixed(client_size.width) - - # Container has no text, so its font doesn't matter. - assert positions["container"] == approx_fixed( - (0, positions["toolbar"].y + sizes["toolbar"].height) - ) - assert sizes["container"] == approx_fixed( - (client_size.width, client_size.height - positions["container"].y) - ) - - # Positions of widgets are relative to the top-level container. - assert font_sizes["fixed"] == 9 # Default font size on Windows - assert positions["fixed"] == approx_fixed((20, 0)) - assert sizes["fixed"].width == approx_fixed(100) - - assert font_sizes["minimal"] == 16 - assert positions["minimal"] == approx_fixed((120, 0)) - assert sizes["minimal"].height == approx_font(sizes["fixed"].height * 16 / 9) - - assert font_sizes["flex"] == 9 - assert positions["flex"] == approx_fixed((120 + sizes["minimal"].width, 15)) - assert sizes["flex"] == approx_fixed( - (client_size.width - positions["flex"].x, 50) - ) - - # Trigger the DPI change - lParam = cast(byref(scaled_window_rect), c_void_p).value - mock_dpi = int(mock_scale * 96) - # high word = X dpi, low word = Y dpi -- should be the same - wParam = mock_dpi * 0x10001 - - handle = int(native_window.Handle.ToString()) - # We don't actually need uIdSubclass and dwRefData here, so we pad them out - # with 0s. - main_window._impl._subclass_proc(handle, wc.WM_DPICHANGED, wParam, lParam, 0, 0) - - # We cannot directly compare against new width and height here, as CI's screen - # size is limited and clips the window when we resize it too large. - if scale_change > 1: - assert native_window.Width > bounds.Width - else: - assert native_window.Height > bounds.Height - - client_size = main_window_probe.client_size - - await main_window_probe.redraw( - f"Triggered dpi change event with {mock_scale} dpi scale" - ) - - # Check Widget size DPI scaling - positions_scaled, sizes_scaled, font_sizes_scaled = get_metrics() - for id in ids: - if id != "container": - assert font_sizes_scaled[id] == approx_fixed( - font_sizes[id] * scale_change - ) - - assert positions_scaled["menubar"] == approx_fixed((0, 0)) - # WinForms seems to impose a minimum height on the menubar and toolbar - # for touchablility if the font size gets small; this limit is done relative - # to the current DPI, and because we have no way to mock WinForms' internals, - # we have to accept that if we're scaling to a very small scale our menubar - # height may not be preserved correctly. - if scale_change <= 1.5 / 1.25: - assert sizes_scaled["menubar"][0] == approx_fixed(client_size.width) - else: - assert sizes_scaled["menubar"] == ( - approx_fixed(client_size.width), - approx_font(sizes["menubar"].height * scale_change), - ) - - assert positions_scaled["toolbar"] == approx_fixed( - (0, sizes_scaled["menubar"].height) - ) - if scale_change <= 1.5 / 1.25: - assert sizes_scaled["toolbar"][0] == approx_fixed(client_size.width) - else: - assert sizes_scaled["toolbar"] == ( - approx_fixed(client_size.width), - approx_font(sizes["toolbar"].height * scale_change), - ) - - assert positions_scaled["container"] == approx_fixed( - (0, positions_scaled["toolbar"].y + sizes_scaled["toolbar"].height) - ) - assert sizes_scaled["container"] == approx_fixed( - (client_size.width, client_size.height - positions_scaled["container"].y) - ) - - assert positions_scaled["fixed"] == approx_fixed(Position(20, 0) * scale_change) - assert sizes_scaled["fixed"] == ( - approx_fixed(100 * scale_change), - approx_font(sizes["fixed"].height * scale_change), - ) - - assert positions_scaled["minimal"] == approx_fixed( - Position(120, 0) * scale_change - ) - assert sizes_scaled["minimal"] == approx_font(sizes["minimal"] * scale_change) - - assert positions_scaled["flex"] == approx_fixed( - ( - positions_scaled["minimal"].x + sizes_scaled["minimal"].width, - 15 * scale_change, - ) - ) - assert sizes_scaled["flex"] == approx_fixed( - ( - client_size.width - positions_scaled["flex"].x, - 50 * scale_change, - ) - ) - - finally: - user32.AdjustWindowRectExForDpi = AdjustWindowRectExForDpi_original - # Trigger the DPI change - lParam = cast(byref(original_window_rect), c_void_p).value - real_dpi = int(real_scale * 96) - # high word = X dpi, low word = Y dpi -- should be the same - wParam = real_dpi * 0x10001 - - handle = int(native_window.Handle.ToString()) - # We don't actually need uIdSubclass and dwRefData here, so we pad them out with - # 0s. - main_window._impl._subclass_proc(handle, wc.WM_DPICHANGED, wParam, lParam, 0, 0) - - client_size = main_window_probe.client_size - await main_window_probe.redraw("Restored original state of main_window") - assert get_metrics() == (positions, sizes, font_sizes) - - main_window.toolbar.clear() - main_window.content = original_content +async def test_system_dpi_change(main_window_probe, mock_scale): + """Test that backend specific DPI changes are implemented correctly.""" + await main_window_probe.assert_system_dpi_change(get_probe, mock_scale) async def test_session_based_app( diff --git a/testbed/tests/testbed.py b/testbed/tests/testbed.py index 2cbbf25b6b..4ae7eea314 100644 --- a/testbed/tests/testbed.py +++ b/testbed/tests/testbed.py @@ -57,9 +57,6 @@ def append(value): if toga.backend == "toga_winui3": print(f"toga_winui3 startup time = {0.05 * i}s") - else: - raise ValueError("DEBUG - only running WinUI 3 tested.") - if ( # On GitHub Actions, Windows/ARM64 runners don't have an interactive # logon session, so you can't run most of the GUI tests. For details, diff --git a/winforms/tests_backend/window.py b/winforms/tests_backend/window.py index f84f0078fc..fb4f4ffc0e 100644 --- a/winforms/tests_backend/window.py +++ b/winforms/tests_backend/window.py @@ -1,5 +1,9 @@ import asyncio +from ctypes import byref, c_void_p, cast +from ctypes.wintypes import RECT +from functools import partial +import pytest from System import EventArgs from System.Windows.Forms import ( Form, @@ -11,7 +15,9 @@ ToolStripSeparator, ) -from toga import Size +from toga import Box, Command, Label, Position, Size +from toga.style.pack import Pack +from toga_winforms.libs import user32, win32constants as wc from .dialogs import DialogsMixin from .probe import BaseProbe @@ -143,3 +149,243 @@ def assert_toolbar_item(self, index, label, tooltip, has_icon, enabled): def press_toolbar_button(self, index): self._native_toolbar_item(index).OnClick(EventArgs.Empty) + + async def assert_system_dpi_change(self, get_probe, mock_scale): + real_scale = self.scale_factor + if real_scale == mock_scale: + pytest.skip("mock scale and real scale are the same") + scale_change = mock_scale / real_scale + client_size = self.client_size + + original_content = self.window.content + AdjustWindowRectExForDpi_original = user32.AdjustWindowRectExForDpi + + # During our testing, we mock DPICHANGED events, but the system does not + # actually change the DPI of the titlebar decors. Thus, we need to be able + # to keep proper track of those ourselves. + def AdjustWindowRectExForDpi_mock(lpRect, dwStyle, bMenu, dwExStyle, dpi): + return AdjustWindowRectExForDpi_original( + lpRect, dwStyle, bMenu, dwExStyle, real_scale * 96 + ) + + user32.AdjustWindowRectExForDpi = AdjustWindowRectExForDpi_mock + + native_window = self.window._impl.native + bounds = native_window.Bounds + new_width, new_height = ( + int(bounds.Width * scale_change), + int(bounds.Height * scale_change), + ) + original_window_rect = RECT( + bounds.X, bounds.Y, bounds.X + bounds.Width, bounds.Y + bounds.Height + ) + scaled_window_rect = RECT( + bounds.X, + bounds.Y, + bounds.X + new_width, + bounds.Y + new_height, + ) + + try: + self.window.toolbar.add(Command(None, "Test command")) + + # Include widgets which are sized in different ways, with margin and fixed + # sizes in both dimensions. + self.window.content = Box( + style=Pack(direction="row"), + children=[ + Label( + "fixed", + id="fixed", + style=Pack( + background_color="yellow", margin_left=20, width=100 + ), + ), + Label( + "minimal", # Shrink to fit content + id="minimal", + style=Pack(background_color="cyan", font_size=16), + ), + Label( + "flex", + id="flex", + style=Pack( + background_color="pink", flex=1, margin_top=15, height=50 + ), + ), + ], + ) + await self.redraw("main_window is ready for testing") + + widget_ids = ["fixed", "minimal", "flex"] + probes = {id: get_probe(self.window.widgets[id]) for id in widget_ids} + + decor_ids = ["menubar", "toolbar", "container"] + probes.update({id: getattr(self, f"{id}_probe") for id in decor_ids}) + ids = widget_ids + decor_ids + + def get_metrics(): + return ( + {id: Position(probes[id].x, probes[id].y) for id in ids}, + {id: Size(probes[id].width, probes[id].height) for id in ids}, + {id: probes[id].font_size for id in ids}, + ) + + positions, sizes, font_sizes = get_metrics() + + # Because of hinting, font size changes can have non-linear effects on pixel + # sizes. + approx_fixed = partial(pytest.approx, abs=1) + approx_font = partial(pytest.approx, rel=0.25) + + # Positions of the menubar, toolbar and top-level container are relative to + # the window client area. + assert font_sizes["menubar"] == 9 + assert positions["menubar"] == approx_fixed((0, 0)) + assert sizes["menubar"].width == approx_fixed(client_size.width) + + assert font_sizes["toolbar"] == 9 + assert positions["toolbar"] == approx_fixed((0, sizes["menubar"].height)) + assert sizes["toolbar"].width == approx_fixed(client_size.width) + + # Container has no text, so its font doesn't matter. + assert positions["container"] == approx_fixed( + (0, positions["toolbar"].y + sizes["toolbar"].height) + ) + assert sizes["container"] == approx_fixed( + (client_size.width, client_size.height - positions["container"].y) + ) + + # Positions of widgets are relative to the top-level container. + assert font_sizes["fixed"] == 9 # Default font size on Windows + assert positions["fixed"] == approx_fixed((20, 0)) + assert sizes["fixed"].width == approx_fixed(100) + + assert font_sizes["minimal"] == 16 + assert positions["minimal"] == approx_fixed((120, 0)) + assert sizes["minimal"].height == approx_font( + sizes["fixed"].height * 16 / 9 + ) + + assert font_sizes["flex"] == 9 + assert positions["flex"] == approx_fixed((120 + sizes["minimal"].width, 15)) + assert sizes["flex"] == approx_fixed( + (client_size.width - positions["flex"].x, 50) + ) + + # Trigger the DPI change + lParam = cast(byref(scaled_window_rect), c_void_p).value + mock_dpi = int(mock_scale * 96) + # high word = X dpi, low word = Y dpi -- should be the same + wParam = mock_dpi * 0x10001 + + handle = int(native_window.Handle.ToString()) + # We don't actually need uIdSubclass and dwRefData here, so we pad them out + # with 0s. + self.window._impl._subclass_proc( + handle, wc.WM_DPICHANGED, wParam, lParam, 0, 0 + ) + + # We cannot directly compare against new width and height here, as CI's + # screen size is limited and clips the window when we resize it too large. + if scale_change > 1: + assert native_window.Width > bounds.Width + else: + assert native_window.Height > bounds.Height + + client_size = self.client_size + + await self.redraw(f"Triggered dpi change event with {mock_scale} dpi scale") + + # Check Widget size DPI scaling + positions_scaled, sizes_scaled, font_sizes_scaled = get_metrics() + for id in ids: + if id != "container": + assert font_sizes_scaled[id] == approx_fixed( + font_sizes[id] * scale_change + ) + + assert positions_scaled["menubar"] == approx_fixed((0, 0)) + # WinForms seems to impose a minimum height on the menubar and toolbar + # for touchablility if the font size gets small; this limit is done relative + # to the current DPI, and because we have no way to mock WinForms' + # internals, we have to accept that if we're scaling to a very small scale + # our menubar height may not be preserved correctly. + if scale_change <= 1.5 / 1.25: + assert sizes_scaled["menubar"][0] == approx_fixed(client_size.width) + else: + assert sizes_scaled["menubar"] == ( + approx_fixed(client_size.width), + approx_font(sizes["menubar"].height * scale_change), + ) + + assert positions_scaled["toolbar"] == approx_fixed( + (0, sizes_scaled["menubar"].height) + ) + if scale_change <= 1.5 / 1.25: + assert sizes_scaled["toolbar"][0] == approx_fixed(client_size.width) + else: + assert sizes_scaled["toolbar"] == ( + approx_fixed(client_size.width), + approx_font(sizes["toolbar"].height * scale_change), + ) + + assert positions_scaled["container"] == approx_fixed( + (0, positions_scaled["toolbar"].y + sizes_scaled["toolbar"].height) + ) + assert sizes_scaled["container"] == approx_fixed( + ( + client_size.width, + client_size.height - positions_scaled["container"].y, + ) + ) + + assert positions_scaled["fixed"] == approx_fixed( + Position(20, 0) * scale_change + ) + assert sizes_scaled["fixed"] == ( + approx_fixed(100 * scale_change), + approx_font(sizes["fixed"].height * scale_change), + ) + + assert positions_scaled["minimal"] == approx_fixed( + Position(120, 0) * scale_change + ) + assert sizes_scaled["minimal"] == approx_font( + sizes["minimal"] * scale_change + ) + + assert positions_scaled["flex"] == approx_fixed( + ( + positions_scaled["minimal"].x + sizes_scaled["minimal"].width, + 15 * scale_change, + ) + ) + assert sizes_scaled["flex"] == approx_fixed( + ( + client_size.width - positions_scaled["flex"].x, + 50 * scale_change, + ) + ) + + finally: + user32.AdjustWindowRectExForDpi = AdjustWindowRectExForDpi_original + # Trigger the DPI change + lParam = cast(byref(original_window_rect), c_void_p).value + real_dpi = int(real_scale * 96) + # high word = X dpi, low word = Y dpi -- should be the same + wParam = real_dpi * 0x10001 + + handle = int(native_window.Handle.ToString()) + # We don't actually need uIdSubclass and dwRefData here, so we pad them out + # with 0s. + self.window._impl._subclass_proc( + handle, wc.WM_DPICHANGED, wParam, lParam, 0, 0 + ) + + client_size = self.client_size + await self.redraw("Restored original state of main_window") + assert get_metrics() == (positions, sizes, font_sizes) + + self.window.toolbar.clear() + self.window.content = original_content diff --git a/winui3/tests_backend/window.py b/winui3/tests_backend/window.py index 9708d80cf8..866aa3da5c 100644 --- a/winui3/tests_backend/window.py +++ b/winui3/tests_backend/window.py @@ -1,8 +1,9 @@ import asyncio from ctypes import byref, sizeof, windll from typing import Literal +from unittest.mock import Mock -from pytest import skip +from pytest import approx, skip from win32more.Microsoft.UI.Interop import GetWindowFromWindowId from win32more.Microsoft.UI.Windowing import ( AppWindowPresenterKind, @@ -16,6 +17,7 @@ ) from toga import Size +from toga.constants import WindowState from .probe import BaseProbe @@ -133,3 +135,78 @@ def unminimize(self): def has_toolbar(self): skip("Toolbars are not implemented on on toga_winui3 yet.") + + async def assert_system_dpi_change_for_state(self, mock_scale): + # WinUI 3 uses CSS pixels for measurements for the layout within a window, but + # physical pixels for measurements external to the window. From a Toga point of + # view, DPI scaling is all handled internally except for minimum size + # constraints. So this test only deals with the window size. + # There are no Microsoft supported ways to programmatically change monitor + # DPIs. The method here is to monkeypatch the window's DPI and then manually + # fire the DPI changed event. + mock_dpi = int(mock_scale * 96) + if mock_dpi == self.impl._dpi: + return + + # Store the original values + dpi_ratio = mock_dpi / self.impl._dpi + original_size = self.window.size + original_dpi_property = type(self.impl)._dpi + + # Monkeypatch the DPI property. + type(self.impl)._dpi = int(mock_scale * 96) + + # Add a `on_resize` handler. + on_resize_handler = Mock() + self.window.on_resize_handler = on_resize_handler + + # Manually trigger the DPI changed event. + self.impl.native_event_xaml_root_changed(None, None) + await self.redraw( + f"Simulated DPI change: Window should be {dpi_ratio}x its original size", + delay=0.1, + ) + + # Save the scaled size. There is an adjustment for the normal state, since the + # DPI has not actually been changed. + if self.window.state == WindowState.NORMAL: + scaled_size = self.window.size * float(1 / dpi_ratio) + elif self.window.state == WindowState.MAXIMIZED: + scaled_size = self.window.size + + # Restore the DPI property. + type(self.impl)._dpi = original_dpi_property + + # Manually trigger the DPI changed event. + self.impl.native_event_xaml_root_changed(None, None) + + await self.redraw( + "Simulated DPI change: Window should be its original size", + delay=0.1, + ) + + # There are difference in decor, etc. for the different scales which isn't + # tested here. Accept within 10% of the size. + assert scaled_size == approx(original_size, rel=0.1) + + # The original size should be restored. + assert original_size == self.window.size + + # A DPI event should not trigger a on_resize event since the Toga size never + # changes. + on_resize_handler.assert_not_called() + + async def assert_system_dpi_change(self, get_probe, mock_scale): + # Test DPI change for the normal window state. + await self.assert_system_dpi_change_for_state(mock_scale) + + # Test DPI change while maximized. + self.window.state = WindowState.MAXIMIZED + await self.wait_for_window( + "Maximizing window before simulating another DPI change." + ) + + await self.assert_system_dpi_change_for_state(mock_scale) + + self.window.state = WindowState.NORMAL + await self.wait_for_window("Returning window to the normal state.") From fec9e1c53bbc719f6a513a671c275bb692eb33dd Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:31:57 +0200 Subject: [PATCH 109/110] Fix DPI test --- winui3/tests_backend/window.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/winui3/tests_backend/window.py b/winui3/tests_backend/window.py index 866aa3da5c..04f5fafe71 100644 --- a/winui3/tests_backend/window.py +++ b/winui3/tests_backend/window.py @@ -150,7 +150,8 @@ async def assert_system_dpi_change_for_state(self, mock_scale): # Store the original values dpi_ratio = mock_dpi / self.impl._dpi - original_size = self.window.size + original_width = self.impl.native.AppWindow.Size.Width + original_height = self.impl.native.AppWindow.Size.Height original_dpi_property = type(self.impl)._dpi # Monkeypatch the DPI property. @@ -169,10 +170,13 @@ async def assert_system_dpi_change_for_state(self, mock_scale): # Save the scaled size. There is an adjustment for the normal state, since the # DPI has not actually been changed. + scaled_size = self.impl.native.AppWindow.Size if self.window.state == WindowState.NORMAL: - scaled_size = self.window.size * float(1 / dpi_ratio) + scaled_width = scaled_size.Width * float(1 / dpi_ratio) + scaled_height = scaled_size.Height * float(1 / dpi_ratio) elif self.window.state == WindowState.MAXIMIZED: - scaled_size = self.window.size + scaled_width = scaled_size.Width + scaled_height = scaled_size.Height # Restore the DPI property. type(self.impl)._dpi = original_dpi_property @@ -185,12 +189,13 @@ async def assert_system_dpi_change_for_state(self, mock_scale): delay=0.1, ) - # There are difference in decor, etc. for the different scales which isn't - # tested here. Accept within 10% of the size. - assert scaled_size == approx(original_size, rel=0.1) + # Accept within 1% of the size due to rounding. + assert scaled_width == approx(original_width, rel=0.01) + assert scaled_height == approx(original_height, rel=0.01) # The original size should be restored. - assert original_size == self.window.size + assert self.impl.native.AppWindow.Size.Width == original_width + assert self.impl.native.AppWindow.Size.Height == original_height # A DPI event should not trigger a on_resize event since the Toga size never # changes. From fb13e2c439a0c87d160dfbf47e80e8ed8ece556d Mon Sep 17 00:00:00 2001 From: Oliver Leigh <139253492+Oliver-Leigh@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:06:13 +0200 Subject: [PATCH 110/110] Reduce DPI test window size to fit CI runner --- winui3/tests_backend/window.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/winui3/tests_backend/window.py b/winui3/tests_backend/window.py index 04f5fafe71..938f47e81e 100644 --- a/winui3/tests_backend/window.py +++ b/winui3/tests_backend/window.py @@ -189,9 +189,9 @@ async def assert_system_dpi_change_for_state(self, mock_scale): delay=0.1, ) - # Accept within 1% of the size due to rounding. - assert scaled_width == approx(original_width, rel=0.01) - assert scaled_height == approx(original_height, rel=0.01) + # Accept within 2% of the size due to rounding, and differences in decor. + assert scaled_width == approx(original_width, rel=0.02) + assert scaled_height == approx(original_height, rel=0.02) # The original size should be restored. assert self.impl.native.AppWindow.Size.Width == original_width @@ -202,6 +202,9 @@ async def assert_system_dpi_change_for_state(self, mock_scale): on_resize_handler.assert_not_called() async def assert_system_dpi_change(self, get_probe, mock_scale): + # The GitHub runner has a resolution of 1024x768. So reduce the window size. + self.window.size = Size(400, 300) + # Test DPI change for the normal window state. await self.assert_system_dpi_change_for_state(mock_scale)