diff --git a/android/src/toga_android/app.py b/android/src/toga_android/app.py index 1ca3257897..19671b70c4 100644 --- a/android/src/toga_android/app.py +++ b/android/src/toga_android/app.py @@ -313,16 +313,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..0cf69ee297 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, + rapid_state_switching=False, + ): + await self.redraw( + message, + delay=(0.5 if (full_screen or rapid_state_switching) 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 321a4dc21d..3d5fce3ef7 100644 --- a/cocoa/src/toga_cocoa/app.py +++ b/cocoa/src/toga_cocoa/app.py @@ -29,7 +29,6 @@ NSCursor, NSMenu, NSMenuItem, - NSNumber, NSScreen, ) from .screens import Screen as ScreenImpl @@ -370,36 +369,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 5a0604c7d6..124993cd70 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,69 @@ 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 + ): + # Marking as no cover, since the operation is native OS delay + # dependent and this doesn't get consistently covered under macOS CI. + self.impl._apply_state(WindowState.NORMAL) # pragma: no cover + 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: + # Marking as no cover, since the operation is native OS delay + # dependent and this doesn't get consistently covered under macOS CI. + self.impl._apply_state(WindowState.NORMAL) # pragma: no cover + + @objc_method + def windowDidExitFullScreen_(self, notification) -> None: + self.impl._apply_state(self.impl._pending_state_transition) + ###################################################################### # Toolbar delegate methods ###################################################################### @@ -166,6 +226,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()) @@ -288,11 +351,132 @@ def get_visible(self): # 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 3390ba4de6..e93d568463 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 ce2036d1b2..d1c425dd79 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, + rapid_state_switching=False, + ): await self.redraw( message, - delay=0.75 if full_screen else 0.5 if minimize else 0.1, + delay=( + 2 + if rapid_state_switching + 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 7b54c9d4d4..f22d0f8b1c 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 @@ -347,8 +348,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) @@ -839,35 +838,53 @@ 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. + def enter_presentation_mode( + self, + windows: list[Window] | dict[Screen, Window], + ) -> None: + """Enter into presentation mode with one or more windows on different screens. - Full screen is not the same as "maximized"; full screen mode is when all window - borders and other window decorations are no longer visible. + 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: The list of windows to go full screen, in order of allocation to + :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. If no windows are specified, the app will - exit full screen mode. + 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 + 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 @@ -929,6 +946,47 @@ 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 9a443ed9f1..1b2094bd66 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 @@ -188,7 +189,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 @@ -464,21 +464,31 @@ 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. + When the window is in transition, then this will return the state it + is transitioning towards, instead of the actual instantaneous state. """ - return self._is_full_screen - - @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) + # 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.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 @@ -817,6 +827,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 6ad1e23aac..9157cb425e 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, @@ -465,57 +466,202 @@ 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): @@ -838,3 +984,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 c619577a60..5a9e60803a 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,105 @@ 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.""" + 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.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.full_screen = False - assert not window.full_screen - assert_action_performed_with(window, "set full screen", full_screen=False) + window.state = state + assert window.state == state + assert_action_not_performed(window, f"set window state to {state}") + + +@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) + 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() def test_close_direct(window, app): @@ -1159,3 +1249,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 4eb5172016..fc3e2e459b 100644 --- a/dummy/src/toga_dummy/app.py +++ b/dummy/src/toga_dummy/app.py @@ -129,16 +129,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 e4d6fd032d..d543f295b8 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 +from toga.constants import COLUMN, RIGHT, WindowState from toga.style import Pack @@ -30,14 +30,33 @@ def do_small(self, widget, **kwargs): def do_large(self, widget, **kwargs): self.main_window.size = (1500, 1000) - 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()}" @@ -187,12 +206,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( @@ -263,8 +309,13 @@ def startup(self): btn_do_right_current_screen, 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 467614e6d8..ca6ff6eef7 100644 --- a/gtk/src/toga_gtk/app.py +++ b/gtk/src/toga_gtk/app.py @@ -240,15 +240,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 dd13787267..f2a0e14481 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,32 @@ 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 slight delay to prevent glitching on wayland during rapid + # state switching. + 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 +177,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 e00d9b205c..4164fed26b 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 as a diff --git a/gtk/tests_backend/window.py b/gtk/tests_backend/window.py index ae6f6f0a07..568752abd5 100644 --- a/gtk/tests_backend/window.py +++ b/gtk/tests_backend/window.py @@ -23,8 +23,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, + rapid_state_switching=False, + ): + await self.redraw( + message, + delay=(0.5 if (full_screen or minimize) else 0.1), + ) def close(self): if self.is_closable: @@ -36,10 +45,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() @@ -50,7 +55,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() @@ -58,6 +63,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 3a24161845..f55e7df8d2 100644 --- a/iOS/src/toga_iOS/app.py +++ b/iOS/src/toga_iOS/app.py @@ -151,15 +151,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 5fe3736dda..6095c89b69 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 @@ -141,8 +142,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..f8805aa8c1 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, + rapid_state_switching=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 5e36ee03a0..faace31839 100644 --- a/testbed/tests/app/test_desktop.py +++ b/testbed/tests/app/test_desktop.py @@ -1,9 +1,11 @@ +import itertools from unittest.mock import Mock import pytest import toga -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 @@ -168,159 +170,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, + # 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 ) - initial_window2_widget_size = ( - window2_widget_probe.width, - window2_widget_probe.height, + 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 ) - - # 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.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 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 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 ) - - 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] + 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 ) - - # 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.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 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] - ) - - # Exit full screen - app.exit_full_screen() - await window1_probe.wait_for_window( - "No longer full screen", - full_screen=True, - ) - - 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 not app.in_presentation_mode + assert window1_probe.instantaneous_state != WindowState.PRESENTATION + assert window2_probe.instantaneous_state != WindowState.PRESENTATION + + +@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 d019c70f88..ca07cc8035 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, main_window, main_window_probe): diff --git a/testbed/tests/conftest.py b/testbed/tests/conftest.py index ad0a9d085f..9c0141b018 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 @@ -70,7 +71,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. @@ -83,12 +84,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 cf284945bf..6765ee08e3 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 @@ -138,13 +139,189 @@ 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), + ], + ) + @pytest.mark.parametrize( + "intermediate_states", + [ + # This set of intermediate states is randomly chosen and is not intended + # to check for specific problematic state transitions. Instead, it is used + # to test that rapidly passing new states to the window.state setter does + # not cause any unexpected glitches. + ( + WindowState.PRESENTATION, + WindowState.FULLSCREEN, + WindowState.NORMAL, + WindowState.PRESENTATION, + WindowState.NORMAL, + WindowState.FULLSCREEN, + ), + ], + ) + async def test_window_state_change_with_intermediate_states( + app, + main_window, + main_window_probe, + initial_state, + final_state, + intermediate_states, + ): + """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 the intermediate states but don't wait for the OS delay. + for state in intermediate_states: + main_window.state = state + + # Set to final state + main_window.state = final_state + await main_window_probe.wait_for_window( + f"Main window is in {final_state}", rapid_state_switching=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 relative screen positions.""" @@ -507,6 +684,180 @@ 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( + "intermediate_states", + [ + # These sets of intermediate states are specifically chosen to trigger cases that + # will cause test failures if the implementation is incorrect on certain backends, + # such as macOS. + ( + WindowState.MINIMIZED, + WindowState.FULLSCREEN, + WindowState.PRESENTATION, + WindowState.MAXIMIZED, + WindowState.MINIMIZED, + WindowState.FULLSCREEN, + ), + ( + WindowState.FULLSCREEN, + WindowState.MAXIMIZED, + WindowState.MINIMIZED, + WindowState.PRESENTATION, + WindowState.FULLSCREEN, + WindowState.MINIMIZED, + ), + ], + ) + @pytest.mark.parametrize( + "second_window_class, second_window_kwargs", + [ + ( + toga.MainWindow, + dict(title="Secondary Window", position=(200, 150)), + ) + ], + ) + async def test_window_state_change_with_intermediate_states( + app, + app_probe, + second_window, + second_window_probe, + initial_state, + final_state, + intermediate_states, + ): + """Window state can be changed to another state while passing + through intermediate states with an expected OS delay.""" + 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 the intermediate states but don't wait for the OS delay. + for state in intermediate_states: + second_window.state = 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}", rapid_state_switching=True + ) + assert second_window_probe.instantaneous_state == final_state + + @pytest.mark.parametrize( + "state", + [ + WindowState.NORMAL, + WindowState.MINIMIZED, + 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_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", [ @@ -516,47 +867,49 @@ 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_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 079abc9592..5f8f61fbba 100644 --- a/textual/src/toga_textual/app.py +++ b/textual/src/toga_textual/app.py @@ -107,11 +107,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 6b4aca65b7..4cece6ce38 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 5ca159a80f..fb12c27b77 100644 --- a/web/src/toga_web/app.py +++ b/web/src/toga_web/app.py @@ -140,11 +140,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 54194a20a5..3dd824c4e8 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -255,15 +255,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 96d990fc1a..792338dd00 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -8,6 +8,7 @@ from System.IO import MemoryStream from toga.command import Separator +from toga.constants import WindowState from toga.types import Position, Size from .container import Container @@ -33,6 +34,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 @@ -43,7 +48,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 whether window resizability is enabled or not. + self.native.FormBorderStyle = getattr( + WinForms.FormBorderStyle, + "Sizable" if self.interface.resizable else "FixedSingle", + ) def create(self): self.native = WinForms.Form() @@ -180,17 +189,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 ###################################################################### @@ -218,9 +284,9 @@ def create(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 8985cffc57..7b36016ba1 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 as a diff --git a/winforms/tests_backend/window.py b/winforms/tests_backend/window.py index 7a63d1235b..401ac581ba 100644 --- a/winforms/tests_backend/window.py +++ b/winforms/tests_backend/window.py @@ -31,7 +31,13 @@ def __init__(self, app, window): self.native = 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, + rapid_state_switching=False, + ): await self.redraw(message) def close(self): @@ -47,13 +53,6 @@ def content_size(self): ), ) - @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 @@ -73,6 +72,10 @@ def minimize(self): def unminimize(self): self.native.WindowState = FormWindowState.Normal + @property + def instantaneous_state(self): + return self.impl.get_window_state(in_progress_state=False) + def _native_toolbar(self): for control in self.native.Controls: if isinstance(control, ToolStrip) and not isinstance(control, MenuStrip):