diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30eae95c27..80da0e14be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -265,6 +265,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" @@ -519,6 +521,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-pwsh: | + 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-pwsh: | + 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-15" @@ -561,7 +589,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: | diff --git a/android/tests_backend/app.py b/android/tests_backend/app.py index 9085b27903..2f97f8e4f5 100644 --- a/android/tests_backend/app.py +++ b/android/tests_backend/app.py @@ -85,13 +85,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() @@ -105,8 +105,8 @@ def assert_menu_order(self, path, expected): else: assert menu.getItem(i - separator_offset).getTitle() == title - def assert_system_menus(self): - self.assert_menu_item(["About Toga Testbed"]) + async def assert_system_menus(self): + 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") @@ -140,10 +140,10 @@ 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") async def assert_event_loop(self): diff --git a/android/tests_backend/icons.py b/android/tests_backend/icons.py index 722195a0ec..94a4439845 100644 --- a/android/tests_backend/icons.py +++ b/android/tests_backend/icons.py @@ -11,13 +11,14 @@ 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) 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 +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_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/android/tests_backend/widgets/base.py b/android/tests_backend/widgets/base.py index 8e973f9358..a895595edc 100644 --- a/android/tests_backend/widgets/base.py +++ b/android/tests_backend/widgets/base.py @@ -192,6 +192,9 @@ async def undo(self): async def redo(self): pytest.skip("Redo not supported on this platform") + async def assert_backend_specific_properties(self): + pytest.skip("Test not implemented for this platform") + def find_view_by_type(root, cls): assert isinstance(root, View) diff --git a/android/tests_backend/window.py b/android/tests_backend/window.py index 64980323dd..ce809a22e8 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): await self.redraw(message, delay=0.1) if state: timeout = 5 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. diff --git a/cocoa/tests_backend/app.py b/cocoa/tests_backend/app.py index cb03b62454..4a97d6bfdf 100644 --- a/cocoa/tests_backend/app.py +++ b/cocoa/tests_backend/app.py @@ -150,7 +150,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): @@ -161,37 +161,37 @@ 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): - 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) + async def assert_system_menus(self): + 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) @@ -223,11 +223,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) @@ -333,10 +333,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/cocoa/tests_backend/icons.py b/cocoa/tests_backend/icons.py index 6be0ccf5eb..bb1bba7b34 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__() @@ -19,7 +20,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): match path: case "resources/icons/green": assert ( @@ -34,13 +35,13 @@ def assert_icon_content(self, path): case _: 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/cocoa/tests_backend/widgets/base.py b/cocoa/tests_backend/widgets/base.py index 5ed6b4c35b..7b9c697f19 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 @@ -224,3 +225,6 @@ async def undo(self): async def redo(self): await self.type_character("z", alt=True, shift=True) + + async def assert_backend_specific_properties(self): + skip("Test not implemented for this platform") diff --git a/cocoa/tests_backend/window.py b/cocoa/tests_backend/window.py index 368af2e56d..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 @@ -29,11 +30,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): await self.redraw(message, delay=0.1) if state: @@ -87,7 +84,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 @@ -113,7 +110,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): @@ -176,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/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/gtk/tests_backend/app.py b/gtk/tests_backend/app.py index 3d5a3ec69e..2473a4db10 100644 --- a/gtk/tests_backend/app.py +++ b/gtk/tests_backend/app.py @@ -136,7 +136,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"]) @@ -151,25 +151,25 @@ 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) - self.assert_menu_item(["*", "Quit"], enabled=True) + await self.assert_menu_item(["*", "Preferences"], enabled=False) + await 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) + 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) - self.assert_menu_item(["Help", "Visit homepage"], enabled=True) - self.assert_menu_item(["Help", "About Toga Testbed"], 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") @@ -180,13 +180,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) @@ -282,12 +282,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/gtk/tests_backend/icons.py b/gtk/tests_backend/icons.py index 9efe4fcbe2..02bdf3961b 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__() @@ -31,7 +32,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.path == { @@ -52,13 +53,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 == { 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.path == { 32: self.app.paths.app / "resources/logo-linux-32.png", diff --git a/gtk/tests_backend/widgets/base.py b/gtk/tests_backend/widgets/base.py index 91a501f473..1b78084fd4 100644 --- a/gtk/tests_backend/widgets/base.py +++ b/gtk/tests_backend/widgets/base.py @@ -241,3 +241,6 @@ async def undo(self): async def redo(self): pytest.skip("Redo not supported on this platform") + + async def assert_backend_specific_properties(self): + pytest.skip("Test not implemented for this platform") diff --git a/gtk/tests_backend/window.py b/gtk/tests_backend/window.py index f4bb27e8e6..6b66706630 100644 --- a/gtk/tests_backend/window.py +++ b/gtk/tests_backend/window.py @@ -50,11 +50,7 @@ def __init__(self, app, window): else: assert isinstance(self.native, (Adw.Window, Adw.ApplicationWindow)) - async def wait_for_window( - self, - message, - state=None, - ): + async def wait_for_window(self, message, state=None): await self.redraw(message, delay=0.1) if state: timeout = 5 @@ -93,7 +89,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): @@ -123,7 +119,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: @@ -164,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/iOS/tests_backend/app.py b/iOS/tests_backend/app.py index 661f3688a6..c215482b52 100644 --- a/iOS/tests_backend/app.py +++ b/iOS/tests_backend/app.py @@ -58,19 +58,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): @@ -92,10 +92,10 @@ 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") async def assert_event_loop(self): diff --git a/iOS/tests_backend/icons.py b/iOS/tests_backend/icons.py index a3bb0d291f..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__() @@ -17,7 +18,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 +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_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/iOS/tests_backend/widgets/base.py b/iOS/tests_backend/widgets/base.py index 6506b9f03b..546b21e142 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") + + async def assert_backend_specific_properties(self): + pytest.skip("Test not implemented for this platform") 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/qt/tests_backend/app.py b/qt/tests_backend/app.py index d153d45d0e..9ad035c3fd 100644 --- a/qt/tests_backend/app.py +++ b/qt/tests_backend/app.py @@ -81,7 +81,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): @@ -90,18 +90,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 "---" @@ -109,27 +109,27 @@ def assert_menu_order(self, path, expected): ] assert actual_titles == expected - def assert_system_menus(self): - self.assert_menu_item( + async def assert_system_menus(self): + await self.assert_menu_item( ["Settings", "Configure Toga Testbed (Qt)"], enabled=False, ) - self.assert_menu_item(["File", "Quit"], enabled=True) + await 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) + 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) - self.assert_menu_item(["Help", "About Toga Testbed (Qt)"], enabled=True) + await 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(["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") @@ -170,12 +170,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/qt/tests_backend/icons.py b/qt/tests_backend/icons.py index 7bb1f00da2..7d12503953 100644 --- a/qt/tests_backend/icons.py +++ b/qt/tests_backend/icons.py @@ -12,13 +12,14 @@ class IconProbe(BaseProbe): alternate_resource = "resources/icons/orange" + alternate_bad = "resources/icons/bad_ico" def __init__(self, app, icon): self.icon = 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 +36,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/qt/tests_backend/widgets/base.py b/qt/tests_backend/widgets/base.py index aa6b6058b6..929a96b17e 100644 --- a/qt/tests_backend/widgets/base.py +++ b/qt/tests_backend/widgets/base.py @@ -104,3 +104,6 @@ async def undo(self): async def redo(self): await self.type_character("z", ctrl=True, shift=True) + + async def assert_backend_specific_properties(self): + pytest.skip("Test not implemented for this platform") diff --git a/qt/tests_backend/window.py b/qt/tests_backend/window.py index 7ad72673fe..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 @@ -67,7 +68,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() @@ -91,7 +92,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): @@ -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/pyproject.toml b/testbed/pyproject.toml index aa58e71210..fb8006b416 100644 --- a/testbed/pyproject.toml +++ b/testbed/pyproject.toml @@ -159,6 +159,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/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/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/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/app/test_app.py b/testbed/tests/app/test_app.py index bfeb78c882..8c86a1e42f 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_event_loop(app_probe): """Runs tests for the apps event loop.""" @@ -25,6 +27,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) @@ -108,10 +111,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. @@ -145,7 +149,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() @@ -154,53 +158,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, ) @@ -210,12 +218,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, ) @@ -225,11 +233,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/testbed/tests/app/test_desktop.py b/testbed/tests/app/test_desktop.py index b52e6dd93e..7906daec80 100644 --- a/testbed/tests/app/test_desktop.py +++ b/testbed/tests/app/test_desktop.py @@ -1,11 +1,12 @@ +import asyncio import itertools -from functools import partial +import os +import platform 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 @@ -46,7 +47,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. @@ -62,7 +63,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 @@ -78,7 +79,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 @@ -96,7 +97,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 @@ -106,7 +107,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. @@ -263,24 +264,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") @@ -328,6 +332,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}:" @@ -547,6 +554,11 @@ async def test_current_window(app, app_probe, main_window, main_window_probe): main_window.show() 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() @@ -576,6 +588,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): @@ -604,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.platform.current_platform != "windows": - 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/app/test_dialogs.py b/testbed/tests/app/test_dialogs.py index 7007b7e952..e4ec0d3e74 100644 --- a/testbed/tests/app/test_dialogs.py +++ b/testbed/tests/app/test_dialogs.py @@ -12,7 +12,8 @@ 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, ) diff --git a/testbed/tests/app/test_document_app.py b/testbed/tests/app/test_document_app.py index 6b3361cc70..3bc3eb1e25 100644 --- a/testbed/tests/app/test_document_app.py +++ b/testbed/tests/app/test_document_app.py @@ -134,7 +134,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/test_fonts.py b/testbed/tests/test_fonts.py index e0a40beaed..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 @@ -83,10 +87,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/test_icons.py b/testbed/tests/test_icons.py index 1cda317928..6507722a7f 100644 --- a/testbed/tests/test_icons.py +++ b/testbed/tests/test_icons.py @@ -21,13 +21,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): @@ -39,16 +41,24 @@ 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() + + # 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/testbed/tests/test_images.py b/testbed/tests/test_images.py index b8ac4debd0..7a00864135 100644 --- a/testbed/tests/test_images.py +++ b/testbed/tests/test_images.py @@ -14,7 +14,8 @@ 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 ac59fdb5ae..04829dceba 100644 --- a/testbed/tests/test_keys.py +++ b/testbed/tests/test_keys.py @@ -2,6 +2,14 @@ from toga.keys import Key +from .conftest import skip_on_backends + +skip_on_backends( + "toga_winui3", + reason="Keys are not implemented on this backend.", + allow_module_level=True, +) + @pytest.mark.parametrize( "key_combo, key_data", diff --git a/testbed/tests/test_statusicons.py b/testbed/tests/test_statusicons.py index ff9e85985b..7ea682c7fb 100644 --- a/testbed/tests/test_statusicons.py +++ b/testbed/tests/test_statusicons.py @@ -145,7 +145,7 @@ async def test_change_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"]) @@ -153,7 +153,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/testbed/tests/testbed.py b/testbed/tests/testbed.py index 261d0c20b8..4ae7eea314 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 @@ -23,13 +25,27 @@ 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: + if i % 5 == 0: + print(f"i:{i}") time.sleep(0.05) ready = getattr(app, "is_visible", False) i += 1 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")) + + time.sleep(1) + app.returncode = 1 return @@ -37,7 +53,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 @@ -61,6 +79,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 @@ -104,6 +138,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 @@ -116,6 +163,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 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") diff --git a/testbed/tests/widgets/canvas/test_canvas.py b/testbed/tests/widgets/canvas/test_canvas.py index 0a36e20bfa..84645e582d 100644 --- a/testbed/tests/widgets/canvas/test_canvas.py +++ b/testbed/tests/widgets/canvas/test_canvas.py @@ -38,7 +38,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/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/conftest.py b/testbed/tests/widgets/conftest.py index 5b67845d4a..3523aeeef8 100644 --- a/testbed/tests/widgets/conftest.py +++ b/testbed/tests/widgets/conftest.py @@ -45,7 +45,12 @@ async def container_probe(widget): @pytest.fixture async def other(widget): """A separate widget that can take focus""" - 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 diff --git a/testbed/tests/widgets/test_activityindicator.py b/testbed/tests/widgets/test_activityindicator.py index 4260d19863..ea3bf4787d 100644 --- a/testbed/tests/widgets/test_activityindicator.py +++ b/testbed/tests/widgets/test_activityindicator.py @@ -14,7 +14,8 @@ 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, ) diff --git a/testbed/tests/widgets/test_base.py b/testbed/tests/widgets/test_base.py index 137f4939f6..fbec17cf6c 100644 --- a/testbed/tests/widgets/test_base.py +++ b/testbed/tests/widgets/test_base.py @@ -167,13 +167,11 @@ 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 + + +async def test_backend_specific_properties(widget, probe): + await probe.assert_backend_specific_properties() diff --git a/testbed/tests/widgets/test_dateinput.py b/testbed/tests/widgets/test_dateinput.py index 8b4db62075..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, ) diff --git a/testbed/tests/widgets/test_detailedlist.py b/testbed/tests/widgets/test_detailedlist.py index 2a17cd9371..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, ) diff --git a/testbed/tests/widgets/test_divider.py b/testbed/tests/widgets/test_divider.py index 9c570f9fac..5a862ca34f 100644 --- a/testbed/tests/widgets/test_divider.py +++ b/testbed/tests/widgets/test_divider.py @@ -13,7 +13,8 @@ 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, ) diff --git a/testbed/tests/widgets/test_imageview.py b/testbed/tests/widgets/test_imageview.py index 0bfa29cb62..e3e1d766a8 100644 --- a/testbed/tests/widgets/test_imageview.py +++ b/testbed/tests/widgets/test_imageview.py @@ -15,7 +15,8 @@ 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, ) @@ -26,7 +27,8 @@ async def widget(): test_cleanup = build_cleanup_test( - toga.ImageView, kwargs={"image": "resources/sample.png"} + toga.ImageView, + kwargs={"image": "resources/sample.png"}, ) 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/testbed/tests/widgets/test_mapview.py b/testbed/tests/widgets/test_mapview.py index bfa09d367b..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, ) diff --git a/testbed/tests/widgets/test_multilinetextinput.py b/testbed/tests/widgets/test_multilinetextinput.py index 75dbebe2a9..9e76ae167a 100644 --- a/testbed/tests/widgets/test_multilinetextinput.py +++ b/testbed/tests/widgets/test_multilinetextinput.py @@ -39,7 +39,8 @@ 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, ) diff --git a/testbed/tests/widgets/test_numberinput.py b/testbed/tests/widgets/test_numberinput.py index e1ea4885d6..8502ed4417 100644 --- a/testbed/tests/widgets/test_numberinput.py +++ b/testbed/tests/widgets/test_numberinput.py @@ -27,7 +27,8 @@ 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, ) diff --git a/testbed/tests/widgets/test_optioncontainer.py b/testbed/tests/widgets/test_optioncontainer.py index 8768f36827..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, ) diff --git a/testbed/tests/widgets/test_passwordinput.py b/testbed/tests/widgets/test_passwordinput.py index 438180a7b5..e20a276fb1 100644 --- a/testbed/tests/widgets/test_passwordinput.py +++ b/testbed/tests/widgets/test_passwordinput.py @@ -36,7 +36,8 @@ 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, ) diff --git a/testbed/tests/widgets/test_progressbar.py b/testbed/tests/widgets/test_progressbar.py index 444ef56da7..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, ) diff --git a/testbed/tests/widgets/test_scrollcontainer.py b/testbed/tests/widgets/test_scrollcontainer.py index bfe705d5c2..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, ) diff --git a/testbed/tests/widgets/test_selection.py b/testbed/tests/widgets/test_selection.py index 5d59bbda2a..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, ) diff --git a/testbed/tests/widgets/test_slider.py b/testbed/tests/widgets/test_slider.py index 261deae998..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, ) diff --git a/testbed/tests/widgets/test_splitcontainer.py b/testbed/tests/widgets/test_splitcontainer.py index cc7b8b3557..0aa5f24fd4 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 @@ -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_platforms("android", "iOS") 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_platforms=("android", "iOS"), ) diff --git a/testbed/tests/widgets/test_switch.py b/testbed/tests/widgets/test_switch.py index 2451d350d7..57a346e772 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 @@ -25,6 +26,12 @@ 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(): diff --git a/testbed/tests/widgets/test_table.py b/testbed/tests/widgets/test_table.py index a374fa857b..d2c5a7e754 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 @@ -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_platforms("iOS") 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_platforms("iOS") 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_platforms("iOS") 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_platforms=("iOS",), ) diff --git a/testbed/tests/widgets/test_textinput.py b/testbed/tests/widgets/test_textinput.py index 7559f1c3a9..e4a550e21a 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 .probe import get_probe @@ -28,6 +29,12 @@ 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(): diff --git a/testbed/tests/widgets/test_timeinput.py b/testbed/tests/widgets/test_timeinput.py index 35ec04f08e..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") return toga.TimeInput() -test_cleanup = build_cleanup_test( - toga.TimeInput, - skip_backends=("toga_gtk",), -) +test_cleanup = build_cleanup_test(toga.TimeInput) async def test_init(normalize): "Properties can be set in the constructor" - skip_on_backends("toga_gtk") 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 1b67c0ce1b..71f47b678f 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 @@ -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_platforms("iOS", "android") 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_platforms("iOS", "android") 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_platforms("iOS", "android") 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_platforms=("iOS", "android"), ) diff --git a/testbed/tests/widgets/test_webview.py b/testbed/tests/widgets/test_webview.py index 96cf5a21fd..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, ) @@ -135,7 +136,10 @@ 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",), +) @pytest.mark.flaky(retries=5, delay=1) diff --git a/testbed/tests/window/test_dialogs.py b/testbed/tests/window/test_dialogs.py index 4e20f03ffe..d5f77d2075 100644 --- a/testbed/tests/window/test_dialogs.py +++ b/testbed/tests/window/test_dialogs.py @@ -14,7 +14,8 @@ 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, ) diff --git a/testbed/tests/window/test_window.py b/testbed/tests/window/test_window.py index 78497a1863..c2ddceb3f9 100644 --- a/testbed/tests/window/test_window.py +++ b/testbed/tests/window/test_window.py @@ -400,7 +400,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" ) @@ -412,7 +412,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" ) @@ -530,7 +530,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 @@ -562,7 +562,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 @@ -631,7 +631,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", @@ -653,7 +653,7 @@ async def test_visibility(app, second_window, second_window_probe): # Window size hasn't changed as a result of min/unmin cycle assert_size(second_window, 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 @@ -1335,6 +1335,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/textual/tests_backend/widgets/base.py b/textual/tests_backend/widgets/base.py index bc9e5447aa..f175b21732 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.") + async def assert_backend_specific_properties(self): + pytest.skip("Test not implemented for this platform") + class TextualWidgetProbe(SimpleProbe): native_class = TextualWidget diff --git a/winforms/tests_backend/app.py b/winforms/tests_backend/app.py index 5a699a81bb..2ef471ede4 100644 --- a/winforms/tests_backend/app.py +++ b/winforms/tests_backend/app.py @@ -199,7 +199,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): @@ -208,7 +208,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): @@ -222,7 +222,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 @@ -239,7 +239,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) @@ -249,18 +249,18 @@ def assert_menu_order(self, path, expected): else: assert item.Text == title - 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"]) + 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"]) - self.assert_menu_item(["Help", "Visit homepage"]) - self.assert_menu_item(["Help", "About Toga Testbed"]) + 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") @@ -303,7 +303,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( @@ -311,7 +311,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) diff --git a/winforms/tests_backend/icons.py b/winforms/tests_backend/icons.py index 9175f71642..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__() @@ -19,7 +20,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 +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_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): diff --git a/winforms/tests_backend/widgets/base.py b/winforms/tests_backend/widgets/base.py index 3f3b3a7589..598c6e6cf8 100644 --- a/winforms/tests_backend/widgets/base.py +++ b/winforms/tests_backend/widgets/base.py @@ -104,3 +104,15 @@ async def undo(self): async def redo(self): pytest.skip("Redo not supported on this platform") + + async def assert_backend_specific_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/winforms/tests_backend/window.py b/winforms/tests_backend/window.py index b596bc1abb..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 @@ -39,11 +45,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): await self.redraw(message) if state: @@ -66,7 +68,7 @@ async def cleanup(self): self.window.close() await self.redraw("Closing window") - def close(self): + async def close(self): self.native.Close() @property @@ -96,7 +98,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 @@ -147,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/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..1d8358de17 --- /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}", + # Specify the version of the Windows App SDK to be used. + "win32more-Microsoft.WindowsAppSDK == 0.8.2.3.1", + "win32more >= 0.8.1", +] + +[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..c7503ec515 --- /dev/null +++ b/winui3/src/toga_winui3/__init__.py @@ -0,0 +1,39 @@ +from ctypes import WinError +from importlib.metadata import version +from sys import getwindowsversion +from warnings import warn + +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: # 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." + ) + + +# 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: # pragma: no cover + 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, + ) + + +__version__ = version("toga-winui3") diff --git a/winui3/src/toga_winui3/app.py b/winui3/src/toga_winui3/app.py new file mode 100644 index 0000000000..060ee09f7e --- /dev/null +++ b/winui3/src/toga_winui3/app.py @@ -0,0 +1,173 @@ +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 +from win32more.Windows.Win32.UI.WindowsAndMessaging import ShowCursor + +from .libs.nativeapp import NativeApp +from .libs.proactor import WinUI3ProactorEventLoop +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 + self._cursor_visible = True + + self.loop = WinUI3ProactorEventLoop() + self.native_instance: NativeApp + + def create(self): + self.native = NativeApp + + # 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: + # 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 + 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) + + def set_icon(self, icon): + 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. + 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 NativeApp 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 + # + # 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): + 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): + 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 + #################################################################################### + + 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..945625d5f3 --- /dev/null +++ b/winui3/src/toga_winui3/colors.py @@ -0,0 +1,41 @@ +from win32more.Microsoft.UI.Xaml.Media import SolidColorBrush +from win32more.Windows.UI import Color as NativeColor + +from toga.constants import TRANSPARENT + +COLOR_CACHE = {TRANSPARENT: NativeColor(R=0, G=0, B=0, A=0)} +BRUSH_CACHE = {} + + +def native_color(toga_color): + if not toga_color: + return None + + 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): + color = native_color(toga_color) + + if not color: + return None + + 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..17aadb5c82 --- /dev/null +++ b/winui3/src/toga_winui3/command.py @@ -0,0 +1,106 @@ +import sys + +from toga import Command as StandardCommand, Group, Key + +from .libs.nativeevents import events_handled + + +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): + is_enabled = self.interface.enabled + for item in self.native.values(): + item.IsEnabled = is_enabled + + 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 + + if self.interface.shortcut is not None: + self.interface.factory.not_implemented("Command shortcuts") + + item.IsEnabled = self.interface.enabled + + self.native[window_id] = 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..af0b9b025a --- /dev/null +++ b/winui3/src/toga_winui3/container.py @@ -0,0 +1,124 @@ +from math import ceil + +from win32more.Microsoft.UI.Xaml import HorizontalAlignment, VerticalAlignment +from win32more.Microsoft.UI.Xaml.Controls import Canvas + +from .widgets.properties.staged import StagingArea + + +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 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 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. + 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, 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 + self.native.VerticalAlignment = VerticalAlignment.Stretch + self.native.event_handler.SizeChanged += self.native_event_size_changed + + self._content = None + self._on_refresh = on_refresh + + self.widgets = ContainerWidgets(self) + self.staging_area = StagingArea(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 + # FIXME: Remove the 'no branch' when ScrollContainer is implemented. + if widget: # pragma: no branch + 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): + self._on_refresh() diff --git a/winui3/src/toga_winui3/factory.py b/winui3/src/toga_winui3/factory.py new file mode 100644 index 0000000000..deb3254b32 --- /dev/null +++ b/winui3/src/toga_winui3/factory.py @@ -0,0 +1,74 @@ +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 MenuStatusIcon, SimpleStatusIcon, StatusIconSet + +# 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__ = [ + "App", + "Box", + "Button", + "Command", + "Font", + "Icon", + "Label", + "MainWindow", + "MenuStatusIcon", + "Paths", + "SimpleStatusIcon", + "StatusIconSet", + "Window", + "not_implemented", +] + + +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..808c517abc --- /dev/null +++ b/winui3/src/toga_winui3/fonts.py @@ -0,0 +1,162 @@ +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): + """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): + """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 + + 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.""" + 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 UnknownFontError( + 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/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..76ea4f1c58 --- /dev/null +++ b/winui3/src/toga_winui3/icons.py @@ -0,0 +1,119 @@ +from pathlib import Path +from typing import ClassVar + +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.Foundation import PWSTR +from win32more.Windows.Win32.UI.WindowsAndMessaging import ( + HICON, + IMAGE_ICON, + LR_LOADFROMFILE, + LoadImageW, +) + +from .libs.gdiplus import create_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: ClassVar[list[str]] = [ + ".png", + ".ico", + ".bmp", + ".jpg", + ".jpeg", + ".gif", + ".tif", + ".tiff", + ] + SIZES = None + + def __init__(self, interface, path): + self.interface = interface + self._handle: None | HICON = None + self._id: None | IconId = None + self._bitmap_image: None | BitmapImage = None + + if path is None: + raise ValueError( + f"Unable to use path={path}. The app bundle icon is not implemented." + ) + else: + self.path = Path(path) + + @property + 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: + 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 + + @property + def id(self) -> IconId: + """The IconId to the WinRT icon object created using the icon's path.""" + if self._id is None: + self._id = GetIconIdFromIcon(self.handle) + + return self._id + + @property + 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 = events_handled(BitmapImage) + self._bitmap_image.event_handler.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") + + def image_icon(self, size=16): + """The WinUI 3 icon implementation.""" + image_icon = ImageIcon() + image_icon.Height = size + image_icon.Width = size + image_icon.Source = self.bitmap_image + + return image_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/comctl32.py b/winui3/src/toga_winui3/libs/comctl32.py new file mode 100644 index 0000000000..6b3441eb47 --- /dev/null +++ b/winui3/src/toga_winui3/libs/comctl32.py @@ -0,0 +1,24 @@ +import ctypes.wintypes as wt +from ctypes import windll + +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/gdiplus.py b/winui3/src/toga_winui3/libs/gdiplus.py new file mode 100644 index 0000000000..8488971922 --- /dev/null +++ b/winui3/src/toga_winui3/libs/gdiplus.py @@ -0,0 +1,212 @@ +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, + FontStyleRegular, + FontStyleStrikeout, + FontStyleUnderline, + GdiplusStartupInput, + GdiplusStartupOutput, + GpBitmap, + GpFontFamily, + GpImage, +) +from win32more.Windows.Win32.UI.WindowsAndMessaging import HICON + +######################################################################################## +# 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", + 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", +} + + +def gdi_plus_function(function): + def wrapper(*args, **kwargs): + status_code = function(*args, **kwargs) + if status_code != 0 and status_code is not None: + error = str(STATUS_DICT[status_code]) + function_name = str(function._prototype.__name__) + 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.""" + + 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): + GdiplusStartup(byref(self._token), byref(self._input), byref(self._output)) + + def __exit__(self, exc_type, exc_value, traceback): + GdiplusShutdown(self._token) + + +gdi_plus_context = GdiPlusContext() + + +######################################################################################## +# GDI+ fonts +######################################################################################## + +ALL_FONT_STYLES = ( + FontStyleRegular + | FontStyleBold + | FontStyleItalic + | FontStyleBoldItalic + | FontStyleUnderline + | FontStyleStrikeout +) + + +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. + + 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() + + 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 + + +######################################################################################## +# GDI+ icons +######################################################################################## + +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: + """Creates a Win32 icon from a file.""" + bitmap_ptr = BitmapPtr() + icon_handle = HICON() + + with gdi_plus_context: + GdipCreateBitmapFromFile(String(icon_path), byref(bitmap_ptr)) + GdipCreateHICONFromBitmap(bitmap_ptr, byref(icon_handle)) + + 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 diff --git a/winui3/src/toga_winui3/libs/misc.py b/winui3/src/toga_winui3/libs/misc.py new file mode 100644 index 0000000000..e40c4b2cd9 --- /dev/null +++ b/winui3/src/toga_winui3/libs/misc.py @@ -0,0 +1,79 @@ +from ctypes.wintypes import SHORT + +from win32more.Microsoft.UI.Xaml import GridLength, GridUnitType +from win32more.Microsoft.UI.Xaml.Controls import ( + ColumnDefinition, + RowDefinition, +) + +######################################################################################## +# 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 + + return grid_length + + +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 + + return grid_length + + +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) + + return column_definition + + +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() + + return row_definition + + +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.""" + 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 diff --git a/winui3/src/toga_winui3/libs/nativeapp.py b/winui3/src/toga_winui3/libs/nativeapp.py new file mode 100644 index 0000000000..152df86580 --- /dev/null +++ b/winui3/src/toga_winui3/libs/nativeapp.py @@ -0,0 +1,64 @@ +######################################################################################## +# 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): # pragma: no cover + raise WinError(hr) + + def ApplicationInitializationCallback(*_args): + return cls() + + Application.Start(ApplicationInitializationCallback) + + # 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/nativeevents.py b/winui3/src/toga_winui3/libs/nativeevents.py new file mode 100644 index 0000000000..407a034391 --- /dev/null +++ b/winui3/src/toga_winui3/libs/nativeevents.py @@ -0,0 +1,172 @@ +from typing import ClassVar + +from win32more import ComError + +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: ClassVar[dict] = {} + + 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 + for attribute in split_name[:-1]: + self._owner = getattr(self._owner, attribute) + + self._name = split_name[-1] + 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. + token = event_adder(WeakrefCallable(callback)) + + # Keep a local reference to the callback. + self._registry[id(token)] = (token, 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: + 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): + 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. + 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: + 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, 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.") + + if event_name not in self._event_registry: + 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() + + self._event_registry = {} + + +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] + + def __del__(self): + if getattr(self, "_event_handler", None): + self.event_handler.clear() + + # This is a safety catch for future changes in the native backend. + if hasattr(self.native_class, "__del__"): # pragma: no cover + 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): + """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 + + @native_cls.setter + def native_cls(self, cls): + self._native_cls = cls + self.native = events_handled(cls) diff --git a/winui3/src/toga_winui3/libs/proactor.py b/winui3/src/toga_winui3/libs/proactor.py new file mode 100644 index 0000000000..07d4d87c38 --- /dev/null +++ b/winui3/src/toga_winui3/libs/proactor.py @@ -0,0 +1,343 @@ +import _overlapped +import _winapi +import asyncio +import sys +import threading +import traceback +from asyncio import events +from collections import deque +from ctypes import byref + +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 + + +class ReadyDeque(deque): + """A deque that enqueues a WinUI3 event tick when a value is appended.""" + + def __init__(self, loop): + self._loop = loop + super().__init__(loop._ready) + + def append(self, value): + super().append(value) + + if self._loop._idle: + self._loop.enqueue_tick(delay=0) + + +class TwoThreadIocpProactor(asyncio.IocpProactor): + """A version of the IocpProactor class where the IOCP will run on its own thread.""" + + #################################################################################### + # Overrides of asyncio.IocpProactor methods + #################################################################################### + + 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 + + # 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. + + def exit_native(): # pragma: no cover + app._exiting() + app.native.Exit(app.native_instance) + + 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 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: + 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. + + 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. + """ + # 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() + + # Change the ready deque to an instance of ReadyDeque. + self._ready = ReadyDeque(self) + + def on_lauched(winui3_app, args): + return self.native_app_launched(winui3_app, args) + + app.native.OnLaunched = on_lauched + + # 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. + + 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 _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 + 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. + # === START BaseEventLoop.run_forever() finally handling === + self._stopping = False + self._thread_id = None + events._set_running_loop(None) + self._set_coroutine_origin_tracking(False) + sys.set_asyncgen_hooks(*self._old_agen_hooks) + # === END BaseEventLoop.run_forever() finally handling === + 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.""" + 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. + # delay is given in 100-nanosecond units i.e. 1E-7 seconds. + self.tick_scheduler.Interval = TimeSpan(delay) + self.tick_scheduler.Start() + + 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.tick_scheduler.IsRunning # noqa: B018 + self.run_once_recurring() + + 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: + # 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 + # 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: + 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 + traceback.print_exc() diff --git a/winui3/src/toga_winui3/libs/shell.py b/winui3/src/toga_winui3/libs/shell.py new file mode 100644 index 0000000000..18c82a7257 --- /dev/null +++ b/winui3/src/toga_winui3/libs/shell.py @@ -0,0 +1,18 @@ +import ctypes.wintypes as wt +from ctypes import POINTER, windll + +from . import win32structures as ws + +shell32 = windll.shell32 + + +# 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/win32constants.py b/winui3/src/toga_winui3/libs/win32constants.py new file mode 100644 index 0000000000..e3472730b5 --- /dev/null +++ b/winui3/src/toga_winui3/libs/win32constants.py @@ -0,0 +1,21 @@ +# 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..aa32ca21ed --- /dev/null +++ b/winui3/src/toga_winui3/libs/win32structures.py @@ -0,0 +1,69 @@ +import ctypes.wintypes as wt +from ctypes import WINFUNCTYPE, Structure as c_Structure, Union, c_size_t + +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/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: + LRESULT, + # Argument types: + wt.HWND, + wt.UINT, + wt.WPARAM, + wt.LPARAM, + UINT_PTR, + DWORD_PTR, +) 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 0000000000..3524138b8a Binary files /dev/null and b/winui3/src/toga_winui3/resources/toga.png differ diff --git a/winui3/src/toga_winui3/screens.py b/winui3/src/toga_winui3/screens.py new file mode 100644 index 0000000000..7bc18ce089 --- /dev/null +++ b/winui3/src/toga_winui3/screens.py @@ -0,0 +1,88 @@ +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 +from win32more.Windows.Win32.UI.Shell import GetScaleFactorForMonitor +from win32more.Windows.Win32.UI.Shell.Common import DEVICE_SCALE_FACTOR + +from toga import App +from toga.screens import Screen as ScreenInterface +from toga.types import Position, Size + + +def round_pixels(value) -> int: + return int(Decimal(value).to_integral(ROUND_HALF_EVEN)) + + +class Screen: + _instances: ClassVar[dict] = {} + + 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 GetMonitorFromDisplayId(self.native.DisplayId) + + 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 css_to_physical(self, value): + return round_pixels(value * self.dpi_scale) + + def physical_to_css(self, value): + 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 + physical_to_css = App.app._impl.get_primary_screen().physical_to_css + + 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.physical_to_css(native_bounds.Width), + self.physical_to_css(native_bounds.Width), + ) + + #################################################################################### + # Screen capabilities + #################################################################################### + + def get_image_data(self): + self.interface.factory.not_implemented("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..2ef33ea590 --- /dev/null +++ b/winui3/src/toga_winui3/statusicons.py @@ -0,0 +1,251 @@ +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, + MenuFlyoutItem, + MenuFlyoutSeparator, + MenuFlyoutSubItem, + RelativePanel, +) +from win32more.Windows.Foundation import Point +from win32more.Windows.Graphics import PointInt32, SizeInt32 +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, +) + +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.misc import get_x_lparam, get_y_lparam, loword +from .libs.nativeevents import events_handled +from .libs.shell import Shell_NotifyIconW + + +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 + + 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): + # 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) + + # 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. + 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)) + + # 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)) + self.native_window.Close() + self.native_window = None + + @property + def _hwnd(self): + return GetWindowFromWindowId(self.native_window.AppWindow.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): + def native_event_click(self, x, y): + self.interface.on_press() + + +class MenuStatusIcon(StatusIcon): + 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.event_handler.Closing += self.native_event_closing + 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): + coords = POINT(x, y) + ScreenToClient(self._hwnd, byref(coords)) + 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) + + +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] + 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): + """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 = events_handled(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(0, MenuFlyoutItem) + + submenu.Items.Append(menu_item) 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..1404b2b627 --- /dev/null +++ b/winui3/src/toga_winui3/widgets/base.py @@ -0,0 +1,146 @@ +from abc import ABC, abstractmethod + +from travertino.size import at_least +from win32more.Microsoft.UI.Xaml import FocusState, Visibility +from win32more.Microsoft.UI.Xaml.Controls import Canvas + +from ..colors import native_brush +from ..libs.nativeevents import EventsHandledMixin +from .properties.native import NativeProperties +from .properties.staged import StagedProperties + + +class Widget(EventsHandledMixin, ABC): + #################################################################################### + # Widget creation. + #################################################################################### + + def __init__(self, interface): + super().__init__() + self.interface = interface + 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): + ... + # 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. + pass + + def set_window(self, window): + # Everything is already handled by the Toga core interface. + pass + + #################################################################################### + # Methods relating to the container. + #################################################################################### + + @property + def container(self): + return self._container + + @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.activate() + + 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): + self._native_properties.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): + self._native_properties.Foreground = native_brush(color) + + def set_font(self, 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): + state = Visibility.Collapsed if hidden else Visibility.Visible + self.native.Visibility = state + + def set_text_align(self, alignment): + # Where appropriate, this is implement on a widget by widget basis. + pass + + #################################################################################### + # 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): + if not self.has_focus: + 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._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 new file mode 100644 index 0000000000..c210730666 --- /dev/null +++ b/winui3/src/toga_winui3/widgets/box.py @@ -0,0 +1,25 @@ +from win32more.Microsoft.UI.Xaml.Controls import Canvas + +from .base import Widget + + +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 + + #################################################################################### + # 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 new file mode 100644 index 0000000000..52dff4d1c1 --- /dev/null +++ b/winui3/src/toga_winui3/widgets/button.py @@ -0,0 +1,81 @@ +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 + + +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 because the staged properties are delayed, and + # this allows to the widget to be sized up. + self._min_width = 0 + self._min_height = 0 + + self.native.event_handler.Click += self.native_event_click + + def native_event_click(self, sender, args): + self.interface.on_press() + + #################################################################################### + # Button content + #################################################################################### + + def get_text(self): + return self._text + + def set_text(self, text): + self._text = text + + if self._icon is not None: + return + + self._staged_properties.Content = self.text + + 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 + + def set_icon(self, icon): + self._icon = icon + + if icon is None: + return + + self._staged_properties.Content = self.icon + + def icon(self): + 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 + # 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._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 new file mode 100644 index 0000000000..2d2f3aa338 --- /dev/null +++ b/winui3/src/toga_winui3/widgets/label.py @@ -0,0 +1,125 @@ +from travertino.constants import CENTER, JUSTIFY, LEFT, RIGHT +from travertino.size import at_least +from win32more.Microsoft.UI.Xaml import ( + HorizontalAlignment, + TextAlignment, + VerticalAlignment, +) +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 +from .properties.staged import StagedProperties + + +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 + + # 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 because the staged properties are delayed, and + # this allows to the widget to be 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) + + self.native.HorizontalAlignment = HorizontalAlignment.Stretch + self.native.VerticalAlignment = VerticalAlignment.Stretch + + @property + def container(self): + return self._label.container + + def rehint(self): + self._label.rehint() + + +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): + # 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 + + 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 = "" + + def get_text(self): + 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): + self._background_properties.Background = native_brush(color) + + 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) + + self._native_properties.TextAlignment = native_alignment + + #################################################################################### + # Overrides of other methods called by the Toga core interface. + #################################################################################### + + def get_enabled(self): + # Neither TextBlock nor Grid has the IsEnabled property. + return True + + def set_enabled(self, value): + # Neither TextBlock nor Grid has the IsEnabled property. + pass + + def rehint(self): + self.interface.intrinsic.width = at_least(self.label_text._min_width) + self.interface.intrinsic.height = self.label_text._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..d5066b1301 --- /dev/null +++ b/winui3/src/toga_winui3/widgets/properties/native.py @@ -0,0 +1,65 @@ +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 resets the property to the + default value and restores the 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 + + # 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. + 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..e6e27c4f08 --- /dev/null +++ b/winui3/src/toga_winui3/widgets/properties/staged.py @@ -0,0 +1,183 @@ +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 + +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.Opacity = 0 + + self._staging_clones = [] + + # Add the container + self._container = container + self._container.widgets.add(self) + + def add(self, staging_clone): + self._staging_clones.append(staging_clone) + self.native.Children.Append(staging_clone.native) + + def remove(self, staging_clone): + """Removes a widget and triggers a layout refresh.""" + index = self._staging_clones.index(staging_clone) + self._staging_clones.remove(staging_clone) + self.native.Children.RemoveAt(index) + + +class StagingClone: + """A facsimile of a widget that resizes to fit its content and reports its size. + + 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._removed = False + self._latest_callback_id = 0 + + self.native = type(self._widget.native)() + self.native.event_handler.SizeChanged += self.create_size_changed_callback() + self._native_properties = NativeProperties(self) + + for property, value_creator in properties.items(): + value = value_creator() + if value is not None: + setattr(self.native, property, value) + + 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. + """ + self._widget._staged_properties._clone = None + self._widget.container.staging_area.remove(self) + self._removed = True + + def create_size_changed_callback(self): + self._latest_callback_id += 1 + + def size_changed_callback(sender, args, callback_id=self._latest_callback_id): + if callback_id != self._latest_callback_id: + return + + if self._removed: + 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() + + return size_changed_callback + + 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) + + return native.ActualSize.X + + +class StagedProperties: + _font_properties: ClassVar = {"FontFamily", "FontSize", "FontStyle", "FontWeight"} + + 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. + + 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 + + # 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 + + # Only one clone of the widget exists at any given time. + if not self._clone: + self._clone = StagingClone(self._widget, self._properties_dict) + + self._clone.stage_property(name, value) + + def activate(self): + self._active = True + + if self._initialized: + self._clone = StagingClone(self._widget, self._properties_dict) + + def deactivate(self): + self._active = False + + if self._clone: + self._clone.remove() diff --git a/winui3/src/toga_winui3/window.py b/winui3/src/toga_winui3/window.py new file mode 100644 index 0000000000..336cf7bb15 --- /dev/null +++ b/winui3/src/toga_winui3/window.py @@ -0,0 +1,669 @@ +from __future__ import annotations + +from ctypes import byref +from typing import TYPE_CHECKING + +from win32more.Microsoft.UI.Interop import GetWindowFromWindowId +from win32more.Microsoft.UI.Windowing import ( + AppWindowPresenterKind, + DisplayArea, + DisplayAreaFallback, + FullScreenPresenter, + OverlappedPresenter, + OverlappedPresenterState, + TitleBarTheme, +) +from win32more.Microsoft.UI.Xaml import ( + HorizontalAlignment, + VerticalAlignment, + Visibility, + 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 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, + MF_GRAYED, + SC_CLOSE, + EnableMenuItem, + GetSystemMenu, + GetWindowLongW, +) + +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 .libs.nativeevents import events_handled +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._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._fullscreen_presenter = None + + # 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 + + # 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 + print(f"\ninitial - self._visible:{self._visible} {App.app.loop.time()}") + + 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.container_native.event_handler.Loaded += self.native_event_loaded + + 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 + + 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 = events_handled(Canvas) + self.container = Container(self.container_native, self.content_refreshed) + self.native.Content = self.container_native + + @property + def _hwnd(self): + return GetWindowFromWindowId(self.native.AppWindow.Id) + + def _set_restrictions(self): + """Sets the window properties of being minimizable and resizable.""" + presenter, _ = self._presenter + + if presenter.Kind != AppWindowPresenterKind.Overlapped: + return + + # Set the restrictions. + 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. + #################################################################################### + + 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): + """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 + # 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()) + + # 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() + + if new_state == WindowState.NORMAL: + if old_state in {WindowState.MINIMIZED, WindowState.MAXIMIZED}: + self._state_change_event(new_state) + + if old_state == new_state: + # 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. + 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() + + 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 + + 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 + #################################################################################### + + 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): + # 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() + + 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._visible = True + self.native.AppWindow.Show() + + #################################################################################### + # Window content and resources. + #################################################################################### + + 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.""" + 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 _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 + # 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( + 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 CSS pixels (effective pixels).""" + dpi = self._dpi + + 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( + 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).""" + 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) + 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 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( + client_min_width + frame_size_physical[0], + client_min_height + frame_size_physical[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. + # 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 + + return Position(physical_to_css(position.X), physical_to_css(position.Y)) + + def set_position(self, position: PositionT): + css_to_physical = App.app._impl.get_primary_screen().css_to_physical + + self.native.AppWindow.Move( + PointInt32(css_to_physical(position.x), css_to_physical(position.y)) + ) + + #################################################################################### + # Window visibility. + #################################################################################### + + def get_visible(self) -> bool: + """Returns True if the window is visible and False otherwise.""" + return self._visible + + def hide(self): + """Hides but does not destroy the window.""" + self._visible = False + self.native.AppWindow.Hide() + + #################################################################################### + # 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: # 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: + """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, + FULLSCREEN or PRESENTATION. + """ + 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: + return WindowState.MAXIMIZED + elif 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 + FULLSCREEN or PRESENTATION. + """ + # 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() + + 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) + + elif not from_overlapped and to_overlapped: + # 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: + if hasattr(self, "menu_native"): + self.menu_native.Visibility = Visibility.Collapsed + + # TODO: Implement toolbars. + # if hasattr(self, "toolbar_native"): + # self.menu_native.Visibility = Visibility.Collapsed + + else: + if hasattr(self, "menu_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: + presenter, _ = self._presenter + presenter.Restore() + + case WindowState.MINIMIZED: + presenter, _ = self._presenter + presenter.Minimize() + + case WindowState.MAXIMIZED: + presenter, _ = self._presenter + presenter.Maximize() + + 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() + + 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 == WindowState.MINIMIZED: + self.interface.on_show() + + elif new_state == WindowState.MINIMIZED: + self.interface.on_hide() + + #################################################################################### + # Window capabilities + #################################################################################### + + def get_image_data(self): + self.interface.factory.not_implemented("Window.get_image_data") + # Windows.Graphics.Capture + + +class MainWindow(Window): + def create_content(self): + # 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()) + 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 = events_handled(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, self.content_refreshed) + + # 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 + del impl.native[window_id] + except (AttributeError, KeyError): + pass + + 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): + 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} + + 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(window_id, MenuFlyoutItem) + + submenu.Items.Append(item) + + if not menu_exists: + 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") diff --git a/winui3/tests_backend/app.py b/winui3/tests_backend/app.py new file mode 100644 index 0000000000..06a8bdcf49 --- /dev/null +++ b/winui3/tests_backend/app.py @@ -0,0 +1,461 @@ +import _overlapped +import asyncio +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.nativeapp import NativeApp +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 Window +from win32more.Microsoft.UI.Xaml.Controls import ( + MenuBarItem, + MenuFlyout, + MenuFlyoutItem, + MenuFlyoutSeparator, + MenuFlyoutSubItem, +) +from win32more.Windows.Win32.UI.Input.KeyboardAndMouse import ( + VK_B, + VK_RETURN, + VK_RWIN, +) +from win32more.Windows.Win32.UI.WindowsAndMessaging import ( + CURSORINFO, + TITLEBARINFOEX, + WM_GETICON, + WM_GETTITLEBARINFOEX, + GetCursorInfo, + SendMessageW, +) + +import toga + +from .probe import BaseProbe + + +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 + beep_delay = 0.1 + + def __init__(self, app): + super().__init__() + self.app = app + self.main_window = app.main_window + + # The NativeApp class is a descendant class of the Microsoft.UI.Xaml.Application + # class, which is a singleton instance. + assert self.app._impl.native == NativeApp + assert isinstance(self.app._impl.native_instance, NativeApp) + + @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_open_or_select(self, path, item, final_select: bool): + # Wait to enure that the item has received 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: + 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. + count = 0 + + while not item_selected[0] and count < 50: + count += 1 + await asyncio.sleep(0.01) + + 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): + """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: + await self._menu_item_open_or_select( + path[: i + 1], + item, + len(path) == i + 1, + ) + + 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) + casted.Items # noqa B018 + return casted + except OSError: + pass + + # Attempt to cast as MenuFlyoutItem + try: + casted = MenuFlyoutItem(value=menu_item.value) + casted.Text # noqa B018 + return casted + except OSError: + 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"]) + + 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 + #################################################################################### + + @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 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 + + # 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") + + # 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 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") + + 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 _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) + 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 + + def get_midpoint(): + 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) + + # Make sure the overflow tray is fully open by tracking when the midpoint stops + # moving. + count = 0 + old_midpoint = None + midpoint = get_midpoint() + while midpoint != old_midpoint and count < 10: + await asyncio.sleep(0.05) + old_midpoint = midpoint + midpoint = get_midpoint() + + if midpoint != old_midpoint: + raise ValueError("System icon overflow tray never stabilized.") + + return midpoint + + 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] + + # 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() + + midpoint = await self._get_status_icon_midpoint(status_icon) + await self._send_click(*midpoint) + + # Close the overflow tray + 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] + 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) + + # Make sure that the menu item is selected before sending select command. + await self._wait_for_focus(items[index]) + await self._keyboard_select() + + # Close the overflow tray + await self._keyboard_escape() 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/icons.py b/winui3/tests_backend/icons.py new file mode 100644 index 0000000000..e8943aadc8 --- /dev/null +++ b/winui3/tests_backend/icons.py @@ -0,0 +1,94 @@ +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" + alternate_bad = "resources/icons/bad_ico" + + 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_native = app.main_window._impl.container.native + self.button = Button() + image_icon = self.icon._impl.image_icon() + self.button.Content = image_icon + 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_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 + + 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 diff --git a/winui3/tests_backend/probe.py b/winui3/tests_backend/probe.py new file mode 100644 index 0000000000..7680304c3f --- /dev/null +++ b/winui3/tests_backend/probe.py @@ -0,0 +1,190 @@ +import asyncio +import os +import platform +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, + 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 + + 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() + staging_areas = {widget._impl.container.staging_area for widget in widgets} + + def staging_complete(): + for staging_area in staging_areas: + if len(staging_area._staging_clones) > 0: + return False + + return True + + count = 0 + while not staging_complete() and count < 50: + count += 1 + await asyncio.sleep(0.02) + + if not staging_complete(): + 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.""" + 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 + ) + + 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): + """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 + 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): + # 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.") + + 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.05) + + async def _keyboard_select(self): + await self._send_key(VK_RETURN) + + 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.") 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/widgets/base.py b/winui3/tests_backend/widgets/base.py new file mode 100644 index 0000000000..ecfedca06c --- /dev/null +++ b/winui3/tests_backend/widgets/base.py @@ -0,0 +1,298 @@ +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 + + +class SimpleProbe(BaseProbe, FontMixin): + invalid_size_while_hidden = False + supports_tab_index = True + + def __init__(self, widget): + self.app = widget.app + self.widget = widget + self.impl = widget._impl + super().__init__(self.impl.native) + + # 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 + 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 + + @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 + + 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 + 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 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 diff --git a/winui3/tests_backend/widgets/button.py b/winui3/tests_backend/widgets/button.py new file mode 100644 index 0000000000..f16d0ca4e8 --- /dev/null +++ b/winui3/tests_backend/widgets/button.py @@ -0,0 +1,68 @@ +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 + +from .base import SimpleProbe + + +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: + 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) 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) 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] diff --git a/winui3/tests_backend/window.py b/winui3/tests_backend/window.py new file mode 100644 index 0000000000..938f47e81e --- /dev/null +++ b/winui3/tests_backend/window.py @@ -0,0 +1,220 @@ +import asyncio +from ctypes import byref, sizeof, windll +from typing import Literal +from unittest.mock import Mock + +from pytest import approx, skip +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 ( + TITLEBARINFOEX, + WM_GETTITLEBARINFOEX, + SetForegroundWindow, +) + +from toga import Size +from toga.constants import WindowState + +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): + # A small delay to allow the window to resize. + await self.redraw(message, delay=0.1) + + 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 + + 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() + + 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_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. + 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. + scaled_size = self.impl.native.AppWindow.Size + if self.window.state == WindowState.NORMAL: + 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_width = scaled_size.Width + scaled_height = scaled_size.Height + + # 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, + ) + + # 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 + 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. + 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) + + # 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.")