Skip to content

Commit

Permalink
Added on_gain_focus, on_lose_focus, on_show & on_hide handler…
Browse files Browse the repository at this point in the history
…s 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.
  • Loading branch information
proneon267 authored Jan 24, 2025
1 parent 20e16f0 commit 2bfdda6
Show file tree
Hide file tree
Showing 17 changed files with 586 additions and 13 deletions.
33 changes: 26 additions & 7 deletions android/src/toga_android/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from android.content import Context
from android.graphics.drawable import BitmapDrawable
from android.media import RingtoneManager
from android.os import Build
from android.view import Menu, MenuItem
from androidx.core.content import ContextCompat
from java import dynamic_proxy
Expand Down Expand Up @@ -35,22 +36,40 @@ def onCreate(self):

def onStart(self):
print("Toga app: onStart")
self._impl.interface.current_window.on_show()

def onResume(self):
def onResume(self): # pragma: no cover
print("Toga app: onResume")

def onPause(self):
print("Toga app: onPause") # pragma: no cover

def onStop(self):
print("Toga app: onStop") # pragma: no cover
# onTopResumedActivityChanged is not available on android versions less
# than Q. onResume is the best indicator for the gain input focus event.
# https://developer.android.com/reference/android/app/Activity#onWindowFocusChanged(boolean):~:text=If%20the%20intent,the%20best%20indicator.
if Build.VERSION.SDK_INT < Build.VERSION_CODES.Q:
self._impl.interface.current_window.on_gain_focus()

def onPause(self): # pragma: no cover
print("Toga app: onPause")
# onTopResumedActivityChanged is not available on android versions less
# than Q. onPause is the best indicator for the lost input focus event.
if Build.VERSION.SDK_INT < Build.VERSION_CODES.Q:
self._impl.interface.current_window.on_lose_focus()

def onStop(self): # pragma: no cover
print("Toga app: onStop")
self._impl.interface.current_window.on_hide()

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

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

def onTopResumedActivityChanged(self, isTopResumedActivity): # pragma: no cover
print("Toga app: onTopResumedActivityChanged")
if isTopResumedActivity:
self._impl.interface.current_window.on_gain_focus()
else:
self._impl.interface.current_window.on_lose_focus()

def onActivityResult(self, requestCode, resultCode, resultData):
print(f"Toga app: onActivityResult {requestCode=} {resultCode=} {resultData=}")
try:
Expand Down
1 change: 1 addition & 0 deletions changes/2009.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Windows can now respond to changes in focus and visibility.
17 changes: 17 additions & 0 deletions cocoa/src/toga_cocoa/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,17 @@ def windowDidResize_(self, notification) -> None:
# Set the window to the new size
self.interface.content.refresh()

@objc_method
def windowDidBecomeMain_(self, notification):
self.interface.on_gain_focus()

@objc_method
def windowDidResignMain_(self, notification):
self.interface.on_lose_focus()

@objc_method
def windowDidMiniaturize_(self, notification) -> None:
self.interface.on_hide()
if (
self.impl._pending_state_transition
and self.impl._pending_state_transition != WindowState.MINIMIZED
Expand All @@ -80,6 +89,7 @@ def windowDidMiniaturize_(self, notification) -> None:

@objc_method
def windowDidDeminiaturize_(self, notification) -> None:
self.interface.on_show()
self.impl._apply_state(self.impl._pending_state_transition)

@objc_method
Expand Down Expand Up @@ -258,6 +268,10 @@ def set_app(self, app):

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

######################################################################
# Window content and resources
Expand Down Expand Up @@ -334,6 +348,9 @@ def set_position(self, position):

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

def get_visible(self):
return (
Expand Down
97 changes: 97 additions & 0 deletions core/src/toga/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,58 @@ def __call__(self, window: Window, **kwargs: Any) -> bool:
"""


class OnGainFocusHandler(Protocol):
def __call__(self, window: Window, **kwargs: Any) -> None:
"""A handler to invoke when a window gains input focus.
:param window: The window instance that gains input focus.
:param kwargs: Ensures compatibility with additional arguments introduced in
future versions.
"""
...


class OnLoseFocusHandler(Protocol):
def __call__(self, window: Window, **kwargs: Any) -> None:
"""A handler to invoke when a window loses input focus.
:param window: The window instance that loses input focus.
:param kwargs: Ensures compatibility with additional arguments introduced in
future ver
"""
...


class OnShowHandler(Protocol):
def __call__(self, window: Window, **kwargs: Any) -> None:
"""A handler to invoke when a window becomes visible.
This event will be triggered when a window is first displayed, and when the
window is restored from a minimized or hidden state. On mobile platforms, it is
also triggered when an app is made the currently active app.
:param window: The window instance that becomes visible.
:param kwargs: Ensures compatibility with additional arguments introduced in
future ver
"""
...


class OnHideHandler(Protocol):
def __call__(self, window: Window, **kwargs: Any) -> None:
"""A handler to invoke when a window stops being visible.
This event will be triggered when a window moves to a minimized or hidden state.
On mobile platforms, it is also triggered when an app is moved to the background
and is no longer the currently active app.
:param window: The window instance that becomes not visible to the user.
:param kwargs: Ensures compatibility with additional arguments introduced in
future ver
"""
...


_DialogResultT = TypeVar("_DialogResultT")


Expand Down Expand Up @@ -141,6 +193,10 @@ def __init__(
closable: bool = True,
minimizable: bool = True,
on_close: OnCloseHandler | None = None,
on_gain_focus: OnGainFocusHandler | None = None,
on_lose_focus: OnLoseFocusHandler | None = None,
on_show: OnShowHandler | None = None,
on_hide: OnHideHandler | None = None,
content: Widget | None = None,
) -> None:
"""Create a new Window.
Expand Down Expand Up @@ -193,6 +249,11 @@ def __init__(

self.on_close = on_close

self.on_gain_focus = on_gain_focus
self.on_lose_focus = on_lose_focus
self.on_show = on_show
self.on_hide = on_hide

def __lt__(self, other: Window) -> bool:
return self.id < other.id

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

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

@property
def on_gain_focus(self) -> callable:
"""The handler to invoke if the window gains input focus."""
return self._on_gain_focus

@on_gain_focus.setter
def on_gain_focus(self, handler):
self._on_gain_focus = wrapped_handler(self, handler)

@property
def on_lose_focus(self) -> callable:
"""The handler to invoke if the window loses input focus."""
return self._on_lose_focus

@on_lose_focus.setter
def on_lose_focus(self, handler):
self._on_lose_focus = wrapped_handler(self, handler)

@property
def on_show(self) -> callable:
"""The handler to invoke if the window is shown from a hidden state."""
return self._on_show

@on_show.setter
def on_show(self, handler):
self._on_show = wrapped_handler(self, handler)

@property
def on_hide(self) -> callable:
"""The handler to invoke if the window is hidden from a visible state."""
return self._on_hide

@on_hide.setter
def on_hide(self, handler):
self._on_hide = wrapped_handler(self, handler)

######################################################################
# 2024-06: Backwards compatibility for <= 0.4.5
######################################################################
Expand Down
52 changes: 52 additions & 0 deletions core/tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,55 @@ def _create(self):

def __repr__(self):
return f"Widget(id={self.id!r})"


def assert_window_gain_focus(window, trigger_expected=True):
on_gain_focus_handler = window.on_gain_focus._raw
on_lose_focus_handler = window.on_lose_focus._raw
if trigger_expected:
on_gain_focus_handler.assert_called_once_with(window)
else:
on_gain_focus_handler.assert_not_called()
on_lose_focus_handler.assert_not_called()

on_gain_focus_handler.reset_mock()
on_lose_focus_handler.reset_mock()


def assert_window_lose_focus(window, trigger_expected=True):
on_gain_focus_handler = window.on_gain_focus._raw
on_lose_focus_handler = window.on_lose_focus._raw
if trigger_expected:
on_lose_focus_handler.assert_called_once_with(window)
else:
on_lose_focus_handler.assert_not_called()
on_gain_focus_handler.assert_not_called()

on_gain_focus_handler.reset_mock()
on_lose_focus_handler.reset_mock()


def assert_window_on_show(window, trigger_expected=True):
on_show_handler = window.on_show._raw
on_hide_handler = window.on_hide._raw
if trigger_expected:
on_show_handler.assert_called_once_with(window)
else:
on_show_handler.assert_not_called()
on_hide_handler.assert_not_called()

on_show_handler.reset_mock()
on_hide_handler.reset_mock()


def assert_window_on_hide(window, trigger_expected=True):
on_show_handler = window.on_show._raw
on_hide_handler = window.on_hide._raw
if trigger_expected:
on_hide_handler.assert_called_once_with(window)
else:
on_hide_handler.assert_not_called()
on_show_handler.assert_not_called()

on_show_handler.reset_mock()
on_hide_handler.reset_mock()
Loading

0 comments on commit 2bfdda6

Please sign in to comment.