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

Allow the use of toga.Window as the main window #2649

Merged
merged 21 commits into from
Jun 25, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7b1d4fa
Add changenote.
freakboy3742 Jun 13, 2024
ae36406
Add an example of a Simple app.
freakboy3742 Jun 14, 2024
45d635c
Add core API implementation of simple apps.
freakboy3742 Jun 14, 2024
ee75dc4
Add documentation for SimpleApp.
freakboy3742 Jun 14, 2024
fa4efdf
Update menu handling for macOS, GTK and Windows.
freakboy3742 Jun 14, 2024
0f78bd0
Update menu handling for Web backend.
freakboy3742 Jun 14, 2024
5053929
Update menu handling for Textual backend.
freakboy3742 Jun 14, 2024
c2098ae
Update menu and window handling for Android backend.
freakboy3742 Jun 14, 2024
ba0a0d9
Update menu and window handling for iOS backend.
freakboy3742 Jun 14, 2024
882bc08
Allow for some branches we can't test.
freakboy3742 Jun 14, 2024
2a8be15
Modify textual backend to have no decoration on simple windows.
freakboy3742 Jun 15, 2024
4e411c4
Merge branch 'main' into simple-app
freakboy3742 Jun 19, 2024
3f49272
Clarified documentation removing the simple app distinction.
freakboy3742 Jun 19, 2024
e644707
Rework the standard menu item set (and the description of same).
freakboy3742 Jun 20, 2024
88ab5de
Correct a docs typo.
freakboy3742 Jun 20, 2024
8096307
Apply suggestions from code review
freakboy3742 Jun 21, 2024
6e2f9ba
Always install all the app commands, and make preferences an optional…
freakboy3742 Jun 21, 2024
e4ad93d
Remove preferences menu from expected system set in testbed.
freakboy3742 Jun 21, 2024
c51f49c
Clarify default command customization.
freakboy3742 Jun 21, 2024
17bda68
Documentation cleanups.
freakboy3742 Jun 24, 2024
82e75b5
Merge branch 'main' into simple-app
freakboy3742 Jun 25, 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
21 changes: 18 additions & 3 deletions android/src/toga_android/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ def onOptionsItemSelected(self, menuitem):
return True

def onPrepareOptionsMenu(self, menu):
# If the main window doesn't have a toolbar, there's no preparation required;
# this is a simple main window, which can't have commands. This can't be
# validated in the testbed, so it's marked no-cover.
if not hasattr(self._impl.interface.main_window, "toolbar"):
return False # pragma: no cover

menu.clear()
itemid = 1 # 0 is the same as Menu.NONE.
groupid = 1
Expand Down Expand Up @@ -199,7 +205,11 @@ def create(self):
# Commands and menus
######################################################################

def create_app_commands(self):
def create_minimal_app_commands(self):
# A simple app can't have any commands.
pass

def create_standard_app_commands(self):
self.interface.commands.add(
# About should be the last item in the menu, in a section on its own.
Command(
Expand All @@ -211,7 +221,9 @@ def create_app_commands(self):
)

def create_menus(self):
self.native.invalidateOptionsMenu() # Triggers onPrepareOptionsMenu
# Menu items are configured as part of onPrepareOptionsMenu; trigger that
# handler.
self.native.invalidateOptionsMenu()

######################################################################
# App lifecycle
Expand All @@ -233,7 +245,10 @@ def set_icon(self, icon):
pass # pragma: no cover

def set_main_window(self, window):
pass
# 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
11 changes: 11 additions & 0 deletions android/src/toga_android/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ def close(self): # pragma: no cover
# closed, so the platform-specific close handling is never triggered.
pass

def configure_titlebar(self): # pragma: no cover
# Hide the titlebar on a simple window. The testbed can't create a simple
# window, so we can't test this.
self.app.native.getSupportActionBar().hide()

def set_app(self, app):
if len(app.interface.windows) > 1:
raise RuntimeError("Secondary windows cannot be created on Android")
Expand Down Expand Up @@ -155,5 +160,11 @@ def get_image_data(self):


class MainWindow(Window):
def configure_titlebar(self):
# Display the titlebar on a MainWindow.
pass

def create_toolbar(self):
# Toolbar items are configured as part of onPrepareOptionsMenu; trigger that
# handler.
self.app.native.invalidateOptionsMenu()
1 change: 1 addition & 0 deletions changes/1870.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
It is now possible to use an instance of Window as the main window of an app. This allows the creation of windows that don't have a menu bar or toolbar decoration.
1 change: 1 addition & 0 deletions changes/2649.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Toga apps now expose a ``create_app_commands()`` method that can be overridden to customize the set of default menu items that are installed.
39 changes: 23 additions & 16 deletions cocoa/src/toga_cocoa/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,30 +164,21 @@ def _menu_minimize(self, command, **kwargs):
if self.interface.current_window:
self.interface.current_window._impl.native.miniaturize(None)

def create_app_commands(self):
formal_name = self.interface.formal_name
def create_minimal_app_commands(self):
self.interface.commands.add(
# ---- App menu -----------------------------------
# About should be the first menu item
Command(
simple_handler(self.interface.about),
"About " + formal_name,
f"About {self.interface.formal_name}",
group=toga.Group.APP,
id=Command.ABOUT,
section=-1,
),
# Include a preferences menu item; but only enable it if the user has
# overridden it in their App class.
Command(
simple_handler(self.interface.preferences),
"Settings\u2026",
shortcut=toga.Key.MOD_1 + ",",
group=toga.Group.APP,
section=20,
enabled=overridden(self.interface.preferences),
id=Command.PREFERENCES,
),
# App-level window management commands should be in the second last section.
Command(
NativeHandler(SEL("hide:")),
"Hide " + formal_name,
f"Hide {self.interface.formal_name}",
shortcut=toga.Key.MOD_1 + "h",
group=toga.Group.APP,
order=0,
Expand All @@ -213,7 +204,7 @@ def create_app_commands(self):
# logic. It's already a bound handler, so we can use it directly.
Command(
self.interface.on_exit,
f"Quit {formal_name}",
f"Quit {self.interface.formal_name}",
shortcut=toga.Key.MOD_1 + "q",
group=toga.Group.APP,
section=sys.maxsize,
Expand Down Expand Up @@ -320,6 +311,22 @@ def create_app_commands(self):
),
)

def create_standard_app_commands(self):
self.interface.commands.add(
# ---- App menu -----------------------------------
# Include a preferences menu item; but only enable it if the user has
# overridden it in their App class.
Command(
simple_handler(self.interface.preferences),
"Settings\u2026",
shortcut=toga.Key.MOD_1 + ",",
group=toga.Group.APP,
section=20,
enabled=overridden(self.interface.preferences),
id=Command.PREFERENCES,
),
)

def _submenu(self, group, menubar):
"""Obtain the submenu representing the command group.

Expand Down
29 changes: 24 additions & 5 deletions core/src/toga/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,20 @@ def add_background_task(self, handler: BackgroundTask) -> None:
"""
self.loop.call_soon_threadsafe(wrapped_handler(self, handler))

def create_app_commands(self) -> None:
"""Create the default application commands for the platform.

This method is called automatically after :meth:`~toga.App.startup()` has
completed, but before menus have been created.

By default, it will create the commands that are appropriate for your platform.
You can override this method to modify (or remove entirely) the default platform
commands that have been created.
"""
self._impl.create_minimal_app_commands()
if isinstance(self.main_window, MainWindow):
self._impl.create_standard_app_commands()

def exit(self) -> None:
"""Exit the application gracefully.

Expand Down Expand Up @@ -521,26 +535,31 @@ def main_window(self) -> MainWindow | None:

@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, MainWindow):
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 _startup(self) -> None:
# App commands are created before the startup method so that the user's
# code has the opportunity 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.
self.startup()
self._verify_startup()

# Install the platform-specific app commands. This is done *after* startup
# because we need to know the main window type to know which commands
# must be installed.
self.create_app_commands()

# 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
# on-change handler for menus to respond to any future changes.
Expand Down
26 changes: 20 additions & 6 deletions core/tests/app/test_app.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
import importlib.metadata
import signal
import sys
import webbrowser
from pathlib import Path
Expand Down Expand Up @@ -372,6 +373,14 @@ def test_icon(app, construct):
assert_action_performed_with(app, "set_icon", icon=toga.Icon("path/to/icon"))


def test_main_loop(app):
"""The main loop installs signal handlers."""
app.main_loop()

# Assert the default signal handler has been installed
assert signal.getsignal(signal.SIGINT) == signal.SIG_DFL


def test_current_window(app):
"""The current window can be set and changed."""
other_window = toga.Window()
Expand Down Expand Up @@ -465,8 +474,8 @@ def test_startup_method(event_loop):
"""If an app provides a startup method, it will be invoked during startup."""

def startup_assertions(app):
# At time startup is invoked, there should be an app command installed
assert len(app.commands) == 4
# At time startup is invoked, there should no app commands installed
assert len(app.commands) == 0
return toga.Box()

startup = Mock(side_effect=startup_assertions)
Expand All @@ -478,7 +487,8 @@ def startup_assertions(app):
)

# Menus, commands and toolbars have been created
assert_action_performed(app, "create App commands")
assert_action_performed(app, "create minimal App commands")
assert_action_performed(app, "create standard App commands")
startup.assert_called_once_with(app)
assert_action_performed(app, "create App menus")
assert_action_performed(app.main_window, "create Window menus")
Expand All @@ -487,6 +497,9 @@ def startup_assertions(app):
# 4 menu items have been created
assert len(app.commands) == 4

# The app has a main window that is a MainWindow
assert isinstance(app.main_window, toga.MainWindow)


def test_startup_subclass(event_loop):
"""App can be subclassed."""
Expand All @@ -495,8 +508,8 @@ class SubclassedApp(toga.App):
def startup(self):
self.main_window = toga.MainWindow()

# At time startup is invoked, there should be an app command installed
assert len(self.commands) == 4
# At time startup is invoked, there should be no app commands installed
assert len(self.commands) == 0

# Add an extra user command
self.commands.add(toga.Command(None, "User command"))
Expand All @@ -507,7 +520,8 @@ def startup(self):
assert app.main_window.title == "Test App"

# Menus, commands and toolbars have been created
assert_action_performed(app, "create App commands")
assert_action_performed(app, "create minimal App commands")
assert_action_performed(app, "create standard App commands")
assert_action_performed(app, "create App menus")
assert_action_performed(app.main_window, "create Window menus")
assert_action_performed(app.main_window, "create toolbar")
Expand Down
56 changes: 56 additions & 0 deletions core/tests/app/test_simpleapp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import pytest

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


def test_simple_app(event_loop):
"""A simple app can be instantiated."""

class SimpleApp(toga.App):
def startup(self):
self.main_window = toga.Window(title="My App")

# At time startup is invoked, there should be no app commands installed
assert len(self.commands) == 0

# Add an extra user command
self.commands.add(toga.Command(None, "User command"))

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

# The app has a main window that is a Window, but *not* a MainWindow
assert isinstance(app.main_window, toga.Window)
assert not isinstance(app.main_window, toga.MainWindow)

# The main window will exist, and will have the app's formal name.
assert app.main_window.title == "My App"

# The minimal app commands exist, but not the standard ones.
assert_action_performed(app, "create minimal App commands")
assert_action_not_performed(app, "create standard App commands")
assert_action_performed(app, "create App menus")

# A simple app has no window menus
assert_action_not_performed(app.main_window, "create Window menus")

# 3 menu items have been created
assert app._impl.n_menu_items == 3


def test_non_closeable_main_window(event_loop):
"""If the main window isn't closable, an error is raised."""

class SimpleApp(toga.App):
def startup(self):
# Create a non-closable main window
self.main_window = toga.Window(title="My App", closable=False)

with pytest.raises(
ValueError,
match=r"The window used as the main window must be closable\.",
):
SimpleApp(formal_name="Test App", app_id="org.example.test")
26 changes: 25 additions & 1 deletion docs/reference/api/app.rst
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,35 @@ format compatible with :any:`importlib.metadata`. If you deploy your app with `B

A Toga app will install a number of default commands to reflect core application
functionality (such as the Quit/Exit menu item, and the About menu item). The IDs for
these commands are defined as constants on the :class:`~toga.Command` class.
these commands are defined as constants on the :class:`~toga.Command` class. These
commands are installed by the :meth:`~toga.App.create_app_commands()` method; this
method is invoked *after* :meth:`~toga.App.startup()`, but *before* menus are populated
for the first time. If you wish to customize the menu items exposed by your app, you should
override the :meth:`~toga.App.create_app_commands()` method.

Assigning a main window
-----------------------

An app *must* assign ``main_window`` as part of the startup process. However, the value
that is assigned as the main window will affect the behavior of the app.

Standard app
~~~~~~~~~~~~

The most common type of app will assign a :any:`MainWindow` or :any:`toga.MainWindow`
instance as the main window. This window controls the life cycle of the app; when the
main window is closed, the app will exit.

This is the type of app that will be created if you use an instance of :any:`toga.App`
passing in a ``startup`` argument to the constructor.

Notes
-----

* On macOS, menus are tied to the app, not the window; and a menu is mandatory.
Therefore, a minimal macOS app (i.e., an app using a :any:`toga.Window` as the main
window) will still have a menu, but it will only have the bare minimum of menu items.

* Apps executed under Wayland on Linux environment may not show the app's formal name
correctly. Wayland considers many aspects of app operation to be the domain of the
windowing environment, not the app; as a result, some API requests will be ignored
Expand Down
19 changes: 15 additions & 4 deletions docs/reference/api/mainwindow.rst
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,21 @@ Usage
-----

A :class:`toga.MainWindow` is a :class:`toga.Window` that can serve as the main
interface to an application. If the platform places menus inside windows, a
:class:`toga.MainWindow` instance will display a menu bar that contains the app
control commands (such as About, Quit, and anything else required by the
platform's HIG). It may also contain a toolbar.
interface to an application. A :class:`toga.MainWindow` may optionally have a toolbar.
The presentation of :class:`toga.MainWindow` is highly platform dependent:

* On desktop platforms that place menus inside windows (e.g., Windows, and most Linux
window managers, a :class:`toga.MainWindow` instance will display a menu bar that
contains the app control commands (such as About, Quit, and anything else required by
the platform's HIG).

* On desktop platforms that use an app-level menu bar (e.g., macOS, and some Linux
window managers), the window will not have a menu bar; all menu options will be
displayed in the app bar. However, the app-level menu may contain additional default
menu items.

* On mobile, web and console platforms, a :class:`toga.MainWindow` will include a title
bar that can contain both menus and toolbar items.

In addition to the platform's default commands, user-defined commands can be
added to the :class:`toga.MainWindow`'s menu by adding them to
Expand Down
Loading
Loading