diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c60cad739a..3a34649794 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,13 +43,14 @@ jobs: package: name: Package Toga + runs-on: ubuntu-latest permissions: id-token: write contents: read attestations: write strategy: matrix: - subdir: + wheel: - "android" - "cocoa" - "core" @@ -63,11 +64,59 @@ jobs: - "travertino" - "textual" - "web" - - "winforms" - uses: beeware/.github/.github/workflows/python-package-create.yml@main - with: - build-subdirectory: ${{ matrix.subdir }} - attest: ${{ inputs.attest-package }} + - "winforms-x64" + - "winforms-arm64" + include: + - subdir: "" + + - wheel: "winforms-x64" + subdir: "winforms" + plat-name: "win_amd64" + exclude-runtime: "win-arm64" + - wheel: "winforms-arm64" + subdir: "winforms" + plat-name: "win_arm64" + exclude-runtime: "win-x64" + + # On Windows, we need to produce py3-none-win_arm64 and py3-none-win_amd64 wheels + # because the wheel includes a platform-specific binary to support WebView2. The + # existence of a `subdir` value is used as an indicator. However, this means we + # can't use the common BeeWare package building workflow. Reproduce the core parts here, + # with the extra pieces to support windows wheels. + # + # uses: beeware/.github/.github/workflows/python-package-create.yml@main + # with: + # build-subdirectory: ${{ matrix.subdir || matrix.wheel }} + # attest: ${{ inputs.attest-package }} + + steps: + - name: Checkout + uses: actions/checkout@v6.0.2 + with: + repository: ${{ github.repository }} + fetch-depth: 0 # Fetch all refs so setuptools_scm can generate the correct version number + + # A tagged wheel is generated by writing a `bdist_wheel` section to pyproject.toml + - name: Configure platform tag + if: ${{ matrix.subdir }} + run: | + printf '[tool.distutils.bdist_wheel]\nplat-name = "${{ matrix.plat-name }}"\n' >> winforms/pyproject.toml + + # To ensure the binary wheel only contains the binaries for that platform, + # delete content that is for the other architecture. + - name: Remove other-arch native DLL + if: ${{ matrix.subdir }} + run: | + rm -rf winforms/src/toga_winforms/libs/WebView2/runtimes/${{ matrix.exclude-runtime }} + + - name: Build Package & Upload Artifact + id: package + uses: hynek/build-and-inspect-python-package@v2.15.0 + with: + path: ${{ matrix.subdir || matrix.wheel }} + upload-name-suffix: ${{ format('-{0}', matrix.wheel) }} + attest-build-provenance-github: ${{ inputs.attest-package }} + docs-lint: name: Documentation linting @@ -143,7 +192,7 @@ jobs: - name: Get Packages uses: actions/download-artifact@v8.0.1 with: - pattern: ${{ format('{0}-*', needs.package.outputs.artifact-basename) }} + pattern: "Packages-*" merge-multiple: true path: dist @@ -232,7 +281,7 @@ jobs: - name: Get Package uses: actions/download-artifact@v8.0.1 with: - pattern: ${{ format('{0}-*', needs.package.outputs.artifact-basename) }} + pattern: "Packages-travertino" merge-multiple: true path: dist @@ -251,7 +300,9 @@ jobs: backend: - "macOS-x86_64" - "macOS-arm64" - - "windows" + - "windows-netfx-x86_64" + - "windows-netcore-x86_64" + - "windows-netcore-arm64" - "linux-x11-gtk3" - "linux-wayland-gtk3" - "linux-wayland-gtk4" @@ -267,6 +318,7 @@ jobs: - pre-command: "" briefcase-run-prefix: "" briefcase-run-args: "" + briefcase-test-args: "" setup-python: true testbed-app: "testbed" copy-command: "cp -r" @@ -489,10 +541,21 @@ jobs: briefcase-run-args: --config 'requires=["toga-core", "toga-textual"]' --config 'console_app=true' app-user-data-path: '$HOME\AppData\Local\Tiberius Yak\Toga Testbed\Data' - - backend: "windows" + - backend: "windows-netfx-x86_64" platform: "windows" runs-on: "windows-latest" app-user-data-path: '$HOME\AppData\Local\Tiberius Yak\Toga Testbed\Data' + briefcase-run-prefix: TOGA_WINFORMS_USE_NETFX=1 + + - backend: "windows-netcore-x86_64" + platform: "windows" + runs-on: "windows-latest" + app-user-data-path: '$HOME\AppData\Local\Tiberius Yak\Toga Testbed\Data' + + - backend: "windows-netcore-arm64" + platform: "windows" + runs-on: "windows-11-arm" + app-user-data-path: '$HOME\AppData\Local\Tiberius Yak\Toga Testbed\Data' - backend: "iOS" platform: "iOS" @@ -555,7 +618,7 @@ jobs: - name: Get Packages uses: actions/download-artifact@v8.0.1 with: - pattern: ${{ format('{0}-*', needs.package.outputs.artifact-basename) }} + pattern: "Packages-*" merge-multiple: true path: dist @@ -565,7 +628,7 @@ jobs: run: | ${{ matrix.briefcase-run-prefix }} \ briefcase run ${{ matrix.platform }} --log --test \ - ${{ matrix.briefcase-run-args }} --app ${{ matrix.testbed-app }} -- --ci + ${{ matrix.briefcase-run-args }} --app ${{ matrix.testbed-app }} -- --ci ${{ matrix.briefcase-test-args }} - name: Upload Logs uses: actions/upload-artifact@v7.0.1 @@ -634,7 +697,7 @@ jobs: - name: Get Packages uses: actions/download-artifact@v8.0.1 with: - pattern: ${{ format('{0}-*', needs.package.outputs.artifact-basename) }} + pattern: "Packages-positron" merge-multiple: true path: dist diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 07506bf031..435d2d8915 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,7 @@ jobs: - name: Get Packages uses: actions/download-artifact@v8.0.1 with: - pattern: ${{ format('{0}-*', needs.ci.outputs.artifact-basename) }} + pattern: "Packages-*" merge-multiple: true path: dist @@ -79,7 +79,7 @@ jobs: - name: Get Packages uses: actions/download-artifact@v8.0.1 with: - pattern: ${{ format('{0}-*', needs.ci.outputs.artifact-basename) }} + pattern: "Packages-*" merge-multiple: true path: staging_dist diff --git a/changes/2782.feature.md b/changes/2782.feature.md new file mode 100644 index 0000000000..3079b2fff6 --- /dev/null +++ b/changes/2782.feature.md @@ -0,0 +1 @@ +Toga's WinForms backend can now be used on ARM64 machines with a native ARM64 Python interpreter. diff --git a/docs/en/reference/platforms/windows.md b/docs/en/reference/platforms/windows.md index 1f38096b2f..beb07ff16b 100644 --- a/docs/en/reference/platforms/windows.md +++ b/docs/en/reference/platforms/windows.md @@ -8,11 +8,13 @@ The Toga backend for Windows is [`toga-winforms`](https://github.com/beeware/tog `toga-winforms` requires Python 3.10+, and Windows 10 or newer. -/// note | Note +Toga requires the use of either .NET Framework 4.x, or .NET Core 10. -Toga uses the [Python.NET](https://pythonnet.github.io) library to access the underlying Winforms GUI toolkit on Windows. Unfortunately, Python.NET doesn't always keep up with the release schedule of Python itself, and as a result, may not be compatible with recently-released versions of Python (i.e., a Python release with a version number of 3.X.0 or 3.X.1). If you experience problems installing Toga, and you're using a recently-released version of Python, try downgrading to the previous minor release (e.g. 3.13.9 instead of 3.14.0). +If you're on an x86-64 machine, .NET Framework 4.x is installed by default on Windows 10 and 11. Toga will use .NET Core 10 if it is installed. If you explicitly *want* to use .NET Framework 4.x, set the `TOGA_WINFORMS_USE_NETFX` environment variable to "1". -/// +If you're using an ARM64 machine, and you're using a native ARM64 Python interpreter, you *must* use .NET Core 10. The [.NET Desktop Runtime can be downloaded from the .NET website](https://dotnet.microsoft.com/en-us/download/dotnet/10.0). If you're using an x86-64 interpreter on an ARM64 machine, Toga can use the .NET Framework install that is provided by default. + +Toga uses the [Python.NET](https://pythonnet.github.io) library to access the underlying Winforms GUI toolkit on Windows. Unfortunately, Python.NET doesn't always keep up with the release schedule of Python itself. If you experience problems installing Toga, and you're using a recently-released version of Python, try downgrading to the previous minor release (e.g. 3.13.9 instead of 3.14.0). If you are using Windows 10 and want to use a WebView to display web content, you will also need to install the [Edge WebView2 Evergreen Runtime.](https://developer.microsoft.com/en-us/microsoft-edge/webview2/#download) Windows 11 has this runtime installed by default. diff --git a/testbed/src/testbed/app.py b/testbed/src/testbed/app.py index 6f197cdc41..150888672e 100644 --- a/testbed/src/testbed/app.py +++ b/testbed/src/testbed/app.py @@ -238,6 +238,14 @@ async def on_running(self): def main(appname): + if toga.backend == "toga_winforms": + import toga_winforms + + if toga_winforms._use_dotnet_core: + print("Running testbed using .NET Core") + else: + print("Running testbed using .NET Framework 4.x") + return Testbed( app_name=appname, document_types=[ExampleDoc, ReadonlyDoc], diff --git a/testbed/tests/app/test_desktop.py b/testbed/tests/app/test_desktop.py index cb64386b86..c599f3b39e 100644 --- a/testbed/tests/app/test_desktop.py +++ b/testbed/tests/app/test_desktop.py @@ -786,6 +786,8 @@ def find_event(event_path, main_window_probe): from System import Array, Object from System.Reflection import BindingFlags + from toga_winforms import _use_dotnet_core + event_class, event_name = event_path.split(".") if event_class == "Form": return getattr(main_window_probe.native, f"On{event_name}") @@ -801,7 +803,9 @@ def find_event(event_path, main_window_probe): ][0] event_key = SystemEvents_type.GetField( - f"On{event_name}Event", binding_flags + # .NET Core 10 uses a different naming convention for system events. + f"s_on{event_name}Event" if _use_dotnet_core else f"On{event_name}Event", + binding_flags, ).GetValue(None) return lambda event_args: RaiseEvent.Invoke( diff --git a/testbed/tests/testbed.py b/testbed/tests/testbed.py index 1a63e84244..7fb893acb5 100644 --- a/testbed/tests/testbed.py +++ b/testbed/tests/testbed.py @@ -1,4 +1,5 @@ import os +import platform import sys import tempfile import time @@ -34,13 +35,26 @@ def run_tests(app, cov, args, report_coverage, run_slow, running_in_ci): print("ready.") - # Textual backend does not yet support testing. - # However, this will verify a Textual app can at least start. + # 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_textual": - time.sleep(1) # wait for the Textual app to start - app.returncode = 0 if app._impl.native.is_running else 1 + if ( + # Textual doesn't have a test probe + toga.backend == "toga_textual" + # 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, + # see https://github.com/actions/partner-runner-images/issues/174 + or ( + toga.backend == "toga_winforms" + and platform.machine() == "ARM64" + and running_in_ci + ) + ): + time.sleep(1) # wait for the app to start + print("Performing a basic app startup test...", end="") + app.returncode = 0 if app._impl.loop.is_running() else 1 + print("done.") return # Control the run speed of the test app. @@ -144,10 +158,12 @@ def main(main_package_name, backend_override=None): cov.set_option( "coverage_conditional_plugin:rules", { + # Linux X vs Wayland "no-cover-if-linux-wayland": "os_environ.get('WAYLAND_DISPLAY', '') != ''", "no-cover-if-linux-x": ( "os_environ.get('WAYLAND_DISPLAY', 'not-set') == 'not-set'" ), + # Linux GTK3/4 + Adwaita versions "no-cover-if-gtk4": "os_environ.get('TOGA_GTK', '') == '4'", "no-cover-if-gtk3": "os_environ.get('TOGA_GTK', '3') == '3'", "no-cover-unless-plain-gtk4": ( @@ -159,6 +175,11 @@ def main(main_package_name, backend_override=None): "os_environ.get('TOGA_GTK', '') != '4' " "or os_environ.get('TOGA_GTKLIB', '') != 'Adw'" ), + # Windows .NET usage + "no-cover-if-netfx": "os_environ.get('TOGA_WINFORMS_USE_NETFX', '') == '1'", + "no-cover-if-netcore": ( + "os_environ.get('TOGA_WINFORMS_USE_NETFX', '') != '1'" + ), }, ) cov.start() diff --git a/winforms/pyproject.toml b/winforms/pyproject.toml index 0c06d60ca0..21d20bc36a 100644 --- a/winforms/pyproject.toml +++ b/winforms/pyproject.toml @@ -110,11 +110,6 @@ WebView = "toga_winforms.widgets.webview:WebView" MainWindow = "toga_winforms.window:MainWindow" Window = "toga_winforms.window:Window" -[tool.distutils.bdist_wheel] -# This backend needs to be tagged `py3-none-win_arm64`. All the code in this backend is -# pure Python, *but* it contains pre-compiled binary libraries to support WebView2. -plat-name = "win_amd64" - [tool.setuptools_scm] root = ".." diff --git a/winforms/src/toga_winforms/__init__.py b/winforms/src/toga_winforms/__init__.py index 53312ecae5..d21fc2b36d 100644 --- a/winforms/src/toga_winforms/__init__.py +++ b/winforms/src/toga_winforms/__init__.py @@ -1,3 +1,66 @@ +import os +import platform +from pathlib import Path + +import clr_loader +from pythonnet import set_runtime + +try: + #################################################################################### + # Toga Winforms requires the use of .NET; either .NET Framework 4.x, or .NET Core. + # + # .NET Framework 4.x is available by default on Windows 10 and 11. However, on + # Windows on ARM64, it is an x86-64 binary, so it can't be used by a native ARM64 + # Python interpreter. + # + # However, it *can* be used on ARM64 if you have an x86-64 Python interpreter - + # which is what you get if you run `py install -3.13` or `py install -3.14`. This + # will apparently change in Python 3.15. + # + # Using .NET Core requires a separate install - but it will be present on a lot of + # systems. + # + # So - try to load .NET Core; if it succeeds, use it. If the load fails, fall back + # to .NET Framework. If we're on ARM64, check to see if the interpreter is running + # in emulation mode. If it is, we're OK; if we're not, stop the interpreter; the + # .NET gives instructions on how to install .NET. + # + # But: If TOGA_WINFORMS_USE_NETFX is defined in the environment, ignore .NET Core + # and prefer .NET Framework 4.x + #################################################################################### + if os.environ.get("TOGA_WINFORMS_USE_NETFX", ""): # pragma: no-cover-if-netcore + raise RuntimeError("Explicitly requesting .NET Framework 4.x") + else: # pragma: no-cover-if-netfx + # runtime.json defines the .NET version. .NET 10 is the current LTS release. + set_runtime( + clr_loader.get_coreclr( + runtime_config=Path(__file__).parent / "resources/runtime.json" + ) + ) + + # .NET Core load succeeded + _use_dotnet_core = True +except (clr_loader.util.clr_error.ClrError, RuntimeError): # pragma: no cover + # .NET Core load failed. This whole branch is no-cover because we can't + # easily describe no-cover conditions for the failure modes. + if platform.machine() == "ARM64" and "ARM64" in platform.python_compiler(): + # If you're on a native ARM64 machine running an ARM64 Python, .NET Framework + # 4.x isn't an option. On Python 3.10 and 3.11, an x86-64 Python running on + # ARM64 will return `platform.machine() == "AMD64"`, so it fails the first + # part of the test. + raise RuntimeError(""" + +On Windows, Toga requires .NET Core 10. Please visit: + + https://dotnet.microsoft.com/en-us/download/dotnet/10.0 + +and install the .NET Desktop Runtime.""") from None + else: + # Either a native x86_64 machine, or an ARM64 machine with and x86_64 Python + # interpreter in emulation mode. We can use .NET Framework 4.x + _use_dotnet_core = False + + import clr import travertino @@ -9,6 +72,14 @@ # Add a reference to the Winforms assembly clr.AddReference("System.Windows.Forms") +# .NET Core requires some other explicit assemblies +if _use_dotnet_core: # pragma: no-cover-if-netfx + clr.AddReference("Microsoft.Win32.SystemEvents") + clr.AddReference("System.Windows.Extensions") +else: # pragma: no-cover-if-netcore + # We can't do conditional branch coverage, so we need a no-op else + pass + # Add a reference to the WindowsBase assembly. This is needed to access # System.Windows.Threading.Dispatcher. # diff --git a/winforms/src/toga_winforms/libs/WebView2/README.md b/winforms/src/toga_winforms/libs/WebView2/README.md index 884179c4c9..868f93b9d4 100644 --- a/winforms/src/toga_winforms/libs/WebView2/README.md +++ b/winforms/src/toga_winforms/libs/WebView2/README.md @@ -1,6 +1,6 @@ # WebView2 -These DLLs are the official stable release v1.0.2592.51 (net462), downloaded from [NuGet](https://www.nuget.org/packages/Microsoft.Web.WebView2). They are released under the terms of the [license in this root of this project](../../../../LICENSE.WebView2). +These DLLs are the official stable release v1.0.3912.50, downloaded from [NuGet](https://www.nuget.org/packages/Microsoft.Web.WebView2). They are released under the terms of the [license in this root of this project](../../../../LICENSE.WebView2). For details on usage and distribution, see [the Microsoft WebView2 documentation](https://docs.microsoft.com/en-us/microsoft-edge/webview2/) diff --git a/winforms/src/toga_winforms/libs/WebView2/runtimes/win-arm64/native/WebView2Loader.dll b/winforms/src/toga_winforms/libs/WebView2/runtimes/win-arm64/native/WebView2Loader.dll new file mode 100644 index 0000000000..54b3a2186f Binary files /dev/null and b/winforms/src/toga_winforms/libs/WebView2/runtimes/win-arm64/native/WebView2Loader.dll differ diff --git a/winforms/src/toga_winforms/libs/WebView2/runtimes/win-x64/native/WebView2Loader.dll b/winforms/src/toga_winforms/libs/WebView2/runtimes/win-x64/native/WebView2Loader.dll index f6d1d7bc96..7d0877261b 100644 Binary files a/winforms/src/toga_winforms/libs/WebView2/runtimes/win-x64/native/WebView2Loader.dll and b/winforms/src/toga_winforms/libs/WebView2/runtimes/win-x64/native/WebView2Loader.dll differ diff --git a/winforms/src/toga_winforms/resources/runtime.json b/winforms/src/toga_winforms/resources/runtime.json new file mode 100644 index 0000000000..aa34ea9d40 --- /dev/null +++ b/winforms/src/toga_winforms/resources/runtime.json @@ -0,0 +1,15 @@ +{ + "runtimeOptions": { + "tfm": "net10.0-windows", + "frameworks": [ + { + "name": "Microsoft.NETCore.App", + "version": "10.0.0" + }, + { + "name": "Microsoft.WindowsDesktop.App", + "version": "10.0.0" + } + ] + } +} diff --git a/winforms/src/toga_winforms/statusicons.py b/winforms/src/toga_winforms/statusicons.py index 6689fda0cb..7da241743e 100644 --- a/winforms/src/toga_winforms/statusicons.py +++ b/winforms/src/toga_winforms/statusicons.py @@ -3,6 +3,20 @@ import toga from toga.command import Group, Separator from toga.handlers import WeakrefCallable +from toga_winforms import _use_dotnet_core + +if _use_dotnet_core: # pragma: no-cover-if-netfx + ToolStripMenuItem = WinForms.ToolStripMenuItem + ContextMenuStrip = WinForms.ContextMenuStrip + MENU_ATTR = "Items" + SUBMENU_ATTR = "DropDownItems" + CONTEXT_MENU_ATTR = "ContextMenuStrip" +else: # pragma: no-cover-if-netcore + ToolStripMenuItem = WinForms.MenuItem + ContextMenuStrip = WinForms.ContextMenu + MENU_ATTR = "MenuItems" + SUBMENU_ATTR = "MenuItems" + CONTEXT_MENU_ATTR = "ContextMenu" class StatusIcon: @@ -55,10 +69,9 @@ def _submenu(self, group, group_cache): else: parent_menu = self._submenu(group.parent, group_cache) - submenu = WinForms.MenuItem(group.text) - - parent_menu.MenuItems.Add(submenu) + submenu = ToolStripMenuItem(group.text) + getattr(parent_menu, MENU_ATTR).Add(submenu) group_cache[group] = submenu return submenu @@ -66,8 +79,8 @@ def create(self): # Menu status icons are the only icons that have extra construction needs. # Clear existing menus for item in self.interface._menu_status_icons: - submenu = WinForms.ContextMenu() - item._impl.native.ContextMenu = submenu + submenu = ContextMenuStrip() + setattr(item._impl.native, CONTEXT_MENU_ATTR, submenu) # Determine the primary status icon. primary_group = self.interface._primary_menu_status_icon @@ -78,11 +91,14 @@ def create(self): # Add the menu status items to the cache group_cache = { - item: item._impl.native.ContextMenu + item: getattr(item._impl.native, CONTEXT_MENU_ATTR) for item 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.ContextMenu + group_cache[Group.COMMANDS] = getattr( + primary_group._impl.native, + CONTEXT_MENU_ATTR, + ) self._menu_items = {} for cmd in self.interface.commands: @@ -97,6 +113,7 @@ def create(self): if isinstance(cmd, Separator): menu_item = "-" else: - menu_item = cmd._impl.create_menu_item(WinForms.MenuItem) + menu_item = cmd._impl.create_menu_item(ToolStripMenuItem) - submenu.MenuItems.Add(menu_item) + attr = MENU_ATTR if cmd.group.parent is None else SUBMENU_ATTR + getattr(submenu, attr).Add(menu_item) diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index 5b5705ae28..f7a6aef719 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -49,10 +49,11 @@ def __init__(self, interface, title, position, size): # triggering of visibility events. self._previous_state = WindowState.NORMAL # On minimization, winforms returns window size as 0 x 0, but this behavior is - # inconsistent with other platforms as minimization does not constitute a - # window resize operation. Therefore, it should return the same size as before - # minimization. So, cache the previous window size before performing - # minimization. + # inconsistent with other platforms as minimization does not constitute a window + # resize operation. Therefore, it should return the same size as before + # minimization. Under .NET Core, there's also issues with correctly restoring + # the window size when coming back from FULLSCREEN or PRESENTATION mode. This + # variable stores the window size so it can be returned/restored as required. self._cached_window_size = None self.set_title(title) @@ -332,11 +333,20 @@ def set_window_state(self, state): self.native.WindowState = WinForms.FormWindowState.Minimized case WindowState.NORMAL, WindowState.FULLSCREEN: + # .NET Core doesn't always restore the window size coming back from + # FULLSCREEN mode. Save the window size to make sure it is restored. + self._cached_window_size = self.interface.size + self.native.FormBorderStyle = getattr(WinForms.FormBorderStyle, "None") self.native.WindowState = WinForms.FormWindowState.Maximized case WindowState.NORMAL, WindowState.PRESENTATION: + # .NET Core doesn't always restore the window size coming back from + # PRESENTATION mode. Save the window size and screen to make sure it is + # restored. self._before_presentation_mode_screen = self.interface.screen + self._cached_window_size = self.interface.size + if self.native.MainMenuStrip: self.native.MainMenuStrip.Visible = False if getattr(self, "toolbar_native", None): @@ -362,12 +372,15 @@ def set_window_state(self, state): WinForms.FormBorderStyle, "Sizable" if self.interface.resizable else "FixedSingle", ) - # Clear the cached window size. - self._cached_window_size = None self.native.WindowState = WinForms.FormWindowState.Normal - self.set_window_state(state) + # If there was a cached window size, restore that size. + # Required for .NET Core restoration of FULLSCREEN/PRESENTATION. + if self._cached_window_size: + self.set_size(self._cached_window_size) + self._cached_window_size = None + ###################################################################### # Window capabilities ###################################################################### diff --git a/winforms/tests_backend/app.py b/winforms/tests_backend/app.py index 9860122f22..eb0746ebbb 100644 --- a/winforms/tests_backend/app.py +++ b/winforms/tests_backend/app.py @@ -9,7 +9,9 @@ from System.Windows.Forms import Application, Cursor, ToolStripSeparator import toga +from toga_winforms import _use_dotnet_core from toga_winforms.keys import toga_to_winforms_key, winforms_to_toga_key +from toga_winforms.statusicons import CONTEXT_MENU_ATTR, MENU_ATTR from .dialogs import DialogsMixin from .probe import BaseProbe @@ -228,14 +230,16 @@ def has_status_icon(self, status_icon): return status_icon._impl.native is not None def status_menu_items(self, status_icon): - if status_icon._impl.native.ContextMenu: + context_menu_strip = getattr(status_icon._impl.native, CONTEXT_MENU_ATTR) + if context_menu_strip: return [ { - "-": "---", + # .NET Core returns "" for separator text + "" if _use_dotnet_core else "-": "---", "About Toga Testbed": "**ABOUT**", "Exit": "**EXIT**", }.get(str(item.Text), str(item.Text)) - for item in status_icon._impl.native.ContextMenu.MenuItems + for item in getattr(context_menu_strip, MENU_ATTR) ] else: # It's a button status item @@ -250,6 +254,6 @@ def activate_status_icon_button(self, item_id): ) def activate_status_menu_item(self, item_id, title): - menu = self.app.status_icons[item_id]._impl.native.ContextMenu - item = {item.Text: item for item in menu.MenuItems}[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) diff --git a/winforms/tests_backend/dialogs.py b/winforms/tests_backend/dialogs.py index 2759307b2a..da624504cb 100644 --- a/winforms/tests_backend/dialogs.py +++ b/winforms/tests_backend/dialogs.py @@ -3,11 +3,20 @@ from System import Array as WinArray, String as WinString +from toga_winforms import _use_dotnet_core + class DialogsMixin: supports_multiple_select_folder = False - def _setup_dialog_result(self, dialog, char, alt=False, pre_close_test_method=None): + def _setup_dialog_result( + self, + dialog, + char, + alt=False, + char2=None, + pre_close_test_method=None, + ): # Install an overridden show method that invokes the original, # but then closes the open dialog. orig_show = dialog._impl.show @@ -27,6 +36,11 @@ async def _close_dialog(): finally: try: await self.type_character(char, alt=alt) + if char2: + # If a second character press is needed, wait a moment + # for the effect of the first character to take effect. + await self.redraw("wait for char", delay=0.1) + await self.type_character(char2) except Exception as e: # An error occurred closing the dialog; that means the dialog # isn't what as expected, so record that in the future. @@ -91,7 +105,11 @@ def setup_select_folder_dialog_result(self, dialog, result, multiple_select): dialog._impl.native.SelectedPath = str( result[-1] if multiple_select else result ) - self._setup_dialog_result(dialog, "\n") + # Under .NET Core, selecting pressing Enter once + # displays the contents of the selected folder. + # A second enter is needed to select that folder. + char2 = "\n" if _use_dotnet_core else None + self._setup_dialog_result(dialog, "\n", char2=char2) def is_modal_dialog(self, dialog): return True