Skip to content

Commit 2bfdda6

Browse files
authored
Added on_gain_focus, on_lose_focus, on_show & on_hide handlers on toga.Window (#2096)
Add events to track changes in focus, and changes in visibility for windows. This also allows mobile applications to track when they are about to move into the background, and when they have been restored from the background.
1 parent 20e16f0 commit 2bfdda6

File tree

17 files changed

+586
-13
lines changed

17 files changed

+586
-13
lines changed

android/src/toga_android/app.py

+26-7
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from android.content import Context
55
from android.graphics.drawable import BitmapDrawable
66
from android.media import RingtoneManager
7+
from android.os import Build
78
from android.view import Menu, MenuItem
89
from androidx.core.content import ContextCompat
910
from java import dynamic_proxy
@@ -35,22 +36,40 @@ def onCreate(self):
3536

3637
def onStart(self):
3738
print("Toga app: onStart")
39+
self._impl.interface.current_window.on_show()
3840

39-
def onResume(self):
41+
def onResume(self): # pragma: no cover
4042
print("Toga app: onResume")
41-
42-
def onPause(self):
43-
print("Toga app: onPause") # pragma: no cover
44-
45-
def onStop(self):
46-
print("Toga app: onStop") # pragma: no cover
43+
# onTopResumedActivityChanged is not available on android versions less
44+
# than Q. onResume is the best indicator for the gain input focus event.
45+
# https://developer.android.com/reference/android/app/Activity#onWindowFocusChanged(boolean):~:text=If%20the%20intent,the%20best%20indicator.
46+
if Build.VERSION.SDK_INT < Build.VERSION_CODES.Q:
47+
self._impl.interface.current_window.on_gain_focus()
48+
49+
def onPause(self): # pragma: no cover
50+
print("Toga app: onPause")
51+
# onTopResumedActivityChanged is not available on android versions less
52+
# than Q. onPause is the best indicator for the lost input focus event.
53+
if Build.VERSION.SDK_INT < Build.VERSION_CODES.Q:
54+
self._impl.interface.current_window.on_lose_focus()
55+
56+
def onStop(self): # pragma: no cover
57+
print("Toga app: onStop")
58+
self._impl.interface.current_window.on_hide()
4759

4860
def onDestroy(self):
4961
print("Toga app: onDestroy") # pragma: no cover
5062

5163
def onRestart(self):
5264
print("Toga app: onRestart") # pragma: no cover
5365

66+
def onTopResumedActivityChanged(self, isTopResumedActivity): # pragma: no cover
67+
print("Toga app: onTopResumedActivityChanged")
68+
if isTopResumedActivity:
69+
self._impl.interface.current_window.on_gain_focus()
70+
else:
71+
self._impl.interface.current_window.on_lose_focus()
72+
5473
def onActivityResult(self, requestCode, resultCode, resultData):
5574
print(f"Toga app: onActivityResult {requestCode=} {resultCode=} {resultData=}")
5675
try:

changes/2009.feature.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Windows can now respond to changes in focus and visibility.

cocoa/src/toga_cocoa/window.py

+17
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,17 @@ def windowDidResize_(self, notification) -> None:
6868
# Set the window to the new size
6969
self.interface.content.refresh()
7070

71+
@objc_method
72+
def windowDidBecomeMain_(self, notification):
73+
self.interface.on_gain_focus()
74+
75+
@objc_method
76+
def windowDidResignMain_(self, notification):
77+
self.interface.on_lose_focus()
78+
7179
@objc_method
7280
def windowDidMiniaturize_(self, notification) -> None:
81+
self.interface.on_hide()
7382
if (
7483
self.impl._pending_state_transition
7584
and self.impl._pending_state_transition != WindowState.MINIMIZED
@@ -80,6 +89,7 @@ def windowDidMiniaturize_(self, notification) -> None:
8089

8190
@objc_method
8291
def windowDidDeminiaturize_(self, notification) -> None:
92+
self.interface.on_show()
8393
self.impl._apply_state(self.impl._pending_state_transition)
8494

8595
@objc_method
@@ -258,6 +268,10 @@ def set_app(self, app):
258268

259269
def show(self):
260270
self.native.makeKeyAndOrderFront(None)
271+
# Cocoa doesn't provide a native window delegate notification that would
272+
# be triggered when makeKeyAndOrderFront_ is called. So, trigger the event
273+
# here instead.
274+
self.interface.on_show()
261275

262276
######################################################################
263277
# Window content and resources
@@ -334,6 +348,9 @@ def set_position(self, position):
334348

335349
def hide(self):
336350
self.native.orderOut(self.native)
351+
# Cocoa doesn't provide a native window delegate notification that would
352+
# be triggered when orderOut_ is called. So, trigger the event here instead.
353+
self.interface.on_hide()
337354

338355
def get_visible(self):
339356
return (

core/src/toga/window.py

+97
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,58 @@ def __call__(self, window: Window, **kwargs: Any) -> bool:
106106
"""
107107

108108

109+
class OnGainFocusHandler(Protocol):
110+
def __call__(self, window: Window, **kwargs: Any) -> None:
111+
"""A handler to invoke when a window gains input focus.
112+
113+
:param window: The window instance that gains input focus.
114+
:param kwargs: Ensures compatibility with additional arguments introduced in
115+
future versions.
116+
"""
117+
...
118+
119+
120+
class OnLoseFocusHandler(Protocol):
121+
def __call__(self, window: Window, **kwargs: Any) -> None:
122+
"""A handler to invoke when a window loses input focus.
123+
124+
:param window: The window instance that loses input focus.
125+
:param kwargs: Ensures compatibility with additional arguments introduced in
126+
future ver
127+
"""
128+
...
129+
130+
131+
class OnShowHandler(Protocol):
132+
def __call__(self, window: Window, **kwargs: Any) -> None:
133+
"""A handler to invoke when a window becomes visible.
134+
135+
This event will be triggered when a window is first displayed, and when the
136+
window is restored from a minimized or hidden state. On mobile platforms, it is
137+
also triggered when an app is made the currently active app.
138+
139+
:param window: The window instance that becomes visible.
140+
:param kwargs: Ensures compatibility with additional arguments introduced in
141+
future ver
142+
"""
143+
...
144+
145+
146+
class OnHideHandler(Protocol):
147+
def __call__(self, window: Window, **kwargs: Any) -> None:
148+
"""A handler to invoke when a window stops being visible.
149+
150+
This event will be triggered when a window moves to a minimized or hidden state.
151+
On mobile platforms, it is also triggered when an app is moved to the background
152+
and is no longer the currently active app.
153+
154+
:param window: The window instance that becomes not visible to the user.
155+
:param kwargs: Ensures compatibility with additional arguments introduced in
156+
future ver
157+
"""
158+
...
159+
160+
109161
_DialogResultT = TypeVar("_DialogResultT")
110162

111163

@@ -141,6 +193,10 @@ def __init__(
141193
closable: bool = True,
142194
minimizable: bool = True,
143195
on_close: OnCloseHandler | None = None,
196+
on_gain_focus: OnGainFocusHandler | None = None,
197+
on_lose_focus: OnLoseFocusHandler | None = None,
198+
on_show: OnShowHandler | None = None,
199+
on_hide: OnHideHandler | None = None,
144200
content: Widget | None = None,
145201
) -> None:
146202
"""Create a new Window.
@@ -193,6 +249,11 @@ def __init__(
193249

194250
self.on_close = on_close
195251

252+
self.on_gain_focus = on_gain_focus
253+
self.on_lose_focus = on_lose_focus
254+
self.on_show = on_show
255+
self.on_hide = on_hide
256+
196257
def __lt__(self, other: Window) -> bool:
197258
return self.id < other.id
198259

@@ -554,6 +615,42 @@ def cleanup(window: Window, should_close: bool) -> None:
554615

555616
self._on_close = wrapped_handler(self, handler, cleanup=cleanup)
556617

618+
@property
619+
def on_gain_focus(self) -> callable:
620+
"""The handler to invoke if the window gains input focus."""
621+
return self._on_gain_focus
622+
623+
@on_gain_focus.setter
624+
def on_gain_focus(self, handler):
625+
self._on_gain_focus = wrapped_handler(self, handler)
626+
627+
@property
628+
def on_lose_focus(self) -> callable:
629+
"""The handler to invoke if the window loses input focus."""
630+
return self._on_lose_focus
631+
632+
@on_lose_focus.setter
633+
def on_lose_focus(self, handler):
634+
self._on_lose_focus = wrapped_handler(self, handler)
635+
636+
@property
637+
def on_show(self) -> callable:
638+
"""The handler to invoke if the window is shown from a hidden state."""
639+
return self._on_show
640+
641+
@on_show.setter
642+
def on_show(self, handler):
643+
self._on_show = wrapped_handler(self, handler)
644+
645+
@property
646+
def on_hide(self) -> callable:
647+
"""The handler to invoke if the window is hidden from a visible state."""
648+
return self._on_hide
649+
650+
@on_hide.setter
651+
def on_hide(self, handler):
652+
self._on_hide = wrapped_handler(self, handler)
653+
557654
######################################################################
558655
# 2024-06: Backwards compatibility for <= 0.4.5
559656
######################################################################

core/tests/utils.py

+52
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,55 @@ def _create(self):
2424

2525
def __repr__(self):
2626
return f"Widget(id={self.id!r})"
27+
28+
29+
def assert_window_gain_focus(window, trigger_expected=True):
30+
on_gain_focus_handler = window.on_gain_focus._raw
31+
on_lose_focus_handler = window.on_lose_focus._raw
32+
if trigger_expected:
33+
on_gain_focus_handler.assert_called_once_with(window)
34+
else:
35+
on_gain_focus_handler.assert_not_called()
36+
on_lose_focus_handler.assert_not_called()
37+
38+
on_gain_focus_handler.reset_mock()
39+
on_lose_focus_handler.reset_mock()
40+
41+
42+
def assert_window_lose_focus(window, trigger_expected=True):
43+
on_gain_focus_handler = window.on_gain_focus._raw
44+
on_lose_focus_handler = window.on_lose_focus._raw
45+
if trigger_expected:
46+
on_lose_focus_handler.assert_called_once_with(window)
47+
else:
48+
on_lose_focus_handler.assert_not_called()
49+
on_gain_focus_handler.assert_not_called()
50+
51+
on_gain_focus_handler.reset_mock()
52+
on_lose_focus_handler.reset_mock()
53+
54+
55+
def assert_window_on_show(window, trigger_expected=True):
56+
on_show_handler = window.on_show._raw
57+
on_hide_handler = window.on_hide._raw
58+
if trigger_expected:
59+
on_show_handler.assert_called_once_with(window)
60+
else:
61+
on_show_handler.assert_not_called()
62+
on_hide_handler.assert_not_called()
63+
64+
on_show_handler.reset_mock()
65+
on_hide_handler.reset_mock()
66+
67+
68+
def assert_window_on_hide(window, trigger_expected=True):
69+
on_show_handler = window.on_show._raw
70+
on_hide_handler = window.on_hide._raw
71+
if trigger_expected:
72+
on_hide_handler.assert_called_once_with(window)
73+
else:
74+
on_hide_handler.assert_not_called()
75+
on_show_handler.assert_not_called()
76+
77+
on_show_handler.reset_mock()
78+
on_hide_handler.reset_mock()

0 commit comments

Comments
 (0)