Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added on_gain_focus, on_lose_focus, on_show & on_hide handlers on toga.Window #2096

Open
wants to merge 78 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 75 commits
Commits
Show all changes
78 commits
Select commit Hold shift + click to select a range
6e2eda0
Added support for `WinForms` and `gtk`.
proneon267 Aug 23, 2023
4bb2a42
Added changelog.
proneon267 Aug 23, 2023
3a202e4
Added support for `cocoa`.
proneon267 Aug 24, 2023
9cecb4a
Added support for `web`
proneon267 Aug 24, 2023
60a3cac
Merge branch 'beeware:main' into patch-12
proneon267 Aug 24, 2023
87ea0a4
Merge branch 'beeware:main' into patch-12
proneon267 Aug 25, 2023
6f38ae2
Added support for `android`.
proneon267 Aug 28, 2023
6dce43e
Merge branch 'patch-12' of https://github.com/proneon267/toga into pa…
proneon267 Aug 28, 2023
3133dfb
Merge branch 'beeware:main' into patch-12
proneon267 Aug 28, 2023
3b03ec4
Coreected `cocoa` implementation.
proneon267 Aug 28, 2023
d746ba8
Merge branch 'patch-12' of https://github.com/proneon267/toga into pa…
proneon267 Aug 28, 2023
096e6bf
Re: `cocoa` implementation correction.
proneon267 Aug 28, 2023
943612d
Added support for `iOS`.
proneon267 Aug 28, 2023
7b864fe
Fixed `winforms` implementation.
proneon267 Aug 28, 2023
88c56b9
Added a test in window example app.
proneon267 Aug 28, 2023
156bc9e
Corrected `Android` implementation.
proneon267 Aug 30, 2023
aa4714e
Merge branch 'beeware:main' into patch-12
proneon267 Aug 30, 2023
6895d8d
Merge branch 'beeware:main' into patch-12
proneon267 Sep 4, 2023
27bf2a7
Merge branch 'beeware:main' into patch-12
proneon267 Sep 11, 2023
9fabc3e
Merge branch 'beeware:main' into patch-12
proneon267 Sep 12, 2023
7b23209
Added `on_show` & `on_hide` handlers on winforms.
proneon267 Sep 12, 2023
0427a8f
Fixed overlapping event triggers on winforms.
proneon267 Sep 12, 2023
b9e0412
Added `on_show` & `on_hide` handlers on gtk.
proneon267 Sep 13, 2023
7f7468d
Added `on_show()` & `on_hide()` handlers on cocoa.
proneon267 Sep 14, 2023
429568b
Added `on_show()` & `on_hide()` on iOS.
proneon267 Sep 14, 2023
3440221
Corrected iOS implementation.
proneon267 Sep 14, 2023
fc8a29c
Added `on_show` & `on_hide` handlers on Android.
proneon267 Sep 14, 2023
6fd5e3c
Added `on_show` & `on_hide` on web.
proneon267 Sep 14, 2023
44bade9
Merge branch 'beeware:main' into patch-12
proneon267 Sep 14, 2023
8604faa
Modified as per suggestions.
proneon267 Sep 15, 2023
84234e8
Merge branch 'patch-12' of https://github.com/proneon267/toga into pa…
proneon267 Sep 15, 2023
eaeaf5d
Added comment for android version exclusion.
proneon267 Sep 15, 2023
ea28017
Merge branch 'main' into patch-12
proneon267 Nov 18, 2023
894a89c
Rebasing on the latest main branch
proneon267 Nov 18, 2023
cb2e541
Miscellaneous Fixes
proneon267 Nov 18, 2023
f7a014b
Added Core Tests
proneon267 Nov 18, 2023
0a1d093
Miscellaneous Fixes
proneon267 Nov 18, 2023
c441e77
Added tests for windows testbed
proneon267 Nov 19, 2023
059ea14
Fixed event triggers on gtk, cocoa
proneon267 Nov 19, 2023
81dd316
Fixed event triggers on Android
proneon267 Nov 19, 2023
d958394
Merge branch 'beeware:main' into patch-12
proneon267 Nov 19, 2023
d94a69a
Fixed cocoa implementation
Nov 19, 2023
8c364a1
Empty commit for CI
proneon267 Nov 19, 2023
c0405c3
Miscellaneous Fixes
proneon267 Nov 19, 2023
927b3d4
Fixed iOS implementation
proneon267 Nov 19, 2023
869da6a
Miscellaneous Fixes
proneon267 Nov 20, 2023
2423548
Miscellaneous Fixes
proneon267 Nov 20, 2023
9fd0e0e
Update changes/2009.feature.rst
proneon267 Nov 20, 2023
77b8822
Miscellaneous Fixes
proneon267 Nov 20, 2023
89f095d
Merge branch 'patch-12' of https://github.com/proneon267/toga into pa…
proneon267 Nov 20, 2023
69f90d5
Miscellaneous Fixes
proneon267 Nov 20, 2023
d5e8e3f
Merge branch 'beeware:main' into patch-12
proneon267 Dec 2, 2023
52e75b8
Merge branch 'beeware:main' into patch-12
proneon267 Dec 10, 2023
dc8b52d
Merge branch 'beeware:main' into patch-12
proneon267 Dec 17, 2023
004a98a
Merge branch 'beeware:main' into patch-12
proneon267 Dec 19, 2023
2fa2bfa
Merge branch 'beeware:main' into patch-12
proneon267 Dec 22, 2023
246eeb0
Merge branch 'beeware:main' into patch-12
proneon267 Jan 13, 2024
673e2cd
Merge branch 'beeware:main' into patch-12
proneon267 Jan 14, 2024
5cf9bbe
Merge branch 'beeware:main' into patch-12
proneon267 Jan 17, 2024
bfe62fa
Merge branch 'beeware:main' into patch-12
proneon267 Jan 17, 2024
e65c528
Merge branch 'beeware:main' into patch-12
proneon267 Jan 20, 2024
5273e54
Merge branch 'beeware:main' into patch-12
proneon267 Jan 25, 2024
69f3f25
Merge branch 'main' into patch-12
proneon267 Feb 3, 2024
30cb114
Merge branch 'beeware:main' into patch-12
proneon267 Feb 9, 2024
40d0320
Revert Changes
proneon267 Dec 23, 2024
f25ee59
Merge branch 'main' into pr/proneon267/2096
proneon267 Dec 23, 2024
11c6323
Add back PR changes
proneon267 Dec 23, 2024
4063113
Fix cocoa crash
proneon267 Dec 24, 2024
c3a55d8
Remove unused code from macOS
proneon267 Dec 24, 2024
cc9a023
Restart CI
proneon267 Dec 24, 2024
43b895f
Add New Tests
proneon267 Jan 1, 2025
b072c6b
Remove complexity from gtk
proneon267 Jan 1, 2025
8d0477d
Add test descriptions
proneon267 Jan 1, 2025
a017847
Cleanup winforms naming
proneon267 Jan 2, 2025
cb75101
Add DocStrings
proneon267 Jan 2, 2025
9b9b569
Add textual implementation
proneon267 Jan 2, 2025
db8e5e4
Fix textual
proneon267 Jan 2, 2025
e3fc53b
Cleanup textual
proneon267 Jan 2, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 32 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,46 @@ def onCreate(self):

def onStart(self):
print("Toga app: onStart")
for window in self._impl.interface.windows:
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:
proneon267 marked this conversation as resolved.
Show resolved Hide resolved
for window in self._impl.interface.windows:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is iterating over all windows appropriate? Won't it just be current_window that gains focus? Admittedly, this is mostly a moot point as long as Toga doesn't support multiple windows on Android, but in the event that we ever do support multiple windows (e.g., supporting an external display), it's worth getting the logic right here.

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:
for window in self._impl.interface.windows:
window.on_lose_focus()

def onStop(self): # pragma: no cover
print("Toga app: onStop")
for window in self._impl.interface.windows:
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:
for window in self._impl.interface.windows:
window.on_gain_focus()
else:
for window in self._impl.interface.windows:
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 @@
Window can now respond to changes in focus and visibility.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Window can now respond to changes in focus and visibility.
Windows can now respond to changes in focus and visibility.

12 changes: 12 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.impl.interface.on_gain_focus()

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

@objc_method
def windowDidMiniaturize_(self, notification) -> None:
self.impl.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.impl.interface.on_show()
self.impl._apply_state(self.impl._pending_state_transition)

@objc_method
Expand Down Expand Up @@ -265,6 +275,7 @@ def set_app(self, app):

def show(self):
self.native.makeKeyAndOrderFront(None)
self.interface.on_show()

######################################################################
# Window content and resources
Expand Down Expand Up @@ -341,6 +352,7 @@ def set_position(self, position):

def hide(self):
self.native.orderOut(self.native)
self.interface.on_hide()

def get_visible(self):
return (
Expand Down
88 changes: 88 additions & 0 deletions core/src/toga/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,49 @@ 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.
Comment on lines +111 to +112
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"""A handler to invoke when a window gains input focus.
:param window: The window instance that gains input focus.
"""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.
Comment on lines +121 to +122
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"""A handler to invoke when a window loses input focus.
:param window: The window instance that loses input focus.
"""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 to the user from a not
visible state. Not visible to the user refers to window states like minimized,
hidden, etc.
:param window: The window instance that becomes visible.
Comment on lines +133 to +134
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
hidden, etc.
:param window: The window instance that becomes visible.
hidden, etc.
: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 becomes not visible to the user.
Not visible to the user refers to window states like minimized, hidden, etc.
:param window: The window instance that becomes not visible to the user.
Comment on lines +144 to +145
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Not visible to the user refers to window states like minimized, hidden, etc.
:param window: The window instance that becomes not visible to the user.
Not visible to the user refers to window states like minimized, hidden, etc.
: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 +184,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 +240,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 @@ -530,6 +582,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
123 changes: 123 additions & 0 deletions core/tests/window/test_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,129 @@ def test_close_rejected_handler(window, app):
on_close_handler.assert_called_once_with(window)


def test_on_gain_focus(window):
assert window._on_gain_focus._raw is None

on_gain_focus_handler = Mock()
window.on_gain_focus = on_gain_focus_handler

assert window.on_gain_focus._raw == on_gain_focus_handler

window._impl.simulate_on_gain_focus()

on_gain_focus_handler.assert_called_once_with(window)


def test_on_lose_focus(window):
assert window.on_lose_focus._raw is None

on_lose_focus_handler = Mock()
window.on_lose_focus = on_lose_focus_handler

assert window.on_lose_focus._raw == on_lose_focus_handler

window._impl.simulate_on_lose_focus()

on_lose_focus_handler.assert_called_once_with(window)


def test_on_show(window):
assert window.on_show._raw is None

on_show_handler = Mock()
window.on_show = on_show_handler

assert window.on_show._raw == on_show_handler

window._impl.simulate_on_show()

on_show_handler.assert_called_once_with(window)


def test_on_hide(window):
assert window.on_hide._raw is None

on_hide_handler = Mock()
window.on_hide = on_hide_handler

assert window.on_hide._raw == on_hide_handler

window._impl.simulate_on_hide()

on_hide_handler.assert_called_once_with(window)
Comment on lines +587 to +636
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had written these core tests when there was no API for window states. Currently, I have added new tests that test the behavior of triggering the events with changes in window state. So, should I keep these tests?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They're not testing anything that isn't covered by the "real" focus/show tests, so I'd say they're not needed.



def test_focus_events(app):
"""The window can trigger on_gain_focus() and on_lose_focus()
event handlers, when the window gains or loses input focus."""
window1 = toga.Window()
window1.show()
window1_on_gain_focus_handler = Mock()
window1_on_lose_focus_handler = Mock()
window1.on_gain_focus = window1_on_gain_focus_handler
window1.on_lose_focus = window1_on_lose_focus_handler

window2 = toga.Window()
window2.show()
window2_on_gain_focus_handler = Mock()
window2_on_lose_focus_handler = Mock()
window2.on_gain_focus = window2_on_gain_focus_handler
window2.on_lose_focus = window2_on_lose_focus_handler

app.current_window = window1
window1_on_gain_focus_handler.assert_called_once_with(window1)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These calls assertions will be potentially misleading, as the mocks haven't been reset between each use. The test also isn't asserting the handlers that aren't called.

Suggested change
window1_on_gain_focus_handler.assert_called_once_with(window1)
window1_on_gain_focus_handler.assert_called_once_with(window1)
window1_on_lose_focus_handler.assert_not_called()
window2_on_gain_focus_handler.assert_not_called()
window2_on_lose_focus_handler.assert_not_called()
window1_on_gain_focus_handler.reset_mock()
window1_on_lose_focus_handler.reset_mock()
window2_on_gain_focus_handler.reset_mock()
window2_on_lose_focus_handler.reset_mock()

Given that this will be a repeated pattern, it might be worth wrapping this sequence of calls in a utility assert methods.


app.current_window = window2
window2_on_gain_focus_handler.assert_called_once_with(window2)
window1_on_lose_focus_handler.assert_called_once_with(window1)

app.current_window = window1
window1_on_gain_focus_handler.assert_called_with(window1)
window2_on_lose_focus_handler.assert_called_once_with(window2)


def test_visibility_events(window):
"""The window can trigger on_show() and on_hide() event handlers,
when the window is shown or hidden respectively."""
window.show()
on_show_handler = Mock()
on_hide_handler = Mock()
window.on_show = on_show_handler
window.on_hide = on_hide_handler

window.hide()
on_hide_handler.assert_called_once_with(window)

window.show()
on_show_handler.assert_called_once_with(window)


@pytest.mark.parametrize(
"state",
[
WindowState.NORMAL,
WindowState.MAXIMIZED,
WindowState.FULLSCREEN,
WindowState.PRESENTATION,
],
)
def test_visibility_events_on_window_state_change(window, state):
"""The window can trigger on_hide() and on_show() event handlers,
when the window is MINIMIZED and UN-MINIMIZED respectively."""
window.show()
on_show_handler = Mock()
on_hide_handler = Mock()
window.on_show = on_show_handler
window.on_hide = on_hide_handler
window.state = state

window.state = WindowState.MINIMIZED
on_hide_handler.assert_called_once_with(window)

window.state = state
on_show_handler.assert_called_once_with(window)


def test_as_image(window):
"""A window can be captured as an image."""
image = window.as_image()
Expand Down
6 changes: 6 additions & 0 deletions dummy/src/toga_dummy/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,15 @@ def get_current_window(self):
return self._get_value("current_window", main_window)

def set_current_window(self, window):
previous_current_window = getattr(self.get_current_window(), "interface", None)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIR, get_current_window() can return None; but if the window exists, it will always have an interface attribute. On that basis, this getattr() is protecting against the wrong missing attribute.


self._action("set_current_window", window=window)
self._set_value("current_window", window._impl)

if previous_current_window and previous_current_window != window:
previous_current_window.on_lose_focus()
window.on_gain_focus()


class DocumentApp(App):
def create(self):
Expand Down
Loading
Loading