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

Add support for background and session-based apps #2651

Merged
merged 21 commits into from
Jun 26, 2024
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f261f73
Allow for some branches we can't test.
freakboy3742 Jun 14, 2024
356d615
Add status icon example app, and updated API usage of document app ex…
freakboy3742 Jun 14, 2024
a4dfb92
Add API documentation for session and background apps.
freakboy3742 Jun 14, 2024
5913384
Modify core API and tests to support background and session apps.
freakboy3742 Jun 14, 2024
9b74a52
Add backend implementations for background and session apps.
freakboy3742 Jun 15, 2024
6dac1a7
Don't assign main window as app context.
freakboy3742 Jun 17, 2024
55bac77
Don't set the role on windows.
freakboy3742 Jun 17, 2024
1149058
Add testbed tests for session and background apps.
freakboy3742 Jun 17, 2024
e788574
Correct final testing and coverage issues.
freakboy3742 Jun 17, 2024
89c1ec0
Final tweaks to app API docs.
freakboy3742 Jun 17, 2024
72acfc9
Merge branch 'simple-app' into other-app-types
freakboy3742 Jun 24, 2024
635cd89
Add error handling for apps with no open windows.
freakboy3742 Jun 25, 2024
f7ca20e
Split testbed app tests into desktop and mobile modules.
freakboy3742 Jun 25, 2024
35f2dfd
Simplify description of main_window handling, and add release notes.
freakboy3742 Jun 25, 2024
b7e6e5b
Merge branch 'simple-app' into other-app-types
freakboy3742 Jun 25, 2024
767674a
Merge branch 'main' into other-app-types
mhsmith Jun 25, 2024
88dc1f6
Simplify app descriptions.
freakboy3742 Jun 25, 2024
71f78f4
More docs cleanups.
freakboy3742 Jun 25, 2024
3475249
Apply suggestions from code review
mhsmith Jun 26, 2024
5c22048
Fix whitespace
mhsmith Jun 26, 2024
08f7722
Merge branch 'main' into other-app-types
freakboy3742 Jun 26, 2024
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
15 changes: 11 additions & 4 deletions android/src/toga_android/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from java import dynamic_proxy
from org.beeware.android import IPythonApp, MainActivity

import toga
from toga.command import Command, Group, Separator
from toga.handlers import simple_handler

Expand Down Expand Up @@ -182,6 +183,9 @@ def onPrepareOptionsMenu(self, menu):


class App:
# Android apps exit when the last window is closed
CLOSE_ON_LAST_WINDOW = True

def __init__(self, interface):
self.interface = interface
self.interface._impl = self
Expand Down Expand Up @@ -241,10 +245,13 @@ def set_icon(self, icon):
pass # pragma: no cover

def set_main_window(self, window):
# The default layout of an Android app includes a titlebar; a simple App then
# hides that titlebar. We know what type of app we have when the main window is
# set.
self.interface.main_window._impl.configure_titlebar()
if window is None or window == toga.App.BACKGROUND:
raise ValueError("Apps without main windows are not supported on Android")
else:
# The default layout of an Android app includes a titlebar; a simple App
# then hides that titlebar. We know what type of app we have when the main
# window is set.
self.interface.main_window._impl.configure_titlebar()

######################################################################
# App resources
Expand Down
1 change: 1 addition & 0 deletions changes/2209.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Toga can now define an app whose life cycle isn't tied to a single main window.
1 change: 1 addition & 0 deletions changes/2653.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
On Winforms, the window of an application that is set as the main window is no longer shown as a result of assigning the window as ``App.main_window``.
mhsmith marked this conversation as resolved.
Show resolved Hide resolved
1 change: 1 addition & 0 deletions changes/97.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Toga can now define apps that persist in the background without having any open windows.
21 changes: 10 additions & 11 deletions cocoa/src/toga_cocoa/app.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import asyncio
import inspect
import os
import sys
from pathlib import Path
from urllib.parse import unquote, urlparse
Expand Down Expand Up @@ -28,6 +27,7 @@
NSAboutPanelOptionApplicationVersion,
NSAboutPanelOptionVersion,
NSApplication,
NSApplicationActivationPolicyAccessory,
NSApplicationActivationPolicyRegular,
NSBeep,
NSBundle,
Expand Down Expand Up @@ -108,6 +108,9 @@ def validateMenuItem_(self, sender) -> bool:


class App:
# macOS apps persist when there are no windows open
CLOSE_ON_LAST_WINDOW = False

def __init__(self, interface):
self.interface = interface
self.interface._impl = self
Expand All @@ -117,28 +120,21 @@ def __init__(self, interface):
asyncio.set_event_loop_policy(EventLoopPolicy())
self.loop = asyncio.new_event_loop()

# Stimulate the build of the app
self.create()

def create(self):
self.native = NSApplication.sharedApplication
self.native.setActivationPolicy(NSApplicationActivationPolicyRegular)

# The app icon been set *before* the app instance is created. However, we only
# need to set the icon on the app if it has been explicitly defined; the default
# icon is... the default. We can't test this branch in the testbed.
if self.interface.icon._impl.path:
self.set_icon(self.interface.icon) # pragma: no cover

self.resource_path = os.path.dirname(
os.path.dirname(NSBundle.mainBundle.bundlePath)
)
self.resource_path = Path(NSBundle.mainBundle.bundlePath).parent.parent

self.appDelegate = AppDelegate.alloc().init()
self.appDelegate.impl = self
self.appDelegate.interface = self.interface
self.appDelegate.native = self.native
self.native.setDelegate_(self.appDelegate)
self.native.setDelegate(self.appDelegate)

# Create the lookup table for menu items
self._menu_groups = {}
Expand Down Expand Up @@ -422,7 +418,10 @@ def set_icon(self, icon):
self.native.setApplicationIconImage(None)

def set_main_window(self, window):
pass
if window == toga.App.BACKGROUND:
self.native.setActivationPolicy(NSApplicationActivationPolicyAccessory)
else:
self.native.setActivationPolicy(NSApplicationActivationPolicyRegular)

######################################################################
# App resources
Expand Down
8 changes: 8 additions & 0 deletions cocoa/tests_backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,3 +229,11 @@ def keystroke(self, combination):
keyCode=key_code,
)
return toga_key(event)

async def restore_standard_app(self):
# If the tesbed app has been made a background app, it will no longer be
# active, which affects whether it can receive focus and input events.
# Make it active again. This won't happen immediately; allow for a short
# delay.
self.app._impl.native.activateIgnoringOtherApps(True)
await self.redraw("Restore to standard app", delay=0.1)
94 changes: 72 additions & 22 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,13 @@ 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"

_UNDEFINED: str = "<main window not assigned>"

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

self._startup_method = startup

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

self._full_screen_windows: tuple[Window, ...] | None = None
Expand Down Expand Up @@ -515,35 +523,61 @@ def main_loop(self) -> None:
self._impl.main_loop()

@property
def main_window(self) -> MainWindow | None:
"""The main window for the app."""
def main_window(self) -> Window | str | None:
"""The main window for the app.

See :ref:`the documentation on assigning a main window <assigning-main-window>`
for values that can be used for this attribute.
"""
if self._main_window is App._UNDEFINED:
raise ValueError("Application has not set a main window.")

return self._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.")
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.")

old_window = self._main_window
self._main_window = window
try:
self._impl.set_main_window(window)
except Exception as e:
# If the main window could not be changed, revert to the previous value
# then reraise the exception
if old_window is not App._UNDEFINED:
self._main_window = old_window
raise e
else:
raise ValueError(f"Don't know how to use {window!r} as a main window.")

self._main_window = window
self._impl.set_main_window(window)
def _create_initial_windows(self):
# TODO: Create the initial windows for the app.

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?"
)
# Safety check: Do we have at least one window?
if len(self.app.windows) == 0 and self.main_window is None:
# macOS document-based apps are allowed to have no open windows.
if self.app._impl.CLOSE_ON_LAST_WINDOW:
raise ValueError("App doesn't define any initial windows.")

def _startup(self) -> None:
# Install the platform-specific app commands. This is done *before* startup so
# the user's code has the opporuntity to remove/change the default commands.
self._impl.create_app_commands()

# 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

# Create any initial windows
self._create_initial_windows()

# Manifest the initial state of the menus. This will cascade down to all
# open windows if the platform has window-based menus. Then install the
Expand All @@ -559,6 +593,17 @@ def _startup(self) -> None:
window._impl.create_toolbar()
window.toolbar.on_change = window._impl.create_toolbar

# 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 @@ -573,6 +618,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 @@ -829,10 +883,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
27 changes: 21 additions & 6 deletions core/src/toga/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,16 +300,31 @@ 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:
if self.content:
self.content.window = None
self.app.windows.discard(self)
self._impl.close()
self._closed = True
close_window = False
elif self.app.main_window is None:
# If this is an app without a main window, 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:
self._close()

def _close(self):
# The actual logic for closing a window. This is abstracted so that the testbed
# can monkeypatch this method, recording the close request without actually
# closing the app.
if self.content:
self.content.window = None
self.app.windows.discard(self)
self._impl.close()
self._closed = True

@property
def closed(self) -> bool:
Expand Down
Loading
Loading