Skip to content

Commit

Permalink
Modify core API and tests to support background and session apps.
Browse files Browse the repository at this point in the history
  • Loading branch information
freakboy3742 committed Jun 14, 2024
1 parent d1b025b commit 709370e
Show file tree
Hide file tree
Showing 9 changed files with 460 additions and 224 deletions.
71 changes: 47 additions & 24 deletions core/src/toga/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import asyncio
import importlib.metadata
import inspect
import signal
import sys
import warnings
Expand Down Expand Up @@ -205,6 +206,11 @@ class App:
_impl: Any
_camera: Camera
_location: Location
_main_window: Window | str | None

#: A constant that can be used as the main window to indicate that an app will
#: run in the background without a main window.
BACKGROUND: str = "background app"

def __init__(
self,
Expand Down Expand Up @@ -377,7 +383,6 @@ def __init__(

self._startup_method = startup

self._main_window: MainWindow | None = None
self._windows = WindowSet(self)

self._full_screen_windows: tuple[Window, ...] | None = None
Expand Down Expand Up @@ -529,31 +534,33 @@ def main_loop(self) -> None:
self._impl.main_loop()

@property
def main_window(self) -> MainWindow | None:
def main_window(self) -> Window | str | None:
"""The main window for the app."""
return self._main_window
try:
return self._main_window
except AttributeError:
raise ValueError("Application has not set a main window.")

@main_window.setter
def main_window(self, window: MainWindow | None) -> None:
# The main window must be closable
if isinstance(window, Window) and not window.closable:
raise ValueError("The window used as the main window must be closable.")

self._main_window = window
self._impl.set_main_window(window)

def _verify_startup(self) -> None:
if not isinstance(self.main_window, Window):
raise ValueError(
"Application does not have a main window. "
"Does your startup() method assign a value to self.main_window?"
)
def main_window(self, window: MainWindow | str | None) -> None:
if window is None or window is App.BACKGROUND or isinstance(window, Window):
# The main window must be closable
if isinstance(window, Window) and not window.closable:
raise ValueError("The window used as the main window must be closable.")

self._main_window = window
self._impl.set_main_window(window)
else:
raise ValueError(f"Don't know how to use {window!r} as a main window.")

def _startup(self) -> None:
# This is a wrapper around the user's startup method that performs any
# post-setup validation.
# Invoke the user's startup method (or the default implementation)
self.startup()
self._verify_startup()

# Validate that the startup requirements have been met.
# Accessing the main window attribute will raise an exception if the app hasn't
# defined a main window.
_ = self.main_window

# Install the platform-specific app commands. This is done *after* startup
# because we need to know the main window type to know which commands
Expand All @@ -575,6 +582,17 @@ def _startup(self) -> None:
# Now that we have a finalized impl, set the on_change handler for commands
self.commands.on_change = self._impl.create_menus

# Queue a task to run as soon as the event loop starts.
if inspect.iscoroutinefunction(self.running):
# running is a co-routine; create a sync wrapper
def on_app_running():
asyncio.ensure_future(self.running())

else:
on_app_running = self.running

self.loop.call_soon_threadsafe(on_app_running)

def startup(self) -> None:
"""Create and show the main window for the application.
Expand All @@ -589,6 +607,15 @@ def startup(self) -> None:

self.main_window.show()

def running(self) -> None:
"""Logic to execute as soon as the main event loop is running.
Override this method to add any logic you want to run as soon as the app's event
loop is running.
If necessary, the overridden method can be defined as as an ``async`` coroutine.
"""

######################################################################
# App resources
######################################################################
Expand Down Expand Up @@ -845,10 +872,6 @@ def __init__(
def _create_impl(self) -> None:
self.factory.DocumentApp(interface=self)

def _verify_startup(self) -> None:
# No post-startup validation required for DocumentApps
pass

@property
def document_types(self) -> dict[str, type[Document]]:
"""The document types this app can manage.
Expand Down
8 changes: 6 additions & 2 deletions core/src/toga/documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ def __init__(
# Create a platform specific implementation of the Document
self._impl = app.factory.Document(interface=self)

def can_close(self) -> bool:
# TODO: This will be covered when the document API is finalized
def can_close(self) -> bool: # pragma: no cover
"""Is the main document window allowed to close?
The default implementation always returns ``True``; subclasses can override this
Expand All @@ -46,7 +47,10 @@ def can_close(self) -> bool:
"""
return True

async def handle_close(self, window: Window, **kwargs: object) -> bool:
# TODO: This will be covered when the document API is finalized
async def handle_close(
self, window: Window, **kwargs: object
) -> bool: # pragma: no cover
"""An ``on-close`` handler for the main window of this document that implements
platform-specific document close behavior.
Expand Down
11 changes: 10 additions & 1 deletion core/src/toga/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,11 +298,20 @@ def close(self) -> None:
undefined, except for :attr:`closed` which can be used to check if the window
was closed.
"""
close_window = True
if self.app.main_window == self:
# Closing the window marked as the main window is a request to exit.
# Trigger on_exit handling, which may cause the window to close.
self.app.on_exit()
else:
close_window = False
elif self.app.main_window is None:
# If this is a session-based app, this is the last window in the app,
# and the platform exits on last window close, trigger an exit.
if len(self.app.windows) == 1 and self.app._impl.CLOSE_ON_LAST_WINDOW:
self.app.on_exit()
close_window = False

if close_window:
if self.content:
self.content.window = None
self.app.windows.discard(self)
Expand Down
83 changes: 82 additions & 1 deletion core/tests/app/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -533,7 +533,18 @@ class SubclassedApp(toga.App):
def startup(self):
pass

with pytest.raises(ValueError, match=r"Application does not have a main window."):
with pytest.raises(ValueError, match=r"Application has not set a main window."):
SubclassedApp(formal_name="Test App", app_id="org.example.test")


def test_startup_subclass_unknown_main_window(event_loop):
"""If a subclassed app uses an unknown main window type, an error is raised"""

class SubclassedApp(toga.App):
def startup(self):
self.main_window = 42

with pytest.raises(ValueError, match=r"Don't know how to use 42 as a main window"):
SubclassedApp(formal_name="Test App", app_id="org.example.test")


Expand Down Expand Up @@ -624,6 +635,36 @@ def test_exit_rejected_handler(app):
on_exit_handler.assert_called_once_with(app)


def test_no_exit_last_window_close(app):
"""Windows can be created and closed without closing the app."""
# App has 1 window initially
assert len(app.windows) == 1

# Create a second, non-main window
window1 = toga.Window()
window1.content = toga.Box()
window1.show()

window2 = toga.Window()
window2.content = toga.Box()
window2.show()

# App has 3 windows
assert len(app.windows) == 3

# Close one of the secondary windows
window1.close()

# Window has been closed, but the app hasn't exited.
assert len(app.windows) == 2
assert_action_performed(window1, "close")
assert_action_not_performed(app, "exit")

# Closing the MainWindow kills the app
app.main_window.close()
assert_action_performed(app, "exit")


def test_loop(app, event_loop):
"""The main thread's event loop can be accessed."""
assert isinstance(app.loop, asyncio.AbstractEventLoop)
Expand All @@ -649,6 +690,46 @@ async def waiter():
canary.assert_called_once()


def test_running(event_loop):
"""The running() method is invoked when the main loop starts"""
running = {}

class SubclassedApp(toga.App):
def startup(self):
self.main_window = toga.MainWindow()

def running(self):
running["called"] = True

app = SubclassedApp(formal_name="Test App", app_id="org.example.test")

# Run a fake main loop.
app.loop.run_until_complete(asyncio.sleep(0.5))

# The running method was invoked
assert running["called"]


def test_async_running_method(event_loop):
"""The running() method can be a coroutine."""
running = {}

class SubclassedApp(toga.App):
def startup(self):
self.main_window = toga.MainWindow()

async def running(self):
running["called"] = True

app = SubclassedApp(formal_name="Test App", app_id="org.example.test")

# Run a fake main loop.
app.loop.run_until_complete(asyncio.sleep(0.5))

# The running coroutine was invoked
assert running["called"]


def test_deprecated_id(event_loop):
"""The deprecated `id` constructor argument is ignored, and the property of the same
name is redirected to `app_id`"""
Expand Down
52 changes: 52 additions & 0 deletions core/tests/app/test_background_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import pytest

import toga
from toga_dummy.utils import (
assert_action_not_performed,
assert_action_performed,
)


class ExampleBackgroundApp(toga.App):
def startup(self):
self.main_window = toga.App.BACKGROUND


@pytest.fixture
def background_app(event_loop):
app = ExampleBackgroundApp(
"Test App",
"org.beeware.background-app",
)
return app


def test_create(background_app):
"""A background app can be created."""
# App has been created
assert background_app._impl.interface == background_app
assert_action_performed(background_app, "create App")

# App has no windows
assert len(background_app.windows) == 0


def test_no_exit_last_window_close(background_app):
"""Windows can be created and closed without closing the app."""
# App has no windows initially
assert len(background_app.windows) == 0

window = toga.Window()
window.content = toga.Box()
window.show()

# App has a window
assert len(background_app.windows) == 1

# Close the window
window.close()

# Window has been closed, but the app hasn't exited.
assert len(background_app.windows) == 0
assert_action_performed(window, "close")
assert_action_not_performed(background_app, "exit")
Loading

0 comments on commit 709370e

Please sign in to comment.