Skip to content

Commit 32000af

Browse files
authored
Detect app level hide/unhide and trigger appropriate visibility event on macOS (beeware#3166)
Modifies the macOS backend to ensure that app-level hide triggers on_hide and on_show events.
1 parent 0444409 commit 32000af

File tree

8 files changed

+124
-7
lines changed

8 files changed

+124
-7
lines changed

changes/3166.misc.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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.

cocoa/src/toga_cocoa/app.py

+17
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
import toga
1515
from toga.command import Command, Group, Separator
16+
from toga.constants import WindowState
1617
from toga.handlers import NativeHandler
1718

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

47+
@objc_method
48+
def applicationWillHide_(self, notification):
49+
for window in self.interface.windows:
50+
# on_hide() is triggered only on windows which are in
51+
# visible-to-user (i.e., not in minimized or hidden).
52+
if window.visible and window.state != WindowState.MINIMIZED:
53+
window.on_hide()
54+
55+
@objc_method
56+
def applicationDidUnhide_(self, notification):
57+
for window in self.interface.windows:
58+
# on_show() is triggered only on windows which are in
59+
# visible-to-user (i.e., not in minimized or hidden).
60+
if window.visible and window.state != WindowState.MINIMIZED:
61+
window.on_show()
62+
4663
@objc_method
4764
def applicationSupportsSecureRestorableState_(self, app) -> bool:
4865
return True

cocoa/src/toga_cocoa/window.py

+7-3
Original file line numberDiff line numberDiff line change
@@ -353,9 +353,13 @@ def hide(self):
353353
self.interface.on_hide()
354354

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

361365
######################################################################

cocoa/tests_backend/app.py

+15
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ def is_cursor_visible(self):
5555
# fall back to the implementation's proxy variable.
5656
return self.app._impl._cursor_visible
5757

58+
def unhide(self):
59+
self.app._impl.native.unhide(self.app._impl.native)
60+
5861
def assert_app_icon(self, icon):
5962
# We have no real way to check we've got the right icon; use pixel peeping as a
6063
# guess. Construct a PIL image from the current icon.
@@ -129,6 +132,18 @@ def _activate_menu_item(self, path):
129132
argtypes=[objc_id],
130133
)
131134

135+
def activate_menu_hide(self):
136+
item = self._menu_item(["*", "Hide Toga Testbed"])
137+
# To activate the "Hide" in global app menu, we need call the native
138+
# handler on the NSApplication instead of the NSApplicationDelegate.
139+
send_message(
140+
self.app._impl.native,
141+
item.action,
142+
self.app._impl.native,
143+
restype=None,
144+
argtypes=[objc_id],
145+
)
146+
132147
def activate_menu_exit(self):
133148
self._activate_menu_item(["*", "Quit Toga Testbed"])
134149

examples/window/window/app.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ def do_title(self, widget, **kwargs):
110110

111111
def do_new_windows(self, widget, **kwargs):
112112
non_resize_window = toga.Window(
113-
"Non-resizable Window",
113+
title="Non-resizable Window",
114114
size=(300, 300),
115115
resizable=False,
116116
on_close=self.close_handler,
@@ -121,7 +121,7 @@ def do_new_windows(self, widget, **kwargs):
121121
non_resize_window.show()
122122

123123
non_close_window = toga.Window(
124-
"Non-closeable Window",
124+
title="Non-closeable Window",
125125
size=(300, 300),
126126
closable=False,
127127
)
@@ -131,7 +131,7 @@ def do_new_windows(self, widget, **kwargs):
131131
non_close_window.show()
132132

133133
no_close_handler_window = toga.Window(
134-
"No close handler",
134+
title="No close handler",
135135
position=(400, 400),
136136
size=(300, 300),
137137
)
@@ -140,7 +140,7 @@ def do_new_windows(self, widget, **kwargs):
140140
)
141141
no_close_handler_window.show()
142142

143-
second_main_window = toga.MainWindow()
143+
second_main_window = toga.MainWindow(title="Second Main")
144144
extra_command = toga.Command(
145145
lambda cmd: print("A little extra"),
146146
text="Extra",

gtk/tests_backend/app.py

+6
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ def logs_path(self):
4444
def is_cursor_visible(self):
4545
pytest.skip("Cursor visibility not implemented on GTK")
4646

47+
def unhide(self):
48+
pytest.xfail("This platform doesn't have an app level unhide.")
49+
4750
def assert_app_icon(self, icon):
4851
if GTK_VERSION >= (4, 0, 0):
4952
pytest.skip("Checking app icon not implemented in GTK4")
@@ -119,6 +122,9 @@ def _activate_menu_item(self, path):
119122
_, action = self._menu_item(path)
120123
action.emit("activate", None)
121124

125+
def activate_menu_hide(self):
126+
pytest.xfail("This platform doesn't present a app level hide option in menu.")
127+
122128
def activate_menu_exit(self):
123129
if GTK_VERSION >= (4, 0, 0):
124130
pytest.skip("GTK4 doesn't support system menus")

testbed/tests/app/test_desktop.py

+68
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from toga.constants import WindowState
1111
from toga.style.pack import Pack
1212

13+
from ..assertions import assert_window_on_hide, assert_window_on_show
1314
from ..widgets.probe import get_probe
1415
from ..window.test_window import window_probe
1516

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

176177

178+
async def test_app_level_menu_hide(app, app_probe, main_window, main_window_probe):
179+
"""The app can be hidden from the global app menu option, thereby hiding all
180+
the windows of the app."""
181+
initially_visible_window = toga.Window(
182+
title="Initially Visible Window",
183+
size=(200, 200),
184+
content=toga.Box(style=Pack(background_color=CORNFLOWERBLUE)),
185+
)
186+
initially_visible_window.show()
187+
188+
initially_hidden_window = toga.Window(
189+
title="Initially Hidden Window",
190+
size=(200, 200),
191+
content=toga.Box(style=Pack(background_color=REBECCAPURPLE)),
192+
)
193+
initially_hidden_window.hide()
194+
195+
initially_minimized_window = toga.Window(
196+
title="Initially Minimized Window",
197+
size=(200, 200),
198+
content=toga.Box(style=Pack(background_color=GOLDENROD)),
199+
)
200+
initially_minimized_window.show()
201+
initially_minimized_window.state = WindowState.MINIMIZED
202+
203+
await window_probe(app, initially_minimized_window).wait_for_window(
204+
"Test windows have been setup", state=WindowState.MINIMIZED
205+
)
206+
207+
# Setup event mocks after test windows' setup to prevent false positive triggering.
208+
initially_visible_window.on_show = Mock()
209+
initially_visible_window.on_hide = Mock()
210+
211+
initially_hidden_window.on_show = Mock()
212+
initially_hidden_window.on_hide = Mock()
213+
214+
initially_minimized_window.on_show = Mock()
215+
initially_minimized_window.on_hide = Mock()
216+
217+
# Confirm the initial window state
218+
assert initially_visible_window.visible
219+
assert not initially_hidden_window.visible
220+
assert initially_minimized_window.visible
221+
222+
# Test using the "Hide" option from the global app menu.
223+
app_probe.activate_menu_hide()
224+
await main_window_probe.wait_for_window("Hide selected from menu, and accepted")
225+
assert not initially_visible_window.visible
226+
assert not initially_hidden_window.visible
227+
assert not initially_minimized_window.visible
228+
229+
assert_window_on_hide(initially_visible_window)
230+
assert_window_on_hide(initially_hidden_window, trigger_expected=False)
231+
assert_window_on_hide(initially_minimized_window, trigger_expected=False)
232+
233+
# Make the app visible again
234+
app_probe.unhide()
235+
await main_window_probe.wait_for_window("App level unhide has been activated")
236+
assert initially_visible_window.visible
237+
assert not initially_hidden_window.visible
238+
assert initially_minimized_window.visible
239+
240+
assert_window_on_show(initially_visible_window)
241+
assert_window_on_show(initially_hidden_window, trigger_expected=False)
242+
assert_window_on_show(initially_minimized_window, trigger_expected=False)
243+
244+
177245
async def test_presentation_mode(app, app_probe, main_window, main_window_probe):
178246
"""The app can enter presentation mode."""
179247
bg_colors = (CORNFLOWERBLUE, FIREBRICK, REBECCAPURPLE, GOLDENROD)

winforms/tests_backend/app.py

+6
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ class CURSORINFO(ctypes.Structure):
100100
# input through touch or pen instead of the mouse"). hCursor is more reliable.
101101
return info.hCursor is not None
102102

103+
def unhide(self):
104+
pytest.xfail("This platform doesn't have an app level unhide.")
105+
103106
def assert_app_icon(self, icon):
104107
for window in self.app.windows:
105108
# We have no real way to check we've got the right icon; use pixel peeping
@@ -138,6 +141,9 @@ def _menu_item(self, path):
138141
def _activate_menu_item(self, path):
139142
self._menu_item(path).OnClick(EventArgs.Empty)
140143

144+
def activate_menu_hide(self):
145+
pytest.xfail("This platform doesn't present a app level hide option in menu.")
146+
141147
def activate_menu_exit(self):
142148
self._activate_menu_item(["File", "Exit"])
143149

0 commit comments

Comments
 (0)