Skip to content

Commit

Permalink
Detect app level hide/unhide and trigger appropriate visibility event…
Browse files Browse the repository at this point in the history
… on macOS (#3166)

Modifies the macOS backend to ensure that app-level hide triggers on_hide and on_show events.
  • Loading branch information
proneon267 authored Feb 11, 2025
1 parent 0444409 commit 32000af
Show file tree
Hide file tree
Showing 8 changed files with 124 additions and 7 deletions.
1 change: 1 addition & 0 deletions changes/3166.misc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
On macOS, when the app is hidden via the global app menu option and later unhidden, the appropriate visibility events like `on_show()` and `on_hide()` are now triggered.
17 changes: 17 additions & 0 deletions cocoa/src/toga_cocoa/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import toga
from toga.command import Command, Group, Separator
from toga.constants import WindowState
from toga.handlers import NativeHandler

from .command import Command as CommandImpl, submenu_for_group
Expand Down Expand Up @@ -43,6 +44,22 @@ class AppDelegate(NSObject):
def applicationDidFinishLaunching_(self, notification):
self.native.activateIgnoringOtherApps(True)

@objc_method
def applicationWillHide_(self, notification):
for window in self.interface.windows:
# on_hide() is triggered only on windows which are in
# visible-to-user (i.e., not in minimized or hidden).
if window.visible and window.state != WindowState.MINIMIZED:
window.on_hide()

@objc_method
def applicationDidUnhide_(self, notification):
for window in self.interface.windows:
# on_show() is triggered only on windows which are in
# visible-to-user (i.e., not in minimized or hidden).
if window.visible and window.state != WindowState.MINIMIZED:
window.on_show()

@objc_method
def applicationSupportsSecureRestorableState_(self, app) -> bool:
return True
Expand Down
10 changes: 7 additions & 3 deletions cocoa/src/toga_cocoa/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,9 +353,13 @@ def hide(self):
self.interface.on_hide()

def get_visible(self):
return (
bool(self.native.isVisible)
or self.get_window_state(in_progress_state=True) == WindowState.MINIMIZED
# macOS reports minimized windows as non-visible, but Toga considers minimized
# windows to be visible, so we need to override in that case. However,
# minimization state is retained when the app as a whole is hidden; so we also
# need to check for app-level hiding when overriding.
return bool(self.native.isVisible) or (
self.get_window_state(in_progress_state=True) == WindowState.MINIMIZED
and not bool(self.interface.app._impl.native.isHidden())
)

######################################################################
Expand Down
15 changes: 15 additions & 0 deletions cocoa/tests_backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ def is_cursor_visible(self):
# fall back to the implementation's proxy variable.
return self.app._impl._cursor_visible

def unhide(self):
self.app._impl.native.unhide(self.app._impl.native)

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.
Expand Down Expand Up @@ -129,6 +132,18 @@ def _activate_menu_item(self, path):
argtypes=[objc_id],
)

def activate_menu_hide(self):
item = self._menu_item(["*", "Hide Toga Testbed"])
# To activate the "Hide" in global app menu, we need call the native
# handler on the NSApplication instead of the NSApplicationDelegate.
send_message(
self.app._impl.native,
item.action,
self.app._impl.native,
restype=None,
argtypes=[objc_id],
)

def activate_menu_exit(self):
self._activate_menu_item(["*", "Quit Toga Testbed"])

Expand Down
8 changes: 4 additions & 4 deletions examples/window/window/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ def do_title(self, widget, **kwargs):

def do_new_windows(self, widget, **kwargs):
non_resize_window = toga.Window(
"Non-resizable Window",
title="Non-resizable Window",
size=(300, 300),
resizable=False,
on_close=self.close_handler,
Expand All @@ -121,7 +121,7 @@ def do_new_windows(self, widget, **kwargs):
non_resize_window.show()

non_close_window = toga.Window(
"Non-closeable Window",
title="Non-closeable Window",
size=(300, 300),
closable=False,
)
Expand All @@ -131,7 +131,7 @@ def do_new_windows(self, widget, **kwargs):
non_close_window.show()

no_close_handler_window = toga.Window(
"No close handler",
title="No close handler",
position=(400, 400),
size=(300, 300),
)
Expand All @@ -140,7 +140,7 @@ def do_new_windows(self, widget, **kwargs):
)
no_close_handler_window.show()

second_main_window = toga.MainWindow()
second_main_window = toga.MainWindow(title="Second Main")
extra_command = toga.Command(
lambda cmd: print("A little extra"),
text="Extra",
Expand Down
6 changes: 6 additions & 0 deletions gtk/tests_backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ def logs_path(self):
def is_cursor_visible(self):
pytest.skip("Cursor visibility not implemented on GTK")

def unhide(self):
pytest.xfail("This platform doesn't have an app level unhide.")

def assert_app_icon(self, icon):
if GTK_VERSION >= (4, 0, 0):
pytest.skip("Checking app icon not implemented in GTK4")
Expand Down Expand Up @@ -119,6 +122,9 @@ def _activate_menu_item(self, path):
_, action = self._menu_item(path)
action.emit("activate", None)

def activate_menu_hide(self):
pytest.xfail("This platform doesn't present a app level hide option in menu.")

def activate_menu_exit(self):
if GTK_VERSION >= (4, 0, 0):
pytest.skip("GTK4 doesn't support system menus")
Expand Down
68 changes: 68 additions & 0 deletions testbed/tests/app/test_desktop.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from toga.constants import WindowState
from toga.style.pack import Pack

from ..assertions import assert_window_on_hide, assert_window_on_show
from ..widgets.probe import get_probe
from ..window.test_window import window_probe

Expand Down Expand Up @@ -174,6 +175,73 @@ async def test_menu_minimize(app, app_probe):
assert window1_probe.is_minimized


async def test_app_level_menu_hide(app, app_probe, main_window, main_window_probe):
"""The app can be hidden from the global app menu option, thereby hiding all
the windows of the app."""
initially_visible_window = toga.Window(
title="Initially Visible Window",
size=(200, 200),
content=toga.Box(style=Pack(background_color=CORNFLOWERBLUE)),
)
initially_visible_window.show()

initially_hidden_window = toga.Window(
title="Initially Hidden Window",
size=(200, 200),
content=toga.Box(style=Pack(background_color=REBECCAPURPLE)),
)
initially_hidden_window.hide()

initially_minimized_window = toga.Window(
title="Initially Minimized Window",
size=(200, 200),
content=toga.Box(style=Pack(background_color=GOLDENROD)),
)
initially_minimized_window.show()
initially_minimized_window.state = WindowState.MINIMIZED

await window_probe(app, initially_minimized_window).wait_for_window(
"Test windows have been setup", state=WindowState.MINIMIZED
)

# Setup event mocks after test windows' setup to prevent false positive triggering.
initially_visible_window.on_show = Mock()
initially_visible_window.on_hide = Mock()

initially_hidden_window.on_show = Mock()
initially_hidden_window.on_hide = Mock()

initially_minimized_window.on_show = Mock()
initially_minimized_window.on_hide = Mock()

# Confirm the initial window state
assert initially_visible_window.visible
assert not initially_hidden_window.visible
assert initially_minimized_window.visible

# Test using the "Hide" option from the global app menu.
app_probe.activate_menu_hide()
await main_window_probe.wait_for_window("Hide selected from menu, and accepted")
assert not initially_visible_window.visible
assert not initially_hidden_window.visible
assert not initially_minimized_window.visible

assert_window_on_hide(initially_visible_window)
assert_window_on_hide(initially_hidden_window, trigger_expected=False)
assert_window_on_hide(initially_minimized_window, trigger_expected=False)

# Make the app visible again
app_probe.unhide()
await main_window_probe.wait_for_window("App level unhide has been activated")
assert initially_visible_window.visible
assert not initially_hidden_window.visible
assert initially_minimized_window.visible

assert_window_on_show(initially_visible_window)
assert_window_on_show(initially_hidden_window, trigger_expected=False)
assert_window_on_show(initially_minimized_window, trigger_expected=False)


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)
Expand Down
6 changes: 6 additions & 0 deletions winforms/tests_backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ class CURSORINFO(ctypes.Structure):
# input through touch or pen instead of the mouse"). hCursor is more reliable.
return info.hCursor is not None

def unhide(self):
pytest.xfail("This platform doesn't have an app level unhide.")

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
Expand Down Expand Up @@ -138,6 +141,9 @@ def _menu_item(self, path):
def _activate_menu_item(self, path):
self._menu_item(path).OnClick(EventArgs.Empty)

def activate_menu_hide(self):
pytest.xfail("This platform doesn't present a app level hide option in menu.")

def activate_menu_exit(self):
self._activate_menu_item(["File", "Exit"])

Expand Down

0 comments on commit 32000af

Please sign in to comment.