From b90b9cce8c45c646d4a928f1f272a617dcf38b8a Mon Sep 17 00:00:00 2001 From: sarayourfriend Date: Fri, 29 Nov 2024 15:08:08 +1100 Subject: [PATCH 1/6] Remove unneeded dependency https://github.com/beeware/toga/pull/2893\#discussion_r1862904875 --- .github/workflows/ci.yml | 3 +-- changes/2893.misc.rst | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 changes/2893.misc.rst diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4052a5e7e..b747e7b9db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -239,8 +239,7 @@ jobs: sudo apt update -y sudo apt install -y --no-install-recommends \ mutter pkg-config python3-dev libgirepository1.0-dev libcairo2-dev \ - gir1.2-webkit2-4.1 gir1.2-xapp-1.0 \ - libjpeg-dev # remove once the version of Pillow has a wheel on PyPI for Python 3.12 + gir1.2-webkit2-4.1 gir1.2-xapp-1.0 # Start Virtual X Server echo "Start X server..." diff --git a/changes/2893.misc.rst b/changes/2893.misc.rst new file mode 100644 index 0000000000..8c6440855b --- /dev/null +++ b/changes/2893.misc.rst @@ -0,0 +1 @@ +An unnecessary dependency was removed from the Linux Wayland CI environment. From 49c8fb8703bebc74bf2d05ff4ea8c369acc124b3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 1 Dec 2024 20:54:27 +0000 Subject: [PATCH 2/6] Bump pytest from 8.3.3 to 8.3.4 in /core Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.3.3 to 8.3.4. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/8.3.3...8.3.4) --- updated-dependencies: - dependency-name: pytest dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- core/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/pyproject.toml b/core/pyproject.toml index dbcc29309e..35caea9a43 100644 --- a/core/pyproject.toml +++ b/core/pyproject.toml @@ -69,7 +69,7 @@ dev = [ "coverage-conditional-plugin == 0.9.0", "Pillow == 11.0.0", "pre-commit == 4.0.1", - "pytest == 8.3.3", + "pytest == 8.3.4", "pytest-asyncio == 0.24.0", "pytest-freezer == 0.4.8", "setuptools-scm == 8.1.0", From a310cd37bdf0b98679416c97a9c8c379c887862b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 1 Dec 2024 20:54:40 +0000 Subject: [PATCH 3/6] Add changenote. [dependabot skip] --- changes/3006.misc.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/3006.misc.rst diff --git a/changes/3006.misc.rst b/changes/3006.misc.rst new file mode 100644 index 0000000000..4a144c6593 --- /dev/null +++ b/changes/3006.misc.rst @@ -0,0 +1 @@ +Updated pytest from 8.3.3 to 8.3.4 in /core. From 2615ac0e96d3864aef10ef448b31be4a5addb520 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 1 Dec 2024 21:00:30 +0000 Subject: [PATCH 4/6] Bump pytest from 8.3.3 to 8.3.4 in /testbed Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.3.3 to 8.3.4. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/8.3.3...8.3.4) --- updated-dependencies: - dependency-name: pytest dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- testbed/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testbed/pyproject.toml b/testbed/pyproject.toml index 05b94b9715..fdb7fdbc2a 100644 --- a/testbed/pyproject.toml +++ b/testbed/pyproject.toml @@ -11,7 +11,7 @@ test = [ # that can target Android exclusively until 3.13 lands. "fonttools==4.55.0 ; sys.platform == 'linux'", "pillow==11.0.0", - "pytest==8.3.3", + "pytest==8.3.4", "pytest-asyncio==0.24.0", ] From b68019fde672a0617f649a095ebf6b2e991e35ef Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 1 Dec 2024 21:00:42 +0000 Subject: [PATCH 5/6] Add changenote. [dependabot skip] --- changes/3007.misc.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/3007.misc.rst diff --git a/changes/3007.misc.rst b/changes/3007.misc.rst new file mode 100644 index 0000000000..c84b727e17 --- /dev/null +++ b/changes/3007.misc.rst @@ -0,0 +1 @@ +Updated pytest from 8.3.3 to 8.3.4 in /testbed. From 1c8b7d41dbf2ad171a882388005960ddd434a5c4 Mon Sep 17 00:00:00 2001 From: proneon267 <45512885+proneon267@users.noreply.github.com> Date: Sun, 1 Dec 2024 20:23:57 -0800 Subject: [PATCH 6/6] Add window states API (#2473) Adds a new API for controlling window states (minimized, maximised, etc) --- android/src/toga_android/app.py | 10 - android/src/toga_android/window.py | 75 ++++- android/tests_backend/window.py | 21 +- changes/1857.feature.rst | 1 + changes/1857.removal.rst | 1 + cocoa/src/toga_cocoa/app.py | 34 --- cocoa/src/toga_cocoa/window.py | 192 +++++++++++- cocoa/tests_backend/app.py | 9 - cocoa/tests_backend/window.py | 26 +- core/src/toga/app.py | 116 ++++++-- core/src/toga/constants/__init__.py | 39 +++ core/src/toga/window.py | 93 +++++- core/tests/app/test_app.py | 405 ++++++++++++++++++++++++-- core/tests/window/test_window.py | 240 ++++++++++++++- docs/reference/api/window.rst | 8 + dummy/src/toga_dummy/app.py | 10 - dummy/src/toga_dummy/window.py | 14 +- examples/window/window/app.py | 81 +++++- gtk/src/toga_gtk/app.py | 12 - gtk/src/toga_gtk/window.py | 144 ++++++++- gtk/tests_backend/app.py | 9 - gtk/tests_backend/window.py | 23 +- iOS/src/toga_iOS/app.py | 12 - iOS/src/toga_iOS/window.py | 9 +- iOS/tests_backend/window.py | 15 +- testbed/tests/app/test_desktop.py | 363 ++++++++++++++--------- testbed/tests/app/test_mobile.py | 33 ++- testbed/tests/conftest.py | 13 +- testbed/tests/window/test_window.py | 418 +++++++++++++++++++++++++-- textual/src/toga_textual/app.py | 10 +- textual/src/toga_textual/window.py | 9 +- web/src/toga_web/app.py | 10 +- web/src/toga_web/window.py | 9 +- winforms/src/toga_winforms/app.py | 12 - winforms/src/toga_winforms/window.py | 82 +++++- winforms/tests_backend/app.py | 7 - winforms/tests_backend/window.py | 19 +- 37 files changed, 2142 insertions(+), 442 deletions(-) create mode 100644 changes/1857.feature.rst create mode 100644 changes/1857.removal.rst diff --git a/android/src/toga_android/app.py b/android/src/toga_android/app.py index 33ae0ae299..ff5dc08910 100644 --- a/android/src/toga_android/app.py +++ b/android/src/toga_android/app.py @@ -323,16 +323,6 @@ def get_current_window(self): def set_current_window(self, window): pass - ###################################################################### - # Full screen control - ###################################################################### - - def enter_full_screen(self, windows): - pass - - def exit_full_screen(self, windows): - pass - ###################################################################### # Platform-specific APIs ###################################################################### diff --git a/android/src/toga_android/window.py b/android/src/toga_android/window.py index 4ababa5266..58f3aa79a0 100644 --- a/android/src/toga_android/window.py +++ b/android/src/toga_android/window.py @@ -10,6 +10,7 @@ from java import dynamic_proxy from java.io import ByteArrayOutputStream +from toga.constants import WindowState from toga.types import Position, Size from .container import Container @@ -36,6 +37,9 @@ def __init__(self, interface, title, position, size): self.interface = interface self.interface._impl = self self._initial_title = title + # Use a shadow variable since the presence of ActionBar is not + # a reliable indicator for confirmation of presentation mode. + self._in_presentation_mode = False ###################################################################### # Window properties @@ -47,6 +51,11 @@ def get_title(self): def set_title(self, title): self.app.native.setTitle(title) + def show_actionbar(self, show): # pragma: no cover + # The testbed can't create a simple window, so we can't test this. + # ActionBar is always hidden on Window. + pass + ###################################################################### # Window lifecycle ###################################################################### @@ -137,8 +146,63 @@ def get_visible(self): # Window state ###################################################################### - def set_full_screen(self, is_full_screen): - self.interface.factory.not_implemented("Window.set_full_screen()") + def get_window_state(self, in_progress_state=False): + decor_view = self.app.native.getWindow().getDecorView() + system_ui_flags = decor_view.getSystemUiVisibility() + if system_ui_flags & ( + decor_view.SYSTEM_UI_FLAG_FULLSCREEN + | decor_view.SYSTEM_UI_FLAG_HIDE_NAVIGATION + | decor_view.SYSTEM_UI_FLAG_IMMERSIVE + ): + if self._in_presentation_mode: + return WindowState.PRESENTATION + else: + return WindowState.FULLSCREEN + return WindowState.NORMAL + + def set_window_state(self, state): + current_state = self.get_window_state() + decor_view = self.app.native.getWindow().getDecorView() + + if current_state == state: + return + + elif current_state != WindowState.NORMAL: + if current_state == WindowState.FULLSCREEN: + decor_view.setSystemUiVisibility(0) + + else: # current_state == WindowState.PRESENTATION: + decor_view.setSystemUiVisibility(0) + self.show_actionbar(True) + self._in_presentation_mode = False + + self.set_window_state(state) + + else: # current_state == WindowState.NORMAL: + if state == WindowState.MAXIMIZED: + # no-op on Android. + pass + + elif state == WindowState.MINIMIZED: + # no-op on Android. + pass + + elif state == WindowState.FULLSCREEN: + decor_view.setSystemUiVisibility( + # These constants are all marked as deprecated as of API 30. + decor_view.SYSTEM_UI_FLAG_FULLSCREEN + | decor_view.SYSTEM_UI_FLAG_HIDE_NAVIGATION + | decor_view.SYSTEM_UI_FLAG_IMMERSIVE + ) + + else: # state == WindowState.PRESENTATION: + decor_view.setSystemUiVisibility( + decor_view.SYSTEM_UI_FLAG_FULLSCREEN + | decor_view.SYSTEM_UI_FLAG_HIDE_NAVIGATION + | decor_view.SYSTEM_UI_FLAG_IMMERSIVE + ) + self.show_actionbar(False) + self._in_presentation_mode = True ###################################################################### # Window capabilities @@ -168,3 +232,10 @@ def create_toolbar(self): # Toolbar items are configured as part of onPrepareOptionsMenu; trigger that # handler. self.app.native.invalidateOptionsMenu() + + def show_actionbar(self, show): + actionbar = self.app.native.getSupportActionBar() + if show: + actionbar.show() + else: + actionbar.hide() diff --git a/android/tests_backend/window.py b/android/tests_backend/window.py index 6a8806aafd..03bc310802 100644 --- a/android/tests_backend/window.py +++ b/android/tests_backend/window.py @@ -6,13 +6,26 @@ class WindowProbe(BaseProbe, DialogsMixin): + supports_fullscreen = True + supports_presentation = True + def __init__(self, app, window): super().__init__(app) self.native = self.app._impl.native self.window = window + self.impl = self.window._impl - async def wait_for_window(self, message, minimize=False, full_screen=False): - await self.redraw(message) + async def wait_for_window( + self, + message, + minimize=False, + full_screen=False, + state_switch_not_from_normal=False, + ): + await self.redraw( + message, + delay=(0.5 if (full_screen or state_switch_not_from_normal) else 0.1), + ) @property def content_size(self): @@ -34,6 +47,10 @@ def top_bar_height(self): def _native_menu(self): return self.native.findViewById(appcompat_R.id.action_bar).getMenu() + @property + def instantaneous_state(self): + return self.impl.get_window_state(in_progress_state=False) + def _toolbar_items(self): result = [] prev_group = None diff --git a/changes/1857.feature.rst b/changes/1857.feature.rst new file mode 100644 index 0000000000..9b5bdb6a37 --- /dev/null +++ b/changes/1857.feature.rst @@ -0,0 +1 @@ +Toga apps can now detect and set their window states including maximized, minimized, normal, full screen and presentation states. diff --git a/changes/1857.removal.rst b/changes/1857.removal.rst new file mode 100644 index 0000000000..567067bc83 --- /dev/null +++ b/changes/1857.removal.rst @@ -0,0 +1 @@ +"Full screen mode" on an app has been renamed "Presentation mode" to avoid the ambiguity with "full screen mode" on a window. The ``toga.App.enter_full_screen`` and ``toga.App.exit_full_screen`` APIs have been renamed ``toga.App.enter_presentation_mode`` and ``toga.App.exit_presentation_mode``, respectively. ``` diff --git a/cocoa/src/toga_cocoa/app.py b/cocoa/src/toga_cocoa/app.py index 17424a5e96..b1c59c4a85 100644 --- a/cocoa/src/toga_cocoa/app.py +++ b/cocoa/src/toga_cocoa/app.py @@ -29,7 +29,6 @@ NSCursor, NSMenu, NSMenuItem, - NSNumber, NSPanel, NSScreen, ) @@ -386,36 +385,3 @@ def get_current_window(self): def set_current_window(self, window): window._impl.native.makeKeyAndOrderFront(window._impl.native) - - ###################################################################### - # Full screen control - ###################################################################### - - def enter_full_screen(self, windows): - opts = NSMutableDictionary.alloc().init() - opts.setObject( - NSNumber.numberWithBool(True), forKey="NSFullScreenModeAllScreens" - ) - - for window, screen in zip(windows, NSScreen.screens): - # The widgets are actually added to window._impl.container.native, instead - # of window.content._impl.native. And window._impl.native.contentView is - # window._impl.container.native. Hence, we need to go fullscreen on - # window._impl.container.native instead. - window._impl.container.native.enterFullScreenMode(screen, withOptions=opts) - # Going full screen causes the window content to be re-homed - # in a NSFullScreenWindow; teach the new parent window - # about its Toga representations. - window._impl.container.native.window._impl = window._impl - window._impl.container.native.window.interface = window - window.content.refresh() - - def exit_full_screen(self, windows): - opts = NSMutableDictionary.alloc().init() - opts.setObject( - NSNumber.numberWithBool(True), forKey="NSFullScreenModeAllScreens" - ) - - for window in windows: - window._impl.container.native.exitFullScreenModeWithOptions(opts) - window.content.refresh() diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index c4802db5b9..ec85955e5b 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -9,6 +9,7 @@ ) from toga.command import Command, Separator +from toga.constants import WindowState from toga.types import Position, Size from toga.window import _initial_position from toga_cocoa.container import Container @@ -16,6 +17,8 @@ NSBackingStoreBuffered, NSImage, NSMutableArray, + NSMutableDictionary, + NSNumber, NSScreen, NSToolbar, NSToolbarItem, @@ -43,12 +46,66 @@ def windowShouldClose_(self, notification) -> bool: self.interface.on_close() return False + @objc_method + def windowWillClose_(self, notification) -> None: + # Setting the toolbar delegate to None doesn't disconnect + # the delegate and still triggers the delegate events. + # Hence, simply remove the toolbar instead. + if self.toolbar: + self.toolbar = None + # Disconnect the window delegate. + self.delegate = None + + # Disconnecting the window delegate doesn't prevent custom methods + # which are performed with a delay (i.e., having a delay != 0), + # from being triggered when `impl` & `interface` attributes are empty. + # Hence, check guards for empty `impl` and `interface` attributes need + # to be used in such custom methods. + @objc_method def windowDidResize_(self, notification) -> None: if self.interface.content: # Set the window to the new size self.interface.content.refresh() + @objc_method + def windowDidMiniaturize_(self, notification) -> None: + if ( + self.impl._pending_state_transition + and self.impl._pending_state_transition != WindowState.MINIMIZED + ): + self.impl._apply_state(WindowState.NORMAL) + else: + self.impl._pending_state_transition = None + + @objc_method + def windowDidDeminiaturize_(self, notification) -> None: + self.impl._apply_state(self.impl._pending_state_transition) + + @objc_method + def windowDidEnterFullScreen_(self, notification) -> None: + if ( + self.impl._pending_state_transition + and self.impl._pending_state_transition != WindowState.FULLSCREEN + ): + # Directly exiting fullscreen without a delay will result in error: + # ````2024-08-09 15:46:39.050 python[2646:37395] not in fullscreen state```` + # and any subsequent window state calls to the OS will not work or will be + # glitchy. + self.performSelector( + SEL("delayedFullScreenExit:"), withObject=None, afterDelay=0 + ) + else: + self.impl._pending_state_transition = None + + @objc_method + def delayedFullScreenExit_(self, sender) -> None: + self.impl._apply_state(WindowState.NORMAL) + + @objc_method + def windowDidExitFullScreen_(self, notification) -> None: + self.impl._apply_state(self.impl._pending_state_transition) + ###################################################################### # Toolbar delegate methods ###################################################################### @@ -166,6 +223,9 @@ def __init__(self, interface, title, position, size): # in response to the close. self.native.retain() + # Pending Window state transition variable: + self._pending_state_transition = None + self.set_title(title) self.set_size(size) self.set_position(position if position is not None else _initial_position()) @@ -283,17 +343,141 @@ def hide(self): self.native.orderOut(self.native) def get_visible(self): - return bool(self.native.isVisible) + return ( + bool(self.native.isVisible) + or self.get_window_state(in_progress_state=True) == WindowState.MINIMIZED + ) ###################################################################### # Window state ###################################################################### - def set_full_screen(self, is_full_screen): - current_state = bool(self.native.styleMask & NSWindowStyleMask.FullScreen) - if is_full_screen != current_state: + def get_window_state(self, in_progress_state=False): + if in_progress_state and self._pending_state_transition: + return self._pending_state_transition + if bool(self.container.native.isInFullScreenMode()): + return WindowState.PRESENTATION + elif bool(self.native.styleMask & NSWindowStyleMask.FullScreen): + return WindowState.FULLSCREEN + elif bool(self.native.isZoomed): + return WindowState.MAXIMIZED + elif bool(self.native.isMiniaturized): + return WindowState.MINIMIZED + else: + return WindowState.NORMAL + + def set_window_state(self, state): + # Since the requests to the OS for changing window states are non-blocking, + # if we are in the middle of processing a state, we need to store the + # user-requested state and apply the state when we have completed processing + # a transition. There are 2 types of callbacks: + # * EnteredState: + # Here, we need to check if the current state is the same as the pending + # state. + # -- If yes: Clear the pending state variable and return. + # -- If no: Apply NORMAL state, which will later apply the pending state + # when the state is NORMAL. + # * ExitedState: + # Here, since we are in NORMAL state, we just apply the pending state. + # When we enter the user-requested pending state, then clear the pending + # state variable and return. + + if self._pending_state_transition: + self._pending_state_transition = state + else: + # If the app is in presentation mode, but this window isn't, then + # exit app presentation mode before setting the requested state. + if any( + window.state == WindowState.PRESENTATION and window != self.interface + for window in self.interface.app.windows + ): + self.interface.app.exit_presentation_mode() + + self._pending_state_transition = state + if self.get_window_state() != WindowState.NORMAL: + self._apply_state(WindowState.NORMAL) + else: + self._apply_state(state) + + def _apply_state(self, target_state): + if target_state is None: + return + + current_state = self.get_window_state() + # Although same state check is done at the core, yet this is required + # Since, _apply_state() is called internally on the implementation + # side, after the completion of non-blocking APIs(setIsMiniaturized, + # toggleFullScreen), by the delegate. Then this same state check is + # used to terminate further processing. + if target_state == current_state: + self._pending_state_transition = None + return + + elif target_state == WindowState.MAXIMIZED: + self.native.setIsZoomed(True) + # No need to check for other pending states, + # since this is fully applied at this point. + self._pending_state_transition = None + + elif target_state == WindowState.MINIMIZED: + self.native.setIsMiniaturized(True) + + elif target_state == WindowState.FULLSCREEN: self.native.toggleFullScreen(self.native) + elif target_state == WindowState.PRESENTATION: + self._before_presentation_mode_screen = self.interface.screen + opts = NSMutableDictionary.alloc().init() + opts.setObject( + NSNumber.numberWithBool(True), + forKey="NSFullScreenModeAllScreens", + ) + # The widgets are actually added to + # window._impl.container.native, instead of + # window.content._impl.native. And + # window._impl.native.contentView is + # window._impl.container.native. Hence, + # we need to go fullscreen on + # window._impl.container.native instead. + self.container.native.enterFullScreenMode( + self.interface.screen._impl.native, withOptions=opts + ) + + # Going presentation mode causes the window content + # to be re-homed in a NSFullScreenWindow; teach the + # new parent window about its Toga representations. + self.container.native.window._impl = self + self.container.native.window.interface = self.interface + self.interface.content.refresh() + + # No need to check for other pending states, + # since this is fully applied at this point. + self._pending_state_transition = None + + else: # target_state == WindowState.NORMAL: + if current_state == WindowState.MAXIMIZED: + self.native.setIsZoomed(False) + self._apply_state(self._pending_state_transition) + + elif current_state == WindowState.MINIMIZED: + self.native.setIsMiniaturized(False) + + elif current_state == WindowState.FULLSCREEN: + self.native.toggleFullScreen(self.native) + + else: # current_state == WindowState.PRESENTATION: + opts = NSMutableDictionary.alloc().init() + opts.setObject( + NSNumber.numberWithBool(True), forKey="NSFullScreenModeAllScreens" + ) + self.container.native.exitFullScreenModeWithOptions(opts) + self.interface.content.refresh() + + self.interface.screen = self._before_presentation_mode_screen + del self._before_presentation_mode_screen + + self._apply_state(self._pending_state_transition) + ###################################################################### # Window capabilities ###################################################################### diff --git a/cocoa/tests_backend/app.py b/cocoa/tests_backend/app.py index 60f0693929..6b3bd9f431 100644 --- a/cocoa/tests_backend/app.py +++ b/cocoa/tests_backend/app.py @@ -55,15 +55,6 @@ def is_cursor_visible(self): # fall back to the implementation's proxy variable. return self.app._impl._cursor_visible - def is_full_screen(self, window): - return window._impl.container.native.isInFullScreenMode() - - def content_size(self, window): - return ( - window.content._impl.native.frame.size.width, - window.content._impl.native.frame.size.height, - ) - def assert_app_icon(self, icon): # We have no real way to check we've got the right icon; use pixel peeping as a # guess. Construct a PIL image from the current icon. diff --git a/cocoa/tests_backend/window.py b/cocoa/tests_backend/window.py index 6ef29b7c49..a8eabf9ed8 100644 --- a/cocoa/tests_backend/window.py +++ b/cocoa/tests_backend/window.py @@ -22,10 +22,20 @@ def __init__(self, app, window): self.native = window._impl.native assert isinstance(self.native, NSWindow) - async def wait_for_window(self, message, minimize=False, full_screen=False): + async def wait_for_window( + self, + message, + minimize=False, + full_screen=False, + state_switch_not_from_normal=False, + ): await self.redraw( message, - delay=0.75 if full_screen else 0.5 if minimize else 0.1, + delay=( + 1.75 + if state_switch_not_from_normal + else 0.75 if full_screen else 0.5 if minimize else 0.1 + ), ) def close(self): @@ -34,14 +44,10 @@ def close(self): @property def content_size(self): return ( - self.native.contentView.frame.size.width, - self.native.contentView.frame.size.height, + self.impl.container.native.frame.size.width, + self.impl.container.native.frame.size.height, ) - @property - def is_full_screen(self): - return bool(self.native.styleMask & NSWindowStyleMask.FullScreen) - @property def is_resizable(self): return bool(self.native.styleMask & NSWindowStyleMask.Resizable) @@ -64,6 +70,10 @@ def minimize(self): def unminimize(self): self.native.deminiaturize(None) + @property + def instantaneous_state(self): + return self.impl.get_window_state(in_progress_state=False) + def has_toolbar(self): return self.native.toolbar is not None diff --git a/core/src/toga/app.py b/core/src/toga/app.py index 503fee116f..dc1f858ec2 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -13,6 +13,7 @@ from weakref import WeakValueDictionary from toga.command import Command, CommandSet +from toga.constants import WindowState from toga.documents import Document, DocumentSet from toga.handlers import simple_handler, wrapped_handler from toga.hardware.camera import Camera @@ -351,8 +352,6 @@ def __init__( self._main_window = App._UNDEFINED self._windows = WindowSet(self) - self._full_screen_windows: tuple[Window, ...] | None = None - # Create the implementation. This will trigger any startup logic. self.factory.App(interface=self) @@ -853,35 +852,57 @@ def current_window(self, window: Window) -> None: self._impl.set_current_window(window) ###################################################################### - # Full screen control + # Presentation mode controls ###################################################################### - def exit_full_screen(self) -> None: - """Exit full screen mode.""" - if self.is_full_screen: - self._impl.exit_full_screen(self._full_screen_windows) - self._full_screen_windows = None - @property - def is_full_screen(self) -> bool: - """Is the app currently in full screen mode?""" - return self._full_screen_windows is not None + def in_presentation_mode(self) -> bool: + """Is the app currently in presentation mode?""" + return any(window.state == WindowState.PRESENTATION for window in self.windows) - def set_full_screen(self, *windows: Window) -> None: - """Make one or more windows full screen. - - Full screen is not the same as "maximized"; full screen mode is when all window - borders and other window decorations are no longer visible. - - :param windows: The list of windows to go full screen, in order of allocation to - screens. If the number of windows exceeds the number of available displays, - those windows will not be visible. If no windows are specified, the app will - exit full screen mode. + def enter_presentation_mode( + self, + windows: list[Window] | dict[Screen, Window], + ) -> None: + """Enter into presentation mode with one or more windows on different screens. + + Presentation mode is not the same as "Full Screen" mode; presentation mode is + when window borders, other window decorations, app menu and toolbars are no + longer visible. + + :param windows: A list of windows, or a dictionary + mapping screens to windows, to go into presentation, in order of + allocation to screens. If the number of windows exceeds the number + of available displays, those windows will not be visible. The windows + must have a content set on them. + + :raises ValueError: If the presentation layout supplied is not a list of + windows or a dict mapping windows to screens, or if any window does + not have content. """ - self.exit_full_screen() if windows: - self._impl.enter_full_screen(windows) - self._full_screen_windows = windows + screen_window_dict = dict() + if isinstance(windows, list): + for window, screen in zip(windows, self.screens): + screen_window_dict[screen] = window + elif isinstance(windows, dict): + screen_window_dict = windows + else: + raise ValueError( + "Presentation layout should be a list of windows," + " or a dict mapping windows to screens." + ) + + for screen, window in screen_window_dict.items(): + window._impl._before_presentation_mode_screen = window.screen + window.screen = screen + window._impl.set_window_state(WindowState.PRESENTATION) + + def exit_presentation_mode(self) -> None: + """Exit presentation mode.""" + for window in self.windows: + if window.state == WindowState.PRESENTATION: + window._impl.set_window_state(WindowState.NORMAL) ###################################################################### # App events @@ -944,6 +965,51 @@ def add_background_task(self, handler: BackgroundTask) -> None: self.loop.call_soon_threadsafe(wrapped_handler(self, handler)) + ###################################################################### + # 2024-07: Backwards compatibility + ###################################################################### + + def exit_full_screen(self) -> None: + """**DEPRECATED** – Use :any:`App.exit_presentation_mode()`.""" + warnings.warn( + ( + "`App.exit_full_screen()` is deprecated. " + "Use `App.exit_presentation_mode()` instead." + ), + DeprecationWarning, + stacklevel=2, + ) + if self.in_presentation_mode: + self.exit_presentation_mode() + + @property + def is_full_screen(self) -> bool: + """**DEPRECATED** – Use :any:`App.in_presentation_mode`.""" + warnings.warn( + ( + "`App.is_full_screen` is deprecated. " + "Use `App.in_presentation_mode` instead." + ), + DeprecationWarning, + stacklevel=2, + ) + return self.in_presentation_mode + + def set_full_screen(self, *windows: Window) -> None: + """**DEPRECATED** – Use :any:`App.enter_presentation_mode()` and + :any:`App.exit_presentation_mode()`.""" + warnings.warn( + ( + "`App.set_full_screen()` is deprecated. " + "Use `App.enter_presentation_mode()` instead." + ), + DeprecationWarning, + stacklevel=2, + ) + self.exit_presentation_mode() + if windows: + self.enter_presentation_mode(list(windows)) + ###################################################################### # End backwards compatibility ###################################################################### diff --git a/core/src/toga/constants/__init__.py b/core/src/toga/constants/__init__.py index 8c87e451d0..6ace32d11b 100644 --- a/core/src/toga/constants/__init__.py +++ b/core/src/toga/constants/__init__.py @@ -64,3 +64,42 @@ def __str__(self) -> str: # CELLULAR = 0 # WIFI = 1 # HIGHEST = 2 + +########################################################################## +# Window States +########################################################################## + + +class WindowState(Enum): + """The possible window states of an app. + + NOTE: Some platforms do not fully support all states; see the :any:`toga.Window`'s + platform notes for details. + """ + + NORMAL = 0 + """The ``NORMAL`` state represents the default state of the window or app when it is + not in any other specific window state.""" + + MINIMIZED = 1 + """``MINIMIZED`` state is when the window isn't currently visible, although it will + appear in any operating system's list of active windows. + """ + + MAXIMIZED = 2 + """The window is the largest size it can be on the screen with title bar and window + chrome still visible. + """ + + FULLSCREEN = 3 + """``FULLSCREEN`` state is when the window title bar and window chrome remain + hidden; But app menu and toolbars remain visible. + """ + + PRESENTATION = 4 + """``PRESENTATION`` state is when the window title bar, window chrome, app menu + and toolbars all remain hidden. + + A good example is a slideshow app in presentation mode - the only visible content + is the slide. + """ diff --git a/core/src/toga/window.py b/core/src/toga/window.py index b4f8efbd13..fe5072aad9 100644 --- a/core/src/toga/window.py +++ b/core/src/toga/window.py @@ -9,6 +9,7 @@ import toga from toga import dialogs from toga.command import CommandSet +from toga.constants import WindowState from toga.handlers import AsyncResult, wrapped_handler from toga.images import Image from toga.platform import get_platform_factory @@ -189,7 +190,6 @@ def __init__( self._id = str(id if id else identifier(self)) self._impl: Any = None self._content: Widget | None = None - self._is_full_screen = False self._closed = False self._resizable = resizable @@ -385,11 +385,17 @@ def widgets(self) -> FilteredWidgetRegistry: @property def size(self) -> Size: - """Size of the window, in :ref:`CSS pixels `.""" + """Size of the window, in :ref:`CSS pixels `. + + :raises RuntimeError: If resize is requested while in + :any:`WindowState.FULLSCREEN` or :any:`WindowState.PRESENTATION`. + """ return self._impl.get_size() @size.setter def size(self, size: SizeT) -> None: + if self.state in {WindowState.FULLSCREEN, WindowState.PRESENTATION}: + raise RuntimeError(f"Cannot resize window while in {self.state}") self._impl.set_size(size) if self.content: self.content.refresh() @@ -403,6 +409,9 @@ def position(self) -> Position: """Absolute position of the window, in :ref:`CSS pixels `. The origin is the top left corner of the primary screen. + + :raises RuntimeError: If position change is requested while in + :any:`WindowState.FULLSCREEN` or :any:`WindowState.PRESENTATION`. """ absolute_origin = self._app.screens[0].origin absolute_window_position = self._impl.get_position() @@ -412,6 +421,8 @@ def position(self) -> Position: @position.setter def position(self, position: PositionT) -> None: + if self.state in {WindowState.FULLSCREEN, WindowState.PRESENTATION}: + raise RuntimeError(f"Cannot change window position while in {self.state}") absolute_origin = self._app.screens[0].origin absolute_new_position = Position(*position) + absolute_origin self._impl.set_position(absolute_new_position) @@ -431,11 +442,17 @@ def screen(self, app_screen: Screen) -> None: @property def screen_position(self) -> Position: """Position of the window with respect to current screen, in - :ref:`CSS pixels `.""" + :ref:`CSS pixels `. + + :raises RuntimeError: If position change is requested while in + :any:`WindowState.FULLSCREEN` or :any:`WindowState.PRESENTATION`. + """ return self.position - self.screen.origin @screen_position.setter def screen_position(self, position: PositionT) -> None: + if self.state in {WindowState.FULLSCREEN, WindowState.PRESENTATION}: + raise RuntimeError(f"Cannot change window position while in {self.state}") new_relative_position = Position(*position) + self.screen.origin self._impl.set_position(new_relative_position) @@ -465,21 +482,39 @@ def visible(self, visible: bool) -> None: ###################################################################### @property - def full_screen(self) -> bool: - """Is the window in full screen mode? + def state(self) -> WindowState: + """The current state of the window. - Full screen mode is *not* the same as "maximized". A full screen window - has no title bar, toolbar or window controls; some or all of these - items may be visible on a maximized window. A good example of "full screen" - mode is a slideshow app in presentation mode - the only visible content is - the slide. - """ - return self._is_full_screen + When the window is in transition, then this will return the state it + is transitioning towards, instead of the actual instantaneous state. - @full_screen.setter - def full_screen(self, is_full_screen: bool) -> None: - self._is_full_screen = is_full_screen - self._impl.set_full_screen(is_full_screen) + :raises RuntimeError: If state change is requested while the window is + hidden. + + :raises ValueError: If any state other than :any:`WindowState.MINIMIZED` + or :any:`WindowState.NORMAL` is requested on a non-resizable window. + """ + # There are 2 types of window states that we can get from the backend: + # * The instantaneous state -- Used internally on implementation side + # * The in-progress state -- Used for same state checking on the core + # and for the public API. + return self._impl.get_window_state(in_progress_state=True) + + @state.setter + def state(self, state: WindowState) -> None: + if not self.visible: + raise RuntimeError("Window state of a hidden window cannot be changed.") + elif not self.resizable and state in { + WindowState.MAXIMIZED, + WindowState.FULLSCREEN, + WindowState.PRESENTATION, + }: + raise ValueError( + f"A non-resizable window cannot be set to a state of {state}." + ) + else: + if self.state != state: + self._impl.set_window_state(state) ###################################################################### # Window capabilities @@ -824,6 +859,32 @@ def closeable(self) -> bool: # End Backwards compatibility ###################################################################### + ###################################################################### + # 2024-10: Backwards compatibility + ###################################################################### + @property + def full_screen(self) -> bool: + """**DEPRECATED** – Use :any:`Window.state`.""" + warnings.warn( + ("`Window.full_screen` is deprecated. Use `Window.state` instead."), + DeprecationWarning, + ) + return bool(self.state == WindowState.FULLSCREEN) + + @full_screen.setter + def full_screen(self, is_full_screen: bool) -> None: + warnings.warn( + ("`Window.full_screen` is deprecated. Use `Window.state` instead."), + DeprecationWarning, + ) + target_state = WindowState.FULLSCREEN if is_full_screen else WindowState.NORMAL + if self.state != target_state: + self._impl.set_window_state(target_state) + + ###################################################################### + # End Backwards compatibility + ###################################################################### + class MainWindow(Window): _WINDOW_CLASS = "MainWindow" diff --git a/core/tests/app/test_app.py b/core/tests/app/test_app.py index 39b5adc799..a24f7c571e 100644 --- a/core/tests/app/test_app.py +++ b/core/tests/app/test_app.py @@ -9,6 +9,7 @@ import pytest import toga +from toga.constants import WindowState from toga_dummy.utils import ( EventLog, assert_action_not_performed, @@ -468,57 +469,203 @@ def startup(self): BadMainWindowApp(formal_name="Test App", app_id="org.example.test") -def test_full_screen(event_loop): - """The app can be put into full screen mode.""" - window1 = toga.Window() - window2 = toga.Window() +@pytest.mark.parametrize( + "windows", + [ + [{}], # One window + [{}, {}], # Two windows + ], +) +def test_presentation_mode_with_windows_list(event_loop, windows): + """The app can enter presentation mode with a windows list.""" app = toga.App(formal_name="Test App", app_id="org.example.test") + windows_list = [toga.Window() for window in windows] + + assert not app.in_presentation_mode + + # Enter presentation mode with 1 or more windows: + app.enter_presentation_mode(windows_list) + assert app.in_presentation_mode + for window in windows_list: + assert_action_performed_with( + window, + "set window state to WindowState.PRESENTATION", + state=WindowState.PRESENTATION, + ) + # Exit presentation mode: + app.exit_presentation_mode() + assert not app.in_presentation_mode + for window in windows_list: + assert_action_performed_with( + window, + "set window state to WindowState.NORMAL", + state=WindowState.NORMAL, + ) - assert not app.is_full_screen - # If we're not full screen, exiting full screen is a no-op - app.exit_full_screen() - assert_action_not_performed(app, "exit_full_screen") +@pytest.mark.parametrize( + "windows", + [ + [{}], # One window + [{}, {}], # Two windows + ], +) +def test_presentation_mode_with_screen_window_dict(event_loop, windows): + """The app can enter presentation mode with a screen-window paired dict.""" + app = toga.App(formal_name="Test App", app_id="org.example.test") + screen_window_dict = { + app.screens[i]: toga.Window() for i, window in enumerate(windows) + } + + assert not app.in_presentation_mode + + # Enter presentation mode with a 1 or more elements screen-window dict: + app.enter_presentation_mode(screen_window_dict) + assert app.in_presentation_mode + for screen, window in screen_window_dict.items(): + assert_action_performed_with( + window, + "set window state to WindowState.PRESENTATION", + state=WindowState.PRESENTATION, + ) - # Enter full screen with 2 windows - app.set_full_screen(window2, app.main_window) - assert app.is_full_screen + # Exit presentation mode: + app.exit_presentation_mode() + assert not app.in_presentation_mode + for screen, window in screen_window_dict.items(): + assert_action_performed_with( + window, + "set window state to WindowState.NORMAL", + state=WindowState.NORMAL, + ) + + +def test_presentation_mode_with_excess_windows_list(event_loop): + """Entering presentation mode limits windows to available displays.""" + app = toga.App(formal_name="Test App", app_id="org.example.test") + window1 = toga.Window() + window2 = toga.Window() + window3 = toga.Window() + + assert not app.in_presentation_mode + + # Entering presentation mode with 3 windows should drop the last window, + # as the app has only 2 screens: + app.enter_presentation_mode([window1, window2, window3]) + assert app.in_presentation_mode assert_action_performed_with( - app, "enter_full_screen", windows=(window2, app.main_window) + window1, + "set window state to WindowState.PRESENTATION", + state=WindowState.PRESENTATION, ) - - # Change the screens that are full screen - app.set_full_screen(app.main_window, window1) - assert app.is_full_screen assert_action_performed_with( - app, "enter_full_screen", windows=(app.main_window, window1) + window2, + "set window state to WindowState.PRESENTATION", + state=WindowState.PRESENTATION, + ) + assert_action_not_performed( + window3, + "set window state to WindowState.PRESENTATION", ) - # Exit full screen mode - app.exit_full_screen() - assert not app.is_full_screen + # Exit presentation mode: + app.exit_presentation_mode() + assert not app.in_presentation_mode + assert_action_performed_with( + window1, + "set window state to WindowState.NORMAL", + state=WindowState.NORMAL, + ) assert_action_performed_with( - app, "exit_full_screen", windows=(app.main_window, window1) + window2, + "set window state to WindowState.NORMAL", + state=WindowState.NORMAL, + ) + assert_action_not_performed( + window3, + "set window state to WindowState.NORMAL", ) -def test_set_empty_full_screen_window_list(event_loop): - """Setting the full screen window list to [] is an explicit exit.""" +def test_presentation_mode_with_some_windows(event_loop): + """The app can enter presentation mode for some windows while others stay normal.""" app = toga.App(formal_name="Test App", app_id="org.example.test") window1 = toga.Window() window2 = toga.Window() - assert not app.is_full_screen + assert not app.in_presentation_mode - # Change the screens that are full screen - app.set_full_screen(window1, window2) - assert app.is_full_screen - assert_action_performed_with(app, "enter_full_screen", windows=(window1, window2)) + # Entering presentation mode with one window should not put the other + # window into presentation mode. + app.enter_presentation_mode([window1]) + assert app.in_presentation_mode + assert_action_performed_with( + window1, + "set window state to WindowState.PRESENTATION", + state=WindowState.PRESENTATION, + ) + assert_action_not_performed( + window2, + "set window state to WindowState.PRESENTATION", + ) + assert window1.state == WindowState.PRESENTATION + assert window2.state != WindowState.PRESENTATION - # Exit full screen mode by setting no windows full screen - app.set_full_screen() - assert not app.is_full_screen - assert_action_performed_with(app, "exit_full_screen", windows=(window1, window2)) + # Exit presentation mode: + app.exit_presentation_mode() + assert not app.in_presentation_mode + assert_action_performed_with( + window1, + "set window state to WindowState.NORMAL", + state=WindowState.NORMAL, + ) + assert_action_not_performed( + window2, + "set window state to WindowState.NORMAL", + ) + assert window1.state != WindowState.PRESENTATION + assert window2.state != WindowState.PRESENTATION + + +def test_presentation_mode_no_op(event_loop): + """Entering presentation mode with invalid conditions is a no-op.""" + app = toga.App(formal_name="Test App", app_id="org.example.test") + + assert not app.in_presentation_mode + + # Entering presentation mode without any window is a no-op. + with pytest.raises(TypeError): + app.enter_presentation_mode() + assert not app.in_presentation_mode + assert_action_not_performed( + app.main_window, "set window state to WindowState.PRESENTATION" + ) + + # Entering presentation mode with an empty dict, is a no-op: + app.enter_presentation_mode({}) + assert not app.in_presentation_mode + assert_action_not_performed( + app.main_window, "set window state to WindowState.PRESENTATION" + ) + + # Entering presentation mode with an empty windows list, is a no-op: + app.enter_presentation_mode([]) + assert not app.in_presentation_mode + assert_action_not_performed( + app.main_window, "set window state to WindowState.PRESENTATION" + ) + + # Entering presentation mode without proper type of parameter is a no-op. + with pytest.raises( + ValueError, + match="Presentation layout should be a list of windows, " + "or a dict mapping windows to screens.", + ): + app.enter_presentation_mode(toga.Window()) + assert not app.in_presentation_mode + assert_action_not_performed( + app.main_window, "set window state to WindowState.PRESENTATION" + ) def test_show_hide_cursor(app): @@ -847,3 +994,197 @@ async def waiter(): # Once the loop has executed, the background task should have executed as well. canary.assert_called_once() + + +def test_deprecated_full_screen(event_loop): + """The app can be put into full screen mode using the deprecated API.""" + app = toga.App(formal_name="Test App", app_id="org.example.test") + app.main_window.content = toga.Box() + window1 = toga.Window(content=toga.Box()) + window2 = toga.Window(content=toga.Box()) + + is_full_screen_warning = ( + r"`App.is_full_screen` is deprecated. Use `App.in_presentation_mode` instead." + ) + set_full_screen_warning = ( + r"`App.set_full_screen\(\)` is deprecated. " + r"Use `App.enter_presentation_mode\(\)` instead." + ) + exit_full_screen_warning = ( + r"`App.exit_full_screen\(\)` is deprecated. " + r"Use `App.exit_presentation_mode\(\)` instead." + ) + + with pytest.warns( + DeprecationWarning, + match=is_full_screen_warning, + ): + assert not app.is_full_screen + + # If we're not full screen, exiting full screen is a no-op + with pytest.warns( + DeprecationWarning, + match=exit_full_screen_warning, + ): + app.exit_full_screen() + with pytest.warns( + DeprecationWarning, + match=is_full_screen_warning, + ): + assert not app.is_full_screen + assert_action_not_performed( + app.main_window, + "set window state to WindowState.NORMAL", + ) + + # Trying to enter full screen with no windows is a no-op + with pytest.warns( + DeprecationWarning, + match=set_full_screen_warning, + ): + app.set_full_screen() + + with pytest.warns( + DeprecationWarning, + match=is_full_screen_warning, + ): + assert not app.is_full_screen + assert_action_not_performed( + app.main_window, + "set window state to WindowState.PRESENTATION", + ) + + # Enter full screen with 2 windows + with pytest.warns( + DeprecationWarning, + match=set_full_screen_warning, + ): + app.set_full_screen(window2, app.main_window) + with pytest.warns( + DeprecationWarning, + match=is_full_screen_warning, + ): + assert app.is_full_screen + assert_action_performed_with( + window2, + "set window state to WindowState.PRESENTATION", + state=WindowState.PRESENTATION, + ) + assert_action_performed_with( + app.main_window, + "set window state to WindowState.PRESENTATION", + state=WindowState.PRESENTATION, + ) + + # Change the screens that are full screen + with pytest.warns( + DeprecationWarning, + match=set_full_screen_warning, + ): + app.set_full_screen(app.main_window, window1) + with pytest.warns( + DeprecationWarning, + match=is_full_screen_warning, + ): + assert app.is_full_screen + assert_action_performed_with( + app.main_window, + "set window state to WindowState.PRESENTATION", + state=WindowState.PRESENTATION, + ) + assert_action_performed_with( + window1, + "set window state to WindowState.PRESENTATION", + state=WindowState.PRESENTATION, + ) + assert_action_performed_with( + window2, + "set window state to WindowState.NORMAL", + state=WindowState.NORMAL, + ) + # Exit full screen mode + with pytest.warns( + DeprecationWarning, + match=exit_full_screen_warning, + ): + app.exit_full_screen() + with pytest.warns( + DeprecationWarning, + match=is_full_screen_warning, + ): + assert not app.is_full_screen + assert_action_performed_with( + app.main_window, + "set window state to WindowState.NORMAL", + state=WindowState.NORMAL, + ) + assert_action_performed_with( + window1, + "set window state to WindowState.NORMAL", + state=WindowState.NORMAL, + ) + + +def test_deprecated_set_empty_full_screen_window_list(event_loop): + """Setting the full screen window list to [] is an explicit exit.""" + app = toga.App(formal_name="Test App", app_id="org.example.test") + app.main_window.content = toga.Box() + window1 = toga.Window(content=toga.Box()) + window2 = toga.Window(content=toga.Box()) + + is_full_screen_warning = ( + r"`App.is_full_screen` is deprecated. Use `App.in_presentation_mode` instead." + ) + set_full_screen_warning = ( + r"`App.set_full_screen\(\)` is deprecated. " + r"Use `App.enter_presentation_mode\(\)` instead." + ) + + with pytest.warns( + DeprecationWarning, + match=is_full_screen_warning, + ): + assert not app.is_full_screen + + # Change the screens that are full screen + with pytest.warns( + DeprecationWarning, + match=set_full_screen_warning, + ): + app.set_full_screen(window1, window2) + with pytest.warns( + DeprecationWarning, + match=is_full_screen_warning, + ): + assert app.is_full_screen + assert_action_performed_with( + window1, + "set window state to WindowState.PRESENTATION", + state=WindowState.PRESENTATION, + ) + assert_action_performed_with( + window2, + "set window state to WindowState.PRESENTATION", + state=WindowState.PRESENTATION, + ) + # Exit full screen mode by setting no windows full screen + with pytest.warns( + DeprecationWarning, + match=set_full_screen_warning, + ): + app.set_full_screen() + with pytest.warns( + DeprecationWarning, + match=is_full_screen_warning, + ): + assert not app.is_full_screen + assert_action_performed_with( + window1, + "set window state to WindowState.NORMAL", + state=WindowState.NORMAL, + ) + assert_action_performed_with( + window2, + "set window state to WindowState.NORMAL", + state=WindowState.NORMAL, + ) diff --git a/core/tests/window/test_window.py b/core/tests/window/test_window.py index 19088f95d5..b60e60cd0f 100644 --- a/core/tests/window/test_window.py +++ b/core/tests/window/test_window.py @@ -4,7 +4,9 @@ import pytest import toga +from toga.constants import WindowState from toga_dummy.utils import ( + EventLog, assert_action_not_performed, assert_action_performed, assert_action_performed_with, @@ -302,17 +304,178 @@ def test_visibility(window, app): assert not window.visible -def test_full_screen(window, app): - """A window can be set full screen.""" - assert not window.full_screen +@pytest.mark.parametrize( + "initial_state, final_state", + [ + # Direct switch from NORMAL: + (WindowState.NORMAL, WindowState.MINIMIZED), + (WindowState.NORMAL, WindowState.MAXIMIZED), + (WindowState.NORMAL, WindowState.FULLSCREEN), + (WindowState.NORMAL, WindowState.PRESENTATION), + # Direct switch from MINIMIZED: + (WindowState.MINIMIZED, WindowState.NORMAL), + (WindowState.MINIMIZED, WindowState.MAXIMIZED), + (WindowState.MINIMIZED, WindowState.FULLSCREEN), + (WindowState.MINIMIZED, WindowState.PRESENTATION), + # Direct switch from MAXIMIZED: + (WindowState.MAXIMIZED, WindowState.NORMAL), + (WindowState.MAXIMIZED, WindowState.MINIMIZED), + (WindowState.MAXIMIZED, WindowState.FULLSCREEN), + (WindowState.MAXIMIZED, WindowState.PRESENTATION), + # Direct switch from FULLSCREEN: + (WindowState.FULLSCREEN, WindowState.NORMAL), + (WindowState.FULLSCREEN, WindowState.MINIMIZED), + (WindowState.FULLSCREEN, WindowState.MAXIMIZED), + (WindowState.FULLSCREEN, WindowState.PRESENTATION), + # Direct switch from PRESENTATION: + (WindowState.PRESENTATION, WindowState.NORMAL), + (WindowState.PRESENTATION, WindowState.MINIMIZED), + (WindowState.PRESENTATION, WindowState.MAXIMIZED), + (WindowState.PRESENTATION, WindowState.FULLSCREEN), + ], +) +def test_window_state(window, initial_state, final_state): + """A window can have different states.""" + window.show() + assert window.state == WindowState.NORMAL + + window.state = initial_state + assert window.state == initial_state + # A newly created window will always be in NORMAL state. + # Since, both the current state and initial_state, would + # be the same, hence "set window state to WindowState.NORMAL" + # action would not be performed again. + if initial_state != WindowState.NORMAL: + assert_action_performed_with( + window, + f"set window state to {initial_state}", + state=initial_state, + ) + + window.state = final_state + assert window.state == final_state + assert_action_performed_with( + window, + f"set window state to {final_state}", + state=final_state, + ) + + +@pytest.mark.parametrize( + "state", + [ + WindowState.NORMAL, + WindowState.MINIMIZED, + WindowState.MAXIMIZED, + WindowState.FULLSCREEN, + WindowState.PRESENTATION, + ], +) +def test_window_state_same_as_current(window, state): + """Setting window state the same as current is a no-op.""" + window.show() + + window.state = state + assert window.state == state - window.full_screen = True - assert window.full_screen - assert_action_performed_with(window, "set full screen", full_screen=True) + # Reset the EventLog to check that the action was not re-performed. + EventLog.reset() + window.show() + + window.state = state + assert window.state == state + assert_action_not_performed(window, f"set window state to {state}") + + +@pytest.mark.parametrize( + "state", + [ + WindowState.NORMAL, + WindowState.MINIMIZED, + WindowState.MAXIMIZED, + WindowState.FULLSCREEN, + WindowState.PRESENTATION, + ], +) +def test_hidden_window_state(state): + """Window state of a hidden window cannot be changed.""" + hidden_window = toga.Window(title="Hidden Window") + hidden_window.hide() + + with pytest.raises( + RuntimeError, + match="Window state of a hidden window cannot be changed.", + ): + hidden_window.state = state + assert_action_not_performed(hidden_window, f"set window state to {state}") + hidden_window.close() + + +@pytest.mark.parametrize( + "state", + [ + WindowState.MAXIMIZED, + WindowState.FULLSCREEN, + WindowState.PRESENTATION, + ], +) +def test_non_resizable_window_state(state): + """Non-resizable window's states other than minimized or normal are no-ops.""" + non_resizable_window = toga.Window(title="Non-Resizable Window", resizable=False) + non_resizable_window.show() + + with pytest.raises( + ValueError, + match=f"A non-resizable window cannot be set to a state of {state}.", + ): + non_resizable_window.state = state + assert_action_not_performed( + non_resizable_window, f"set window state to {state}" + ) + non_resizable_window.close() + + +@pytest.mark.parametrize( + "state", + [ + WindowState.FULLSCREEN, + WindowState.PRESENTATION, + ], +) +def test_resize_in_window_state(state): + """Window size cannot be changed while in fullscreen or presentation state.""" + window = toga.Window(title="Non-resizing window") + window.show() + window.state = state - window.full_screen = False - assert not window.full_screen - assert_action_performed_with(window, "set full screen", full_screen=False) + with pytest.raises(RuntimeError, match=f"Cannot resize window while in {state}"): + window.size = (100, 200) + window.close() + + +@pytest.mark.parametrize( + "state", + [ + WindowState.FULLSCREEN, + WindowState.PRESENTATION, + ], +) +def test_move_in_window_state(state): + """Window position cannot be changed while in fullscreen or presentation state.""" + window = toga.Window(title="Non-resizing window") + window.show() + window.state = state + + with pytest.raises( + RuntimeError, match=f"Cannot change window position while in {state}" + ): + window.position = (100, 200) + + with pytest.raises( + RuntimeError, match=f"Cannot change window position while in {state}" + ): + window.screen_position = (100, 200) + window.close() def test_close_direct(window, app): @@ -1207,3 +1370,62 @@ def test_deprecated_names_closeable(): match=r"Window.closeable has been renamed Window.closable", ): assert window.closeable + + +def test_deprecated_full_screen(window, app): + """A window can be set full screen using the deprecated API.""" + full_screen_warning = ( + "`Window.full_screen` is deprecated. Use `Window.state` instead." + ) + with pytest.warns( + DeprecationWarning, + match=full_screen_warning, + ): + assert not window.full_screen + with pytest.warns( + DeprecationWarning, + match=full_screen_warning, + ): + window.full_screen = True + with pytest.warns( + DeprecationWarning, + match=full_screen_warning, + ): + assert window.full_screen + assert_action_performed_with( + window, + "set window state to WindowState.FULLSCREEN", + state=WindowState.FULLSCREEN, + ) + with pytest.warns( + DeprecationWarning, + match=full_screen_warning, + ): + window.full_screen = False + with pytest.warns( + DeprecationWarning, + match=full_screen_warning, + ): + assert not window.full_screen + assert_action_performed_with( + window, + "set window state to WindowState.NORMAL", + state=WindowState.NORMAL, + ) + + # Clear the test event log to check that the previous task was not re-performed. + EventLog.reset() + + assert window.state == WindowState.NORMAL + with pytest.warns( + DeprecationWarning, + match=full_screen_warning, + ): + assert not window.full_screen + with pytest.warns( + DeprecationWarning, + match=full_screen_warning, + ): + window.full_screen = False + + assert_action_not_performed(window, "set window state to WindowState.NORMAL") diff --git a/docs/reference/api/window.rst b/docs/reference/api/window.rst index 0455fb4ced..caf28e9899 100644 --- a/docs/reference/api/window.rst +++ b/docs/reference/api/window.rst @@ -100,6 +100,14 @@ Notes window on a mobile platform. If you try to modify the size, position, or visibility of the main window, the request will be ignored. +* On mobile platforms, a window's state cannot be :any:`WindowState.MINIMIZED` or + :any:`WindowState.MAXIMIZED`. Any request to move to these states will be ignored. + +* On Linux, when using Wayland, a request to put a window into a + :any:`WindowState.MINIMIZED` state, or to restore from the + :any:`WindowState.MINIMIZED` state, will be ignored. This is due to + limitations in window management features that Wayland allows apps to use. + Reference --------- diff --git a/dummy/src/toga_dummy/app.py b/dummy/src/toga_dummy/app.py index 1136acda09..e64a79574d 100644 --- a/dummy/src/toga_dummy/app.py +++ b/dummy/src/toga_dummy/app.py @@ -136,16 +136,6 @@ def set_current_window(self, window): self._action("set_current_window", window=window) self._set_value("current_window", window._impl) - ###################################################################### - # Full screen control - ###################################################################### - - def enter_full_screen(self, windows): - self._action("enter_full_screen", windows=windows) - - def exit_full_screen(self, windows): - self._action("exit_full_screen", windows=windows) - class DocumentApp(App): def create(self): diff --git a/dummy/src/toga_dummy/window.py b/dummy/src/toga_dummy/window.py index 368f7834f6..2b7198aef9 100644 --- a/dummy/src/toga_dummy/window.py +++ b/dummy/src/toga_dummy/window.py @@ -2,6 +2,7 @@ from pathlib import Path import toga_dummy +from toga.constants import WindowState from toga.types import Size from toga.window import _initial_position @@ -56,6 +57,8 @@ def __init__(self, interface, title, position, size): self.set_position(position if position is not None else _initial_position()) self.set_size(size) + self._state = WindowState.NORMAL + ###################################################################### # Window properties ###################################################################### @@ -129,8 +132,15 @@ def hide(self): # Window state ###################################################################### - def set_full_screen(self, is_full_screen): - self._action("set full screen", full_screen=is_full_screen) + def get_window_state(self, in_progress_state=False): + return self._state + + def set_window_state(self, state): + self._action(f"set window state to {state}", state=state) + # We cannot store the state value on the EventLog, since the state + # value would be cleared on EventLog.reset(), thereby preventing us + # from testing no-op condition of assigning same state as current. + self._state = state ###################################################################### # Window capabilities diff --git a/examples/window/window/app.py b/examples/window/window/app.py index f5d95e1ac8..ba4d8849ab 100644 --- a/examples/window/window/app.py +++ b/examples/window/window/app.py @@ -3,7 +3,7 @@ from functools import partial import toga -from toga.constants import COLUMN, RIGHT, ROW +from toga.constants import COLUMN, RIGHT, ROW, WindowState from toga.style import Pack @@ -77,14 +77,33 @@ def do_large(self, widget, **kwargs): self.main_window.size = (1500, 1000) self.do_report() - def do_app_full_screen(self, widget, **kwargs): - if self.is_full_screen: - self.exit_full_screen() - else: - self.set_full_screen(self.main_window) + def do_current_window_state(self, widget, **kwargs): + self.label.text = f"Current state: {self.main_window.state}" + + def do_window_state_normal(self, widget, **kwargs): + self.main_window.state = WindowState.NORMAL + + def do_window_state_maximize(self, widget, **kwargs): + self.main_window.state = WindowState.MAXIMIZED + + def do_window_state_minimize(self, widget, **kwargs): + self.main_window.state = WindowState.MINIMIZED + for i in range(5, 0, -1): + print(f"Back in {i}...") + yield 1 + self.main_window.state = WindowState.NORMAL + + def do_window_state_full_screen(self, widget, **kwargs): + self.main_window.state = WindowState.FULLSCREEN - def do_window_full_screen(self, widget, **kwargs): - self.main_window.full_screen = not self.main_window.full_screen + def do_window_state_presentation(self, widget, **kwargs): + self.main_window.state = WindowState.PRESENTATION + + def do_app_presentation_mode(self, widget, **kwargs): + if self.in_presentation_mode: + self.exit_presentation_mode() + else: + self.enter_presentation_mode([self.main_window]) def do_title(self, widget, **kwargs): self.main_window.title = f"Time is {datetime.now()}" @@ -247,12 +266,39 @@ def startup(self): btn_do_large = toga.Button( "Become large", on_press=self.do_large, style=btn_style ) - btn_do_app_full_screen = toga.Button( - "Make app full screen", on_press=self.do_app_full_screen, style=btn_style + btn_do_current_window_state = toga.Button( + "Get current window state", + on_press=self.do_current_window_state, + style=btn_style, + ) + btn_do_window_state_normal = toga.Button( + "Make window state normal", + on_press=self.do_window_state_normal, + style=btn_style, + ) + btn_do_window_state_maximize = toga.Button( + "Make window state maximized", + on_press=self.do_window_state_maximize, + style=btn_style, + ) + btn_do_window_state_minimize = toga.Button( + "Make window state minimized", + on_press=self.do_window_state_minimize, + style=btn_style, + ) + btn_do_window_state_full_screen = toga.Button( + "Make window state full screen", + on_press=self.do_window_state_full_screen, + style=btn_style, + ) + btn_do_window_state_presentation = toga.Button( + "Make window state presentation", + on_press=self.do_window_state_presentation, + style=btn_style, ) - btn_do_window_full_screen = toga.Button( - "Make window full screen", - on_press=self.do_window_full_screen, + btn_do_app_presentation_mode = toga.Button( + "Toggle app presentation mode", + on_press=self.do_app_presentation_mode, style=btn_style, ) btn_do_title = toga.Button( @@ -321,8 +367,13 @@ def startup(self): btn_do_report, btn_do_small, btn_do_large, - btn_do_app_full_screen, - btn_do_window_full_screen, + btn_do_current_window_state, + btn_do_window_state_normal, + btn_do_window_state_maximize, + btn_do_window_state_minimize, + btn_do_window_state_full_screen, + btn_do_window_state_presentation, + btn_do_app_presentation_mode, btn_do_title, btn_do_new_windows, btn_do_current_window_cycling, diff --git a/gtk/src/toga_gtk/app.py b/gtk/src/toga_gtk/app.py index f30004dd83..cfffc512c9 100644 --- a/gtk/src/toga_gtk/app.py +++ b/gtk/src/toga_gtk/app.py @@ -248,15 +248,3 @@ def get_current_window(self): # pragma: no-cover-if-linux-wayland def set_current_window(self, window): window._impl.native.present() - - ###################################################################### - # Full screen control - ###################################################################### - - def enter_full_screen(self, windows): - for window in windows: - window._impl.set_full_screen(True) - - def exit_full_screen(self, windows): - for window in windows: - window._impl.set_full_screen(False) diff --git a/gtk/src/toga_gtk/window.py b/gtk/src/toga_gtk/window.py index 3cc46f653f..7cc71de6a3 100644 --- a/gtk/src/toga_gtk/window.py +++ b/gtk/src/toga_gtk/window.py @@ -1,13 +1,15 @@ from __future__ import annotations +from functools import partial from typing import TYPE_CHECKING from toga.command import Separator +from toga.constants import WindowState from toga.types import Position, Size from toga.window import _initial_position from .container import TogaContainer -from .libs import Gdk, Gtk +from .libs import IS_WAYLAND, Gdk, GLib, Gtk from .screens import Screen as ScreenImpl if TYPE_CHECKING: # pragma: no cover @@ -28,6 +30,12 @@ def __init__(self, interface, title, position, size): "delete-event", self.gtk_delete_event, ) + self.native.connect("window-state-event", self.gtk_window_state_event) + + self._window_state_flags = None + self._in_presentation = False + # Pending Window state transition variable: + self._pending_state_transition = None self.native.set_default_size(size[0], size[1]) @@ -60,6 +68,41 @@ def create(self): # Native event handlers ###################################################################### + def gtk_window_state_event(self, widget, event): + # Get the window state flags + self._window_state_flags = event.new_window_state + + if self._pending_state_transition: + current_state = self.get_window_state() + if current_state != WindowState.NORMAL: + if self._pending_state_transition != current_state: + # Add a 10ms delay to wait for the native window state + # operation to complete to prevent glitching on wayland + # during rapid state switching. + # + # Ideally, we should use a native operation-completion + # callback event or a reliable native signal, but on + # testing none of the currently available gtk APIs or + # signals work reliably. + # For a list of native gtk APIs that were tested but didn't work: + # https://github.com/beeware/toga/pull/2473#discussion_r1833741222 + # + if IS_WAYLAND: # pragma: no-cover-if-linux-x + GLib.timeout_add( + 10, partial(self._apply_state, WindowState.NORMAL) + ) + else: # pragma: no-cover-if-linux-wayland + self._apply_state(WindowState.NORMAL) + else: + self._pending_state_transition = None + else: + if IS_WAYLAND: # pragma: no-cover-if-linux-x + GLib.timeout_add( + 10, partial(self._apply_state, self._pending_state_transition) + ) + else: # pragma: no-cover-if-linux-wayland + self._apply_state(self._pending_state_transition) + def gtk_delete_event(self, widget, data): # Return value of the GTK on_close handler indicates whether the event has been # fully handled. Returning True indicates the event has been handled, so further @@ -143,11 +186,102 @@ def hide(self): # Window state ###################################################################### - def set_full_screen(self, is_full_screen): - if is_full_screen: - self.native.fullscreen() + def get_window_state(self, in_progress_state=False): + if in_progress_state and self._pending_state_transition: + return self._pending_state_transition + window_state_flags = self._window_state_flags + if window_state_flags: # pragma: no branch + if window_state_flags & Gdk.WindowState.MAXIMIZED: + return WindowState.MAXIMIZED + elif window_state_flags & Gdk.WindowState.ICONIFIED: + return WindowState.MINIMIZED # pragma: no-cover-if-linux-wayland + elif window_state_flags & Gdk.WindowState.FULLSCREEN: + return ( + WindowState.PRESENTATION + if self._in_presentation + else WindowState.FULLSCREEN + ) + return WindowState.NORMAL + + def set_window_state(self, state): + if IS_WAYLAND and ( + state == WindowState.MINIMIZED + ): # pragma: no-cover-if-linux-x + # Not implemented on wayland due to wayland interpretation of an app's + # responsibility. + return else: - self.native.unfullscreen() + if self._pending_state_transition: + self._pending_state_transition = state + else: + # If the app is in presentation mode, but this window isn't, then + # exit app presentation mode before setting the requested state. + if any( + window.state == WindowState.PRESENTATION + and window != self.interface + for window in self.interface.app.windows + ): + self.interface.app.exit_presentation_mode() + + self._pending_state_transition = state + if self.get_window_state() != WindowState.NORMAL: + self._apply_state(WindowState.NORMAL) + else: + self._apply_state(state) + + def _apply_state(self, target_state): + if target_state is None: # pragma: no cover + # This is OS delay related and is only sometimes triggered + # when there is a delay in processing the states by the OS. + # Hence, this branch cannot be consistently reached by the + # testbed coverage. + return + + current_state = self.get_window_state() + if target_state == current_state: + self._pending_state_transition = None + return + + elif target_state == WindowState.MAXIMIZED: + self.native.maximize() + + elif target_state == WindowState.MINIMIZED: # pragma: no-cover-if-linux-wayland + self.native.iconify() + + elif target_state == WindowState.FULLSCREEN: + self.native.fullscreen() + + elif target_state == WindowState.PRESENTATION: + self._before_presentation_mode_screen = self.interface.screen + if isinstance(self.native, Gtk.ApplicationWindow): + self.native.set_show_menubar(False) + if getattr(self, "native_toolbar", None): + self.native_toolbar.set_visible(False) + self.native.fullscreen() + self._in_presentation = True + + else: # target_state == WindowState.NORMAL: + if current_state == WindowState.MAXIMIZED: + self.native.unmaximize() + + elif ( + current_state == WindowState.MINIMIZED + ): # pragma: no-cover-if-linux-wayland + # deiconify() doesn't work + self.native.present() + + elif current_state == WindowState.FULLSCREEN: + self.native.unfullscreen() + + else: # current_state == WindowState.PRESENTATION: + if isinstance(self.native, Gtk.ApplicationWindow): + self.native.set_show_menubar(True) + if getattr(self, "native_toolbar", None): + self.native_toolbar.set_visible(True) + self.native.unfullscreen() + self.interface.screen = self._before_presentation_mode_screen + del self._before_presentation_mode_screen + self._in_presentation = False ###################################################################### # Window capabilities diff --git a/gtk/tests_backend/app.py b/gtk/tests_backend/app.py index e1fef60b72..abfbf34578 100644 --- a/gtk/tests_backend/app.py +++ b/gtk/tests_backend/app.py @@ -46,15 +46,6 @@ def logs_path(self): def is_cursor_visible(self): pytest.skip("Cursor visibility not implemented on GTK") - def is_full_screen(self, window): - return bool( - window._impl.native.get_window().get_state() & Gdk.WindowState.FULLSCREEN - ) - - def content_size(self, window): - content_allocation = window._impl.container.get_allocation() - return (content_allocation.width, content_allocation.height) - def assert_app_icon(self, icon): for window in self.app.windows: # We have no real way to check we've got the right icon; use pixel peeping diff --git a/gtk/tests_backend/window.py b/gtk/tests_backend/window.py index 4118058388..6a39554551 100644 --- a/gtk/tests_backend/window.py +++ b/gtk/tests_backend/window.py @@ -24,8 +24,17 @@ def __init__(self, app, window): self.native = window._impl.native assert isinstance(self.native, Gtk.Window) - async def wait_for_window(self, message, minimize=False, full_screen=False): - await self.redraw(message, delay=0.5 if (full_screen or minimize) else 0.1) + async def wait_for_window( + self, + message, + minimize=False, + full_screen=False, + state_switch_not_from_normal=False, + ): + await self.redraw( + message, + delay=(0.5 if (full_screen or minimize) else 0.1), + ) def close(self): if self.is_closable: @@ -37,10 +46,6 @@ def content_size(self): content_allocation = self.impl.container.get_allocation() return (content_allocation.width, content_allocation.height) - @property - def is_full_screen(self): - return bool(self.native.get_window().get_state() & Gdk.WindowState.FULLSCREEN) - @property def is_resizable(self): return self.native.get_resizable() @@ -51,7 +56,7 @@ def is_closable(self): @property def is_minimized(self): - return bool(self.native.get_window().get_state() & Gdk.WindowState.ICONIFIED) + return self.impl._window_state_flags & Gdk.WindowState.ICONIFIED def minimize(self): self.native.iconify() @@ -59,6 +64,10 @@ def minimize(self): def unminimize(self): self.native.deiconify() + @property + def instantaneous_state(self): + return self.impl.get_window_state(in_progress_state=False) + def has_toolbar(self): return self.impl.native_toolbar.get_n_items() > 0 diff --git a/iOS/src/toga_iOS/app.py b/iOS/src/toga_iOS/app.py index a627326675..95d462b012 100644 --- a/iOS/src/toga_iOS/app.py +++ b/iOS/src/toga_iOS/app.py @@ -159,15 +159,3 @@ def get_current_window(self): def set_current_window(self, window): # iOS only has a main window, so this is a no-op pass - - ###################################################################### - # Full screen control - ###################################################################### - - def enter_full_screen(self, windows): - # No-op; mobile doesn't support full screen - pass - - def exit_full_screen(self, windows): - # No-op; mobile doesn't support full screen - pass diff --git a/iOS/src/toga_iOS/window.py b/iOS/src/toga_iOS/window.py index c05e0639b3..7b9e551ac6 100644 --- a/iOS/src/toga_iOS/window.py +++ b/iOS/src/toga_iOS/window.py @@ -6,6 +6,7 @@ objc_id, ) +from toga.constants import WindowState from toga.types import Position, Size from toga_iOS.container import NavigationContainer, RootContainer from toga_iOS.images import nsdata_to_bytes @@ -142,8 +143,12 @@ def hide(self): # Window state ###################################################################### - def set_full_screen(self, is_full_screen): - # Windows are always full screen + def get_window_state(self, in_progress_state=False): + # Windows are always in NORMAL state. + return WindowState.NORMAL + + def set_window_state(self, state): + # Window state setting is not implemented on iOS. pass ###################################################################### diff --git a/iOS/tests_backend/window.py b/iOS/tests_backend/window.py index 00fd816d8f..fd99b8404b 100644 --- a/iOS/tests_backend/window.py +++ b/iOS/tests_backend/window.py @@ -7,6 +7,9 @@ class WindowProbe(BaseProbe, DialogsMixin): + supports_fullscreen = False + supports_presentation = False + def __init__(self, app, window): super().__init__() self.app = app @@ -15,7 +18,13 @@ def __init__(self, app, window): self.native = window._impl.native assert isinstance(self.native, UIWindow) - async def wait_for_window(self, message, minimize=False, full_screen=False): + async def wait_for_window( + self, + message, + minimize=False, + full_screen=False, + state_switch_not_from_normal=False, + ): await self.redraw(message) @property @@ -37,5 +46,9 @@ def top_bar_height(self): + self.native.rootViewController.navigationBar.frame.size.height ) + @property + def instantaneous_state(self): + return self.impl.get_window_state(in_progress_state=False) + def has_toolbar(self): pytest.skip("Toolbars not implemented on iOS") diff --git a/testbed/tests/app/test_desktop.py b/testbed/tests/app/test_desktop.py index a8f9bc1869..4ec5a7a027 100644 --- a/testbed/tests/app/test_desktop.py +++ b/testbed/tests/app/test_desktop.py @@ -1,3 +1,4 @@ +import itertools from functools import partial from unittest.mock import Mock @@ -5,7 +6,8 @@ import toga from toga import Position, Size -from toga.colors import CORNFLOWERBLUE, FIREBRICK, REBECCAPURPLE +from toga.colors import CORNFLOWERBLUE, FIREBRICK, GOLDENROD, REBECCAPURPLE +from toga.constants import WindowState from toga.style.pack import Pack from ..widgets.probe import get_probe @@ -170,159 +172,258 @@ async def test_menu_minimize(app, app_probe): assert window1_probe.is_minimized -async def test_full_screen(app, app_probe): - """Window can be made full screen""" - window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) - window2 = toga.Window("Test Window 2", position=(400, 150), size=(200, 200)) - - window1_widget = toga.Box(style=Pack(flex=1)) - window2_widget = toga.Box(style=Pack(flex=1)) - window1_widget_probe = get_probe(window1_widget) - window2_widget_probe = get_probe(window2_widget) - - window1.content = toga.Box( - children=[window1_widget], style=Pack(background_color=REBECCAPURPLE) +async def test_presentation_mode(app, app_probe, main_window, main_window_probe): + """The app can enter presentation mode.""" + bg_colors = (CORNFLOWERBLUE, FIREBRICK, REBECCAPURPLE, GOLDENROD) + color_cycle = itertools.cycle(bg_colors) + window_information_list = list() + screen_window_dict = dict() + for i in range(len(app.screens)): + window = toga.Window(title=f"Test Window {i}", size=(200, 200)) + window_widget = toga.Box(style=Pack(flex=1, background_color=next(color_cycle))) + window.content = window_widget + window.show() + + window_information = dict() + 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["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" + ] + + # Add delay to ensure windows are visible after animation. + await main_window_probe.wait_for_window("All Test Windows are visible") + + # Enter presentation mode with a screen-window dict via the app + app.enter_presentation_mode(screen_window_dict) + # Add delay to ensure windows are visible after animation. + await main_window_probe.wait_for_window( + "App is in presentation mode", full_screen=True ) - window2.content = toga.Box( - children=[window2_widget], style=Pack(background_color=CORNFLOWERBLUE) + assert app.in_presentation_mode + # All the windows should be in presentation mode. + for window_information in window_information_list: + assert ( + window_information["window_probe"].instantaneous_state + == WindowState.PRESENTATION + ), f"{window_information['window'].title}:" + # 1000x700 is bigger than the original window size, + # while being smaller than any likely screen. + assert ( + window_information["window_probe"].content_size[0] > 1000 + ), f"{window_information['window'].title}:" + assert ( + window_information["window_probe"].content_size[1] > 700 + ), f"{window_information['window'].title}:" + assert ( + window_information["widget_probe"].width + > window_information["initial_widget_size"][0] + and window_information["widget_probe"].height + > window_information["initial_widget_size"][1] + ), f"{window_information['window'].title}:" + assert ( + window_information["window"].screen == window_information["paired_screen"] + ), f"{window_information['window'].title}:" + + # Exit presentation mode + app.exit_presentation_mode() + await main_window_probe.wait_for_window( + "App is not in presentation mode", full_screen=True ) + + assert not app.in_presentation_mode + for window_information in window_information_list: + assert ( + window_information["window_probe"].instantaneous_state == WindowState.NORMAL + ), f"{window_information['window'].title}:" + assert ( + window_information["window_probe"].content_size + == window_information["initial_content_size"] + ), f"{window_information['window'].title}:" + assert ( + window_information["widget_probe"].width + == window_information["initial_widget_size"][0] + and window_information["widget_probe"].height + == window_information["initial_widget_size"][1] + ), f"{window_information['window'].title}:" + assert ( + window_information["window"].screen == window_information["initial_screen"] + ), f"{window_information['window'].title}:" + + +async def test_window_presentation_exit_on_another_window_presentation( + app, main_window_probe +): + window1 = toga.Window(title="Test Window 1", size=(200, 200)) + window2 = toga.Window(title="Test Window 2", size=(200, 200)) window1_probe = window_probe(app, window1) window2_probe = window_probe(app, window2) - + window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) + window2.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) window1.show() window2.show() - await app_probe.redraw("Extra windows are visible") - - assert not app.is_full_screen - assert not app_probe.is_full_screen(window1) - assert not app_probe.is_full_screen(window2) - initial_content1_size = app_probe.content_size(window1) - initial_content2_size = app_probe.content_size(window2) - - initial_window1_widget_size = ( - window1_widget_probe.width, - window1_widget_probe.height, - ) - initial_window2_widget_size = ( - window2_widget_probe.width, - window2_widget_probe.height, - ) - - # Make window 2 full screen via the app - app.set_full_screen(window2) - await window2_probe.wait_for_window( - "Second extra window is full screen", - full_screen=True, - ) - assert app.is_full_screen - - assert not app_probe.is_full_screen(window1) - assert app_probe.content_size(window1) == initial_content1_size - assert ( - window1_widget_probe.width == initial_window1_widget_size[0] - and window1_widget_probe.height == initial_window1_widget_size[1] - ) - - assert app_probe.is_full_screen(window2) - assert app_probe.content_size(window2)[0] > 1000 - assert app_probe.content_size(window2)[1] > 700 - assert ( - window2_widget_probe.width > initial_window2_widget_size[0] - and window2_widget_probe.height > initial_window2_widget_size[1] + # Add delay to ensure windows are visible after animation. + await main_window_probe.wait_for_window("Test windows are shown") + + assert not app.in_presentation_mode + assert window1_probe.instantaneous_state != WindowState.PRESENTATION + assert window2_probe.instantaneous_state != WindowState.PRESENTATION + + # Enter presentation mode with window2 + app.enter_presentation_mode([window2]) + # Add delay to ensure windows are visible after animation. + await main_window_probe.wait_for_window( + "App is in presentation mode", full_screen=True ) - - # Make window 1 full screen via the app, window 2 no longer full screen - app.set_full_screen(window1) - await window1_probe.wait_for_window( - "First extra window is full screen", - full_screen=True, + assert app.in_presentation_mode + assert window2_probe.instantaneous_state == WindowState.PRESENTATION + assert window1_probe.instantaneous_state != WindowState.PRESENTATION + + # Enter presentation mode with window1, window2 no longer in presentation + app.enter_presentation_mode([window1]) + # Add delay to ensure windows are visible after animation. + await main_window_probe.wait_for_window( + "App is in presentation mode", full_screen=True ) - assert app.is_full_screen - - assert app_probe.is_full_screen(window1) - assert app_probe.content_size(window1)[0] > 1000 - assert app_probe.content_size(window1)[1] > 700 - assert ( - window1_widget_probe.width > initial_window1_widget_size[0] - and window1_widget_probe.height > initial_window1_widget_size[1] + assert app.in_presentation_mode + assert window1_probe.instantaneous_state == WindowState.PRESENTATION + assert window2_probe.instantaneous_state != WindowState.PRESENTATION + + # Exit presentation mode + app.exit_presentation_mode() + await main_window_probe.wait_for_window( + "App is not in presentation mode", full_screen=True ) - - assert not app_probe.is_full_screen(window2) - assert app_probe.content_size(window2) == initial_content2_size - assert ( - window2_widget_probe.width == initial_window2_widget_size[0] - and window2_widget_probe.height == initial_window2_widget_size[1] + assert not app.in_presentation_mode + assert window1_probe.instantaneous_state != WindowState.PRESENTATION + assert window2_probe.instantaneous_state != WindowState.PRESENTATION + + # Enter presentation mode again with window1 + app.enter_presentation_mode([window1]) + # Add delay to ensure windows are visible after animation. + await main_window_probe.wait_for_window( + "App is in presentation mode", full_screen=True ) - - # Exit full screen - app.exit_full_screen() - await window1_probe.wait_for_window( - "No longer full screen", - full_screen=True, + assert app.in_presentation_mode + assert window1_probe.instantaneous_state == WindowState.PRESENTATION + assert window2_probe.instantaneous_state != WindowState.PRESENTATION + + # Exit presentation mode + app.exit_presentation_mode() + await main_window_probe.wait_for_window( + "App is not in presentation mode", full_screen=True ) + assert not app.in_presentation_mode + assert window1_probe.instantaneous_state != WindowState.PRESENTATION + assert window2_probe.instantaneous_state != WindowState.PRESENTATION - assert not app.is_full_screen - assert not app_probe.is_full_screen(window1) - assert app_probe.content_size(window1) == initial_content1_size - assert ( - window1_widget_probe.width == initial_window1_widget_size[0] - and window1_widget_probe.height == initial_window1_widget_size[1] +@pytest.mark.parametrize( + "new_window_state", + [ + WindowState.MINIMIZED, + WindowState.MAXIMIZED, + WindowState.FULLSCREEN, + ], +) +async def test_presentation_mode_exit_on_window_state_change( + app, app_probe, main_window, main_window_probe, new_window_state +): + """Changing window state exits presentation mode and sets the new state.""" + if (new_window_state == WindowState.MINIMIZED) and ( + not main_window_probe.supports_minimize + ): + pytest.xfail("This backend doesn't reliably support WindowState.MINIMIZED.") + + window1 = toga.Window(title="Test Window 1", size=(200, 200)) + window2 = toga.Window(title="Test Window 2", size=(200, 200)) + window1_probe = window_probe(app, window1) + window2_probe = window_probe(app, window2) + window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) + window2.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) + window1.show() + window2.show() + # Add delay to ensure windows are visible after animation. + await main_window_probe.wait_for_window("Test windows are shown") + # Enter presentation mode + app.enter_presentation_mode([window1]) + # Add delay to ensure windows are visible after animation. + await main_window_probe.wait_for_window( + "App is in presentation mode", full_screen=True ) - assert not app_probe.is_full_screen(window2) - assert app_probe.content_size(window2) == initial_content2_size - assert ( - window2_widget_probe.width == initial_window2_widget_size[0] - and window2_widget_probe.height == initial_window2_widget_size[1] - ) + assert app.in_presentation_mode + assert window1_probe.instantaneous_state == WindowState.PRESENTATION - # Go full screen again on window 1 - app.set_full_screen(window1) - # A longer delay to allow for genie animations - await window1_probe.wait_for_window( - "First extra window is full screen", - full_screen=True, - ) - assert app.is_full_screen - - assert app_probe.is_full_screen(window1) - assert app_probe.content_size(window1)[0] > 1000 - assert app_probe.content_size(window1)[1] > 700 - assert ( - window1_widget_probe.width > initial_window1_widget_size[0] - and window1_widget_probe.height > initial_window1_widget_size[1] + # Changing window state of main window should make the app exit presentation mode. + window1.state = new_window_state + # Add delay to ensure windows are visible after animation. + await main_window_probe.wait_for_window( + "App is not in presentation mode" f"\nTest Window 1 is in {new_window_state}", + minimize=True if new_window_state == WindowState.MINIMIZED else False, + full_screen=True if new_window_state == WindowState.FULLSCREEN else False, ) - assert not app_probe.is_full_screen(window2) - assert app_probe.content_size(window2) == initial_content2_size - assert ( - window2_widget_probe.width == initial_window2_widget_size[0] - and window2_widget_probe.height == initial_window2_widget_size[1] + assert not app.in_presentation_mode + assert window1_probe.instantaneous_state == new_window_state + + # Reset window states + window1.state = WindowState.NORMAL + window2.state = WindowState.NORMAL + # Add delay to ensure windows are visible after animation. + await main_window_probe.wait_for_window( + "All test windows are in WindowState.NORMAL", + minimize=True if new_window_state == WindowState.MINIMIZED else False, + full_screen=True if new_window_state == WindowState.FULLSCREEN else False, ) - - # Exit full screen by passing no windows - app.set_full_screen() - - await window1_probe.wait_for_window( - "No longer full screen", - full_screen=True, + assert window1_probe.instantaneous_state == WindowState.NORMAL + assert window2_probe.instantaneous_state == WindowState.NORMAL + + # Enter presentation mode again + app.enter_presentation_mode([window1]) + # Add delay to ensure windows are visible after animation. + await main_window_probe.wait_for_window( + "App is in presentation mode", + minimize=True if new_window_state == WindowState.MINIMIZED else False, + full_screen=True if new_window_state == WindowState.FULLSCREEN else False, ) - assert not app.is_full_screen - - assert not app_probe.is_full_screen(window1) - assert app_probe.content_size(window1) == initial_content1_size - assert ( - window1_widget_probe.width == initial_window1_widget_size[0] - and window1_widget_probe.height == initial_window1_widget_size[1] + assert app.in_presentation_mode + assert window1_probe.instantaneous_state == WindowState.PRESENTATION + + # Changing window state of extra window should make the app exit presentation mode. + window2.state = new_window_state + # Add delay to ensure windows are visible after animation. + await main_window_probe.wait_for_window( + "App is not in presentation mode" f"\nTest Window 2 is in {new_window_state}", + minimize=True if new_window_state == WindowState.MINIMIZED else False, + full_screen=True if new_window_state == WindowState.FULLSCREEN else False, ) - assert not app_probe.is_full_screen(window2) - assert app_probe.content_size(window2) == initial_content2_size - assert ( - window2_widget_probe.width == initial_window2_widget_size[0] - and window2_widget_probe.height == initial_window2_widget_size[1] + assert not app.in_presentation_mode + assert window2_probe.instantaneous_state == new_window_state + + # Reset window states + window1.state = WindowState.NORMAL + window2.state = WindowState.NORMAL + # Add delay to ensure windows are visible after animation. + await main_window_probe.wait_for_window( + "All test windows are in WindowState.NORMAL", + minimize=True if new_window_state == WindowState.MINIMIZED else False, + full_screen=True if new_window_state == WindowState.FULLSCREEN else False, ) + assert window1_probe.instantaneous_state == WindowState.NORMAL + assert window2_probe.instantaneous_state == WindowState.NORMAL async def test_show_hide_cursor(app, app_probe): diff --git a/testbed/tests/app/test_mobile.py b/testbed/tests/app/test_mobile.py index c62c86a637..1ef908a7ee 100644 --- a/testbed/tests/app/test_mobile.py +++ b/testbed/tests/app/test_mobile.py @@ -2,6 +2,7 @@ import toga from toga.colors import REBECCAPURPLE +from toga.constants import WindowState from toga.style import Pack #################################################################################### @@ -35,12 +36,32 @@ async def test_show_hide_cursor(app): app.hide_cursor() -async def test_full_screen(app): - """Window can be made full screen""" - # Invoke the methods to verify the endpoints exist. However, they're no-ops, - # so there's nothing to test. - app.set_full_screen(app.current_window) - app.exit_full_screen() +async def test_presentation_mode(app, main_window, main_window_probe): + """The app can enter into presentation mode""" + if not main_window_probe.supports_presentation: + pytest.xfail("This backend doesn't support presentation window state.") + + assert not app.in_presentation_mode + assert main_window.state != WindowState.PRESENTATION + + # Enter presentation mode with main window via the app + app.enter_presentation_mode({app.screens[0]: main_window}) + await main_window_probe.wait_for_window( + "Main window is in presentation mode", full_screen=True + ) + + assert app.in_presentation_mode + assert main_window.state == WindowState.PRESENTATION + + # Exit presentation mode + app.exit_presentation_mode() + await main_window_probe.wait_for_window( + "Main window is no longer in presentation mode", + full_screen=True, + ) + + assert not app.in_presentation_mode + assert main_window.state == WindowState.NORMAL async def test_current_window(app, app_probe, main_window, main_window_probe): diff --git a/testbed/tests/conftest.py b/testbed/tests/conftest.py index e86e015e7c..7ce25b9298 100644 --- a/testbed/tests/conftest.py +++ b/testbed/tests/conftest.py @@ -8,6 +8,7 @@ import toga from toga.colors import GOLDENROD +from toga.constants import WindowState from toga.style import Pack # Ideally, we'd register rewrites for "tests" and get all the submodules @@ -83,7 +84,7 @@ def main_window(app): @fixture(autouse=True) -async def window_cleanup(app, main_window): +async def window_cleanup(app, app_probe, main_window, main_window_probe): # Ensure that at the end of every test, all windows that aren't the # main window have been closed and deleted. This needs to be done in # 2 passes because we can't modify the list while iterating over it. @@ -96,12 +97,22 @@ async def window_cleanup(app, main_window): while kill_list: window = kill_list.pop() window.close() + await main_window_probe.wait_for_window("Closing window") del window # Force a GC pass on the main thread. This isn't perfect, but it helps # minimize garbage collection on the test thread. gc.collect() + main_window_state = main_window.state + main_window.state = WindowState.NORMAL + app.current_window = main_window + await main_window_probe.wait_for_window( + "Resetting main_window", + minimize=True if main_window_state == WindowState.MINIMIZED else False, + full_screen=True if main_window_state == WindowState.FULLSCREEN else False, + ) + @fixture(scope="session") async def main_window_probe(app, main_window): diff --git a/testbed/tests/window/test_window.py b/testbed/tests/window/test_window.py index 2684737105..721ab441d4 100644 --- a/testbed/tests/window/test_window.py +++ b/testbed/tests/window/test_window.py @@ -8,6 +8,7 @@ import toga from toga.colors import CORNFLOWERBLUE, GOLDENROD, REBECCAPURPLE +from toga.constants import WindowState from toga.style.pack import COLUMN, Pack @@ -139,13 +140,167 @@ async def test_move_and_resize(main_window, main_window_probe, capsys): finally: main_window.content = orig_content - async def test_full_screen(main_window, main_window_probe): - """Window can be made full screen""" - main_window.full_screen = True - await main_window_probe.wait_for_window("Full screen is a no-op") + @pytest.mark.parametrize( + "initial_state, final_state", + [ + # Direct switch from NORMAL: + (WindowState.NORMAL, WindowState.FULLSCREEN), + (WindowState.NORMAL, WindowState.PRESENTATION), + # Direct switch from FULLSCREEN: + (WindowState.FULLSCREEN, WindowState.NORMAL), + (WindowState.FULLSCREEN, WindowState.PRESENTATION), + # Direct switch from PRESENTATION: + (WindowState.PRESENTATION, WindowState.NORMAL), + (WindowState.PRESENTATION, WindowState.FULLSCREEN), + ], + ) + async def test_window_state_change( + app, + main_window, + main_window_probe, + initial_state, + final_state, + ): + """Window state can be directly changed to another state.""" + if not main_window_probe.supports_fullscreen and WindowState.FULLSCREEN in { + initial_state, + final_state, + }: + pytest.xfail("This backend doesn't support fullscreen window state.") + if ( + not main_window_probe.supports_presentation + and WindowState.PRESENTATION + in { + initial_state, + final_state, + } + ): + pytest.xfail("This backend doesn't support presentation window state.") + + # Set to initial state + main_window.state = initial_state + await main_window_probe.wait_for_window(f"Main window is in {initial_state}") + + assert main_window_probe.instantaneous_state == initial_state + + # Set to final state + main_window.state = final_state + await main_window_probe.wait_for_window( + f"Main window is in {final_state}", state_switch_not_from_normal=True + ) + assert main_window_probe.instantaneous_state == final_state + + @pytest.mark.parametrize( + "state", + [ + WindowState.NORMAL, + WindowState.FULLSCREEN, + WindowState.PRESENTATION, + ], + ) + async def test_window_state_same_as_current_without_intermediate_states( + app, main_window, main_window_probe, state + ): + """Setting window state the same as current without any intermediate states is + a no-op and there should be no expected delay from the OS.""" + if ( + not main_window_probe.supports_fullscreen + and state == WindowState.FULLSCREEN + ): + pytest.xfail("This backend doesn't support fullscreen window state.") + if ( + not main_window_probe.supports_presentation + and state == WindowState.PRESENTATION + ): + pytest.xfail("This backend doesn't support presentation window state.") + + # Set the window state: + main_window.state = state + await main_window_probe.wait_for_window(f"Secondary window is in {state}") + assert main_window_probe.instantaneous_state == state + + # Set the window state the same as current: + main_window.state = state + assert main_window_probe.instantaneous_state == state + + @pytest.mark.parametrize( + "state", + [ + WindowState.FULLSCREEN, + WindowState.PRESENTATION, + ], + ) + async def test_window_state_content_size_increase( + app, app_probe, main_window, main_window_probe, state + ): + """The size of the window content should increase when the window state is set + to maximized, fullscreen or presentation.""" + if ( + not main_window_probe.supports_fullscreen + and state == WindowState.FULLSCREEN + ): + pytest.xfail("This backend doesn't support fullscreen window state.") + if ( + not main_window_probe.supports_presentation + and state == WindowState.PRESENTATION + ): + pytest.xfail("This backend doesn't support presentation window state.") + + main_window.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) + # Add delay to ensure windows are visible after animation. + await main_window_probe.wait_for_window("Main window is shown") - main_window.full_screen = False - await main_window_probe.wait_for_window("Full screen is a no-op") + assert main_window_probe.instantaneous_state == WindowState.NORMAL + initial_content_size = main_window_probe.content_size + + main_window.state = state + # Add delay to ensure windows are visible after animation. + await main_window_probe.wait_for_window( + f"Main window is in {state}", + full_screen=True if state == WindowState.FULLSCREEN else False, + ) + assert main_window_probe.instantaneous_state == state + # At least one of the dimension should have increased. + assert ( + main_window_probe.content_size[0] > initial_content_size[0] + or main_window_probe.content_size[1] > initial_content_size[1] + ) + + main_window.state = state + await main_window_probe.wait_for_window( + f"Main window is still in {state}", + full_screen=True if state == WindowState.FULLSCREEN else False, + ) + assert main_window_probe.instantaneous_state == state + # At least one of the dimension should have increased. + assert ( + main_window_probe.content_size[0] > initial_content_size[0] + or main_window_probe.content_size[1] > initial_content_size[1] + ) + + main_window.state = WindowState.NORMAL + # Add delay to ensure windows are visible after animation. + await main_window_probe.wait_for_window( + f"Main window is not in {state}", + full_screen=True if state == WindowState.FULLSCREEN else False, + ) + assert main_window_probe.instantaneous_state == WindowState.NORMAL + assert main_window_probe.content_size == initial_content_size + + @pytest.mark.parametrize( + "state", + [ + WindowState.MINIMIZED, + WindowState.MAXIMIZED, + ], + ) + async def test_window_state_no_op_states(main_window, main_window_probe, state): + """MINIMIZED and MAXIMIZED states are no-op on mobile platforms.""" + assert main_window.state == WindowState.NORMAL + # Assign the no-op state. + main_window.state = state + # The state should still be NORMAL: + assert main_window.state == WindowState.NORMAL async def test_screen(main_window, main_window_probe): """The window can be relocated to another screen, using both absolute and @@ -510,6 +665,162 @@ async def test_move_and_resize(second_window, second_window_probe): assert second_window.size == (250 + extra_width, 210 + extra_height) assert second_window_probe.content_size == (250, 210) + @pytest.mark.parametrize( + "initial_state, final_state", + [ + # Switch from NORMAL: + (WindowState.NORMAL, WindowState.MINIMIZED), + (WindowState.NORMAL, WindowState.MAXIMIZED), + (WindowState.NORMAL, WindowState.FULLSCREEN), + (WindowState.NORMAL, WindowState.PRESENTATION), + (WindowState.NORMAL, WindowState.NORMAL), + # Switch from MINIMIZED: + (WindowState.MINIMIZED, WindowState.NORMAL), + (WindowState.MINIMIZED, WindowState.MAXIMIZED), + (WindowState.MINIMIZED, WindowState.FULLSCREEN), + (WindowState.MINIMIZED, WindowState.PRESENTATION), + (WindowState.MINIMIZED, WindowState.MINIMIZED), + # Switch from MAXIMIZED: + (WindowState.MAXIMIZED, WindowState.NORMAL), + (WindowState.MAXIMIZED, WindowState.MINIMIZED), + (WindowState.MAXIMIZED, WindowState.FULLSCREEN), + (WindowState.MAXIMIZED, WindowState.PRESENTATION), + (WindowState.MAXIMIZED, WindowState.MAXIMIZED), + # Switch from FULLSCREEN: + (WindowState.FULLSCREEN, WindowState.NORMAL), + (WindowState.FULLSCREEN, WindowState.MINIMIZED), + (WindowState.FULLSCREEN, WindowState.MAXIMIZED), + (WindowState.FULLSCREEN, WindowState.PRESENTATION), + (WindowState.FULLSCREEN, WindowState.FULLSCREEN), + # Switch from PRESENTATION: + (WindowState.PRESENTATION, WindowState.NORMAL), + (WindowState.PRESENTATION, WindowState.MINIMIZED), + (WindowState.PRESENTATION, WindowState.MAXIMIZED), + (WindowState.PRESENTATION, WindowState.FULLSCREEN), + (WindowState.PRESENTATION, WindowState.PRESENTATION), + ], + ) + @pytest.mark.parametrize( + "second_window_class, second_window_kwargs", + [ + ( + toga.MainWindow, + dict(title="Secondary Window", position=(200, 150)), + ) + ], + ) + async def test_window_state_change( + app, + app_probe, + second_window, + second_window_probe, + initial_state, + final_state, + ): + """Window state can be directly changed to another state.""" + if ( + WindowState.MINIMIZED in {initial_state, final_state} + and not second_window_probe.supports_minimize + ): + pytest.xfail( + "This backend doesn't reliably support minimized window state." + ) + second_window.toolbar.add(app.cmd1) + second_window.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) + second_window.show() + # Add delay to ensure windows are visible after animation. + await second_window_probe.wait_for_window("Secondary window is visible") + assert second_window_probe.instantaneous_state == WindowState.NORMAL + + # Set to initial state + second_window.state = initial_state + # Add delay to ensure windows are visible after animation. + await second_window_probe.wait_for_window( + f"Secondary window is in {initial_state}", + minimize=True if initial_state == WindowState.MINIMIZED else False, + full_screen=True if initial_state == WindowState.FULLSCREEN else False, + ) + assert second_window_probe.instantaneous_state == initial_state + + # Set to final state + second_window.state = final_state + # Add delay to ensure windows are visible after animation. + await second_window_probe.wait_for_window( + f"Secondary window is in {final_state}", + state_switch_not_from_normal=( + True if initial_state != WindowState.NORMAL else False + ), + ) + assert second_window_probe.instantaneous_state == final_state + + @pytest.mark.parametrize( + "states", + [ + # Testing all possible window state change cases would be ideal, + # but doing so would significantly increase the runtime of the + # testbed. For practicality we are only testing the most complex + # cases to ensure full coverage across all platforms. + # + # Complex state changes on cocoa: + (WindowState.MINIMIZED, WindowState.FULLSCREEN), + (WindowState.FULLSCREEN, WindowState.MINIMIZED), + # Complex state changes on gtk: + (WindowState.FULLSCREEN, WindowState.PRESENTATION), + (WindowState.PRESENTATION, WindowState.NORMAL), + ], + ) + @pytest.mark.parametrize( + "second_window_class, second_window_kwargs", + [ + ( + toga.MainWindow, + dict(title="Secondary Window", position=(200, 150)), + ) + ], + ) + async def test_window_state_rapid_assignment( + app, second_window, second_window_probe, states + ): + """The backends can handle rapid assignment of new window states.""" + # Check that the state to be asserted is supported by the backend. + if ( + states[-1] == WindowState.MINIMIZED + and not second_window_probe.supports_minimize + ): + pytest.xfail( + "This backend doesn't reliably support minimized window state." + ) + second_window.toolbar.add(app.cmd1) + second_window.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) + second_window.show() + # Add delay to ensure windows are visible after animation. + await second_window_probe.wait_for_window("Secondary window is visible") + assert second_window_probe.instantaneous_state == WindowState.NORMAL + + # Assign new states without waiting for each transition to complete, + # to test that the backend can handle rapid window state assignments. + for state in states: + second_window.state = state + + # Add delay to ensure windows are visible after the final state animation. + await second_window_probe.wait_for_window( + f"Secondary window is in {states[-1]}", state_switch_not_from_normal=True + ) + + # Verify that the backend handled rapid assignments by checking if + # the window reached the correct final window state. + assert second_window_probe.instantaneous_state == states[-1] + + @pytest.mark.parametrize( + "state", + [ + WindowState.NORMAL, + WindowState.MINIMIZED, + WindowState.MAXIMIZED, + WindowState.FULLSCREEN, + WindowState.PRESENTATION, + ], + ) @pytest.mark.parametrize( "second_window_class, second_window_kwargs", [ @@ -519,47 +830,96 @@ async def test_move_and_resize(second_window, second_window_probe): ) ], ) - async def test_full_screen(second_window, second_window_probe): - """Window can be made full screen""" - assert not second_window_probe.is_full_screen + async def test_window_state_same_as_current_without_intermediate_states( + app_probe, second_window, second_window_probe, state + ): + """Setting window state the same as current without any intermediate states is + a no-op and there should be no expected delay from the OS.""" + if state == WindowState.MINIMIZED and not second_window_probe.supports_minimize: + pytest.xfail( + "This backend doesn't reliably support minimized window state." + ) + + second_window.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) + second_window.show() + # Add delay to ensure windows are visible after animation. + await second_window_probe.wait_for_window("Secondary window is shown") + + # Set the window state: + second_window.state = state + # Add delay to ensure windows are visible after animation. + await second_window_probe.wait_for_window( + f"Secondary window is in {state}", + minimize=True if state == WindowState.MINIMIZED else False, + full_screen=True if state == WindowState.FULLSCREEN else False, + ) + assert second_window_probe.instantaneous_state == state + + # Set the window state the same as current: + second_window.state = state + # No need to wait for OS delay as the above operation should be a no-op. + assert second_window_probe.instantaneous_state == state + + @pytest.mark.parametrize( + "state", + [ + WindowState.MAXIMIZED, + WindowState.FULLSCREEN, + WindowState.PRESENTATION, + ], + ) + @pytest.mark.parametrize( + "second_window_class, second_window_kwargs", + [ + ( + toga.Window, + dict(title="Secondary Window", position=(200, 150)), + ) + ], + ) + async def test_window_state_content_size_increase( + second_window, second_window_probe, state + ): + """The size of the window content should increase when the window state is set + to maximized, fullscreen or presentation.""" + second_window.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) + second_window.show() + # Add delay to ensure windows are visible after animation. + await second_window_probe.wait_for_window("Secondary window is shown") + + assert second_window_probe.instantaneous_state == WindowState.NORMAL assert second_window_probe.is_resizable initial_content_size = second_window_probe.content_size - second_window.full_screen = True - # A longer delay to allow for genie animations + second_window.state = state + # Add delay to ensure windows are visible after animation. await second_window_probe.wait_for_window( - "Secondary window is full screen", - full_screen=True, + f"Secondary window is in {state}", + full_screen=True if state == WindowState.FULLSCREEN else False, ) - assert second_window_probe.is_full_screen + assert second_window_probe.instantaneous_state == state assert second_window_probe.content_size[0] > initial_content_size[0] assert second_window_probe.content_size[1] > initial_content_size[1] - second_window.full_screen = True + second_window.state = state await second_window_probe.wait_for_window( - "Secondary window is still full screen" + f"Secondary window is still in {state}", + full_screen=True if state == WindowState.FULLSCREEN else False, ) - assert second_window_probe.is_full_screen + assert second_window_probe.instantaneous_state == state assert second_window_probe.content_size[0] > initial_content_size[0] assert second_window_probe.content_size[1] > initial_content_size[1] - second_window.full_screen = False - # A longer delay to allow for genie animations + second_window.state = WindowState.NORMAL + # Add delay to ensure windows are visible after animation. await second_window_probe.wait_for_window( - "Secondary window is not full screen", - full_screen=True, + f"Secondary window is not in {state}", + full_screen=True if state == WindowState.FULLSCREEN else False, ) - assert not second_window_probe.is_full_screen + assert second_window_probe.instantaneous_state == WindowState.NORMAL assert second_window_probe.is_resizable assert second_window_probe.content_size == initial_content_size - second_window.full_screen = False - await second_window_probe.wait_for_window( - "Secondary window is still not full screen" - ) - assert not second_window_probe.is_full_screen - assert second_window_probe.content_size == initial_content_size - @pytest.mark.parametrize( "second_window_class, second_window_kwargs", [ diff --git a/textual/src/toga_textual/app.py b/textual/src/toga_textual/app.py index 9ee6eab600..521eec3bd1 100644 --- a/textual/src/toga_textual/app.py +++ b/textual/src/toga_textual/app.py @@ -115,11 +115,11 @@ def set_current_window(self, window): self.native.title = window.get_title() ###################################################################### - # Full screen control + # Presentation mode controls ###################################################################### - def enter_full_screen(self, windows): - pass + def enter_presentation_mode(self, screen_window_dict): + self.interface.factory.not_implemented("App.enter_presentation_mode()") - def exit_full_screen(self, windows): - pass + def exit_presentation_mode(self): + self.interface.factory.not_implemented("App.exit_presentation_mode()") diff --git a/textual/src/toga_textual/window.py b/textual/src/toga_textual/window.py index 7ac95dfa50..f309515447 100644 --- a/textual/src/toga_textual/window.py +++ b/textual/src/toga_textual/window.py @@ -6,6 +6,7 @@ from textual.widget import Widget as TextualWidget from textual.widgets import Button as TextualButton from toga import Position, Size +from toga.constants import WindowState from .container import Container from .screens import Screen as ScreenImpl @@ -200,8 +201,12 @@ def hide(self): # Window state ###################################################################### - def set_full_screen(self, is_full_screen): - pass + def get_window_state(self): + # Windows are always normal + return WindowState.NORMAL + + def set_window_state(self, state): + self.interface.factory.not_implemented("Window.set_window_state()") ###################################################################### # Window capabilities diff --git a/web/src/toga_web/app.py b/web/src/toga_web/app.py index 5f9e2b8e80..fec494d7d7 100644 --- a/web/src/toga_web/app.py +++ b/web/src/toga_web/app.py @@ -148,11 +148,11 @@ def set_current_window(self): self.interface.factory.not_implemented("App.set_current_window()") ###################################################################### - # Full screen control + # Presentation mode controls ###################################################################### - def enter_full_screen(self, windows): - self.interface.factory.not_implemented("App.enter_full_screen()") + def enter_presentation_mode(self, screen_window_dict): + self.interface.factory.not_implemented("App.enter_presentation_mode()") - def exit_full_screen(self, windows): - self.interface.factory.not_implemented("App.exit_full_screen()") + def exit_presentation_mode(self): + self.interface.factory.not_implemented("App.exit_presentation_mode()") diff --git a/web/src/toga_web/window.py b/web/src/toga_web/window.py index 65b9ed0652..712ce71872 100644 --- a/web/src/toga_web/window.py +++ b/web/src/toga_web/window.py @@ -1,4 +1,5 @@ from toga.command import Group, Separator +from toga.constants import WindowState from toga.types import Position, Size from toga_web.libs import create_element, js @@ -111,8 +112,12 @@ def hide(self): # Window state ###################################################################### - def set_full_screen(self, is_full_screen): - self.interface.factory.not_implemented("Window.set_full_screen()") + def get_window_state(self): + # Windows are always normal + return WindowState.NORMAL + + def set_window_state(self, state): + self.interface.factory.not_implemented("Window.set_window_state()") ###################################################################### # Window capabilities diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index d40e6e1036..f26445b6f1 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -264,15 +264,3 @@ def get_current_window(self): def set_current_window(self, window): window._impl.native.Activate() - - ###################################################################### - # Full screen control - ###################################################################### - - def enter_full_screen(self, windows): - for window in windows: - window._impl.set_full_screen(True) - - def exit_full_screen(self, windows): - for window in windows: - window._impl.set_full_screen(False) diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index a01839538e..dc63466405 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -9,6 +9,7 @@ from toga import App from toga.command import Separator +from toga.constants import WindowState from toga.types import Position, Size from .container import Container @@ -41,6 +42,10 @@ def __init__(self, interface, title, position, size): self.native.MinimizeBox = self.interface.minimizable self.native.MaximizeBox = self.interface.resizable + # Use a shadow variable since a window without any app menu and toolbar + # in presentation mode would be indistinguishable from full screen mode. + self._in_presentation_mode = False + self.set_title(title) self.set_size(size) # Winforms does window cascading by default; use that behavior, rather than @@ -52,7 +57,11 @@ def __init__(self, interface, title, position, size): self.native.Resize += WeakrefCallable(self.winforms_Resize) self.resize_content() # Store initial size - self.set_full_screen(self.interface.full_screen) + # Set window border style based on the window resizability setting at interface. + self.native.FormBorderStyle = getattr( + WinForms.FormBorderStyle, + "Sizable" if self.interface.resizable else "FixedSingle", + ) def create(self): self.native = WinForms.Form() @@ -235,17 +244,74 @@ def hide(self): # Window state ###################################################################### - def set_full_screen(self, is_full_screen): - if is_full_screen: - self.native.FormBorderStyle = getattr(WinForms.FormBorderStyle, "None") - self.native.WindowState = WinForms.FormWindowState.Maximized - else: + def get_window_state(self, in_progress_state=False): + window_state = self.native.WindowState + if window_state == WinForms.FormWindowState.Maximized: + if self.native.FormBorderStyle == getattr(WinForms.FormBorderStyle, "None"): + if self._in_presentation_mode: + return WindowState.PRESENTATION + else: + return WindowState.FULLSCREEN + else: + return WindowState.MAXIMIZED + elif window_state == WinForms.FormWindowState.Minimized: + return WindowState.MINIMIZED + else: # window_state == WinForms.FormWindowState.Normal: + return WindowState.NORMAL + + def set_window_state(self, state): + # If the app is in presentation mode, but this window isn't, then + # exit app presentation mode before setting the requested state. + if any( + window.state == WindowState.PRESENTATION and window != self.interface + for window in self.interface.app.windows + ): + self.interface.app.exit_presentation_mode() + + current_state = self.get_window_state() + if current_state == state: + return + + elif current_state != WindowState.NORMAL: + if current_state == WindowState.PRESENTATION: + if self.native.MainMenuStrip: + self.native.MainMenuStrip.Visible = True + if getattr(self, "toolbar_native", None): + self.toolbar_native.Visible = True + + self.interface.screen = self._before_presentation_mode_screen + del self._before_presentation_mode_screen + self._in_presentation_mode = False + self.native.FormBorderStyle = getattr( WinForms.FormBorderStyle, "Sizable" if self.interface.resizable else "FixedSingle", ) self.native.WindowState = WinForms.FormWindowState.Normal + self.set_window_state(state) + + else: # current_state == WindowState.NORMAL: + if state == WindowState.MAXIMIZED: + self.native.WindowState = WinForms.FormWindowState.Maximized + + elif state == WindowState.MINIMIZED: + self.native.WindowState = WinForms.FormWindowState.Minimized + + elif state == WindowState.FULLSCREEN: + self.native.FormBorderStyle = getattr(WinForms.FormBorderStyle, "None") + self.native.WindowState = WinForms.FormWindowState.Maximized + + else: # state == WindowState.PRESENTATION: + self._before_presentation_mode_screen = self.interface.screen + if self.native.MainMenuStrip: + self.native.MainMenuStrip.Visible = False + if getattr(self, "toolbar_native", None): + self.toolbar_native.Visible = False + self.native.FormBorderStyle = getattr(WinForms.FormBorderStyle, "None") + self.native.WindowState = WinForms.FormWindowState.Maximized + self._in_presentation_mode = True + ###################################################################### # Window capabilities ###################################################################### @@ -281,9 +347,9 @@ def update_dpi(self): def _top_bars_height(self): vertical_shift = 0 - if self.toolbar_native: + if self.toolbar_native and self.toolbar_native.Visible: vertical_shift += self.toolbar_native.Height - if self.native.MainMenuStrip: + if self.native.MainMenuStrip and self.native.MainMenuStrip.Visible: vertical_shift += self.native.MainMenuStrip.Height return vertical_shift diff --git a/winforms/tests_backend/app.py b/winforms/tests_backend/app.py index b746422ae3..f9de578d2f 100644 --- a/winforms/tests_backend/app.py +++ b/winforms/tests_backend/app.py @@ -13,7 +13,6 @@ from .dialogs import DialogsMixin from .probe import BaseProbe -from .window import WindowProbe class AppProbe(BaseProbe, DialogsMixin): @@ -101,12 +100,6 @@ class CURSORINFO(ctypes.Structure): # input through touch or pen instead of the mouse"). hCursor is more reliable. return info.hCursor is not None - def is_full_screen(self, window): - return WindowProbe(self.app, window).is_full_screen - - def content_size(self, window): - return WindowProbe(self.app, window).content_size - def assert_app_icon(self, icon): for window in self.app.windows: # We have no real way to check we've got the right icon; use pixel peeping diff --git a/winforms/tests_backend/window.py b/winforms/tests_backend/window.py index 3ca2ac2c5f..fa96ec0c6b 100644 --- a/winforms/tests_backend/window.py +++ b/winforms/tests_backend/window.py @@ -33,7 +33,13 @@ def __init__(self, app, window): super().__init__(window._impl.native) assert isinstance(self.native, Form) - async def wait_for_window(self, message, minimize=False, full_screen=False): + async def wait_for_window( + self, + message, + minimize=False, + full_screen=False, + state_switch_not_from_normal=False, + ): await self.redraw(message) def close(self): @@ -54,13 +60,6 @@ def client_size(self): self.native.ClientSize.Height / self.scale_factor, ) - @property - def is_full_screen(self): - return ( - self.native.FormBorderStyle == getattr(FormBorderStyle, "None") - and self.native.WindowState == FormWindowState.Maximized - ) - @property def is_resizable(self): return self.native.FormBorderStyle == FormBorderStyle.Sizable @@ -88,6 +87,10 @@ def container_probe(self): assert len(panels) == 1 return BaseProbe(panels[0]) + @property + def instantaneous_state(self): + return self.impl.get_window_state(in_progress_state=False) + @property def menubar_probe(self): return BaseProbe(bar) if (bar := self.native.MainMenuStrip) else None