diff --git a/android/src/toga_android/app.py b/android/src/toga_android/app.py index 542fb870d1..1ca3257897 100644 --- a/android/src/toga_android/app.py +++ b/android/src/toga_android/app.py @@ -1,5 +1,4 @@ import asyncio -import sys import warnings from android.content import Context @@ -11,9 +10,8 @@ from org.beeware.android import IPythonApp, MainActivity import toga -from toga.command import Command, Group, Separator +from toga.command import Group, Separator from toga.dialogs import InfoDialog -from toga.handlers import simple_handler from .libs import events from .screens import Screen as ScreenImpl @@ -186,6 +184,9 @@ def onPrepareOptionsMenu(self, menu): class App: # Android apps exit when the last window is closed CLOSE_ON_LAST_WINDOW = True + # Android doesn't have command line handling; + # but saying it does shortcuts the default handling + HANDLES_COMMAND_LINE = True def __init__(self, interface): self.interface = interface @@ -210,16 +211,8 @@ def create(self): # Commands and menus ###################################################################### - def create_app_commands(self): - self.interface.commands.add( - # About should be the last item in the menu, in a section on its own. - Command( - simple_handler(self.interface.about), - f"About {self.interface.formal_name}", - section=sys.maxsize, - id=Command.ABOUT, - ), - ) + def create_standard_commands(self): + pass def create_menus(self): # Menu items are configured as part of onPrepareOptionsMenu; trigger that diff --git a/android/src/toga_android/command.py b/android/src/toga_android/command.py index 1cec2474cc..b54534e62d 100644 --- a/android/src/toga_android/command.py +++ b/android/src/toga_android/command.py @@ -1,10 +1,38 @@ +import sys + from org.beeware.android import MainActivity +from toga import Command as StandardCommand + class Command: def __init__(self, interface): self.interface = interface self.native = [] + @classmethod + def standard(cls, app, id): + # ---- Help menu ---------------------------------- + if id == StandardCommand.ABOUT: + return { + "text": f"About {app.formal_name}", + "section": sys.maxsize, + } + # ---- Undefined commands-------------------------- + elif id in { + StandardCommand.EXIT, + StandardCommand.NEW, + StandardCommand.OPEN, + StandardCommand.PREFERENCES, + StandardCommand.SAVE, + StandardCommand.SAVE_AS, + StandardCommand.SAVE_ALL, + StandardCommand.VISIT_HOMEPAGE, + }: + # These are valid commands, but they're not defined on Android. + return None + + raise ValueError(f"Unknown standard command {id!r}") + def set_enabled(self, value): MainActivity.singletonThis.invalidateOptionsMenu() diff --git a/changes/2209.feature.rst b/changes/2209.feature.1.rst similarity index 100% rename from changes/2209.feature.rst rename to changes/2209.feature.1.rst diff --git a/changes/2209.feature.2.rst b/changes/2209.feature.2.rst new file mode 100644 index 0000000000..67ce4f6b71 --- /dev/null +++ b/changes/2209.feature.2.rst @@ -0,0 +1 @@ +The API for Documents and document types has been finalized. Document handling behavior is now controlled by declaring document types as part of your ``toga.App`` definition. diff --git a/changes/2209.removal.rst b/changes/2209.removal.rst new file mode 100644 index 0000000000..7de1a9f877 --- /dev/null +++ b/changes/2209.removal.rst @@ -0,0 +1,13 @@ +The API for Documents and Document-based apps has been significantly modified. Unfortunately, these changes are not backwards compatible; any existing Document-based app will require modification. + +The ``DocumentApp`` base class is no longer required. Apps can subclass ``App`` directly, passing the document types as a ``list`` of ``Document`` classes, rather than a mapping of extension to document type. + +The API for ``Document`` subclasses has also changed: + +* A path is no longer provided as an argument to the Document constructor; + +* The ``document_type`` is now specified as a class property called ``description``; and + +* Extensions are now defined as a class property of the ``Document``; and + +* The ``can_close()`` handler is no longer honored. Documents now track if they are modified, and have a default ``on_close`` handler that uses the modification status of a document to control whether a document can close. Invoking ``touch()`` on document will mark a document as modified. This modification flag is cleared by saving the document. diff --git a/cocoa/src/toga_cocoa/app.py b/cocoa/src/toga_cocoa/app.py index 3e92c583ea..053dbc351f 100644 --- a/cocoa/src/toga_cocoa/app.py +++ b/cocoa/src/toga_cocoa/app.py @@ -1,12 +1,9 @@ import asyncio -import inspect import sys from pathlib import Path -from urllib.parse import unquote, urlparse from rubicon.objc import ( SEL, - NSMutableArray, NSMutableDictionary, NSObject, objc_method, @@ -15,13 +12,11 @@ from rubicon.objc.eventloop import CocoaLifecycle, EventLoopPolicy import toga -from toga.app import overridden -from toga.command import Command, Separator -from toga.handlers import NativeHandler, simple_handler +from toga.command import Command, Group, Separator +from toga.handlers import NativeHandler from .keys import cocoa_key from .libs import ( - NSURL, NSAboutPanelOptionApplicationIcon, NSAboutPanelOptionApplicationName, NSAboutPanelOptionApplicationVersion, @@ -32,13 +27,10 @@ NSBeep, NSBundle, NSCursor, - NSDocumentController, NSMenu, NSMenuItem, NSNumber, - NSOpenPanel, NSScreen, - NSString, ) from .screens import Screen as ScreenImpl @@ -52,49 +44,22 @@ def applicationDidFinishLaunching_(self, notification): self.native.activateIgnoringOtherApps(True) @objc_method - def applicationSupportsSecureRestorableState_( - self, app - ) -> bool: # pragma: no cover + def applicationSupportsSecureRestorableState_(self, app) -> bool: return True @objc_method - def applicationOpenUntitledFile_(self, sender) -> bool: # pragma: no cover - self.impl.select_file() + def applicationOpenUntitledFile_(self, sender) -> bool: + asyncio.create_task(self.interface.documents.request_open()) return True @objc_method - def addDocument_(self, document) -> None: # pragma: no cover - # print("Add Document", document) - super().addDocument_(document) + def applicationShouldOpenUntitledFile_(self, sender) -> bool: + return bool(self.interface.documents.types) @objc_method - def applicationShouldOpenUntitledFile_(self, sender) -> bool: # pragma: no cover - return True - - @objc_method - def application_openFiles_(self, app, filenames) -> None: # pragma: no cover - for i in range(0, len(filenames)): - filename = filenames[i] - # If you start your Toga application as `python myapp.py` or - # `myapp.py`, the name of the Python script is included as a - # filename to be processed. Inspect the stack, and ignore any - # "document" that matches the file doing the executing - if filename == inspect.stack(-1)[-1].filename: - continue - - if isinstance(filename, NSString): - fileURL = NSURL.fileURLWithPath(filename) - - elif isinstance(filename, NSURL): - # This case only exists because we aren't using the - # DocumentController to display the file open dialog. - # If we were, *all* filenames passed in would be - # string paths. - fileURL = filename - else: - return - - self.impl.open_document(str(fileURL.absoluteString)) + def application_openFiles_(self, app, filenames) -> None: + for filename in filenames: + self.interface._open_initial_document(str(filename)) @objc_method def selectMenuItem_(self, sender) -> None: @@ -110,6 +75,9 @@ def validateMenuItem_(self, sender) -> bool: class App: # macOS apps persist when there are no windows open CLOSE_ON_LAST_WINDOW = False + # macOS has handling for command line arguments tied to document handling; + # this also allows documents to be opened by dragging an icon onto the app. + HANDLES_COMMAND_LINE = True def __init__(self, interface): self.interface = interface @@ -160,23 +128,17 @@ def _menu_minimize(self, command, **kwargs): if self.interface.current_window: self.interface.current_window._impl.native.miniaturize(None) - def create_app_commands(self): + def create_standard_commands(self): + # macOS defines some default management commands that aren't + # exposed as standard commands. self.interface.commands.add( # ---- App menu ----------------------------------- - # About should be the first menu item - Command( - simple_handler(self.interface.about), - f"About {self.interface.formal_name}", - group=toga.Group.APP, - id=Command.ABOUT, - section=-1, - ), # App-level window management commands should be in the second last section. Command( NativeHandler(SEL("hide:")), f"Hide {self.interface.formal_name}", shortcut=toga.Key.MOD_1 + "h", - group=toga.Group.APP, + group=Group.APP, order=0, section=sys.maxsize - 1, ), @@ -184,28 +146,17 @@ def create_app_commands(self): NativeHandler(SEL("hideOtherApplications:")), "Hide Others", shortcut=toga.Key.MOD_1 + toga.Key.MOD_2 + "h", - group=toga.Group.APP, + group=Group.APP, order=1, section=sys.maxsize - 1, ), Command( NativeHandler(SEL("unhideAllApplications:")), "Show All", - group=toga.Group.APP, + group=Group.APP, order=2, section=sys.maxsize - 1, ), - # Quit should always be the last item, in a section on its own. Invoke - # `_request_exit` rather than `exit`, because we want to trigger the "OK to - # exit?" logic. - Command( - simple_handler(self.interface._request_exit), - f"Quit {self.interface.formal_name}", - shortcut=toga.Key.MOD_1 + "q", - group=toga.Group.APP, - section=sys.maxsize, - id=Command.EXIT, - ), # ---- File menu ---------------------------------- # This is a bit of an oddity. Apple HIG apps that don't have tabs as # part of their interface (so, Preview and Numbers, but not Safari) @@ -216,7 +167,7 @@ def create_app_commands(self): self._menu_close_window, "Close", shortcut=toga.Key.MOD_1 + "w", - group=toga.Group.FILE, + group=Group.FILE, order=1, section=50, ), @@ -224,7 +175,7 @@ def create_app_commands(self): self._menu_close_all_windows, "Close All", shortcut=toga.Key.MOD_2 + toga.Key.MOD_1 + "w", - group=toga.Group.FILE, + group=Group.FILE, order=2, section=50, ), @@ -233,21 +184,21 @@ def create_app_commands(self): NativeHandler(SEL("undo:")), "Undo", shortcut=toga.Key.MOD_1 + "z", - group=toga.Group.EDIT, + group=Group.EDIT, order=10, ), Command( NativeHandler(SEL("redo:")), "Redo", shortcut=toga.Key.SHIFT + toga.Key.MOD_1 + "z", - group=toga.Group.EDIT, + group=Group.EDIT, order=20, ), Command( NativeHandler(SEL("cut:")), "Cut", shortcut=toga.Key.MOD_1 + "x", - group=toga.Group.EDIT, + group=Group.EDIT, section=10, order=10, ), @@ -255,7 +206,7 @@ def create_app_commands(self): NativeHandler(SEL("copy:")), "Copy", shortcut=toga.Key.MOD_1 + "c", - group=toga.Group.EDIT, + group=Group.EDIT, section=10, order=20, ), @@ -263,7 +214,7 @@ def create_app_commands(self): NativeHandler(SEL("paste:")), "Paste", shortcut=toga.Key.MOD_1 + "v", - group=toga.Group.EDIT, + group=Group.EDIT, section=10, order=30, ), @@ -271,14 +222,14 @@ def create_app_commands(self): NativeHandler(SEL("pasteAsPlainText:")), "Paste and Match Style", shortcut=toga.Key.MOD_2 + toga.Key.SHIFT + toga.Key.MOD_1 + "v", - group=toga.Group.EDIT, + group=Group.EDIT, section=10, order=40, ), Command( NativeHandler(SEL("delete:")), "Delete", - group=toga.Group.EDIT, + group=Group.EDIT, section=10, order=50, ), @@ -286,7 +237,7 @@ def create_app_commands(self): NativeHandler(SEL("selectAll:")), "Select All", shortcut=toga.Key.MOD_1 + "a", - group=toga.Group.EDIT, + group=Group.EDIT, section=10, order=60, ), @@ -295,31 +246,10 @@ def create_app_commands(self): self._menu_minimize, "Minimize", shortcut=toga.Key.MOD_1 + "m", - group=toga.Group.WINDOW, - ), - # ---- Help menu ---------------------------------- - Command( - simple_handler(self.interface.visit_homepage), - "Visit homepage", - enabled=self.interface.home_page is not None, - group=toga.Group.HELP, - id=Command.VISIT_HOMEPAGE, + group=Group.WINDOW, ), ) - # If the user has overridden preferences, provide a menu item. - if overridden(self.interface.preferences): - self.interface.commands.add( - Command( - simple_handler(self.interface.preferences), - "Settings\u2026", - shortcut=toga.Key.MOD_1 + ",", - group=toga.Group.APP, - section=20, - id=Command.PREFERENCES, - ), - ) # pragma: no cover - def _submenu(self, group, menubar): """Obtain the submenu representing the command group. @@ -518,48 +448,3 @@ def exit_full_screen(self, windows): for window in windows: window.content._impl.native.exitFullScreenModeWithOptions(opts) window.content.refresh() - - -class DocumentApp(App): # pragma: no cover - def create_app_commands(self): - super().create_app_commands() - self.interface.commands.add( - toga.Command( - self._menu_open_file, - text="Open\u2026", - shortcut=toga.Key.MOD_1 + "o", - group=toga.Group.FILE, - section=0, - ), - ) - - def _menu_open_file(self, app, **kwargs): - self.select_file() - - def select_file(self, **kwargs): - # FIXME This should be all we need; but for some reason, application types - # aren't being registered correctly.. - # NSDocumentController.sharedDocumentController().openDocument_(None) - - # ...so we do this instead. - panel = NSOpenPanel.openPanel() - # print("Open documents of type", NSDocumentController.sharedDocumentController().defaultType) - - fileTypes = NSMutableArray.alloc().init() - for filetype in self.interface.document_types: - fileTypes.addObject(filetype) - - NSDocumentController.sharedDocumentController.runModalOpenPanel( - panel, forTypes=fileTypes - ) - - # print("Untitled File opened?", panel.URLs) - self.appDelegate.application_openFiles_(None, panel.URLs) - - def open_document(self, fileURL): - # Convert a cocoa fileURL to a file path. - fileURL = fileURL.rstrip("/") - path = Path(unquote(urlparse(fileURL).path)) - - # Create and show the document instance - self.interface._open(path) diff --git a/cocoa/src/toga_cocoa/command.py b/cocoa/src/toga_cocoa/command.py index 876a7226d5..a5598d5cf4 100644 --- a/cocoa/src/toga_cocoa/command.py +++ b/cocoa/src/toga_cocoa/command.py @@ -1,3 +1,6 @@ +import sys + +from toga import Command as StandardCommand, Group, Key from toga_cocoa.libs import NSMenuItem @@ -6,6 +9,82 @@ def __init__(self, interface): self.interface = interface self.native = set() + @classmethod + def standard(cls, app, id): + # ---- App menu ----------------------------------- + if id == StandardCommand.ABOUT: + # About should be the first menu item + return { + "text": f"About {app.formal_name}", + "group": Group.APP, + "section": -1, + } + elif id == StandardCommand.PREFERENCES: + return { + "text": "Settings\u2026", + "shortcut": Key.MOD_1 + ",", + "group": Group.APP, + "section": 20, + } + elif id == StandardCommand.EXIT: + # Quit should always be the last item, in a section on its own. + return { + "text": f"Quit {app.formal_name}", + "shortcut": Key.MOD_1 + "q", + "group": Group.APP, + "section": sys.maxsize, + } + # ---- File menu ---------------------------------- + elif id == StandardCommand.NEW: + return { + "text": "New", + "shortcut": Key.MOD_1 + "n", + "group": Group.FILE, + "section": 0, + "order": 0, + } + elif id == StandardCommand.OPEN: + return { + "text": "Open\u2026", + "shortcut": Key.MOD_1 + "o", + "group": Group.FILE, + "section": 0, + "order": 10, + } + elif id == StandardCommand.SAVE: + return { + "text": "Save", + "shortcut": Key.MOD_1 + "s", + "group": Group.FILE, + "section": 30, + "order": 10, + } + elif id == StandardCommand.SAVE_AS: + return { + "text": "Save As\u2026", + "shortcut": Key.MOD_1 + "S", + "group": Group.FILE, + "section": 30, + "order": 11, + } + elif id == StandardCommand.SAVE_ALL: + return { + "text": "Save All", + "shortcut": Key.MOD_1 + Key.MOD_2 + "s", + "group": Group.FILE, + "section": 30, + "order": 12, + } + # ---- Help menu ---------------------------------- + elif id == StandardCommand.VISIT_HOMEPAGE: + return { + "text": "Visit homepage", + "enabled": app.home_page is not None, + "group": Group.HELP, + } + + raise ValueError(f"Unknown standard command {id!r}") + def set_enabled(self, value): for item in self.native: if isinstance(item, NSMenuItem): diff --git a/cocoa/src/toga_cocoa/documents.py b/cocoa/src/toga_cocoa/documents.py deleted file mode 100644 index efa7b91092..0000000000 --- a/cocoa/src/toga_cocoa/documents.py +++ /dev/null @@ -1,36 +0,0 @@ -import os -from urllib.parse import quote - -from toga_cocoa.libs import NSURL, NSDocument, objc_method, objc_property - - -class TogaDocument(NSDocument): # pragma: no cover - interface = objc_property(object, weak=True) - impl = objc_property(object, weak=True) - - @objc_method - def autosavesInPlace(self) -> bool: - return True - - @objc_method - def readFromFileWrapper_ofType_error_( - self, fileWrapper, typeName, outError - ) -> bool: - self.interface.read() - return True - - -class Document: # pragma: no cover - # macOS has multiple documents in a single app instance. - SINGLE_DOCUMENT_APP = False - - def __init__(self, interface): - self.native = TogaDocument.alloc() - self.native.interface = interface - self.native.impl = self - - self.native.initWithContentsOfURL( - NSURL.URLWithString(f"file://{quote(os.fsdecode(interface.path))}"), - ofType=interface.document_type, - error=None, - ) diff --git a/cocoa/src/toga_cocoa/factory.py b/cocoa/src/toga_cocoa/factory.py index b0e70390da..ffe7968543 100644 --- a/cocoa/src/toga_cocoa/factory.py +++ b/cocoa/src/toga_cocoa/factory.py @@ -1,9 +1,8 @@ from toga import NotImplementedWarning from . import dialogs -from .app import App, DocumentApp +from .app import App from .command import Command -from .documents import Document from .fonts import Font from .hardware.camera import Camera from .hardware.location import Location @@ -35,7 +34,7 @@ from .widgets.textinput import TextInput from .widgets.tree import Tree from .widgets.webview import WebView -from .window import DocumentMainWindow, MainWindow, Window +from .window import MainWindow, Window def not_implemented(feature): @@ -45,9 +44,7 @@ def not_implemented(feature): __all__ = [ "not_implemented", "App", - "DocumentApp", "Command", - "Document", # Resources "Font", "Icon", @@ -82,7 +79,6 @@ def not_implemented(feature): "Tree", "WebView", # Windows, - "DocumentMainWindow", "MainWindow", "Window", ] diff --git a/cocoa/src/toga_cocoa/libs/appkit.py b/cocoa/src/toga_cocoa/libs/appkit.py index 25141d9b25..3d81abf368 100644 --- a/cocoa/src/toga_cocoa/libs/appkit.py +++ b/cocoa/src/toga_cocoa/libs/appkit.py @@ -273,15 +273,6 @@ class NSBezelStyle(IntEnum): NSCursor = ObjCClass("NSCursor") -###################################################################### -# NSDocument.h -NSDocument = ObjCClass("NSDocument") - -###################################################################### -# NSDocumentController.h -NSDocumentController = ObjCClass("NSDocumentController") -NSDocumentController.declare_class_property("sharedDocumentController") - ###################################################################### # NSEvent.h NSEvent = ObjCClass("NSEvent") diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index 36fb93deb0..5a0604c7d6 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -374,7 +374,3 @@ def purge_toolbar(self): for item_native in dead_items: cmd._impl.native.remove(item_native) item_native.release() - - -class DocumentMainWindow(Window): - pass diff --git a/cocoa/tests_backend/app.py b/cocoa/tests_backend/app.py index 1357bbdd27..125415bdc7 100644 --- a/cocoa/tests_backend/app.py +++ b/cocoa/tests_backend/app.py @@ -1,7 +1,7 @@ from pathlib import Path import PIL.Image -from rubicon.objc import NSPoint, ObjCClass, objc_id, send_message +from rubicon.objc import SEL, NSPoint, ObjCClass, objc_id, send_message import toga from toga_cocoa.keys import cocoa_key, toga_key @@ -159,6 +159,12 @@ def assert_system_menus(self): self.assert_menu_item(["*", "Show All"], enabled=True) self.assert_menu_item(["*", "Quit Toga Testbed"], enabled=True) + self.assert_menu_item(["File", "New Example Document"], enabled=True) + self.assert_menu_item(["File", "New Read-only Document"], enabled=True) + self.assert_menu_item(["File", "Open\u2026"], enabled=True) + self.assert_menu_item(["File", "Save"], enabled=True) + self.assert_menu_item(["File", "Save As\u2026"], enabled=True) + self.assert_menu_item(["File", "Save All"], enabled=True) self.assert_menu_item(["File", "Close"], enabled=True) self.assert_menu_item(["File", "Close All"], enabled=True) @@ -273,3 +279,37 @@ def automated_show(host_window, future): dialog._impl.completion_handler(result) dialog._impl.show = automated_show + + async def open_initial_document(self, monkeypatch, document_path): + # Mock the async menu item with an implementation that directly opens the doc + async def _mock_open(): + self.app.documents.open(document_path) + + monkeypatch.setattr(self.app.documents, "request_open", _mock_open) + + # Call the APIs that are triggered when the app is activated. + nsapp = self.app._impl.native + + # We are running in an async context. Invoking a selector moves + # to an sync context, but the handling needs to queue an async + # task. By invoking the relevant methods using Objective C's + # deferred invocation mechanism, the method is invoked in a + # sync context, so it is able to queue the required async task. + nsapp.delegate.performSelector( + SEL("applicationShouldOpenUntitledFile:"), + withObject=nsapp, + afterDelay=0.01, + ) + nsapp.delegate.performSelector( + SEL("applicationOpenUntitledFile:"), + withObject=nsapp, + afterDelay=0.02, + ) + + await self.redraw("Initial document has been triggered", delay=0.1) + + def open_document_by_drag(self, document_path): + self.app._impl.native.delegate.application( + self.app._impl.native, + openFiles=[str(document_path)], + ) diff --git a/core/src/toga/__init__.py b/core/src/toga/__init__.py index 7957fbeb9e..0a950d8dcd 100644 --- a/core/src/toga/__init__.py +++ b/core/src/toga/__init__.py @@ -16,7 +16,7 @@ SelectFolderDialog, StackTraceDialog, ) -from .documents import Document +from .documents import Document, DocumentWindow from .fonts import Font from .icons import Icon from .images import Image @@ -48,7 +48,7 @@ from .widgets.timeinput import TimeInput, TimePicker from .widgets.tree import Tree from .widgets.webview import WebView -from .window import DocumentMainWindow, MainWindow, Window +from .window import MainWindow, Window class NotImplementedWarning(RuntimeWarning): @@ -71,6 +71,7 @@ def warn(cls, platform: str, feature: str) -> None: "Group", # Documents "Document", + "DocumentWindow", # Dialogs "ConfirmDialog", "ErrorDialog", @@ -124,7 +125,6 @@ def warn(cls, platform: str, feature: str) -> None: "WebView", "Widget", # Windows - "DocumentMainWindow", "MainWindow", "Window", # Deprecated widget names diff --git a/core/src/toga/app.py b/core/src/toga/app.py index 58c9c50cf6..4dea899619 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -9,10 +9,11 @@ from collections.abc import Coroutine, Iterator from email.message import Message from pathlib import Path -from typing import TYPE_CHECKING, Any, MutableSet, Protocol +from typing import TYPE_CHECKING, Any, Protocol from weakref import WeakValueDictionary -from toga.command import CommandSet +from toga.command import Command, CommandSet +from toga.documents import Document, DocumentSet from toga.handlers import simple_handler, wrapped_handler from toga.hardware.camera import Camera from toga.hardware.location import Location @@ -21,11 +22,10 @@ from toga.platform import get_platform_factory from toga.screens import Screen from toga.widgets.base import Widget -from toga.window import MainWindow, Window +from toga.window import MainWindow, Window, WindowSet if TYPE_CHECKING: from toga.dialogs import Dialog - from toga.documents import Document from toga.icons import IconContentT # Make sure deprecation warnings are shown by default @@ -80,69 +80,6 @@ def __call__(self, app: App, /, **kwargs: Any) -> object: """ -class WindowSet(MutableSet[Window]): - def __init__(self, app: App): - """A collection of windows managed by an app. - - A window is automatically added to the app when it is created, and removed when - it is closed. Adding a window to an App's window set automatically sets the - :attr:`~toga.Window.app` property of the Window. - """ - self.app = app - self.elements: set[Window] = set() - - def add(self, window: Window) -> None: - if not isinstance(window, Window): - raise TypeError("Can only add objects of type toga.Window") - # Silently not add if duplicate - if window not in self.elements: - self.elements.add(window) - window.app = self.app - - def discard(self, window: Window) -> None: - if not isinstance(window, Window): - raise TypeError("Can only discard objects of type toga.Window") - if window not in self.elements: - raise ValueError(f"{window!r} is not part of this app") - self.elements.remove(window) - - ###################################################################### - # 2023-10: Backwards compatibility - ###################################################################### - - def __iadd__(self, window: Window) -> WindowSet: - # The standard set type does not have a += operator. - warnings.warn( - "Windows are automatically associated with the app; += is not required", - DeprecationWarning, - stacklevel=2, - ) - return self - - def __isub__(self, other: Window) -> WindowSet: - # The standard set type does have a -= operator, but it takes sets rather than - # individual items. - warnings.warn( - "Windows are automatically removed from the app; -= is not required", - DeprecationWarning, - stacklevel=2, - ) - return self - - ###################################################################### - # End backwards compatibility - ###################################################################### - - def __iter__(self) -> Iterator[Window]: - return iter(self.elements) - - def __contains__(self, value: object) -> bool: - return value in self.elements - - def __len__(self) -> int: - return len(self.elements) - - class WidgetRegistry: # WidgetRegistry is implemented as a wrapper around a WeakValueDictionary, because # it provides a mapping from ID to widget. The mapping is weak so the registry @@ -194,21 +131,6 @@ def _remove(self, id: str) -> None: del self._registry[id] -def overridable(method): - """Decorate the method as being user-overridable""" - method._overridden = True - return method - - -def overridden(coroutine_or_method): - """Has the user overridden this method? - - This is based on the method *not* having a ``_overridden`` attribute. Overridable - default methods have this attribute; user-defined method will not. - """ - return not hasattr(coroutine_or_method, "_overridden") - - class App: #: The currently running :class:`~toga.App`. Since there can only be one running #: Toga app in a process, this is available as a class property via ``toga.App.app``. @@ -236,6 +158,7 @@ def __init__( home_page: str | None = None, description: str | None = None, startup: AppStartupMethod | None = None, + document_types: list[type[Document]] | None = None, on_running: OnRunningHandler | None = None, on_exit: OnExitHandler | None = None, id: None = None, # DEPRECATED @@ -275,6 +198,8 @@ def __init__( :param startup: A callable to run before starting the app. :param on_running: The initial :any:`on_running` handler. :param on_exit: The initial :any:`on_exit` handler. + :param document_types: A list of :any:`Document` classes that this app + can manage. :param id: **DEPRECATED** - This argument will be ignored. If you need a machine-friendly identifier, use ``app_id``. :param windows: **DEPRECATED** – Windows are now automatically added to the @@ -389,6 +314,12 @@ def __init__( else: self.icon = icon + # Set up the document types and collection of documents being managed. + self._documents = DocumentSet( + self, + types=[] if document_types is None else document_types, + ) + # Install the lifecycle handlers. If passed in as an argument, or assigned using # `app.on_event = my_handler`, the event handler will take the app as the first # argument. If we're using the default value, or we're subclassing app, the app @@ -415,9 +346,6 @@ def __init__( self._full_screen_windows: tuple[Window, ...] | None = None # Create the implementation. This will trigger any startup logic. - self._create_impl() - - def _create_impl(self) -> None: self.factory.App(interface=self) ###################################################################### @@ -507,9 +435,14 @@ def is_bundled(self) -> bool: # App lifecycle ###################################################################### - def _request_exit(self): - # Internal method to request an exit. This triggers on_exit handling, and - # will only exit if the user agrees. + def request_exit(self): + """Request an exit from the application. + + This method will call the :meth:`~toga.App.on_exit` handler to confirm if the + app should be allowed to exit; if that handler confirms the action, the app will + exit. + """ + def cleanup(app, should_exit): if should_exit: app.exit() @@ -519,7 +452,7 @@ def cleanup(app, should_exit): wrapped_handler(self, self.on_exit, cleanup=cleanup)() def exit(self) -> None: - """Exit the application gracefully. + """Unconditionally exit the application. This *does not* invoke the ``on_exit`` handler; the app will be immediately and unconditionally closed. @@ -576,19 +509,105 @@ def main_window(self, window: MainWindow | str | None) -> None: else: raise ValueError(f"Don't know how to use {window!r} as a main window.") + def _open_initial_document(self, filename: Path) -> bool: + """Internal utility method for opening a document provided at the command line. + + This is abstracted so that backends that have their own management of command + line arguments can share the same error handling. + + :param filename: The filename passed as an argument, as a string. + :returns: ``True`` if a document was successfully loaded; ``False`` otherwise. + """ + try: + self.documents.open(filename) + return True + except FileNotFoundError: + print(f"Document {filename} not found") + return False + except Exception as e: + print(f"{filename}: {e}") + return False + + def _create_standard_commands(self): + """Internal utility method to create the standard commands for the app.""" + for cmd_id in [ + Command.ABOUT, + Command.EXIT, + Command.VISIT_HOMEPAGE, + ]: + self.commands.add(Command.standard(self, cmd_id)) + + if self.documents.types: + default_document_type = self.documents.types[0] + command = Command.standard( + self, + Command.NEW, + action=simple_handler(self.documents.new, default_document_type), + ) + if command: + if len(self.documents.types) == 1: + # There's only 1 document type. The new command can be used as is. + self.commands.add(command) + else: + # There's more than one document type. Create a new command for each + # document type, updating the title of the command to disambiguate, + # and modifying the shortcut, order and ID of the document types 2+ + for i, document_class in enumerate(self.documents.types): + command = Command.standard( + self, + Command.NEW, + action=simple_handler(self.documents.new, document_class), + ) + command.text = command.text + f" {document_class.description}" + if i > 0: + command.shortcut = None + command._id = f"{command.id}:{document_class.extensions[0]}" + command.order = command.order + i + + self.commands.add(command) + + for cmd_id in [ + Command.OPEN, + Command.SAVE, + Command.SAVE_AS, + Command.SAVE_ALL, + ]: + self.commands.add(Command.standard(self, cmd_id)) + def _create_initial_windows(self): - # TODO: Create the initial windows for the app. + """Internal utility method for creating initial windows based on command line + arguments. This method is used when the platform doesn't provide it's own + command-line handling interface. - # 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.") + If document types are defined, try to open every argument on the command line as + a document (unless the backend manages the command line arguments). + """ + # If the backend handles the command line, don't do any command line processing. + if self._impl.HANDLES_COMMAND_LINE: + return + doc_count = len(self.windows) + if self.documents.types: + for filename in sys.argv[1:]: + if self._open_initial_document(filename): + doc_count += 1 + + # Safety check: Do we have at least one document? + if self.main_window is None and doc_count == 0: + try: + # Pass in the first document type as the default + default_doc_type = self.documents.types[0] + self.documents.new(default_doc_type) + except IndexError: + # No document types defined. + raise RuntimeError( + "App didn't create any windows, or register any document types." + ) 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() + # Install the standard commands. This is done *before* startup so the user's + # code has the opporuntity to remove/change the default commands. + self._create_standard_commands() + self._impl.create_standard_commands() # Invoke the user's startup method (or the default implementation) self.startup() @@ -653,6 +672,11 @@ def commands(self) -> CommandSet: """The commands available in the app.""" return self._commands + @property + def documents(self) -> DocumentSet: + """The list of documents associated with this app.""" + return self._documents + @property def location(self) -> Location: """A representation of the device's location service.""" @@ -723,19 +747,6 @@ async def dialog(self, dialog: Dialog) -> Coroutine[None, None, Any]: """ return await dialog._show(None) - @overridable - def preferences(self) -> None: - """Open a preferences panel for the app. - - By default, this will do nothing, and the Preferences/Settings menu item will - not be installed. However, if you override this method in your App class, the - :attr:`toga.Command.PREFERENCES` command will be added, and this method will be - invoked when the menu item is selected. - """ - # Default implementation won't ever be invoked, because the menu item - # isn't enabled unless it's overridden. - pass # pragma: no cover - def visit_homepage(self) -> None: """Open the application's :any:`home_page` in the default browser. @@ -810,7 +821,6 @@ def set_full_screen(self, *windows: Window) -> None: # App events ###################################################################### - @overridable def on_exit(self) -> bool: """The event handler that will be invoked when the app is about to exit. @@ -825,7 +835,6 @@ def on_exit(self) -> bool: # Always allow exit return True - @overridable def on_running(self) -> None: """The event handler that will be invoked when the app's event loop starts running. @@ -873,88 +882,39 @@ def add_background_task(self, handler: BackgroundTask) -> None: ###################################################################### -class DocumentApp(App): - def __init__( - self, - formal_name: str | None = None, - app_id: str | None = None, - app_name: str | None = None, - *, - icon: IconContentT | None = None, - author: str | None = None, - version: str | None = None, - home_page: str | None = None, - description: str | None = None, - startup: AppStartupMethod | None = None, - document_types: dict[str, type[Document]] | None = None, - on_exit: OnExitHandler | None = None, - id: None = None, # DEPRECATED - ): - """Create a document-based application. +###################################################################### +# 2024-08: Backwards compatibility +###################################################################### - A document-based application is the same as a normal application, with the - exception that there is no main window. Instead, each document managed by the - app will create and manage its own window (or windows). - :param document_types: Initial :any:`document_types` mapping. +class DocumentApp(App): + def __init__(self, *args, **kwargs): + """**DEPRECATED** - :any:`toga.DocumentApp` can be replaced with + :any:`toga.App`. """ - if document_types is None: - raise ValueError("A document must manage at least one document type.") - - self._document_types = document_types - self._documents: list[Document] = [] - - super().__init__( - formal_name=formal_name, - app_id=app_id, - app_name=app_name, - icon=icon, - author=author, - version=version, - home_page=home_page, - description=description, - startup=startup, - on_exit=on_exit, - id=id, + warnings.warn( + "toga.DocumentApp is no longer required. Use toga.App instead", + DeprecationWarning, + stacklevel=2, ) + # Convert document types from dictionary format to list format. + # The old API guaranteed that document_types was provided + kwargs["document_types"] = list(kwargs["document_types"].values()) - def _create_impl(self) -> None: - self.factory.DocumentApp(interface=self) + super().__init__(*args, **kwargs) @property def document_types(self) -> dict[str, type[Document]]: - """The document types this app can manage. - - A dictionary of file extensions, without leading dots, mapping to the - :class:`toga.Document` subclass that will be created when a document with that - extension is opened. The subclass must take exactly 2 arguments in its - constructor: ``path`` and ``app``. - """ - return self._document_types - - @property - def documents(self) -> list[Document]: - """The list of documents associated with this app.""" - return self._documents - - def startup(self) -> None: - """No-op; a DocumentApp has no windows until a document is opened. - - Subclasses can override this method to define customized startup behavior. - """ - - def _open(self, path: Path) -> None: - """Internal utility method; open a new document in this app, and shows the document. - - :param path: The path to the document to be opened. - :raises ValueError: If the document is of a type that can't be opened. Backends can - suppress this exception if necessary to preserve platform-native behavior. + """**DEPRECATED** - Use ``documents.types``; extensions can be + obtained from the individual document classes itself. """ - try: - DocType = self.document_types[path.suffix[1:]] - except KeyError: - raise ValueError(f"Don't know how to open documents of type {path.suffix}") - else: - document = DocType(path, app=self) - self._documents.append(document) - document.show() + warnings.warn( + "App.document_types is deprecated. Use App.documents.types", + DeprecationWarning, + stacklevel=2, + ) + return { + extension: doc_type + for doc_type in self.documents.types + for extension in doc_type.extensions + } diff --git a/core/src/toga/command.py b/core/src/toga/command.py index 2430849a91..20fc0283b7 100644 --- a/core/src/toga/command.py +++ b/core/src/toga/command.py @@ -3,7 +3,7 @@ from collections.abc import Iterator from typing import TYPE_CHECKING, MutableMapping, MutableSet, Protocol -from toga.handlers import wrapped_handler +from toga.handlers import simple_handler, wrapped_handler from toga.icons import Icon from toga.keys import Key from toga.platform import get_platform_factory @@ -160,18 +160,43 @@ def __call__(self, command: Command, **kwargs) -> bool: class Command: - #: An identifier for the system-installed "About" menu item. This command is - #: always installed. + #: An identifier for the standard "About" menu item. This command is always + #: installed by default. Uses :meth:`toga.App.about` as the default action. ABOUT: str = "about" - #: An identifier for the system-installed "Exit" menu item. This command is always - #: installed. - EXIT: str = "on_exit" - #: An identifier for the system-installed "Preferences" menu item. A command - #: with this identifier will be installed automatically if the app overrides the - #: :meth:`~toga.App.preferences` method. + #: An identifier for the standard "Exit" menu item. This command may be installed by + #: default, depending on platform requirements. Uses :meth:`toga.App.request_exit` + #: as the default action. + EXIT: str = "request_exit" + #: An identifier for the standard "New" menu item. This constant will be used for + #: the default document type for your app; if you specify more than one document + #: type, the command for the subsequent commands will have a colon and the first + #: extension for that data type appended to the ID. Uses + #: :meth:`toga.documents.DocumentSet.new` as the default action. + NEW: str = "documents.new" + #: An identifier for the standard "Open" menu item. This command will be + #: automatically installed if your app declares any document types. Uses + #: :meth:`toga.documents.DocumentSet.request_open` as the default action. + OPEN: str = "documents.request_open" + #: An identifier for the standard "Preferences" menu item. The Preferences item is + #: not installed by default. If you install it manually, it will attempt to use + #: ``toga.App.preferences()`` as the default action; your app will need to define + #: this method, or provide an explicit value for the action. PREFERENCES: str = "preferences" - #: An identifier for the system-installed "Visit Homepage" menu item. This - #: command is always installed. + #: An identifier for the standard "Save" menu item. This command will be + #: automatically installed if your app declares any document types. Uses + #: :meth:`toga.documents.DocumentSet.save` as the default action. + SAVE: str = "documents.save" + #: An identifier for the standard "Save As..." menu item. This command will be + #: automatically installed if your app declares any document types. Uses + #: :meth:`toga.documents.DocumentSet.save_as` as the default action. + SAVE_AS: str = "documents.save_as" + #: An identifier for the standard "Save All" menu item. This command will be + #: automatically installed if your app declares any document types. Uses + #: :meth:`toga.documents.DocumentSet.save_all` as the default action. + SAVE_ALL: str = "documents.save_all" + #: An identifier for the standard "Visit Homepage" menu item. This command may be + #: installed by default, depending on platform requirements. Uses + #: :meth:`toga.App.visit_homepage` as the default action. VISIT_HOMEPAGE: str = "visit_homepage" def __init__( @@ -228,6 +253,45 @@ def __init__( self._enabled = True self.enabled = enabled + @classmethod + def standard(cls, app: App, id, **kwargs): + """Create an instance of a standard command for the provided app. + + The default action for the command will be constructed using the value of the + command's ID as an attribute of the app object. If a method or co-routine + matching that name doesn't exist, a value of ``None`` will be used as the + default action. + + :param app: The app for which the standard command will be created. + :param id: The ID of the standard command to create. + :param kwargs: Overrides for any default properties of the standard command. + Accepts the same arguments as the :class:`~toga.Command` constructor. + """ + # The value of the ID constant is the method on the app instance + cmd_kwargs = {"id": id} + try: + attrs = id.split(".") + action = getattr(app, attrs[0]) + for attr in attrs[1:]: + action = getattr(action, attr) + cmd_kwargs["action"] = simple_handler(action) + except AttributeError: + cmd_kwargs["action"] = None + + # Get the platform-specific keyword arguments for the command + factory = get_platform_factory() + platform_kwargs = factory.Command.standard(app, id) + + if platform_kwargs: + cmd_kwargs.update(platform_kwargs) + cmd_kwargs.update(kwargs) + + # Return the command instance + return Command(**cmd_kwargs) + else: + # Standard command doesn't exist on the platform. + return None + @property def id(self) -> str: """A unique identifier for the command.""" @@ -358,14 +422,18 @@ def __init__( self._commands: dict[str:Command] = {} self.on_change = on_change - def add(self, *commands: Command): + def add(self, *commands: Command | None): """Add a collection of commands to the command set. + A command value of ``None`` will be ignored. This allows you to add standard + commands to a command set without first validating that the platform provides an + implementation of that command. + :param commands: The commands to add to the command set. """ if self.app: self.app.commands.add(*commands) - self._commands.update({cmd.id: cmd for cmd in commands}) + self._commands.update({cmd.id: cmd for cmd in commands if cmd is not None}) if self.on_change: self.on_change() diff --git a/core/src/toga/dialogs.py b/core/src/toga/dialogs.py index ac352c3921..944687aa7c 100644 --- a/core/src/toga/dialogs.py +++ b/core/src/toga/dialogs.py @@ -140,8 +140,11 @@ def __init__( This dialog is not currently supported on Android or iOS. - Returns a path object for the selected file location, or ``None`` if - the user cancelled the save operation. + Returns a path object for the selected file location, or ``None`` if the user + cancelled the save operation. + + If the filename already exists, the user will be prompted to confirm they want + to overwrite the existing file. :param title: The title of the dialog window :param suggested_filename: The initial suggested filename diff --git a/core/src/toga/documents.py b/core/src/toga/documents.py index 3858a2d593..82ec0e3c50 100644 --- a/core/src/toga/documents.py +++ b/core/src/toga/documents.py @@ -1,95 +1,58 @@ from __future__ import annotations -import asyncio -import warnings +import itertools +import sys from abc import ABC, abstractmethod +from collections.abc import Iterator from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Mapping, Sequence + +import toga +from toga import dialogs +from toga.handlers import overridable, overridden +from toga.window import MainWindow, Window if TYPE_CHECKING: from toga.app import App - from toga.window import Window class Document(ABC): - def __init__( - self, - path: str | Path, - document_type: str, - app: App, - ): - """Create a new Document. - - :param path: The path where the document is stored. - :param document_type: A human-readable description of the document type. + #: A short description of the type of document (e.g., "Text document"). This is a + #: class variable that subclasses should define. + description: str + + #: A list of extensions that documents of this type might use, without leading dots (e.g., + #: ``["doc", "txt"]``). The list must have at least one extension; the first is the + #: default extension for documents of this type. This is a class variable that + #: subclasses should define. + extensions: list[str] + + def __init__(self, app: App): + """Create a new Document. Do not call this constructor directly - use + :any:`DocumentSet.new`, :any:`DocumentSet.open` or + :any:`DocumentSet.request_open` instead. + :param app: The application the document is associated with. """ - self._path = Path(path) - self._document_type = document_type + self._path: Path | None = None self._app = app self._main_window: Window | None = None + self.modified = False - # Create the visual representation of the document + # Create the visual representation of the document. self.create() - # Create a platform specific implementation of the Document - self._impl = app.factory.Document(interface=self) - - # 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 - to prevent a window closing with unsaved changes, etc. - - This default implementation is a function; however, subclasses can define it - as an asynchronous co-routine if necessary to allow for dialog confirmations. - """ - return True - - # 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. - - It interrogates the :meth:`~toga.Document.can_close()` method to determine if - the document is allowed to close. - """ - if asyncio.iscoroutinefunction(self.can_close): - can_close = await self.can_close() - else: - can_close = self.can_close() - - if can_close: - if self._impl.SINGLE_DOCUMENT_APP: - self.app.exit() - return False - else: - return True - else: - return False + # Add the document to the list of managed documents. + self.app._documents._add(self) + ###################################################################### + # Document properties + ###################################################################### @property def path(self) -> Path: """The path where the document is stored (read-only).""" return self._path - @property - def filename(self) -> Path: - """**DEPRECATED** - Use :attr:`path`.""" - warnings.warn( - "Document.filename has been renamed Document.path.", - DeprecationWarning, - ) - return self._path - - @property - def document_type(self) -> str: - """A human-readable description of the document type (read-only).""" - return self._document_type - @property def app(self) -> App: """The app that this document is associated with (read-only).""" @@ -104,10 +67,86 @@ def main_window(self) -> Window | None: def main_window(self, window: Window) -> None: self._main_window = window + @property + def title(self) -> str: + """The title of the document. + + This will be used as the default title of a :any:`toga.DocumentWindow` that + contains the document. + """ + return f"{self.description}: {self.path.stem if self.path else 'Untitled'}" + + @property + def modified(self) -> bool: + """Has the document been modified?""" + return self._modified + + @modified.setter + def modified(self, value: bool): + self._modified = bool(value) + + ###################################################################### + # Document operations + ###################################################################### + + def focus(self): + """Give the document focus in the app.""" + self.app.current_window = self.main_window + + def hide(self) -> None: + """Hide the visual representation for this document.""" + self.main_window.hide() + + def open(self, path: str | Path): + """Open a file as a document. + + :param path: The file to open. + """ + self._path = Path(path).absolute() + if self._path.exists(): + self.read() + else: + raise FileNotFoundError() + + # Set the title of the document window to match the path + self._main_window.title = self._main_window._default_title + # Document is initially unmodified + self.modified = False + + def save(self, path: str | Path | None = None): + """Save the document as a file. + + If a path is provided, and the :meth:`~toga.Document.write` method has been + overwritten, the path for the document will be updated. Otherwise, the existing + path will be used. + + :param path: If provided, the new file name for the document. + """ + if overridden(self.write): + if path: + self._path = Path(path).absolute() + # Re-set the title of the document with the new path + self._main_window.title = self._main_window._default_title + self.write() + # Clear the modification flag. + self.modified = False + def show(self) -> None: - """Show the :any:`main_window` for this document.""" + """Show the visual representation for this document.""" self.main_window.show() + def touch(self, *args, **kwargs): + """Mark the document as modified. + + This method accepts `*args` and `**kwargs` so that it can be used as an + ``on_change`` handler; these arguments are not used. + """ + self.modified = True + + ###################################################################### + # Abstract interface + ###################################################################### + @abstractmethod def create(self) -> None: """Create the window (or windows) for the document. @@ -118,5 +157,332 @@ def create(self) -> None: @abstractmethod def read(self) -> None: - """Load a representation of the document into memory and populate the document - window.""" + """Load a representation of the document into memory from + :attr:`~toga.Document.path`, and populate the document window. + """ + + @overridable + def write(self) -> None: + """Persist a representation of the current state of the document. + + This method is a no-op by default, to allow for read-only document types. + """ + + +class DocumentSet(Sequence[Document], Mapping[Path, Document]): + def __init__(self, app: App, types: list[type[Document]]): + """A collection of documents managed by an app. + + A document is automatically added to the app when it is created, and removed + when it is closed. The document collection will be stored in the order that + documents were created. + + :param app: The app that this instance is bound to. + :param types: The document types managed by this app. + """ + self.app = app + for doc_type in types: + if not hasattr(doc_type, "description"): + raise ValueError( + f"Document type {doc_type.__name__!r} " + "doesn't define a 'descriptions' attribute" + ) + if not hasattr(doc_type, "extensions"): + raise ValueError( + f"Document type {doc_type.__name__!r} " + "doesn't define an 'extensions' attribute" + ) + if len(doc_type.extensions) == 0: + raise ValueError( + f"Document type {doc_type.__name__!r} " + "doesn't define at least one extension" + ) + + self._types = types + + self.elements: list[Document] = [] + + @property + def types(self) -> list[type[Document]]: + """The list of document types the app can manage. + + The first document type in the list is the app's default document type. + """ + return self._types + + def __iter__(self) -> Iterator[Document]: + return iter(self.elements) + + def __contains__(self, value: object) -> bool: + return value in self.elements + + def __len__(self) -> int: + return len(self.elements) + + def __getitem__(self, path_or_index): + # Look up by index + if isinstance(path_or_index, int): + return self.elements[path_or_index] + + # Look up by path + if sys.version_info < (3, 9): # pragma: no-cover-if-gte-py39 + # resolve() *should* turn the path into an absolute path; + # but on Windows, with Python 3.8, it doesn't. + path = Path(path_or_index).absolute().resolve() + else: # pragma: no-cover-if-lt-py39 + path = Path(path_or_index).resolve() + for item in self.elements: + if item.path == path: + return item + + # No match found + raise KeyError(path_or_index) + + def _add(self, document: Path): + if document in self: + raise ValueError("Document is already being managed.") + + self.elements.append(document) + + def _remove(self, document: Path): + if document not in self: + raise ValueError("Document is not being managed.") + + self.elements.remove(document) + + def new(self, document_type: type[Document]) -> Document: + """Create a new document of the given type, and show the document window. + + :param document_type: The document type that has been requested. + :returns: The newly created document. + """ + document = document_type(app=self.app) + document.show() + return document + + async def request_open(self) -> Document: + """Present a dialog asking the user for a document to open, and pass the + selected path to :meth:`open`. + + :returns: The document that was opened. + :raises ValueError: If the path describes a file that is of a type that doesn't + match a registered document type. + """ + # A safety catch: if app modal dialogs aren't actually modal (eg, macOS) prevent + # a second open dialog from being opened when one is already active. Attach the + # dialog instance as a private attribute; delete as soon as the future is + # complete. + if hasattr(self, "_open_dialog"): + return + + # CLOSE_ON_LAST_WINDOW is a proxy for the GTK/Windows behavior of loading content + # into the existing window. This is actually implemented by creating a new window + # and disposing of the old one; mark the current window for cleanup + current_window = self.app.current_window + if self.app._impl.CLOSE_ON_LAST_WINDOW: + if hasattr(self.app.current_window, "_commit"): + if await self.app.current_window._commit(): + current_window._replace = True + else: + # The changes in the current document window couldn't be committed + # (e.g., a save was requested, but then cancelled), so we can't + # proceed with opening a new document. + return + + self._open_dialog = dialogs.OpenFileDialog( + self.app.formal_name, + file_types=( + list(itertools.chain(*(doc_type.extensions for doc_type in self.types))) + if self.types + else None + ), + ) + path = await self.app.dialog(self._open_dialog) + del self._open_dialog + + try: + if path: + return self.open(path) + finally: + # Remove the replacement marker + if hasattr(current_window, "_replace"): + del current_window._replace + + def open(self, path: Path | str) -> Document: + """Open a document in the app, and show the document window. + + If the provided path is already an open document, the existing representation for + the document will be given focus. + + :param path: The path to the document to be opened. + :returns: The document that was opened. + :raises ValueError: If the path describes a file that is of a type that doesn't + match a registered document type. + """ + try: + if sys.version_info < (3, 9): # pragma: no-cover-if-gte-py39 + # resolve() *should* turn the path into an absolute path; + # but on Windows, with Python 3.8, it doesn't. + path = Path(path).absolute().resolve() + else: # pragma: no-cover-if-lt-py39 + path = Path(path).resolve() + document = self.app.documents[path] + document.focus() + return document + except KeyError: + # No existing representation for the document. + try: + DocType = { + extension: doc_type + for doc_type in self.types + for extension in doc_type.extensions + }[path.suffix[1:]] + except KeyError: + raise ValueError( + f"Don't know how to open documents with extension {path.suffix}" + ) + else: + prev_window = self.app.current_window + document = DocType(app=self.app) + try: + document.open(path) + + # If the previous window is marked for replacement, close it; but + # put the new document window in the same position as the previous + # one. + if getattr(prev_window, "_replace", False): + document.main_window.position = prev_window.position + prev_window.close() + + document.show() + return document + except Exception: + # Open failed; ensure any windows opened by the document are closed. + document.main_window.close() + raise + + async def save(self): + """Save the current content of an app. + + If there isn't a current window, or current window doesn't define a ``save()`` + method, the save request will be ignored. + """ + if hasattr(self.app.current_window, "save"): + await self.app.current_window.save() + + async def save_as(self): + """Save the current content of an app under a different filename. + + If there isn't a current window, or the current window hasn't defined a + ``save_as()`` method, the save-as request will be ignored. + """ + if hasattr(self.app.current_window, "save_as"): + await self.app.current_window.save_as() + + async def save_all(self): + """Save the state of all content in the app. + + This method will attempt to call ``save()`` on every window associated with the + app. Any windows that do not provide a ``save()`` method will be ignored. + """ + for window in self.app.windows: + if hasattr(window, "save"): + await window.save() + + +class DocumentWindow(MainWindow): + def __init__(self, doc: Document, *args, **kwargs): + """Create a new document Window. + + A document window is a MainWindow (so it will have a menu bar, and *can* have a + toolbar), bound to a document instance. + + In addition to the required ``doc`` argument, accepts the same arguments as + :class:`~toga.Window`. + + The default ``on_close`` handler will use the document's modification status to + determine if the document has been modified. It will allow the window to close + if the document is fully saved, or the user explicitly declines the opportunity + to save. + + :param doc: The document being managed by this window + """ + self._doc = doc + if "on_close" not in kwargs: + kwargs["on_close"] = self._confirm_close + + super().__init__(*args, **kwargs) + + @property + def doc(self) -> Document: + """The document displayed by this window.""" + return self._doc + + @property + def _default_title(self) -> str: + return self.doc.title + + async def _confirm_close(self, window, **kwargs): + if self.doc.modified: + if await self.dialog( + toga.QuestionDialog( + "Save changes?", + "This document has unsaved changes. Do you want to save these changes?", + ) + ): + return await self.save() + return True + + async def _commit(self): + # Get the window into a state where new content could be opened. + # Used by the open method on GTK/Linux to ensure the current document + # has been saved before closing this window and opening a replacement. + return await self._confirm_close(self) + + def _close(self): + # When then window is closed, remove the document it is managing from the app's + # list of managed documents. + self._app._documents._remove(self.doc) + super()._close() + + async def save(self): + """Save the document associated with this window. + + If the document associated with a window hasn't been saved before, and the + document type defines a :meth:`~toga.Document.write` method, the user will be + prompted to provide a filename. + + :returns: True if the save was successful; False if the save was aborted. + """ + if overridden(self.doc.write): + if self.doc.path: + # Document has been saved previously; save using that filename. + self.doc.save() + return True + else: + return await self.save_as() + return False + + async def save_as(self): + """Save the document associated with this window under a new filename. + + The default implementation will prompt the user for a new filename, then save + the document with that new filename. If the document type doesn't define a + :meth:`~toga.Document.write` method, the save-as request will be ignored. + + :returns: True if the save was successful; False if the save was aborted. + """ + if overridden(self.doc.write): + suggested_path = ( + self.doc.path if self.doc.path else f"Untitled.{self.doc.extensions[0]}" + ) + new_path = await self.dialog( + dialogs.SaveFileDialog("Save as...", suggested_path) + ) + # If a filename has been returned, save using that filename. + # If there isn't a filename, the save was cancelled. + if new_path: + self.doc.save(new_path) + return True + + return False diff --git a/core/src/toga/handlers.py b/core/src/toga/handlers.py index 67c33ede81..ad1bbcd564 100644 --- a/core/src/toga/handlers.py +++ b/core/src/toga/handlers.py @@ -36,6 +36,21 @@ WrappedHandlerT: TypeAlias = Callable[..., object] +def overridable(method): + """Decorate the method as being user-overridable""" + method._overridden = True + return method + + +def overridden(coroutine_or_method): + """Has the user overridden this method? + + This is based on the method *not* having a ``_overridden`` attribute. Overridable + default methods have this attribute; user-defined method will not. + """ + return not hasattr(coroutine_or_method, "_overridden") + + class NativeHandler: def __init__(self, handler: Callable[..., object]): self.native = handler @@ -97,11 +112,15 @@ def simple_handler(fn, *args, **kwargs): required ``command`` argument passed to handlers), so that you can use a method like :meth:`~toga.App.about()` as a command handler. - It can accept either a function or a coroutine. + It can accept either a function or a coroutine. Arguments that will be passed to the + function/coroutine are provided at the time the wrapper is defined. It is assumed + that the mechanism invoking the handler will add no additional arguments other than + the ``command`` that is invoking the handler. + :param fn: The callable to invoke as a handler. - :param args: The arguments to pass to the handler when invoked. - :param kwargs: The keyword arguments to pass to the handler when invoked. + :param args: Positional arguments that should be passed to the invoked handler. + :param kwargs: Keyword arguments that should be passed to the invoked handler. :returns: A handler that will invoke the callable. """ if inspect.iscoroutinefunction(fn): diff --git a/core/src/toga/window.py b/core/src/toga/window.py index 5646658cdc..dbf1f40d75 100644 --- a/core/src/toga/window.py +++ b/core/src/toga/window.py @@ -4,12 +4,7 @@ from builtins import id as identifier from collections.abc import Coroutine, Iterator from pathlib import Path -from typing import ( - TYPE_CHECKING, - Any, - Protocol, - TypeVar, -) +from typing import TYPE_CHECKING, Any, MutableSet, Protocol, TypeVar import toga from toga import dialogs @@ -21,7 +16,6 @@ if TYPE_CHECKING: from toga.app import App - from toga.documents import Document from toga.images import ImageT from toga.screens import Screen from toga.types import PositionT, SizeT @@ -298,22 +292,33 @@ def close(self) -> None: or property on a :class:`~toga.Window` instance after it has been closed is undefined, except for :attr:`closed` which can be used to check if the window was closed. + + :returns: True if the window was actually closed; False if closing the window + triggered ``on_exit`` handling. """ close_window = True if self.app.main_window == self: # Closing the window marked as the main window is a request to exit. - self.app._request_exit() + self.app.request_exit() 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, request an exit. - if len(self.app.windows) == 1 and self.app._impl.CLOSE_ON_LAST_WINDOW: - self.app._request_exit() + # If this is an app without a main window, the app is running, this + # is the last window in the app, and the platform exits on last + # window close, request an exit. + if ( + len(self.app.windows) == 1 + and self.app._impl.CLOSE_ON_LAST_WINDOW + and self.app.loop.is_running() + ): + self.app.request_exit() close_window = False if close_window: self._close() + # Return whether the window was actually closed + return close_window + 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 @@ -845,50 +850,64 @@ def toolbar(self) -> CommandSet: return self._toolbar -class DocumentMainWindow(Window): - _WINDOW_CLASS = "DocumentMainWindow" +class WindowSet(MutableSet[Window]): + def __init__(self, app: App): + """A collection of windows managed by an app. - def __init__( - self, - doc: Document, - id: str | None = None, - title: str | None = None, - position: PositionT = Position(100, 100), - size: SizeT = Size(640, 480), - resizable: bool = True, - minimizable: bool = True, - on_close: OnCloseHandler | None = None, - ): - """Create a new document Main Window. + A window is automatically added to the app when it is created, and removed when + it is closed. Adding a window to an App's window set automatically sets the + :attr:`~toga.Window.app` property of the Window. + """ + self.app = app + self.elements: set[Window] = set() + + def add(self, window: Window) -> None: + if not isinstance(window, Window): + raise TypeError("Can only add objects of type toga.Window") + # Silently not add if duplicate + if window not in self.elements: + self.elements.add(window) + window.app = self.app + + def discard(self, window: Window) -> None: + if not isinstance(window, Window): + raise TypeError("Can only discard objects of type toga.Window") + if window not in self.elements: + raise ValueError(f"{window!r} is not part of this app") + self.elements.remove(window) - This installs a default on_close handler that honors platform-specific document - closing behavior. If you want to control whether a document is allowed to close - (e.g., due to having unsaved change), override - :meth:`toga.Document.can_close()`, rather than implementing an on_close handler. + ###################################################################### + # 2023-10: Backwards compatibility + ###################################################################### - :param doc: The document being managed by this window - :param id: The ID of the window. - :param title: Title for the window. Defaults to the formal name of the app. - :param position: Position of the window, as a :any:`toga.Position` or tuple of - ``(x, y)`` coordinates. - :param size: Size of the window, as a :any:`toga.Size` or tuple of - ``(width, height)``, in pixels. - :param resizable: Can the window be manually resized by the user? - :param minimizable: Can the window be minimized by the user? - :param on_close: The initial :any:`on_close` handler. - """ - self.doc = doc - super().__init__( - id=id, - title=title, - position=position, - size=size, - resizable=resizable, - closable=True, - minimizable=minimizable, - on_close=doc.handle_close if on_close is None else on_close, + def __iadd__(self, window: Window) -> WindowSet: + # The standard set type does not have a += operator. + warnings.warn( + "Windows are automatically associated with the app; += is not required", + DeprecationWarning, + stacklevel=2, ) + return self - @property - def _default_title(self) -> str: - return self.doc.path.name + def __isub__(self, other: Window) -> WindowSet: + # The standard set type does have a -= operator, but it takes sets rather than + # individual items. + warnings.warn( + "Windows are automatically removed from the app; -= is not required", + DeprecationWarning, + stacklevel=2, + ) + return self + + ###################################################################### + # End backwards compatibility + ###################################################################### + + def __iter__(self) -> Iterator[Window]: + return iter(self.elements) + + def __contains__(self, value: object) -> bool: + return value in self.elements + + def __len__(self) -> int: + return len(self.elements) diff --git a/core/tests/app/test_app.py b/core/tests/app/test_app.py index 5ee554e8d4..7b0914c774 100644 --- a/core/tests/app/test_app.py +++ b/core/tests/app/test_app.py @@ -532,7 +532,7 @@ def test_startup_method(event_loop): def startup_assertions(app): # At time startup is invoked, the default commands are installed - assert len(app.commands) == 3 + assert len(app.commands) == 2 return toga.Box() startup = Mock(side_effect=startup_assertions) @@ -550,8 +550,8 @@ def startup_assertions(app): assert_action_performed(app.main_window, "create Window menus") assert_action_performed(app.main_window, "create toolbar") - # 3 menu items have been created - assert len(app.commands) == 3 + # 2 menu items have been created + assert len(app.commands) == 2 # The app has a main window that is a MainWindow assert isinstance(app.main_window, toga.MainWindow) @@ -565,7 +565,7 @@ def startup(self): self.main_window = toga.MainWindow() # At time startup is invoked, the default commands are installed - assert len(self.commands) == 3 + assert len(self.commands) == 2 # Add an extra user command self.commands.add(toga.Command(None, "User command")) @@ -581,8 +581,8 @@ def startup(self): assert_action_performed(app.main_window, "create Window menus") assert_action_performed(app.main_window, "create toolbar") - # 4 menu items have been created - assert app._impl.n_menu_items == 4 + # 3 menu items have been created + assert app._impl.n_menu_items == 3 def test_startup_subclass_no_main_window(event_loop): @@ -661,8 +661,8 @@ def test_exit_direct(app): def test_exit_no_handler(app): """An app without an exit handler can be exited.""" - # Exit the app - app._impl.simulate_exit() + # Request an app exit + app.request_exit() # Window has been exited, and is no longer in the app's list of windows. assert_action_performed(app, "exit") @@ -682,8 +682,8 @@ def on_exit(self): app = SubclassedApp(formal_name="Test App", app_id="org.example.test") - # Close the app - app._impl.simulate_exit() + # Request an app exit + app.request_exit() # The exit method was invoked assert exit["called"] @@ -697,8 +697,8 @@ def test_exit_successful_handler(app): on_exit_handler = Mock(return_value=True) app.on_exit = on_exit_handler - # Close the app - app._impl.simulate_exit() + # Request an app exit + app.request_exit() # App has been exited assert_action_performed(app, "exit") @@ -710,8 +710,8 @@ def test_exit_rejected_handler(app): on_exit_handler = Mock(return_value=False) app.on_exit = on_exit_handler - # Close the window - app._impl.simulate_exit() + # Request an app exit + app.request_exit() # App has been *not* exited assert_action_not_performed(app, "exit") diff --git a/core/tests/app/test_customized_app.py b/core/tests/app/test_customized_app.py deleted file mode 100644 index a937a1ca14..0000000000 --- a/core/tests/app/test_customized_app.py +++ /dev/null @@ -1,76 +0,0 @@ -import asyncio -from unittest.mock import Mock - -import pytest - -import toga - - -class CustomizedApp(toga.App): - def startup(self): - self.main_window = toga.MainWindow() - # Create a secondary simple window as part of app startup to verify - # that toolbar handling is skipped. - self.other_window = toga.Window() - - self._preferences = Mock() - - def preferences(self): - self._preferences() - - -class AsyncCustomizedApp(CustomizedApp): - # A custom app where preferences and document-management commands are user-defined - # as async handlers. - - async def preferences(self): - self._preferences() - - -@pytest.mark.parametrize( - "AppClass", - [ - CustomizedApp, - AsyncCustomizedApp, - ], -) -def test_create(event_loop, AppClass): - """An app with overridden commands can be created""" - custom_app = AppClass("Custom App", "org.beeware.customized-app") - - assert custom_app.formal_name == "Custom App" - assert custom_app.app_id == "org.beeware.customized-app" - assert custom_app.app_name == "customized-app" - - # The default implementations of the on_running and on_exit handlers - # have been wrapped as simple handlers - assert custom_app.on_running._raw.__func__ == toga.App.on_running - assert custom_app.on_exit._raw.__func__ == toga.App.on_exit - - # About menu item exists and is disabled - assert toga.Command.ABOUT in custom_app.commands - assert custom_app.commands[toga.Command.ABOUT].enabled - - # Preferences exist and are enabled - assert toga.Command.PREFERENCES in custom_app.commands - assert custom_app.commands[toga.Command.PREFERENCES].enabled - - # A change handler has been added to the MainWindow's toolbar CommandSet - assert custom_app.main_window.toolbar.on_change is not None - - -@pytest.mark.parametrize( - "AppClass", - [ - CustomizedApp, - AsyncCustomizedApp, - ], -) -def test_preferences_menu(event_loop, AppClass): - """The custom preferences method is activated by the preferences menu""" - custom_app = AppClass("Custom App", "org.beeware.customized-app") - - result = custom_app.commands[toga.Command.PREFERENCES].action() - if asyncio.isfuture(result): - custom_app.loop.run_until_complete(result) - custom_app._preferences.assert_called_once_with() diff --git a/core/tests/app/test_document_app.py b/core/tests/app/test_document_app.py index 5e8b51c273..1cae91a399 100644 --- a/core/tests/app/test_document_app.py +++ b/core/tests/app/test_document_app.py @@ -1,41 +1,92 @@ import sys -from pathlib import Path +from unittest.mock import Mock import pytest import toga from toga_dummy.app import App as DummyApp +from toga_dummy.command import Command as DummyCommand from toga_dummy.utils import ( + EventLog, assert_action_not_performed, assert_action_performed, ) class ExampleDocument(toga.Document): - def __init__(self, path, app): - super().__init__(path=path, document_type="Example Document", app=app) + description = "Example Document" + extensions = ["foobar", "fbr"] + read_error = None def create(self): - self.main_window = toga.DocumentMainWindow(self) + self.main_window = toga.DocumentWindow(self) + self._mock_read = Mock() + self._mock_write = Mock() def read(self): - self.content = self.path + if self.read_error: + # If the object has a "read_error" attribute, raise that exception + raise self.read_error + else: + # We don't actually care about the file or it's contents, but it needs to exist; + # so we open it to verify that behavior. + with self.path.open(): + self._mock_read(self.path) + def write(self): + # We don't actually care about the file or it's contents. + self._mock_write(self.path) -class ExampleDocumentApp(toga.DocumentApp): + +class OtherDocument(toga.Document): + description = "Other Document" + extensions = ["other"] + read_error = None + + def create(self): + self.main_window = toga.DocumentWindow(self) + + def read(self): + if self.read_error: + # If the object has a "read_error" attribute, raise that exception + raise self.read_error + + +@pytest.fixture +def example_file(tmp_path): + """Create an actual file with the .foobar extension""" + path = tmp_path / "path/to/filename.foobar" + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w") as f: + f.write("Dummy content") + + return path + + +@pytest.fixture +def other_file(tmp_path): + """Create an actual file with the .other extension""" + path = tmp_path / "path/to/other.other" + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w") as f: + f.write("Dummy content") + + return path + + +class ExampleDocumentApp(toga.App): def startup(self): self.main_window = None @pytest.fixture -def doc_app(event_loop): +def doc_app(monkeypatch, event_loop, example_file): + # Create an instance of an ExampleDocumentApp that has 1 file open. + monkeypatch.setattr(sys, "argv", ["app-exe", str(example_file)]) app = ExampleDocumentApp( "Test App", "org.beeware.document-app", - document_types={ - # Register ExampleDocument - "foobar": ExampleDocument, - }, + document_types=[ExampleDocument, OtherDocument], ) # The app will have a single window; set this window as the current window # so that dialogs have something to hang off. @@ -43,171 +94,971 @@ def doc_app(event_loop): return app -def test_create_no_cmdline(monkeypatch): - """A document app can be created with no command line.""" +def test_create_no_cmdline_no_document_types(monkeypatch): + """A app without document types and no windows raises an error.""" monkeypatch.setattr(sys, "argv", ["app-exe"]) with pytest.raises( - ValueError, - match=r"App doesn't define any initial windows.", + RuntimeError, + match=r"App didn't create any windows, or register any document types.", ): ExampleDocumentApp( "Test App", "org.beeware.document-app", - document_types={"foobar": ExampleDocument}, ) -def test_create_with_cmdline(monkeypatch): +def test_create_no_cmdline(monkeypatch): + """A document app can be created with no command line.""" + monkeypatch.setattr(sys, "argv", ["app-exe"]) + + app = ExampleDocumentApp( + "Test App", + "org.beeware.document-app", + document_types=[ExampleDocument, OtherDocument], + ) + + # An untitled document has been created + assert len(app.documents) == 1 + assert isinstance(app.documents[0], ExampleDocument) + assert app.documents[0].title == "Example Document: Untitled" + + # Document window has been created and shown + assert len(app.windows) == 1 + assert list(app.windows)[0] == app.documents[0].main_window + assert_action_performed(app.documents[0].main_window, "create MainWindow") + assert_action_performed(app.documents[0].main_window, "show") + + # Menus and commands have been created + assert_action_performed(app, "create App commands") + assert_action_performed(app, "create App menus") + + # 8 menu items have been created (About, Exit, plus document management cmds). + assert app._impl.n_menu_items == 8 + assert toga.Command.NEW in app.commands + assert app.commands[toga.Command.NEW].text == "New Example Document" + assert app.commands[toga.Command.NEW].shortcut is not None + assert app.commands[toga.Command.NEW].order == 0 + + assert f"{toga.Command.NEW}:other" in app.commands + assert app.commands[f"{toga.Command.NEW}:other"].text == "New Other Document" + assert app.commands[f"{toga.Command.NEW}:other"].shortcut is None + assert app.commands[f"{toga.Command.NEW}:other"].order == 1 + + assert toga.Command.OPEN in app.commands + assert toga.Command.SAVE in app.commands + assert toga.Command.SAVE_AS in app.commands + assert toga.Command.SAVE_ALL in app.commands + + +def test_create_no_cmdline_default_handling(monkeypatch): + """If the backend uses the app's command line handling, no error is raised for an + empty command line.""" + monkeypatch.setattr(sys, "argv", ["app-exe"]) + + # Monkeypatch the property that makes the backend handle command lines + monkeypatch.setattr(DummyApp, "HANDLES_COMMAND_LINE", True) + + app = ExampleDocumentApp( + "Test App", + "org.beeware.document-app", + document_types=[ExampleDocument], + ) + + assert app._impl.interface == app + assert_action_performed(app, "create App") + + assert app.documents.types == [ExampleDocument] + + # No documents or windows exist + assert len(app.documents) == 0 + assert len(app.windows) == 0 + + +def test_create_with_cmdline(monkeypatch, example_file): """If a document is specified at the command line, it is opened.""" - monkeypatch.setattr(sys, "argv", ["app-exe", "/path/to/filename.foobar"]) + monkeypatch.setattr(sys, "argv", ["app-exe", str(example_file)]) app = ExampleDocumentApp( "Test App", "org.beeware.document-app", - document_types={"foobar": ExampleDocument}, + document_types=[ExampleDocument], ) - app.main_loop() assert app._impl.interface == app - assert_action_performed(app, "create DocumentApp") + assert_action_performed(app, "create App") + + assert app.documents.types == [ExampleDocument] - assert app.document_types == {"foobar": ExampleDocument} + # The document is registered assert len(app.documents) == 1 assert isinstance(app.documents[0], ExampleDocument) + assert app.documents[0].title == "Example Document: filename" # Document content has been read - assert app.documents[0].content == Path("/path/to/filename.foobar") + app.documents[0]._mock_read.assert_called_once_with(example_file) # Document window has been created and shown - assert_action_performed(app.documents[0].main_window, "create DocumentMainWindow") + assert len(app.windows) == 1 + assert list(app.windows)[0] == app.documents[0].main_window + assert_action_performed(app.documents[0].main_window, "create MainWindow") assert_action_performed(app.documents[0].main_window, "show") + # 7 menu items have been created (About, Exit, plus document management cmds). + # There's only one document type, so there's a single New command + assert app._impl.n_menu_items == 7 + assert toga.Command.NEW in app.commands + assert app.commands[toga.Command.NEW].text == "New" + assert toga.Command.OPEN in app.commands + assert toga.Command.SAVE in app.commands + assert toga.Command.SAVE_AS in app.commands + assert toga.Command.SAVE_ALL in app.commands + -def test_create_with_unknown_document_type(monkeypatch): - """If the document specified at the command line is an unknown type, an exception is - raised.""" +def test_create_with_unknown_document_type(monkeypatch, capsys): + """If the document specified at the command line is an unknown type, it is ignored.""" monkeypatch.setattr(sys, "argv", ["app-exe", "/path/to/filename.unknown"]) - with pytest.raises( - ValueError, - match=r"Don't know how to open documents of type .unknown", - ): - ExampleDocumentApp( - "Test App", - "org.beeware.document-app", - document_types={"foobar": ExampleDocument}, - ) + app = ExampleDocumentApp( + "Test App", + "org.beeware.document-app", + document_types=[ExampleDocument], + ) + stdout = capsys.readouterr().out + assert "Don't know how to open documents with extension .unknown" in stdout -def test_create_no_document_type(): - """A document app must manage at least one document type.""" - with pytest.raises( - ValueError, - match=r"A document must manage at least one document type.", - ): - toga.DocumentApp("Test App", "org.beeware.document-app") + # An untitled document has been created + assert len(app.documents) == 1 + assert isinstance(app.documents[0], ExampleDocument) + assert app.documents[0].title == "Example Document: Untitled" + # Document window has been created and shown + assert len(app.windows) == 1 + assert list(app.windows)[0] == app.documents[0].main_window + assert_action_performed(app.documents[0].main_window, "create MainWindow") + assert_action_performed(app.documents[0].main_window, "show") -def test_create_no_windows_non_persistent(event_loop): - """Non-persistent apps must define at least one window in startup.""" - class NoWindowApp(toga.App): - def startup(self): - self.main_window = None +def test_create_with_missing_file(monkeypatch, capsys): + """If the document specified at the command line is a known type, but not present, + an error is logged.""" + monkeypatch.setattr(sys, "argv", ["app-exe", "/path/to/filename.foobar"]) - with pytest.raises( - ValueError, - match=r"App doesn't define any initial windows.", - ): - NoWindowApp(formal_name="Test App", app_id="org.example.test") + app = ExampleDocumentApp( + "Test App", + "org.beeware.document-app", + document_types=[ExampleDocument], + ) + stdout = capsys.readouterr().out + assert "Document /path/to/filename.foobar not found" in stdout -def test_create_no_windows_persistent(monkeypatch, event_loop): - """Persistent apps do not have to define windows during startup.""" - # Monkeypatch the property that makes the backend persistent - monkeypatch.setattr(DummyApp, "CLOSE_ON_LAST_WINDOW", False) + # An untitled document has been created + assert len(app.documents) == 1 + assert isinstance(app.documents[0], ExampleDocument) + assert app.documents[0].title == "Example Document: Untitled" - class NoWindowApp(toga.App): - def startup(self): - self.main_window = None + # Document window has been created and shown + assert len(app.windows) == 1 + assert list(app.windows)[0] == app.documents[0].main_window + assert_action_performed(app.documents[0].main_window, "create MainWindow") + assert_action_performed(app.documents[0].main_window, "show") + + +def test_create_with_bad_file(monkeypatch, example_file, capsys): + """If an error occurs reading the document, an error is logged is raised.""" + monkeypatch.setattr(sys, "argv", ["app-exe", str(example_file)]) + # Mock a reading error. + monkeypatch.setattr( + ExampleDocument, "read_error", ValueError("Bad file. No cookie.") + ) + + app = ExampleDocumentApp( + "Test App", + "org.beeware.document-app", + document_types=[ExampleDocument], + ) + + stdout = capsys.readouterr().out + assert "filename.foobar: Bad file. No cookie.\n" in stdout + + # An untitled document has been created + assert len(app.documents) == 1 + assert isinstance(app.documents[0], ExampleDocument) + assert app.documents[0].title == "Example Document: Untitled" + + # Document window has been created and shown + assert len(app.windows) == 1 + assert list(app.windows)[0] == app.documents[0].main_window + assert_action_performed(app.documents[0].main_window, "create MainWindow") + assert_action_performed(app.documents[0].main_window, "show") + + +def test_no_backend_new_support(monkeypatch, example_file): + """If the backend doesn't define support for new, the commands are not created.""" + orig_standard = DummyCommand.standard + + def mock_standard(app, id): + if id == toga.Command.NEW: + return None + return orig_standard(app, id) + + # Monkeypatch the backend to *not* create the new command + monkeypatch.setattr(DummyCommand, "standard", mock_standard) + + # Mock the command line to open a file. + monkeypatch.setattr(sys, "argv", ["app-exe", str(example_file)]) + + app = ExampleDocumentApp( + "Test App", + "org.beeware.document-app", + document_types=[ExampleDocument], + ) + + # Menus and commands have been created + assert_action_performed(app, "create App commands") + assert_action_performed(app, "create App menus") - # We can create the app without an error - NoWindowApp(formal_name="Test App", app_id="org.example.test") + # 6 menu items have been created (About and Exit). File management + # commands exist, *except* for NEW + assert app._impl.n_menu_items == 6 + assert toga.Command.NEW not in app.commands + assert toga.Command.OPEN in app.commands + assert toga.Command.SAVE in app.commands + assert toga.Command.SAVE_AS in app.commands + assert toga.Command.SAVE_ALL in app.commands -def test_close_last_document_non_persistent(monkeypatch): +def test_no_backend_other_support(monkeypatch, example_file): + """If the backend doesn't define support for other document commands, those commands + not are created.""" + orig_standard = DummyCommand.standard + + def mock_standard(app, id): + if id == toga.Command.OPEN: + return None + return orig_standard(app, id) + + # Monkeypatch the backend to *not* create the open command + monkeypatch.setattr(DummyCommand, "standard", mock_standard) + + # Mock the command line to open a file. + monkeypatch.setattr(sys, "argv", ["app-exe", str(example_file)]) + + app = ExampleDocumentApp( + "Test App", + "org.beeware.document-app", + document_types=[ExampleDocument], + ) + + # Menus and commands have been created + assert_action_performed(app, "create App commands") + assert_action_performed(app, "create App menus") + + # 6 menu items have been created (About and Exit). File management + # commands exist, *except* for Open + assert app._impl.n_menu_items == 6 + assert toga.Command.NEW in app.commands + assert toga.Command.OPEN not in app.commands + assert toga.Command.SAVE in app.commands + assert toga.Command.SAVE_AS in app.commands + assert toga.Command.SAVE_ALL in app.commands + + +def test_close_last_document_non_persistent(monkeypatch, example_file, other_file): """Non-persistent apps exit when the last document is closed""" - monkeypatch.setattr(sys, "argv", ["app-exe", "/path/to/example.foobar"]) + monkeypatch.setattr(sys, "argv", ["app-exe", str(example_file)]) app = ExampleDocumentApp( "Test App", "org.beeware.document-app", - document_types={"foobar": ExampleDocument}, + document_types=[ExampleDocument, OtherDocument], ) - # Create a second window - # TODO: Use the document interface for this - # app.open(other_file) - _ = toga.Window() + + # Create a second document window + app.documents.open(other_file) # There are 2 open documents - # assert len(app.documents) == 2 + assert len(app.documents) == 2 assert len(app.windows) == 2 - # Close the first document window - list(app.windows)[0].close() + # Close the first document window (in a running app loop) + async def close_window(app): + list(app.windows)[0].close() + + app.loop.run_until_complete(close_window(app)) # One document window closed. - # assert len(app.documents) == 1 + assert len(app.documents) == 1 assert len(app.windows) == 1 # App hasn't exited assert_action_not_performed(app, "exit") - # Close the last remaining document window - list(app.windows)[0].close() + # Close the first document window (in a running app loop) + app.loop.run_until_complete(close_window(app)) # App has now exited assert_action_performed(app, "exit") -def test_close_last_document_persistent(monkeypatch): +def test_close_last_document_persistent(monkeypatch, example_file, other_file): """Persistent apps don't exit when the last document is closed""" # Monkeypatch the property that makes the backend persistent monkeypatch.setattr(DummyApp, "CLOSE_ON_LAST_WINDOW", False) - monkeypatch.setattr(sys, "argv", ["app-exe", "/path/to/example.foobar"]) + monkeypatch.setattr(sys, "argv", ["app-exe", str(example_file)]) app = ExampleDocumentApp( "Test App", "org.beeware.document-app", - document_types={"foobar": ExampleDocument}, + document_types=[ExampleDocument, OtherDocument], ) - # Create a second window - # TODO: Use the document interface for this - # app.open(other_file) - _ = toga.Window() + + # Create a second document window + app.documents.open(other_file) # There are 2 open documents - # assert len(app.documents) == 2 + assert len(app.documents) == 2 assert len(app.windows) == 2 - # Close the first document window - list(app.windows)[0].close() + # Close the first document window (in a running app loop) + async def close_window(app): + list(app.windows)[0].close() + + app.loop.run_until_complete(close_window(app)) # One document window closed. - # assert len(app.documents) == 1 + assert len(app.documents) == 1 assert len(app.windows) == 1 # App hasn't exited assert_action_not_performed(app, "exit") # Close the last remaining document window - list(app.windows)[0].close() + app.loop.run_until_complete(close_window(app)) # No document windows. - # assert len(app.documents) == 0 + assert len(app.documents) == 0 assert len(app.windows) == 0 # App still hasn't exited assert_action_not_performed(app, "exit") + + +def test_open_missing_file(doc_app): + """Attempting to read a missing file of a known type raises an error.""" + with pytest.raises(FileNotFoundError): + doc_app.documents.open("/does/not/exist.foobar") + + # Only the original document and window exists + assert len(doc_app.documents) == 1 + assert len(doc_app.windows) == 1 + + +def test_open_bad_file(monkeypatch, doc_app, other_file): + """If an error occurs reading the document, an error is logged is raised.""" + + # Mock a reading error. + monkeypatch.setattr(OtherDocument, "read_error", ValueError("Bad file. No cookie.")) + + with pytest.raises(ValueError, match=r"Bad file. No cookie."): + doc_app.documents.open(other_file) + + # Only the original document and window exists + assert len(doc_app.documents) == 1 + assert len(doc_app.windows) == 1 + + +def test_open_existing_file(doc_app, example_file, other_file): + """If a document is already open, the existing document instance is returned and focused.""" + # Only the original document and window exists + assert len(doc_app.documents) == 1 + assert len(doc_app.windows) == 1 + + # Retrieve the existing document by filename. + example_doc = doc_app.documents[example_file] + + # Open a new document. + other_doc = doc_app.documents.open(other_file) + + # There are now 2 documents, each with a window + assert len(doc_app.documents) == 2 + assert len(doc_app.windows) == 2 + + assert other_doc in doc_app.documents + + EventLog.reset() + + # Open the example doc + repeat_example_doc = doc_app.documents.open(example_file) + + # There are still only 2 documents + assert len(doc_app.documents) == 2 + assert len(doc_app.windows) == 2 + + assert repeat_example_doc == example_doc + assert doc_app.current_window == example_doc.main_window + + +def test_new_menu(doc_app): + """The new method is activated by the new menu.""" + doc_app.commands[toga.Command.NEW.format("foobar")].action() + + # There are now 2 documents, and 2 windows + assert len(doc_app.documents) == 2 + assert len(doc_app.windows) == 2 + + # The second document is the one we just loaded + new_doc = doc_app.documents[1] + assert new_doc.path is None + assert new_doc.title == "Example Document: Untitled" + assert new_doc.main_window.doc == new_doc + assert new_doc.main_window in doc_app.windows + + +def test_open_menu(doc_app, example_file, other_file): + """The open menu item replaces the current document window.""" + # There is initially 1 document, and 1 window + assert len(doc_app.documents) == 1 + assert len(doc_app.windows) == 1 + assert doc_app.documents[0].path == example_file + orig_position = doc_app.documents[0].main_window.position + + # Select the other file as the new document + doc_app._impl.dialog_responses["OpenFileDialog"] = [other_file] + + future = doc_app.commands[toga.Command.OPEN].action() + doc_app.loop.run_until_complete(future) + + # There is still only 1 document, and 1 window + assert len(doc_app.documents) == 1 + assert len(doc_app.windows) == 1 + + # The second document is the one we just loaded + new_doc = doc_app.documents[-1] + assert new_doc.path == other_file + assert new_doc.main_window.doc == new_doc + assert new_doc.main_window in doc_app.windows + + # New window is in the same position as the old one. + assert new_doc.main_window.position == orig_position + + +def test_open_menu_save_existing(doc_app, example_file, other_file): + """The user can choose to save existing changes before opening a new file.""" + # There is initially 1 document, and 1 window + assert len(doc_app.documents) == 1 + assert len(doc_app.windows) == 1 + example_doc = doc_app.documents[0] + assert example_doc.path == example_file + orig_position = doc_app.documents[0].main_window.position + + # Mark document 1 as modified; approve the save, and open other_file + example_doc.touch() + example_doc.main_window._impl.dialog_responses["QuestionDialog"] = [True] + doc_app._impl.dialog_responses["OpenFileDialog"] = [other_file] + + future = doc_app.commands[toga.Command.OPEN].action() + doc_app.loop.run_until_complete(future) + + # There is still only 1 document, and 1 window + assert len(doc_app.documents) == 1 + assert len(doc_app.windows) == 1 + + # The second document is the one we just loaded + new_doc = doc_app.documents[-1] + assert new_doc.path == other_file + assert new_doc.main_window.doc == new_doc + assert new_doc.main_window in doc_app.windows + + # New window is in the same position as the old one. + assert new_doc.main_window.position == orig_position + + # The example doc has been saved + assert not example_doc.modified + + +def test_open_menu_no_save_existing(doc_app, example_file, other_file): + """The user can choose not to save existing changes before opening a new file.""" + # There is initially 1 document, and 1 window + assert len(doc_app.documents) == 1 + assert len(doc_app.windows) == 1 + example_doc = doc_app.documents[0] + assert example_doc.path == example_file + orig_position = doc_app.documents[0].main_window.position + + # Mark document 1 as modified; don't save, and open other_file + example_doc.touch() + example_doc.main_window._impl.dialog_responses["QuestionDialog"] = [False] + doc_app._impl.dialog_responses["OpenFileDialog"] = [other_file] + + future = doc_app.commands[toga.Command.OPEN].action() + doc_app.loop.run_until_complete(future) + + # There is still only 1 document, and 1 window + assert len(doc_app.documents) == 1 + assert len(doc_app.windows) == 1 + + # The second document is the one we just loaded + new_doc = doc_app.documents[-1] + assert new_doc.path == other_file + assert new_doc.main_window.doc == new_doc + assert new_doc.main_window in doc_app.windows + + # New window is in the same position as the old one. + assert new_doc.main_window.position == orig_position + + # The example doc has not been saved + assert example_doc.modified + + +def test_open_menu_cancel_save_existing(doc_app, example_file, other_file): + """If the user cancels the save of existing changes, a new file isn't opened.""" + # There is initially 1 document, and 1 window + assert len(doc_app.documents) == 1 + assert len(doc_app.windows) == 1 + example_doc = doc_app.documents[0] + + # Make Document 1 an untitled, unsaved document. + example_doc._path = None + + # Mark document 1 as modified; say we want to save, but cancel that save + example_doc.touch() + example_doc.main_window._impl.dialog_responses["QuestionDialog"] = [True] + example_doc.main_window._impl.dialog_responses["SaveFileDialog"] = [None] + + future = doc_app.commands[toga.Command.OPEN].action() + doc_app.loop.run_until_complete(future) + + # There is still only 1 document, and 1 window + assert len(doc_app.documents) == 1 + assert len(doc_app.windows) == 1 + + # The open document is still the example doc + assert doc_app.documents[-1] == example_doc + + # The example doc has not been saved + assert example_doc.modified + + +def test_open_menu_no_replace(monkeypatch, doc_app, example_file, other_file): + """If the backend doesn't close on last window, open creates a new window.""" + # Monkeypatch the property that makes the backend persistent + monkeypatch.setattr(DummyApp, "CLOSE_ON_LAST_WINDOW", False) + + # There is initially 1 document, and 1 window + assert len(doc_app.documents) == 1 + assert len(doc_app.windows) == 1 + example_doc = doc_app.documents[0] + assert example_doc.path == example_file + orig_position = doc_app.documents[0].main_window.position + + # Select the other file as the new document + doc_app._impl.dialog_responses["OpenFileDialog"] = [other_file] + + future = doc_app.commands[toga.Command.OPEN].action() + doc_app.loop.run_until_complete(future) + + # There are now 2 documents, and 2 windows + assert len(doc_app.documents) == 2 + assert len(doc_app.windows) == 2 + assert doc_app.documents[0].path == example_file + assert doc_app.documents[1].path == other_file + + # The second document is the one we just loaded + new_doc = doc_app.documents[-1] + assert new_doc.path == other_file + assert new_doc.main_window.doc == new_doc + assert new_doc.main_window in doc_app.windows + + # New window is *not* in the same position as the old one. + assert new_doc.main_window.position != orig_position + + +def test_open_menu_cancel(doc_app): + """The open menu action can be cancelled by not selecting a file.""" + doc_app._impl.dialog_responses["OpenFileDialog"] = [None] + + future = doc_app.commands[toga.Command.OPEN].action() + doc_app.loop.run_until_complete(future) + + # No second window was opened + assert len(doc_app.documents) == 1 + assert len(doc_app.windows) == 1 + + # The replace attribute has been removed. + assert not hasattr(doc_app.documents[0].main_window, "_replace") + + +def test_open_menu_cancel_no_replace(monkeypatch, doc_app): + """If the replace attribute was never set, it won't be removed.""" + # Monkeypatch the property that makes the backend persistent + monkeypatch.setattr(DummyApp, "CLOSE_ON_LAST_WINDOW", False) + + doc_app._impl.dialog_responses["OpenFileDialog"] = [None] + + future = doc_app.commands[toga.Command.OPEN].action() + doc_app.loop.run_until_complete(future) + + # No second window was opened + assert len(doc_app.documents) == 1 + assert len(doc_app.windows) == 1 + + # The replace attribute has been removed. + assert not hasattr(doc_app.documents[0].main_window, "_replace") + + +def test_open_menu_duplicate(doc_app, example_file): + """The open menu is modal.""" + # Mock a pre-existing open dialog + doc_app.documents._open_dialog = Mock() + + # Activate the open dialog a second time. + future = doc_app.commands[toga.Command.OPEN].action() + + doc_app.loop.run_until_complete(future) + + # There is still only one document + assert len(doc_app.documents) == 1 + assert len(doc_app.windows) == 1 + + +def test_open_menu_read_fail(monkeypatch, doc_app, example_file, other_file): + """If the new file open fails, the existing window won't be cleaned up.""" + # Mock a reading error. + monkeypatch.setattr(OtherDocument, "read_error", ValueError("Bad file. No cookie.")) + + doc_app._impl.dialog_responses["OpenFileDialog"] = [other_file] + + future = doc_app.commands[toga.Command.OPEN].action() + doc_app.loop.run_until_complete(future) + + # No second window was opened; the open window is the old file. + assert len(doc_app.documents) == 1 + assert len(doc_app.windows) == 1 + assert doc_app.documents[0].path == example_file + + # The replace attribute has been removed. + assert not hasattr(doc_app.documents[0].main_window, "_replace") + + +def test_open_non_document_window(doc_app, example_file, other_file): + """If the current window isn't a document window, commit/cleanup behavior isn't used.""" + # Make a non-document window current. + non_doc_window = toga.Window(title="Not a Document", content=toga.Box()) + non_doc_window.show() + doc_app.current_window = non_doc_window + + # There is 1 document, but 2 windows + assert len(doc_app.documents) == 1 + assert len(doc_app.windows) == 2 + assert doc_app.documents[0].path == example_file + + # Open a new file. + doc_app._impl.dialog_responses["OpenFileDialog"] = [other_file] + + future = doc_app.commands[toga.Command.OPEN].action() + doc_app.loop.run_until_complete(future) + + # There is now 2 documents, and 3 windows + assert len(doc_app.documents) == 2 + assert len(doc_app.windows) == 3 + assert doc_app.documents[0].path == example_file + assert doc_app.documents[1].path == other_file + + # There are no replace attributes. + assert not hasattr(doc_app.documents[0].main_window, "_replace") + assert not hasattr(non_doc_window, "_replace") + + +def test_save_menu(doc_app, example_file): + """The save method is activated by the save menu.""" + current_window = doc_app.current_window + first_doc = current_window.doc + assert first_doc.path == example_file + + # Open a second new document + second_doc = doc_app.documents.new(ExampleDocument) + + # Activate the save menu + future = doc_app.commands[toga.Command.SAVE].action() + doc_app.loop.run_until_complete(future) + + # The first document is the one we saved + first_doc._mock_write.assert_called_once_with(example_file) + second_doc._mock_write.assert_not_called() + + +def test_save_menu_readonly(doc_app, example_file, other_file, tmp_path): + """The save method is a no-op on readonly files.""" + current_window = doc_app.current_window + first_doc = current_window.doc + assert first_doc.path == example_file + + # Open a readonly document, set to be current + second_doc = doc_app.documents.open(other_file) + doc_app.current_window = second_doc.main_window + + # Activate the save menu + future = doc_app.commands[toga.Command.SAVE].action() + doc_app.loop.run_until_complete(future) + + # Second document hasn't changed properties updated + assert second_doc.path == other_file + assert second_doc.title == "Other Document: other" + + +def test_save_menu_untitled(doc_app, example_file, tmp_path): + """The save method can can be activated on an untitled file.""" + current_window = doc_app.current_window + first_doc = current_window.doc + assert first_doc.path == example_file + + # Open a second new document, set to be current + second_doc = doc_app.documents.new(ExampleDocument) + doc_app.current_window = second_doc.main_window + + # Prime the save dialog on the second window + path = tmp_path / "path/to/filename2.foobar" + second_doc.main_window._impl.dialog_responses["SaveFileDialog"] = [path] + + # Activate the save menu + future = doc_app.commands[toga.Command.SAVE].action() + doc_app.loop.run_until_complete(future) + + # The second document is the one we saved + first_doc._mock_write.assert_not_called() + second_doc._mock_write.assert_called_once_with(path) + + # Second document has had properties updated + assert second_doc.path == path + assert second_doc.title == "Example Document: filename2" + + +def test_save_menu_untitled_cancel(doc_app, example_file, tmp_path): + """Saving an untitled file can be cancelled.""" + current_window = doc_app.current_window + first_doc = current_window.doc + assert first_doc.path == example_file + + # Open a second new document, set to be current + second_doc = doc_app.documents.new(ExampleDocument) + doc_app.current_window = second_doc.main_window + + # Prime the save dialog on the second window + second_doc.main_window._impl.dialog_responses["SaveFileDialog"] = [None] + + # Activate the save menu + future = doc_app.commands[toga.Command.SAVE].action() + doc_app.loop.run_until_complete(future) + + # Neither document is saved. + first_doc._mock_write.assert_not_called() + second_doc._mock_write.assert_not_called() + + +def test_save_menu_non_document(doc_app, example_file): + """On a non-document window, save is ignored.""" + current_window = doc_app.current_window + first_doc = current_window.doc + assert first_doc.path == example_file + + # Open a second new document + second_doc = doc_app.documents.new(ExampleDocument) + + # Open a non-document window, and make it current + third_window = toga.Window(title="Not a document") + doc_app.current_window = third_window + + # Activate the save menu + future = doc_app.commands[toga.Command.SAVE].action() + doc_app.loop.run_until_complete(future) + + # No document is saved; the current window isn't a document. + first_doc._mock_write.assert_not_called() + second_doc._mock_write.assert_not_called() + + +def test_save_as_menu(doc_app, example_file, tmp_path): + """The save as method is activated by the save as menu.""" + current_window = doc_app.current_window + first_doc = current_window.doc + assert first_doc.path == example_file + + # Open a second new document + second_doc = doc_app.documents.new(ExampleDocument) + + # Prime the save dialog on the first window + path = tmp_path / "path/to/filename2.foobar" + first_doc.main_window._impl.dialog_responses["SaveFileDialog"] = [path] + + # Activate the Save As menu + future = doc_app.commands[toga.Command.SAVE_AS].action() + doc_app.loop.run_until_complete(future) + + # The first document is the one we saved + first_doc._mock_write.assert_called_once_with(path) + second_doc._mock_write.assert_not_called() + + # First document has had properties updated + assert first_doc.path == path + assert first_doc.title == "Example Document: filename2" + + +def test_save_as_menu_readonly(doc_app, example_file, other_file, tmp_path): + """The save-as method is a no-op on readonly files.""" + current_window = doc_app.current_window + first_doc = current_window.doc + assert first_doc.path == example_file + + # Open a readonly document, set to be current + second_doc = doc_app.documents.open(other_file) + doc_app.current_window = second_doc.main_window + + # Activate the Save As menu + future = doc_app.commands[toga.Command.SAVE_AS].action() + doc_app.loop.run_until_complete(future) + + # Second document hasn't changed properties updated + assert second_doc.path == other_file + assert second_doc.title == "Other Document: other" + + +def test_save_as_menu_untitled(doc_app, example_file, tmp_path): + """The save as method can can be activated on an untitled file.""" + current_window = doc_app.current_window + first_doc = current_window.doc + assert first_doc.path == example_file + + # Open a second new document, set to be current + second_doc = doc_app.documents.new(ExampleDocument) + doc_app.current_window = second_doc.main_window + + # Prime the save dialog on the second window + path = tmp_path / "path/to/filename2.foobar" + second_doc.main_window._impl.dialog_responses["SaveFileDialog"] = [path] + + # Activate the Save As menu + future = doc_app.commands[toga.Command.SAVE_AS].action() + doc_app.loop.run_until_complete(future) + + # The second document is the one we saved + first_doc._mock_write.assert_not_called() + second_doc._mock_write.assert_called_once_with(path) + + # Second document has had properties updated + assert second_doc.path == path + assert second_doc.title == "Example Document: filename2" + + +def test_save_as_menu_cancel(doc_app, example_file, tmp_path): + """A save as request can be cancelled by the user.""" + current_window = doc_app.current_window + first_doc = current_window.doc + assert first_doc.path == example_file + + # Open a second new document + second_doc = doc_app.documents.new(ExampleDocument) + + # Cancel the request to save + first_doc.main_window._impl.dialog_responses["SaveFileDialog"] = [None] + + # Activate the Save As menu + future = doc_app.commands[toga.Command.SAVE_AS].action() + doc_app.loop.run_until_complete(future) + + # Neither document is saved. + first_doc._mock_write.assert_not_called() + second_doc._mock_write.assert_not_called() + + +def test_save_as_menu_non_document(doc_app, example_file): + """On a non-document window, save as is ignored.""" + current_window = doc_app.current_window + first_doc = current_window.doc + assert first_doc.path == example_file + + # Open a second new document + second_doc = doc_app.documents.new(ExampleDocument) + + # Open a non-document window, and make it current + third_window = toga.Window(title="Not a document") + doc_app.current_window = third_window + + # Activate the Save As menu + future = doc_app.commands[toga.Command.SAVE_AS].action() + doc_app.loop.run_until_complete(future) + + # No document is saved; the current window isn't a document. + first_doc._mock_write.assert_not_called() + second_doc._mock_write.assert_not_called() + + +def test_save_all_menu(doc_app, example_file, tmp_path): + """The save all method is activated by the save all menu.""" + current_window = doc_app.current_window + first_doc = current_window.doc + assert first_doc.path == example_file + + # Open a second new document + second_doc = doc_app.documents.new(ExampleDocument) + + # Open a third window, with no document attached + third_window = toga.Window(title="Not a document") + third_window.show() + + # Prime the save dialog on the second window + path = tmp_path / "path/to/filename2.foobar" + second_doc.main_window._impl.dialog_responses["SaveFileDialog"] = [path] + + # Activate the Save All menu + future = doc_app.commands[toga.Command.SAVE_ALL].action() + doc_app.loop.run_until_complete(future) + + # Both documents have been saved + first_doc._mock_write.assert_called_once_with(example_file) + second_doc._mock_write.assert_called_once_with(path) + + # Second document has had properties updated + assert second_doc.path == path + assert second_doc.title == "Example Document: filename2" + + +def test_deprecated_document_app(monkeypatch, event_loop, example_file): + """The deprecated API for creating Document-based apps still works.""" + + class DeprecatedDocumentApp(toga.DocumentApp): + def startup(self): + self.main_window = None + + monkeypatch.setattr(sys, "argv", ["app-exe", str(example_file)]) + + with pytest.warns( + DeprecationWarning, + match=r"toga.DocumentApp is no longer required. Use toga.App instead", + ): + app = DeprecatedDocumentApp( + "Deprecated App", + "org.beeware.deprecated-app", + document_types={ + "foobar": ExampleDocument, + "fbr": ExampleDocument, + "other": OtherDocument, + }, + ) + + # The app has an open document + assert len(app.documents) == 1 + assert isinstance(app.documents[0], ExampleDocument) + + with pytest.warns( + DeprecationWarning, + match=r"App.document_types is deprecated. Use App.documents.types", + ): + assert app.document_types == { + "foobar": ExampleDocument, + "fbr": ExampleDocument, + "other": OtherDocument, + } diff --git a/core/tests/app/test_documentset.py b/core/tests/app/test_documentset.py new file mode 100644 index 0000000000..cc7ef768c1 --- /dev/null +++ b/core/tests/app/test_documentset.py @@ -0,0 +1,102 @@ +from pathlib import Path +from unittest.mock import Mock + +import pytest + +import toga +from toga.app import DocumentSet + + +class ExampleDocument(toga.Document): + document_type = "Example Document" + + def create(self): + self.main_document = Mock() + + def read(self): + pass + + +@pytest.fixture +def document1(app): + doc = ExampleDocument(app) + doc._path = Path.cwd() / "somefile.txt" + return doc + + +@pytest.fixture +def document2(app): + return ExampleDocument(app) + + +def test_create(app): + """An empty documentset can be created.""" + documentset = DocumentSet(app, []) + + assert len(documentset) == 0 + assert list(documentset) == [] + assert documentset.types == [] + + +def test_create_with_types(app): + """An documentset can be created with document types.""" + first_type = Mock(extensions=["first", "1st"]) + second_type = Mock(extensions=["second"]) + documentset = DocumentSet(app, [first_type, second_type]) + + assert len(documentset) == 0 + assert list(documentset) == [] + assert documentset.types == [first_type, second_type] + + +def test_add_discard(app, document1, document2): + """Documents can be added and removed to a documentset.""" + documentset = DocumentSet(app, []) + + # Add a document + documentset._add(document2) + assert len(documentset) == 1 + assert list(documentset) == [document2] + + # Add a second document; iteration order is creation order + documentset._add(document1) + assert len(documentset) == 2 + assert list(documentset) == [document2, document1] + + # Re-add a document that already exists + with pytest.raises(ValueError, match=r"Document is already being managed."): + documentset._add(document1) + + # Remove a document + documentset._remove(document2) + assert len(documentset) == 1 + assert list(documentset) == [document1] + + # Remove a document that isn't in the set + with pytest.raises(ValueError, match=r"Document is not being managed."): + documentset._remove(document2) + + +def test_retrieve(app, document1, document2): + """Documents can be added and removed to a documentset.""" + documentset = DocumentSet(app, []) + documentset._add(document2) + documentset._add(document1) + + # Retrieve by index + assert documentset[0] == document2 + assert documentset[1] == document1 + assert documentset[-2] == document2 + assert documentset[-1] == document1 + + # Retrieve by path + assert documentset[Path.cwd() / "somefile.txt"] == document1 + + # Retrieve by string + assert documentset[str(Path.cwd() / "somefile.txt")] == document1 + + # Retrieve by non-absolute path + assert documentset[Path("somefile.txt")] == document1 + + # Retrieve by non-absolute path string + assert documentset["somefile.txt"] == document1 diff --git a/core/tests/app/test_simpleapp.py b/core/tests/app/test_simpleapp.py index dbabac75c7..accef58abe 100644 --- a/core/tests/app/test_simpleapp.py +++ b/core/tests/app/test_simpleapp.py @@ -15,7 +15,7 @@ def startup(self): self.main_window = toga.Window(title="My App") # At time startup is invoked, the default commands are installed - assert len(self.commands) == 3 + assert len(self.commands) == 2 # Add an extra user command self.commands.add(toga.Command(None, "User command")) @@ -36,8 +36,8 @@ def startup(self): # A simple app has no window menus assert_action_not_performed(app.main_window, "create Window menus") - # 4 menu items have been created - assert app._impl.n_menu_items == 4 + # 3 menu items have been created + assert app._impl.n_menu_items == 3 def test_non_closeable_main_window(event_loop): diff --git a/core/tests/command/test_command.py b/core/tests/command/test_command.py index f0e686c990..32b399ea03 100644 --- a/core/tests/command/test_command.py +++ b/core/tests/command/test_command.py @@ -63,6 +63,43 @@ def test_create(): ) +def test_standard_command(app): + """A standard command can be created.""" + cmd = toga.Command.standard(app, toga.Command.ABOUT) + + assert cmd.text == "About Test App" + assert cmd.shortcut is None + assert cmd.tooltip is None + assert cmd.group == toga.Group.HELP + assert cmd.section == 0 + assert cmd.order == 0 + assert cmd.id == toga.Command.ABOUT + # Connected to the app's about method, as a wrapped simple handler + assert cmd.action._raw._raw == app.about + + +def test_standard_command_override(app): + """A standard command can be created with overrides.""" + action = Mock() + cmd = toga.Command.standard(app, toga.Command.ABOUT, action=action, section=1) + + assert cmd.text == "About Test App" + assert cmd.shortcut is None + assert cmd.tooltip is None + assert cmd.group == toga.Group.HELP + assert cmd.order == 0 + assert cmd.id == toga.Command.ABOUT + # Overrides have been applied + assert cmd.action._raw == action + assert cmd.section == 1 + + +def test_unknown_standard_command(app): + """An unknown standard command raises an exception""" + with pytest.raises(ValueError, match=r"Unknown standard command 'mystery'"): + toga.Command.standard(app, "mystery") + + def test_change_action(): """A command's action can be changed to another handler.""" action1 = Mock() diff --git a/core/tests/command/test_commandset.py b/core/tests/command/test_commandset.py index 89993e4b26..55608e83ed 100644 --- a/core/tests/command/test_commandset.py +++ b/core/tests/command/test_commandset.py @@ -127,6 +127,19 @@ def test_add_clear_with_app(app, change_handler): assert list(app.commands) == [cmd_a, cmd1b, cmd2, cmd1a, cmd_b] +def test_add_missing_command(app): + """Missing commands are ignored by addition.""" + # Make sure the app commands are clear to start with. + app.commands.clear() + + # Put some commands (and some missing commands) into the app + cmd_a = toga.Command(None, text="App command a") + cmd_b = toga.Command(None, text="App command b", order=10) + app.commands.add(cmd_a, None, cmd_b, None) + # The missing commands are ignored. + assert list(app.commands) == [cmd_a, cmd_b] + + @pytest.mark.parametrize("change_handler", [(None), (Mock())]) def test_add_by_existing_id(change_handler): """Commands can be added by ID.""" @@ -342,17 +355,16 @@ def test_default_command_ordering(app): assert [ ( - obj.id + (obj.group.text, obj.id) if isinstance(obj, toga.Command) - else f"---{obj.group.text}---" if isinstance(obj, Separator) else "?" + else "---" if isinstance(obj, Separator) else "?" ) for obj in app.commands ] == [ # App menu - toga.Command.EXIT, + ("*", toga.Command.EXIT), # Help menu - toga.Command.ABOUT, - toga.Command.VISIT_HOMEPAGE, + ("Help", toga.Command.ABOUT), ] diff --git a/core/tests/test_documents.py b/core/tests/test_documents.py index a0da06a9a9..aaf98e36d7 100644 --- a/core/tests/test_documents.py +++ b/core/tests/test_documents.py @@ -1,52 +1,276 @@ +import os from pathlib import Path +from unittest.mock import Mock import pytest import toga +from toga_dummy.utils import assert_action_performed_with class MyDoc(toga.Document): - def __init__(self, path, app): - super().__init__(path, "Dummy Document", app) - pass + description = "My Document" + extensions = ["doc"] def create(self): - pass + self.main_window = Mock(title="Mock Window") + self.content = None + self._mock_content = Mock() + + def read(self): + self.content = "file content" + self._mock_content.read(self.path) + + def write(self): + self._mock_content.write(self.path) + + +class OtherDoc(toga.Document): + description = "Other Document" + extensions = ["other"] + + def create(self): + self.main_window = Mock(title="Mock Window") def read(self): pass -@pytest.mark.parametrize("path", ["/path/to/doc.mydoc", Path("/path/to/doc.mydoc")]) -def test_create_document(app, path): - doc = MyDoc(path, app) +def test_create_document(app): + doc = MyDoc(app) - assert doc.path == Path(path) + assert doc.path is None assert doc.app == app - assert doc.document_type == "Dummy Document" + assert doc.description == "My Document" + assert doc.title == "My Document: Untitled" + # create() has been invoked + assert doc.content is None + assert doc.main_window.title == "Mock Window" -class MyDeprecatedDoc(toga.Document): - def __init__(self, filename, app): - super().__init__( - path=filename, - document_type="Deprecated Document", - app=app, + # Document can be shown + doc.show() + doc.main_window.show.assert_called_once_with() + + # Document can be hidden + doc.hide() + doc.main_window.hide.assert_called_once_with() + + +def test_no_description(event_loop): + """If a document class doesn't define a description, an error is raised.""" + + class BadDoc(toga.Document): + def create(self): + self.main_window = Mock(title="Mock Window") + + def read(self): + pass + + with pytest.raises( + ValueError, + match=r"Document type 'BadDoc' doesn't define a 'descriptions' attribute", + ): + toga.App( + "Test App", + "org.beeware.document-app", + document_types=[MyDoc, BadDoc], ) - def create(self): - pass - def read(self): - pass +def test_no_extensions(event_loop): + """If a document class doesn't define extensions, an error is raised.""" + + class BadDoc(toga.Document): + description = "Bad Document" + def create(self): + self.main_window = Mock(title="Mock Window") -def test_deprecated_names(app): - """Deprecated names still work.""" - doc = MyDeprecatedDoc("/path/to/doc.mydoc", app) + def read(self): + pass - with pytest.warns( - DeprecationWarning, - match=r"Document.filename has been renamed Document.path.", + with pytest.raises( + ValueError, + match=r"Document type 'BadDoc' doesn't define an 'extensions' attribute", ): - assert doc.filename == Path("/path/to/doc.mydoc") + toga.App( + "Test App", + "org.beeware.document-app", + document_types=[MyDoc, BadDoc], + ) + + +def test_empty_extensions(event_loop): + """If a document class doesn't define extensions, an error is raised.""" + + class BadDoc(toga.Document): + description = "Bad Document" + extensions = [] + + def create(self): + self.main_window = Mock(title="Mock Window") + + def read(self): + pass + + with pytest.raises( + ValueError, + match=r"Document type 'BadDoc' doesn't define at least one extension", + ): + toga.App( + "Test App", + "org.beeware.document-app", + document_types=[MyDoc, BadDoc], + ) + + +@pytest.mark.parametrize("converter", [str, lambda s: s]) +def test_open_absolute_document(app, converter, tmp_path): + """A document can be opened with an absolute path.""" + doc = MyDoc(app) + + path = tmp_path / "doc.mydoc" + path.write_text("sample file") + + # Read the file + doc.open(converter(path)) + + # Calling absolute() ensures the expected value is correct on Windows + assert doc.path == path.absolute() + assert doc.content == "file content" + assert doc._mock_content.read(path.absolute()) + assert not doc.modified + + +@pytest.mark.parametrize("converter", [str, lambda s: s]) +def test_open_relative_document(app, converter, tmp_path): + """A document can be opened with a relative path.""" + doc = MyDoc(app) + + orig_cwd = Path.cwd() + try: + (tmp_path / "cwd").mkdir() + os.chdir(tmp_path / "cwd") + + path = tmp_path / "cwd/doc.mydoc" + path.write_text("sample file") + + # Read the file + doc.open(converter(path)) + + # Calling absolute() ensures the expected value is correct on Windows + assert doc.path == path.absolute() + assert doc.content == "file content" + assert doc._mock_content.read(path.absolute()) + assert not doc.modified + finally: + os.chdir(orig_cwd) + + +def test_open_missing_document(app, tmp_path): + """A missing document raises an error.""" + doc = MyDoc(app) + + # Read the file + with pytest.raises(FileNotFoundError): + doc.open(tmp_path / "doc.mydoc") + + +@pytest.mark.parametrize("converter", [str, lambda s: s]) +def test_save_absolute_document(app, converter, tmp_path): + """A document can be saved with an absolute path.""" + doc = MyDoc(app) + + path = tmp_path / "doc.mydoc" + + # Touch the document to mark it as modified + doc.touch() + assert doc.modified + + # Read the file + doc.save(converter(path)) + + # Calling absolute() ensures the expected value is correct on Windows + assert doc.path == path.absolute() + assert doc.title == "My Document: doc" + assert doc._mock_content.write(path.absolute()) + # Saving clears the modification flag + assert not doc.modified + + +@pytest.mark.parametrize("converter", [str, lambda s: s]) +def test_save_relative_document(app, converter, tmp_path): + """A document can be saved with a relative path.""" + doc = MyDoc(app) + + path = Path("doc.mydoc") + + # Touch the document to mark it as modified + doc.touch() + assert doc.modified + + # Read the file + doc.save(converter(path)) + + # Calling absolute() ensures the expected value is correct on Windows + assert doc.path == (Path.cwd() / path).absolute() + assert doc.title == "My Document: doc" + assert doc._mock_content.write(path.absolute()) + # Saving clears the modification flag + assert not doc.modified + + +def test_save_existing_document(app, tmp_path): + """A document can be saved at its existing path.""" + doc = MyDoc(app) + path = tmp_path / "doc.mydoc" + # Prime the document's path + doc._path = path + + # Touch the document to mark it as modified + doc.touch() + assert doc.modified + + # Save the file + doc.save() + + # Calling absolute() ensures the expected value is correct on Windows + assert doc.path == path.absolute() + assert doc.title == "My Document: doc" + assert doc._mock_content.write(path.absolute()) + # Saving clears the modification flag + assert not doc.modified + + +def test_save_readonly_document(app, tmp_path): + """Save is a no-op on a readonly document.""" + doc = OtherDoc(app) + path = tmp_path / "doc.other" + # Prime the document's path + doc._path = path + + # Touch the document to mark it as modified. This isn't a likely setup for a + # readonly document, but it's possible. + doc.touch() + assert doc.modified + + # Save the file + doc.save() + + # Calling absolute() ensures the expected value is correct on Windows + assert doc.path == path.absolute() + assert doc.title == "Other Document: doc" + # There's no write method, so modifications can't be committed. + assert doc.modified + + +def test_focus(app): + """A document can be given focus.""" + doc1 = MyDoc(app) + + # Give the document focus. + doc1.focus() + + # The app's current window has been set. + assert_action_performed_with(app, "set_current_window", window=doc1.main_window) diff --git a/core/tests/window/test_document_window.py b/core/tests/window/test_document_window.py new file mode 100644 index 0000000000..9c603ffda9 --- /dev/null +++ b/core/tests/window/test_document_window.py @@ -0,0 +1,242 @@ +from unittest.mock import Mock + +import toga +from toga_dummy.utils import assert_action_not_performed, assert_action_performed + + +class ExampleDocument(toga.Document): + description = "Example Document" + extensions = "exampledoc" + read_error = None + write_error = None + + def create(self): + self.main_window = toga.DocumentWindow(self) + self._mock_read = Mock() + self._mock_write = Mock() + + def read(self): + if self.read_error: + # If the object has a "read_error" attribute, raise that exception + raise self.read_error + else: + # We don't actually care about the file or it's contents, but it needs to exist; + # so we open it to verify that behavior. + with self.path.open(): + self._mock_read(self.path) + + def write(self): + # We don't actually care about the file or it's contents. + self._mock_write(self.path) + + +def test_create(app): + """A MainWindow can be created with minimal arguments.""" + doc = ExampleDocument(app) + + # The document has a main window. + window = doc.main_window + + assert window.app == app + assert window.content is None + # Document reference is preserved + assert window.doc == doc + + assert window._impl.interface == window + assert_action_performed(window, "create MainWindow") + + # This is a secondary main window; app menus have not been created, but + # window menus and toolbars have been. + assert_action_not_performed(window, "create App menus") + assert_action_performed(window, "create Window menus") + assert_action_performed(window, "create toolbar") + + # We can't know what the ID is, but it must be a string. + assert isinstance(window.id, str) + # Window title is the document title. + assert window.title == "Example Document: Untitled" + # The app has created a main window, so this will be the second window. + assert window.position == (150, 150) + assert window.size == (640, 480) + assert window.resizable + assert window.closable + assert window.minimizable + # Default on-close handler is to confirm close. + assert window.on_close._raw == window._confirm_close + + # The window has an empty toolbar; but it's also a secondary MainWindow created + # *after* the app has finished initializing; check it has a change handler + assert len(window.toolbar) == 0 + assert window.toolbar.on_change is not None + + +def test_create_explicit(app): + """Explicit arguments at construction are stored.""" + on_close_handler = Mock() + window_content = toga.Box() + # Don't use a document, because we want to exercise the constructor + doc = Mock() + + window = toga.DocumentWindow( + doc=doc, + id="my-window", + title="My Window", + position=toga.Position(10, 20), + size=toga.Position(200, 300), + resizable=False, + minimizable=False, + content=window_content, + on_close=on_close_handler, + ) + + assert window.app == app + assert window.content == window_content + # Document reference is preserved + assert window.doc == doc + + window_content.window == window + window_content.app == app + + assert window._impl.interface == window + assert_action_performed(window, "create MainWindow") + + # This is a secondary main window; app menus have not been created, but + # window menus and toolbars have been. + assert_action_not_performed(window, "create App menus") + assert_action_performed(window, "create Window menus") + assert_action_performed(window, "create toolbar") + + assert window.id == "my-window" + assert window.title == "My Window" + assert window.position == toga.Position(10, 20) + assert window.size == toga.Size(200, 300) + assert not window.resizable + assert window.closable + assert not window.minimizable + assert window.on_close._raw == on_close_handler + + # The window has an empty toolbar; but it's also a secondary MainWindow created + # *after* the app has finished initializing; check it has a change handler + assert len(window.toolbar) == 0 + assert window.toolbar.on_change is not None + + +def test_close_unmodified(app): + """An unmodified document doesn't need to be saved.""" + doc = ExampleDocument(app) + assert not doc.modified + + window = doc.main_window + + # Trigger a window close + window._impl.simulate_close() + + # Window has been closed, and is no longer in the app's list of windows. + assert window.closed + assert window.app == app + assert window not in app.windows + assert_action_performed(window, "close") + + # No save attempt was made. + doc._mock_write.assert_not_called() + + +def test_close_modified(app): + """If the save succeeds, the window will close.""" + mock_path = Mock() + doc = ExampleDocument(app) + doc._path = mock_path + doc.touch() + assert doc.modified + + window = doc.main_window + # Prime the user's responses to the dialogs + window._impl.dialog_responses["QuestionDialog"] = [True] + + # Trigger a window close + window._impl.simulate_close() + + # Window has been closed, and is no longer in the app's list of windows. + assert window.closed + assert window.app == app + assert window not in app.windows + assert_action_performed(window, "close") + + # A save attempt was made. + doc._mock_write.assert_called_once_with(mock_path) + + +def test_close_modified_cancel(app): + """The user can choose to *not* save.""" + mock_path = Mock() + doc = ExampleDocument(app) + doc._path = mock_path + doc.touch() + assert doc.modified + + window = doc.main_window + # Prime the user's responses to the dialogs + window._impl.dialog_responses["QuestionDialog"] = [False] + + # Trigger a window close + window._impl.simulate_close() + + # Window has been closed, and is no longer in the app's list of windows. + assert window.closed + assert window.app == app + assert window not in app.windows + assert_action_performed(window, "close") + + # No save attempt was made. + doc._mock_write.assert_not_called() + + +def test_close_modified_unsaved(monkeypatch, app, tmp_path): + """If a document is modified but unsaved, the save prompts for a filename.""" + monkeypatch.setattr(app.documents, "_types", ExampleDocument) + doc = ExampleDocument(app) + doc.touch() + assert doc.modified + + window = doc.main_window + # Prime the user's responses to the dialogs + window._impl.dialog_responses["QuestionDialog"] = [True] + new_path = tmp_path / "foo.exampledoc" + window._impl.dialog_responses["SaveFileDialog"] = [new_path] + + # Trigger a window close + window._impl.simulate_close() + + # Window has been closed, and is no longer in the app's list of windows. + assert window.closed + assert window.app == app + assert window not in app.windows + assert_action_performed(window, "close") + + # A save attempt was made. + doc._mock_write.assert_called_once_with(new_path) + + +def test_close_modified_save_cancel(monkeypatch, app, tmp_path): + """A close is aborted by canceling the save.""" + monkeypatch.setattr(app.documents, "_types", ExampleDocument) + doc = ExampleDocument(app) + doc.touch() + assert doc.modified + + window = doc.main_window + # Prime the user's responses to the dialogs + window._impl.dialog_responses["QuestionDialog"] = [True] + window._impl.dialog_responses["SaveFileDialog"] = [None] + + # Trigger a window close + window._impl.simulate_close() + + # Window has been closed, and is no longer in the app's list of windows. + assert not window.closed + assert window.app == app + assert window in app.windows + assert_action_not_performed(window, "close") + + # No save attempt was made. + doc._mock_write.assert_not_called() diff --git a/core/tests/window/test_mainwindow.py b/core/tests/window/test_main_window.py similarity index 100% rename from core/tests/window/test_mainwindow.py rename to core/tests/window/test_main_window.py diff --git a/core/tests/window/test_window.py b/core/tests/window/test_window.py index 51da5120c1..c619577a60 100644 --- a/core/tests/window/test_window.py +++ b/core/tests/window/test_window.py @@ -325,7 +325,7 @@ def test_close_direct(window, app): assert window in app.windows # Close the window directly - window.close() + assert window.close() # Window has been closed, but the close handler has *not* been invoked. assert window.closed @@ -349,8 +349,8 @@ def test_close_direct_main_window(app): assert window.app == app assert window in app.windows - # Close the window directly - window.close() + # Close the window directly; + assert not window.close() # Window has *not* been closed. assert not window.closed diff --git a/docs/reference/api/app.rst b/docs/reference/api/app.rst index c927fa055f..60d440192a 100644 --- a/docs/reference/api/app.rst +++ b/docs/reference/api/app.rst @@ -107,7 +107,9 @@ different on each platform, reflecting platform differences. On macOS, the app is allowed to continue running without having any open windows. The app can open and close windows as required; the app will keep running until explicitly -exited. +exited. If you give the app focus when it has no open windows, a file dialog will be +displayed prompting you to select a file to open. If the file is already open, the +existing representation for the document will be given focus. On Linux and Windows, when an app closes the last window it is managing, the app will automatically exit. Attempting to close the last window will trigger any app-level @@ -153,6 +155,18 @@ method must accept an ``app`` argument. This argument is not required when subcl as the app instance can be implied. Regardless of how they are defined, event handlers *can* be defined as ``async`` methods. +Managing documents +------------------ + +When you create an App instance, you can declare the type of documents that your app is +able to manage by providing a value for ``document_types``. When an app declares that it +can manage document types, the app will automatically create file management menu items +(such as New, Open and Save), and the app will process command line arguments, creating +a :class:`toga.Document` instance for each argument matching a registered document type. + +For details on how to define and register document types, refer to :doc:`the +documentation on document handling <./resources/document>`. + Notes ----- diff --git a/docs/reference/api/documentapp.rst b/docs/reference/api/documentapp.rst deleted file mode 100644 index 88a96fe724..0000000000 --- a/docs/reference/api/documentapp.rst +++ /dev/null @@ -1,90 +0,0 @@ -DocumentApp -=========== - -The top-level representation of an application that manages documents. - -.. rst-class:: widget-support -.. csv-filter:: Availability (:ref:`Key `) - :header-rows: 1 - :file: ../data/widgets_by_platform.csv - :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!(DocumentApp|Component))'} - - -Usage ------ - -A DocumentApp is a specialized subclass of App that is used to manage documents. A -DocumentApp does *not* have a main window; each document that the app manages has it's -own main window. Each document may also define additional windows, if necessary. - -The types of documents that the DocumentApp can manage must be declared as part of the -instantiation of the DocumentApp. This requires that you define a subclass of -:class:`toga.Document` that describes how your document can be read and displayed. In -this example, the code declares an "Example Document" document type, whose files have an -extension of ``mydoc``: - -.. code-block:: python - - import toga - - class ExampleDocument(toga.Document): - def __init__(self, path, app): - super().__init__(document_type="Example Document", path=path, app=app) - - def create(self): - # Create the representation for the document's main window - self.main_window = toga.DocumentMainWindow(self) - self.main_window.content = toga.MultilineTextInput() - - def read(self): - # Put your logic to read the document here. For example: - with self.path.open() as f: - self.content = f.read() - - self.main_window.content.value = self.content - - app = toga.DocumentApp("Document App", "com.example.document", {"mydoc": MyDocument}) - app.main_loop() - -The exact behavior of a DocumentApp is slightly different on each platform, reflecting -platform differences. - -macOS -~~~~~ - -On macOS, there is only ever a single instance of a DocumentApp running at any given -time. That instance can manage multiple documents. If you use the Finder to open a -second document of a type managed by the DocumentApp, it will be opened in the existing -DocumentApp instance. Closing all documents will not cause the app to exit; the app will -keep executing until explicitly exited. - -If the DocumentApp is started without an explicit file reference, a file dialog will be -displayed prompting the user to select a file to open. If this dialog can be dismissed, -the app will continue running. Selecting "Open" from the file menu will also display this -dialog; if a file is selected, a new document window will be opened. - -Linux/Windows -~~~~~~~~~~~~~ - -On Linux and Windows, each DocumentApp instance manages a single document. If your app -is running, and you use the file manager to open a second document, a second instance of -the app will be started. If you close a document's main window, the app instance -associated with that document will exit, but any other app instances will keep running. - -If the DocumentApp is started without an explicit file reference, a file dialog will be -displayed prompting the user to select a file to open. If this dialog is dismissed, the -app will continue running, but will show an empty document. Selecting "Open" from the -file menu will also display this dialog; if a file is selected, the current document -will be replaced. - -Reference ---------- - -.. autoclass:: toga.DocumentApp - :members: - :undoc-members: - -.. autoclass:: toga.Document - :members: - :undoc-members: diff --git a/docs/reference/api/documentwindow.rst b/docs/reference/api/documentwindow.rst new file mode 100644 index 0000000000..a309ca9c0a --- /dev/null +++ b/docs/reference/api/documentwindow.rst @@ -0,0 +1,55 @@ +DocumentWindow +============== + +A window that can be used as the main interface to a document-based app. + +.. tabs:: + + .. group-tab:: macOS + + .. figure:: /reference/images/mainwindow-cocoa.png + :align: center + :width: 450px + + .. group-tab:: Linux + + .. figure:: /reference/images/mainwindow-gtk.png + :align: center + :width: 450px + + .. group-tab:: Windows + + .. figure:: /reference/images/mainwindow-winforms.png + :align: center + :width: 450px + + .. group-tab:: Android |no| + + Not supported + + .. group-tab:: iOS |no| + + Not supported + + .. group-tab:: Web |no| + + Not supported + + .. group-tab:: Textual |no| + + Not supported + +Usage +----- + +A DocumentWindow is the same as a :any:`toga.MainWindow`, except that it is bound to +a :any:`toga.Document` instance, exposed as the :any:`toga.DocumentWindow.doc` +attribute. + +Instances of :any:`toga.DocumentWindow` should be created as part of the +:meth:`~toga.Document.create()` method of an implementation of :any:`toga.Document`. + +Reference +--------- + +.. autoclass:: toga.DocumentWindow diff --git a/docs/reference/api/index.rst b/docs/reference/api/index.rst index 2d7e445cc2..7c05fc79fc 100644 --- a/docs/reference/api/index.rst +++ b/docs/reference/api/index.rst @@ -7,14 +7,14 @@ API Reference Core application components --------------------------- -================================================= ============================================================================= - Component Description -================================================= ============================================================================= - :doc:`App ` The top-level representation of an application. - :doc:`DocumentApp ` An application that manages documents. - :doc:`Window ` An operating system-managed container of widgets. - :doc:`MainWindow ` A window that can use the full set of window-level user interface elements. -================================================= ============================================================================= +======================================================= ============================================================================= + Component Description +======================================================= ============================================================================= + :doc:`App ` The top-level representation of an application. + :doc:`Window ` An operating system-managed container of widgets. + :doc:`MainWindow ` A window that can use the full set of window-level user interface elements. + :doc:`DocumentWindow ` A window that can be used as the main interface to a document-based app. +======================================================= ============================================================================= General widgets --------------- @@ -81,7 +81,9 @@ Resources :doc:`Command ` A representation of app functionality that the user can invoke from menus or toolbars. :doc:`Dialogs ` A short-lived window asking the user for input. - :doc:`Font ` Fonts + :doc:`Document ` A representation of a file on disk that will be displayed in one or + more windows + :doc:`Font ` A representation of a Font :doc:`Icon ` An icon for buttons, menus, etc :doc:`Image ` An image :doc:`Source ` A base class for data source implementations. @@ -117,9 +119,9 @@ Other :hidden: app - documentapp window mainwindow + documentwindow containers/index hardware/index resources/index diff --git a/docs/reference/api/resources/command.rst b/docs/reference/api/resources/command.rst index 80d0a2ea28..47b10e5841 100644 --- a/docs/reference/api/resources/command.rst +++ b/docs/reference/api/resources/command.rst @@ -22,6 +22,9 @@ A command encapsulates a piece of functionality that the user can invoke - no ma they invoke it. It doesn't matter if they select a menu item, press a button on a toolbar, or use a key combination - the functionality is wrapped up in a Command. +Adding commands +--------------- + Commands are added to an app using the properties :any:`toga.App.commands` and :any:`toga.MainWindow.toolbar`. Toga then takes control of ensuring that the command is exposed to the user in a way that they can access. On desktop platforms, @@ -73,10 +76,8 @@ as well. It isn't possible to have functionality exposed on a toolbar that isn't also exposed by the app. So, ``cmd2`` will be added to the app, even though it wasn't explicitly added to the app commands. -Each command has an :attr:`~toga.Command.id` attribute. This is set when the command is -defined; if no ID is provided, a random ID will be generated for the Command. This -identifier can be used to retrieve a command from :any:`toga.App.commands` and -:any:`toga.MainWindow.toolbar`. +Removing commands +----------------- Commands can be removed using set-like and dictionary-like APIs. The set-like APIs use the command instance; the dictionary-like APIs use the command ID: @@ -89,6 +90,59 @@ the command instance; the dictionary-like APIs use the command ID: # Remove a command by ID del app.commands["Some-Command-ID"] +Standard commands +----------------- + +Each command has an :attr:`~toga.Command.id` attribute. This is set when the command is +defined; if no ID is provided, a random ID will be generated for the Command. This +identifier can be used to retrieve a command from :any:`toga.App.commands` and +:any:`toga.MainWindow.toolbar`. + +These command IDs are also used to create *standard* commands. These are commands that +are expected functionality in most applications, such as :attr:`~toga.Command.ABOUT` and +:attr:`~toga.Command.EXIT`, as well as document management commands such as +:attr:`~toga.Command.NEW`, :attr:`~toga.Command.OPEN` and :attr:`~toga.Command.SAVE`. + +These commands are automatically added to your app, depending on platform requirements +and app definition. For example, mobile apps won't have an Exit command as mobile apps +don't have a concept of "exiting". Document management commands will be automatically +added if your app defines :doc:`document types <./document>`. + +The label, shortcut, grouping and ordering of these commands is platform dependent. For +example, on macOS, the :attr:`~toga.Command.EXIT` command will be labeled "Quit My App", +and have a shortcut of Command-q; on Windows, the command will be labeled "Exit", and +won't have a keyboard shortcut. + +Any automatically added standard commands will be installed *before* your app's +:meth:`~toga.App.startup()` method is invoked. If you wish to remove or modify and a +standard app command, you can use the standard command's ID to retrieve the command +instance from :attr:`toga.App.commands`. If you wish to add or override a standard +command that hasn't been installed by default (for example, to add an Open command +without defining a document type), you can use the :meth:`toga.Command.standard()` +method to create an instance of the standard command, and add that command to your app: + +.. code-block:: python + + import toga + + class MyApp(toga.app): + def startup(self): + ... + # Delete the default Preferences command + del self.commands[toga.Command.PREFERENCES] + + # Modify the text of the "About" command + self.commands[toga.Command.ABOUT].text = "I'm Customized!!" + + # Add an Open command + custom_open = toga.Command.standard( + self, + toga.Command.OPEN, + action=self.custom_open + ) + + self.commands.add(custom_open) + Reference --------- diff --git a/docs/reference/api/resources/document.rst b/docs/reference/api/resources/document.rst new file mode 100644 index 0000000000..641e08ab07 --- /dev/null +++ b/docs/reference/api/resources/document.rst @@ -0,0 +1,106 @@ +Document +======== + +A representation of a file on disk that will be displayed in one or more windows. + +.. rst-class:: widget-support +.. csv-filter:: Availability (:ref:`Key `) + :header-rows: 1 + :file: ../../data/widgets_by_platform.csv + :included_cols: 4,5,6,7,8,9,10 + :include: {0: '^Document$'} + + +Usage +----- + +A common requirement for apps is to view or edit a particular type of file. In Toga, you +define a :class:`toga.Document` class to represent each type of content that your app is +able to manipulate. This :class:`~toga.Document` class is then registered with your app +when the :class:`~toga.App` instance is created. + +The :class:`toga.Document` class describes how your document can be read, displayed, and +saved. It also tracks whether the document has been modified. In this example, the code +declares an "Example Document" document type, which will create files with the +extensions ``.mydoc`` and ``.mydocument``; because it is listed first, the ``.mydoc`` +extension will be the default for documents of this type. The main window for this +document type contains a :class:`~toga.MultilineTextInput`. Whenever the content of that +widget changes, the document is marked as modified: + +.. code-block:: python + + import toga + + class ExampleDocument(toga.Document): + description = "Example Document" + extensions = ["mydoc", "mydocument"] + + def create(self): + # Create the main window for the document. The window has a single widget; + # when that widget changes, the document is modified. + self.main_window = toga.DocumentMainWindow( + doc=self, + content=toga.MultilineTextInput(on_change=self.touch), + ) + + def read(self): + # Read the content of the file represented by the document, and populate the + # widgets in the main window with that content. + with self.path.open() as f: + self.main_window.content.value = f.read() + + def write(self): + # Save the content currently displayed by the main window. + with self.path.open("w") as f: + f.write(self.main_window.content.value) + +The document window uses the modification status to determine whether the window is +allowed to close. If a document is modified, the user will be asked if they want to +save changes to the document. + +Registering document types +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A document type is used by registering it with an app instance. The constructor for +:class:`toga.App` allows you to declare the collection of document types that your app +supports. The first declared document type is treated as the default document type for +your app; this is the type that will be connected to the keyboard shortcut of the +:attr:`~toga.Command.NEW` command. + +After :meth:`~toga.App.startup` returns, any filenames which were passed to the app by +the operating system will be opened using the registered document types. If after this +the app still has no windows, then: + +* On Windows and GTK, an untitled document of the default type will be opened. +* On macOS, an Open dialog will be shown. + +In the following example, the app will be able to manage documents of type +``ExampleDocument`` or ``OtherDocument``, with ``ExampleDocument`` being the default +content type. The app is configured :ref:`to not have a single "main" window +`, so the life cycle of the app is not tied to a specific window. + +.. code-block:: python + + import toga + + class ExampleApp(toga.App): + def startup(self): + # The app does not have a single main window + self.main_window = None + + app = ExampleApp( + "Document App", + "com.example.documentapp", + document_types=[ExampleDocument, OtherDocument] + ) + + app.main_loop() + +By declaring these document types, the app will automatically have file management +commands (New, Open, Save, etc) added. + +Reference +--------- + +.. autoclass:: toga.Document +.. autoclass:: toga.documents.DocumentSet diff --git a/docs/reference/api/resources/index.rst b/docs/reference/api/resources/index.rst index 72640442c6..ae8e3a57b3 100644 --- a/docs/reference/api/resources/index.rst +++ b/docs/reference/api/resources/index.rst @@ -7,6 +7,7 @@ Resources dialogs fonts command + document icons images sources/source diff --git a/docs/reference/api/window.rst b/docs/reference/api/window.rst index ea9851353f..0455fb4ced 100644 --- a/docs/reference/api/window.rst +++ b/docs/reference/api/window.rst @@ -104,6 +104,7 @@ Reference --------- .. autoclass:: toga.Window +.. autoclass:: toga.app.WindowSet .. autoprotocol:: toga.window.Dialog .. autoprotocol:: toga.window.OnCloseHandler diff --git a/docs/reference/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index e3c821b75a..2ce67aef7f 100644 --- a/docs/reference/data/widgets_by_platform.csv +++ b/docs/reference/data/widgets_by_platform.csv @@ -1,7 +1,7 @@ Component,Type,Component,Description,macOS,GTK,Windows,iOS,Android,Web,Terminal Application,Core Component,:class:`~toga.App`,The application itself,|y|,|y|,|y|,|y|,|y|,|b|,|b| -DocumentApp,Core Component,:class:`~toga.DocumentApp`,An application that manages documents.,|b|,|b|,,,,, -Window,Core Component,:class:`~toga.Window`,An operating system-managed container of widgets.,|y|,|y|,|y|,,,, +Window,Core Component,:class:`~toga.Window`,An operating system-managed container of widgets.,|y|,|y|,|y|,|y|,|y|,|b|,|b| +DocumentWindow,Core Component,:class:`~toga.DocumentWindow`,A window that can be used as the main interface to a document-based app.,|y|,|y|,|y|,,,, MainWindow,Core Component,:class:`~toga.MainWindow`,A window that can use the full set of window-level user interface elements.,|y|,|y|,|y|,|y|,|y|,|b|,|b| ActivityIndicator,General Widget,:class:`~toga.ActivityIndicator`,A spinning activity animation,|y|,|y|,,,,|b|, Button,General Widget,:class:`~toga.Button`,Basic clickable Button,|y|,|y|,|y|,|y|,|y|,|b|,|b| @@ -35,6 +35,7 @@ Screen,Hardware,:class:`~toga.screens.Screen`,A representation of a screen attac App Paths,Resource,:class:`~toga.paths.Paths`,A mechanism for obtaining platform-appropriate filesystem locations for an application.,|y|,|y|,|y|,|y|,|y|,,|b| Command,Resource,:class:`~toga.Command`,Command,|y|,|y|,|y|,,|y|,, Dialogs,Resource,:ref:`Dialogs `,A short-lived window asking the user for input,|y|,|y|,|y|,|y|,|y|,|b|,|b| +Document,Resource,:class:`~toga.Document`,A representation of a file on disk that will be displayed in one or more windows.,|y|,|y|,|y|,,,, Font,Resource,:class:`~toga.Font`,A text font,|y|,|y|,|y|,|y|,|y|,, Icon,Resource,:class:`~toga.Icon`,"A small, square image, used to provide easily identifiable visual context to a widget.",|y|,|y|,|y|,|y|,|y|,,|b| Image,Resource,:class:`~toga.Image`,Graphical content of arbitrary size.,|y|,|y|,|y|,|y|,|y|,, diff --git a/dummy/src/toga_dummy/app.py b/dummy/src/toga_dummy/app.py index b10d261d21..4eb5172016 100644 --- a/dummy/src/toga_dummy/app.py +++ b/dummy/src/toga_dummy/app.py @@ -2,10 +2,6 @@ import sys from pathlib import Path -from toga.app import overridden -from toga.command import Command, Group -from toga.handlers import simple_handler - from .screens import Screen as ScreenImpl from .utils import LoggedObject @@ -13,6 +9,8 @@ class App(LoggedObject): # Dummy apps close on the last window close CLOSE_ON_LAST_WINDOW = True + # Dummy backend uses default command line handling + HANDLES_COMMAND_LINE = False def __init__(self, interface): super().__init__() @@ -31,43 +29,8 @@ def create(self): # Commands and menus ###################################################################### - def create_app_commands(self): + def create_standard_commands(self): self._action("create App commands") - self.interface.commands.add( - # Invoke `_request_exit` rather than `exit`, because we want to trigger the - # "OK to exit?" logic. - Command( - simple_handler(self.interface._request_exit), - "Exit", - group=Group.APP, - section=sys.maxsize, - id=Command.EXIT, - ), - Command( - simple_handler(self.interface.about), - f"About {self.interface.formal_name}", - group=Group.HELP, - id=Command.ABOUT, - ), - Command( - simple_handler(self.interface.visit_homepage), - "Visit homepage", - enabled=self.interface.home_page is not None, - group=Group.HELP, - id=Command.VISIT_HOMEPAGE, - ), - ) - - # If the user has overridden preferences, provide a menu item. - if overridden(self.interface.preferences): - self.interface.commands.add( - Command( - simple_handler(self.interface.preferences), - "Preferences", - group=Group.APP, - id=Command.PREFERENCES, - ), - ) # pragma: no cover def create_menus(self): self._action("create App menus") @@ -176,13 +139,6 @@ def enter_full_screen(self, windows): def exit_full_screen(self, windows): self._action("exit_full_screen", windows=windows) - ###################################################################### - # Simulation interface - ###################################################################### - - def simulate_exit(self): - self.interface._request_exit() - class DocumentApp(App): def create(self): diff --git a/dummy/src/toga_dummy/command.py b/dummy/src/toga_dummy/command.py index 1007b7b6ae..5c3908b77f 100644 --- a/dummy/src/toga_dummy/command.py +++ b/dummy/src/toga_dummy/command.py @@ -1,3 +1,6 @@ +import sys + +from toga import Command as StandardCommand, Group, Key from toga_dummy.utils import LoggedObject @@ -6,5 +9,71 @@ def __init__(self, interface): super().__init__() self.interface = interface + @classmethod + def standard(cls, app, id): + # ---- File menu ----------------------------------- + if id == StandardCommand.PREFERENCES: + return { + "text": "Preferences", + "group": Group.APP, + } + elif id == StandardCommand.EXIT: + # Quit should always be the last item, in a section on its own. + return { + "text": "Exit", + "group": Group.APP, + "section": sys.maxsize, + } + # ---- File menu ---------------------------------- + elif id == StandardCommand.NEW: + return { + "text": "New", + "shortcut": Key.MOD_1 + "n", + "group": Group.FILE, + "section": 0, + "order": 0, + } + elif id == StandardCommand.OPEN: + return { + "text": "Open", + "group": Group.FILE, + "section": 0, + "order": 10, + } + elif id == StandardCommand.SAVE: + return { + "text": "Save", + "group": Group.FILE, + "section": 10, + "order": 0, + } + elif id == StandardCommand.SAVE_AS: + return { + "text": "Save As", + "group": Group.FILE, + "section": 10, + "order": 1, + } + elif id == StandardCommand.SAVE_ALL: + return { + "text": "Save All", + "group": Group.FILE, + "section": 10, + "order": 2, + } + # ---- Help menu ---------------------------------- + elif id == StandardCommand.ABOUT: + return { + "text": f"About {app.formal_name}", + "group": Group.HELP, + } + elif id == StandardCommand.VISIT_HOMEPAGE: + # Dummy doesn't have a visit homepage menu item. + # This lets us verify the "platform doesn't support command" + # logic. + return None + + raise ValueError(f"Unknown standard command {id!r}") + def set_enabled(self, value): self._action("set enabled", value=value) diff --git a/dummy/src/toga_dummy/documents.py b/dummy/src/toga_dummy/documents.py deleted file mode 100644 index 9de290b969..0000000000 --- a/dummy/src/toga_dummy/documents.py +++ /dev/null @@ -1,8 +0,0 @@ -from .utils import LoggedObject - - -class Document(LoggedObject): - def __init__(self, interface): - super().__init__() - self.interface = interface - self.interface.read() diff --git a/dummy/src/toga_dummy/factory.py b/dummy/src/toga_dummy/factory.py index ca72352db0..34863850b3 100644 --- a/dummy/src/toga_dummy/factory.py +++ b/dummy/src/toga_dummy/factory.py @@ -3,7 +3,6 @@ from . import dialogs from .app import App, DocumentApp from .command import Command -from .documents import Document from .fonts import Font from .hardware.camera import Camera from .hardware.location import Location @@ -36,7 +35,7 @@ from .widgets.timeinput import TimeInput from .widgets.tree import Tree from .widgets.webview import WebView -from .window import DocumentMainWindow, MainWindow, Window +from .window import MainWindow, Window def not_implemented(feature): @@ -48,7 +47,6 @@ def not_implemented(feature): "App", "DocumentApp", "Command", - "Document", "Font", "Icon", "Image", @@ -84,7 +82,6 @@ def not_implemented(feature): "Tree", "WebView", # Windows - "DocumentMainWindow", "MainWindow", "Window", # Widget is also required for testing purposes diff --git a/dummy/src/toga_dummy/window.py b/dummy/src/toga_dummy/window.py index 02dc94cb46..368f7834f6 100644 --- a/dummy/src/toga_dummy/window.py +++ b/dummy/src/toga_dummy/window.py @@ -119,7 +119,7 @@ def set_position(self, position): ###################################################################### def get_visible(self): - return self._get_value("visible") + return self._get_value("visible", False) def hide(self): self._action("hide") @@ -147,7 +147,7 @@ def get_image_data(self): def simulate_close(self): result = self.interface.on_close() - if asyncio.iscoroutine(result): + if isinstance(result, asyncio.Task): self.interface.app.loop.run_until_complete(result) @@ -158,7 +158,3 @@ def create_menus(self): def create_toolbar(self): self._action("create toolbar") - - -class DocumentMainWindow(Window): - pass diff --git a/examples/documentapp/documentapp/app.py b/examples/documentapp/documentapp/app.py index 4a4e39ce2e..40841b77e8 100644 --- a/examples/documentapp/documentapp/app.py +++ b/examples/documentapp/documentapp/app.py @@ -2,38 +2,35 @@ class ExampleDocument(toga.Document): - def __init__(self, path, app): - super().__init__(path=path, document_type="Example Document", app=app) - - async def can_close(self): - return await self.main_window.dialog( - toga.QuestionDialog( - "Are you sure?", - "Do you want to close this document?", - ) - ) + description = "Example Document" + extensions = ["exampledoc"] def create(self): - # Create the main window for the document. - self.main_window = toga.DocumentMainWindow( + # Create the main window for the document. The window has a single widget; + # when that widget changes, the document is modified. + self.main_window = toga.DocumentWindow( doc=self, - title=f"Example: {self.path.name}", + content=toga.MultilineTextInput(on_change=self.touch), ) - self.main_window.content = toga.MultilineTextInput() def read(self): + # Read the content of the file represented by the document, and populate the + # widgets in the main window with that content. with self.path.open() as f: - self.content = f.read() + self.main_window.content.value = f.read() - self.main_window.content.value = self.content + def write(self): + # Save the content currently displayed by the main window. + with self.path.open("w") as f: + f.write(self.main_window.content.value) -class ExampleDocumentApp(toga.DocumentApp): +class ExampleDocumentApp(toga.App): def startup(self): - # A document-based app is a session app, so it has no main window. A window (or - # windows) will be created from the document(s) specified at the command line; - # or if no document is specified, the platform will determine how to create an - # empty document. + # A document-based app does not have a single main window. A window (or windows) + # will be created from the document(s) specified at the command line; or if no + # document is specified, the platform will determine how to create an initial + # document. self.main_window = None @@ -41,9 +38,7 @@ def main(): return ExampleDocumentApp( "Document App", "org.beeware.toga.examples.documentapp", - document_types={ - "exampledoc": ExampleDocument, - }, + document_types=[ExampleDocument], ) diff --git a/examples/documentapp/icons/exampledoc.icns b/examples/documentapp/icons/exampledoc.icns new file mode 100644 index 0000000000..5b41232433 Binary files /dev/null and b/examples/documentapp/icons/exampledoc.icns differ diff --git a/examples/documentapp/icons/exampledoc.ico b/examples/documentapp/icons/exampledoc.ico new file mode 100644 index 0000000000..16348b28c3 Binary files /dev/null and b/examples/documentapp/icons/exampledoc.ico differ diff --git a/examples/documentapp/icons/exampledoc.png b/examples/documentapp/icons/exampledoc.png new file mode 100644 index 0000000000..fbbcbce490 Binary files /dev/null and b/examples/documentapp/icons/exampledoc.png differ diff --git a/examples/documentapp/pyproject.toml b/examples/documentapp/pyproject.toml index 0c803722be..e7770fe443 100644 --- a/examples/documentapp/pyproject.toml +++ b/examples/documentapp/pyproject.toml @@ -45,3 +45,9 @@ supported = false # Web deployment [tool.briefcase.app.documentapp.web] supported = false + +[tool.briefcase.app.documentapp.document_type.exampledoc] +description = "An example document" +extension = "exampledoc" +icon = "icons/exampledoc" +url = "https://beeware.org" diff --git a/gtk/src/toga_gtk/app.py b/gtk/src/toga_gtk/app.py index d2bdb8e3f7..582a554f68 100644 --- a/gtk/src/toga_gtk/app.py +++ b/gtk/src/toga_gtk/app.py @@ -1,15 +1,11 @@ import asyncio import os import signal -import sys -from pathlib import Path import gbulb -import toga -from toga.app import App as toga_App, overridden -from toga.command import Command, Separator -from toga.handlers import simple_handler +from toga.app import App as toga_App +from toga.command import Separator from .keys import gtk_accel from .libs import TOGA_DEFAULT_STYLES, Gdk, Gio, GLib, Gtk @@ -19,6 +15,8 @@ class App: # GTK apps exit when the last window is closed CLOSE_ON_LAST_WINDOW = True + # GTK apps use default command line handling + HANDLES_COMMAND_LINE = False def __init__(self, interface): self.interface = interface @@ -60,46 +58,8 @@ def gtk_startup(self, data=None): # Commands and menus ###################################################################### - def create_app_commands(self): - self.interface.commands.add( - # ---- App menu ----------------------------------- - # Quit should always be the last item, in a section on its own. Invoke - # `_request_exit` rather than `exit`, because we want to trigger the "OK to - # exit?" logic. - Command( - simple_handler(self.interface._request_exit), - "Quit " + self.interface.formal_name, - shortcut=toga.Key.MOD_1 + "q", - group=toga.Group.APP, - section=sys.maxsize, - id=Command.EXIT, - ), - # ---- Help menu ----------------------------------- - Command( - simple_handler(self.interface.about), - "About " + self.interface.formal_name, - group=toga.Group.HELP, - id=Command.ABOUT, - ), - Command( - simple_handler(self.interface.visit_homepage), - "Visit homepage", - enabled=self.interface.home_page is not None, - group=toga.Group.HELP, - id=Command.VISIT_HOMEPAGE, - ), - ) - - # If the user has overridden preferences, provide a menu item. - if overridden(self.interface.preferences): - self.interface.commands.add( - Command( - simple_handler(self.interface.preferences), - "Preferences", - group=toga.Group.APP, - id=Command.PREFERENCES, - ), - ) # pragma: no cover + def create_standard_commands(self): + pass def _submenu(self, group, menubar): try: @@ -228,7 +188,7 @@ def get_screens(self): def beep(self): Gdk.beep() - def _close_about(self, dialog): + def _close_about(self, dialog, **kwargs): self.native_about_dialog.destroy() self.native_about_dialog = None @@ -251,6 +211,7 @@ def show_about_dialog(self): self.native_about_dialog.show() self.native_about_dialog.connect("close", self._close_about) + self.native_about_dialog.connect("response", self._close_about) ###################################################################### # Cursor control @@ -284,44 +245,3 @@ def enter_full_screen(self, windows): def exit_full_screen(self, windows): for window in windows: window._impl.set_full_screen(False) - - -class DocumentApp(App): # pragma: no cover - def create_app_commands(self): - super().create_app_commands() - self.interface.commands.add( - Command( - self.open_file, - text="Open...", - shortcut=toga.Key.MOD_1 + "o", - group=toga.Group.FILE, - section=0, - ), - ) - - def gtk_startup(self, data=None): - super().gtk_startup(data=data) - - try: - # Look for a filename specified on the command line - self.interface._open(Path(sys.argv[1])) - except IndexError: - # Nothing on the command line; open a file dialog instead. - # Create a temporary window so we have context for the dialog - m = toga.Window() - m.open_file_dialog( - self.interface.formal_name, - file_types=self.interface.document_types.keys(), - on_result=lambda dialog, path: ( - self.interface._open(path) if path else self.exit() - ), - ) - - def open_file(self, widget, **kwargs): - # Create a temporary window so we have context for the dialog - m = toga.Window() - m.open_file_dialog( - self.interface.formal_name, - file_types=self.interface.document_types.keys(), - on_result=lambda dialog, path: self.interface._open(path) if path else None, - ) diff --git a/gtk/src/toga_gtk/command.py b/gtk/src/toga_gtk/command.py index 95d4e0e3e4..862842b355 100644 --- a/gtk/src/toga_gtk/command.py +++ b/gtk/src/toga_gtk/command.py @@ -1,3 +1,8 @@ +import sys + +from toga import Command as StandardCommand, Group, Key + + class Command: """Command `native` property is a list of native widgets associated with the command. @@ -9,6 +14,83 @@ def __init__(self, interface): self.interface = interface self.native = [] + @classmethod + def standard(self, app, id): + # ---- App menu ----------------------------------- + if id == StandardCommand.PREFERENCES: + # Preferences should be towards the end of the File menu. + return { + "text": "Preferences", + "group": Group.APP, + "section": sys.maxsize - 1, + } + elif id == StandardCommand.EXIT: + # Quit should always be the last item, in a section on its own. + return { + "text": "Quit", + "shortcut": Key.MOD_1 + "q", + "group": Group.APP, + "section": sys.maxsize, + } + + # ---- File menu ----------------------------------- + elif id == StandardCommand.NEW: + return { + "text": "New", + "shortcut": Key.MOD_1 + "n", + "group": Group.FILE, + "section": 0, + "order": 0, + } + elif id == StandardCommand.OPEN: + return { + "text": "Open...", + "shortcut": Key.MOD_1 + "o", + "group": Group.FILE, + "section": 0, + "order": 10, + } + + elif id == StandardCommand.SAVE: + return { + "text": "Save", + "shortcut": Key.MOD_1 + "s", + "group": Group.FILE, + "section": 0, + "order": 20, + } + elif id == StandardCommand.SAVE_AS: + return { + "text": "Save As...", + "shortcut": Key.MOD_1 + "S", + "group": Group.FILE, + "section": 0, + "order": 21, + } + elif id == StandardCommand.SAVE_ALL: + return { + "text": "Save All", + "shortcut": Key.MOD_1 + Key.MOD_2 + "s", + "group": Group.FILE, + "section": 0, + "order": 21, + } + # ---- Help menu ----------------------------------- + elif id == StandardCommand.VISIT_HOMEPAGE: + return { + "text": "Visit homepage", + "enabled": app.home_page is not None, + "group": Group.HELP, + } + elif id == StandardCommand.ABOUT: + return { + "text": f"About {app.formal_name}", + "group": Group.HELP, + "section": sys.maxsize, + } + + raise ValueError(f"Unknown standard command {id!r}") + def gtk_activate(self, action, data): self.interface.action() diff --git a/gtk/src/toga_gtk/dialogs.py b/gtk/src/toga_gtk/dialogs.py index 36b450fcce..968c1d2a38 100644 --- a/gtk/src/toga_gtk/dialogs.py +++ b/gtk/src/toga_gtk/dialogs.py @@ -228,6 +228,7 @@ def __init__( action=Gtk.FileChooserAction.SAVE, ok_icon=Gtk.STOCK_SAVE, ) + self.native.set_do_overwrite_confirmation(True) class OpenFileDialog(FileDialog): diff --git a/gtk/src/toga_gtk/documents.py b/gtk/src/toga_gtk/documents.py deleted file mode 100644 index 9000fc5ce5..0000000000 --- a/gtk/src/toga_gtk/documents.py +++ /dev/null @@ -1,7 +0,0 @@ -class Document: # pragma: no cover - # GTK has 1-1 correspondence between document and app instances. - SINGLE_DOCUMENT_APP = True - - def __init__(self, interface): - self.interface = interface - self.interface.read() diff --git a/gtk/src/toga_gtk/factory.py b/gtk/src/toga_gtk/factory.py index c38085dbcf..3aa439b6a6 100644 --- a/gtk/src/toga_gtk/factory.py +++ b/gtk/src/toga_gtk/factory.py @@ -1,9 +1,8 @@ from toga import NotImplementedWarning from . import dialogs -from .app import App, DocumentApp +from .app import App from .command import Command -from .documents import Document from .fonts import Font from .icons import Icon from .images import Image @@ -31,7 +30,7 @@ from .widgets.textinput import TextInput from .widgets.tree import Tree from .widgets.webview import WebView -from .window import DocumentMainWindow, MainWindow, Window +from .window import MainWindow, Window def not_implemented(feature): @@ -41,9 +40,7 @@ def not_implemented(feature): __all__ = [ "not_implemented", "App", - "DocumentApp", "Command", - "Document", # Resources "Font", "Icon", @@ -75,7 +72,6 @@ def not_implemented(feature): "Tree", "WebView", # Windows - "DocumentMainWindow", "MainWindow", "Window", ] diff --git a/gtk/src/toga_gtk/window.py b/gtk/src/toga_gtk/window.py index e177453991..dd13787267 100644 --- a/gtk/src/toga_gtk/window.py +++ b/gtk/src/toga_gtk/window.py @@ -254,8 +254,3 @@ def create_toolbar(self): padding=0, ) self.native_toolbar.show_all() - - -class DocumentMainWindow(MainWindow): - # On GTK, there's no real difference between a DocumentMainWindow and a MainWindow - pass diff --git a/gtk/tests_backend/app.py b/gtk/tests_backend/app.py index 5c525e9d80..7a6a90462a 100644 --- a/gtk/tests_backend/app.py +++ b/gtk/tests_backend/app.py @@ -118,7 +118,7 @@ def _activate_menu_item(self, path): action.emit("activate", None) def activate_menu_exit(self): - self._activate_menu_item(["*", "Quit Toga Testbed"]) + self._activate_menu_item(["*", "Quit"]) def activate_menu_about(self): self._activate_menu_item(["Help", "About Toga Testbed"]) @@ -131,8 +131,17 @@ def activate_menu_visit_homepage(self): pytest.xfail("GTK doesn't have a visit homepage menu item") def assert_system_menus(self): - self.assert_menu_item(["*", "Quit Toga Testbed"], enabled=True) + self.assert_menu_item(["*", "Preferences"], enabled=False) + self.assert_menu_item(["*", "Quit"], enabled=True) + self.assert_menu_item(["File", "New Example Document"], enabled=True) + self.assert_menu_item(["File", "New Read-only Document"], enabled=True) + self.assert_menu_item(["File", "Open..."], enabled=True) + self.assert_menu_item(["File", "Save"], enabled=True) + self.assert_menu_item(["File", "Save As..."], enabled=True) + self.assert_menu_item(["File", "Save All"], enabled=True) + + self.assert_menu_item(["Help", "Visit homepage"], enabled=True) self.assert_menu_item(["Help", "About Toga Testbed"], enabled=True) def activate_menu_close_window(self): @@ -211,3 +220,9 @@ def keystroke(self, combination): async def restore_standard_app(self): # No special handling needed to restore standard app. await self.redraw("Restore to standard app") + + async def open_initial_document(self, monkeypatch, document_path): + pytest.xfail("GTK doesn't require initial document support") + + def open_document_by_drag(self, document_path): + pytest.xfail("GTK doesn't support opening documents by drag") diff --git a/iOS/src/toga_iOS/app.py b/iOS/src/toga_iOS/app.py index 6ce828acd8..3a24161845 100644 --- a/iOS/src/toga_iOS/app.py +++ b/iOS/src/toga_iOS/app.py @@ -1,12 +1,9 @@ import asyncio -import sys from rubicon.objc import objc_method from rubicon.objc.eventloop import EventLoopPolicy, iOSLifecycle import toga -from toga.command import Command -from toga.handlers import simple_handler from toga_iOS.libs import UIResponder, UIScreen, av_foundation from .screens import Screen as ScreenImpl @@ -54,6 +51,9 @@ def application_didChangeStatusBarOrientation_( class App: # iOS apps exit when the last window is closed CLOSE_ON_LAST_WINDOW = True + # iOS doesn't have command line handling; + # but saying it does shortcuts the default handling + HANDLES_COMMAND_LINE = True def __init__(self, interface): self.interface = interface @@ -75,15 +75,8 @@ def create(self): # Commands and menus ###################################################################### - def create_app_commands(self): - self.interface.commands.add( - Command( - simple_handler(self.interface.about), - f"About {self.interface.formal_name}", - section=sys.maxsize, - id=Command.ABOUT, - ), - ) + def create_standard_commands(self): + pass def create_menus(self): # No menus on an iOS app (for now) diff --git a/iOS/src/toga_iOS/command.py b/iOS/src/toga_iOS/command.py index 8575d29e27..6c906ab348 100644 --- a/iOS/src/toga_iOS/command.py +++ b/iOS/src/toga_iOS/command.py @@ -1,7 +1,28 @@ +from toga import Command as StandardCommand + + class Command: def __init__(self, interface): self.interface = interface self.native = [] + @classmethod + def standard(cls, app, id): + if id in { + StandardCommand.ABOUT, + StandardCommand.EXIT, + StandardCommand.NEW, + StandardCommand.OPEN, + StandardCommand.PREFERENCES, + StandardCommand.SAVE, + StandardCommand.SAVE_AS, + StandardCommand.SAVE_ALL, + StandardCommand.VISIT_HOMEPAGE, + }: + # These are valid commands, but they're not defined on iOS. + return None + + raise ValueError(f"Unknown standard command {id!r}") + def set_enabled(self, value): pass diff --git a/pyproject.toml b/pyproject.toml index 66dfec5fb5..9123dc689a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,8 @@ no-cover-if-missing-PIL = "not is_installed('PIL')" no-cover-if-PIL-installed = "is_installed('PIL')" no-cover-if-lt-py310 = "sys_version_info < (3, 10) and os_environ.get('COVERAGE_EXCLUDE_PYTHON_VERSION') != 'disable'" no-cover-if-gte-py310 = "sys_version_info > (3, 10) and os_environ.get('COVERAGE_EXCLUDE_PYTHON_VERSION') != 'disable'" +no-cover-if-lt-py39 = "sys_version_info < (3, 9) and os_environ.get('COVERAGE_EXCLUDE_PYTHON_VERSION') != 'disable'" +no-cover-if-gte-py39 = "sys_version_info > (3, 9) and os_environ.get('COVERAGE_EXCLUDE_PYTHON_VERSION') != 'disable'" [tool.isort] diff --git a/testbed/src/testbed/app.py b/testbed/src/testbed/app.py index 782836a36e..1774a5bdd0 100644 --- a/testbed/src/testbed/app.py +++ b/testbed/src/testbed/app.py @@ -3,6 +3,44 @@ import toga +class ExampleDoc(toga.Document): + description = "Example Document" + extensions = ["testbed", "tbed"] + + def create(self): + # Create the main window for the document. + self.main_window = toga.DocumentWindow( + doc=self, + content=toga.Box(), + ) + self._content = Mock() + + def read(self): + if self.path.name == "broken.testbed": + raise RuntimeError("Unable to load broken document") + else: + self._content.read(self.path) + + def write(self): + self._content.write(self.path) + + +class ReadonlyDoc(toga.Document): + description = "Read-only Document" + extensions = ["other"] + + def create(self): + # Create the main window for the document. + self.main_window = toga.DocumentWindow( + doc=self, + content=toga.Box(), + ) + self._content = Mock() + + def read(self): + self._content.read(self.path) + + class Testbed(toga.App): # Objects can be added to this list to avoid them being garbage collected in the # middle of the tests running. This is problematic, at least, for WebView (#2648). @@ -88,6 +126,9 @@ def startup(self): self.deep_cmd, self.cmd5, self.cmd6, + # Add a default Preferences menu item (with no action) + # so that we can verify the command definition is valid. + toga.Command.standard(self, toga.Command.PREFERENCES), ) self.main_window = toga.MainWindow(title=self.formal_name) @@ -100,4 +141,7 @@ def startup(self): def main(): - return Testbed(app_name="testbed") + return Testbed( + app_name="testbed", + document_types=[ExampleDoc, ReadonlyDoc], + ) diff --git a/testbed/tests/app/docs/broken.testbed b/testbed/tests/app/docs/broken.testbed new file mode 100644 index 0000000000..410e053952 --- /dev/null +++ b/testbed/tests/app/docs/broken.testbed @@ -0,0 +1 @@ +A broken testbed document. diff --git a/testbed/tests/app/docs/example.testbed b/testbed/tests/app/docs/example.testbed new file mode 100644 index 0000000000..ffafcb25e5 --- /dev/null +++ b/testbed/tests/app/docs/example.testbed @@ -0,0 +1 @@ +An example testbed document. diff --git a/testbed/tests/app/test_desktop.py b/testbed/tests/app/test_desktop.py index 5d8bf9f117..53fd42ddab 100644 --- a/testbed/tests/app/test_desktop.py +++ b/testbed/tests/app/test_desktop.py @@ -106,78 +106,65 @@ async def test_menu_close_windows(monkeypatch, app, app_probe, mock_app_exit): window2 = toga.Window("Test Window 2", position=(400, 150), size=(200, 200)) window3 = toga.Window("Test Window 3", position=(300, 400), size=(200, 200)) - try: - window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) - window2.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) - window3.content = toga.Box(style=Pack(background_color=FIREBRICK)) + window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) + window2.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) + window3.content = toga.Box(style=Pack(background_color=FIREBRICK)) - window1.show() - window2.show() - window3.show() + window1.show() + window2.show() + window3.show() - app.current_window = window2 + app.current_window = window2 - await app_probe.redraw("Extra windows added") + await app_probe.redraw("Extra windows added") - app_probe.activate_menu_close_window() - await app_probe.redraw("Window 2 closed") + app_probe.activate_menu_close_window() + await app_probe.redraw("Window 2 closed") - assert window2 not in app.windows + assert window2 not in app.windows - app_probe.activate_menu_close_all_windows() - await app_probe.redraw("All windows closed") + app_probe.activate_menu_close_all_windows() + await app_probe.redraw("All windows closed") - # Close all windows will attempt to close the main window as well. - # This would be an app exit, but we can't allow that; so, the only - # window that *actually* remains will be the main window. - mock_app_exit.assert_called_once_with() - assert window1 not in app.windows - assert window2 not in app.windows - assert window3 not in app.windows + # Close all windows will attempt to close the main window as well. + # This would be an app exit, but we can't allow that; so, the only + # window that *actually* remains will be the main window. + mock_app_exit.assert_called_once_with() + assert window1 not in app.windows + assert window2 not in app.windows + assert window3 not in app.windows - await app_probe.redraw("Extra windows closed") + await app_probe.redraw("Extra windows closed") - # Now that we've "closed" all the windows, we're in a state where there - # aren't any windows. Patch get_current_window to reflect this. - monkeypatch.setattr( - app._impl, - "get_current_window", - Mock(return_value=None), - ) - app_probe.activate_menu_close_window() - await app_probe.redraw("No windows; Close Window is a no-op") + # Now that we've "closed" all the windows, we're in a state where there + # aren't any windows. Patch get_current_window to reflect this. + monkeypatch.setattr( + app._impl, + "get_current_window", + Mock(return_value=None), + ) + app_probe.activate_menu_close_window() + await app_probe.redraw("No windows; Close Window is a no-op") - app_probe.activate_menu_minimize() - await app_probe.redraw("No windows; Minimize is a no-op") - - finally: - if window1 in app.windows: - window1.close() - if window2 in app.windows: - window2.close() - if window3 in app.windows: - window3.close() + app_probe.activate_menu_minimize() + await app_probe.redraw("No windows; Minimize is a no-op") async def test_menu_minimize(app, app_probe): """Windows can be minimized by a menu item""" window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) + window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) + window1.show() - try: - window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) - window1.show() + window1_probe = window_probe(app, window1) - window1_probe = window_probe(app, window1) + app.current_window = window1 + await app_probe.redraw("Extra window added") - app.current_window = window1 - await app_probe.redraw("Extra window added") + app_probe.activate_menu_minimize() - app_probe.activate_menu_minimize() - - await window1_probe.wait_for_window("Extra window minimized", minimize=True) - assert window1_probe.is_minimized - finally: - window1.close() + await window1_probe.wait_for_window("Extra window minimized", minimize=True) + assert window1_probe.is_minimized async def test_full_screen(app, app_probe): @@ -185,101 +172,96 @@ async def test_full_screen(app, app_probe): window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) window2 = toga.Window("Test Window 2", position=(400, 150), size=(200, 200)) - try: - window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) - window2.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) - window1_probe = window_probe(app, window1) - window2_probe = window_probe(app, window2) - - window1.show() - window2.show() - await app_probe.redraw("Extra windows are visible") - - assert not app.is_full_screen - assert not app_probe.is_full_screen(window1) - assert not app_probe.is_full_screen(window2) - initial_content1_size = app_probe.content_size(window1) - initial_content2_size = app_probe.content_size(window2) - - # Make window 2 full screen via the app - app.set_full_screen(window2) - await window2_probe.wait_for_window( - "Second extra window is full screen", - full_screen=True, - ) - assert app.is_full_screen - - assert not app_probe.is_full_screen(window1) - assert app_probe.content_size(window1) == initial_content1_size - - assert app_probe.is_full_screen(window2) - assert app_probe.content_size(window2)[0] > 1000 - assert app_probe.content_size(window2)[1] > 700 - - # Make window 1 full screen via the app, window 2 no longer full screen - app.set_full_screen(window1) - await window1_probe.wait_for_window( - "First extra window is full screen", - full_screen=True, - ) - assert app.is_full_screen - - assert app_probe.is_full_screen(window1) - assert app_probe.content_size(window1)[0] > 1000 - assert app_probe.content_size(window1)[1] > 700 - - assert not app_probe.is_full_screen(window2) - assert app_probe.content_size(window2) == initial_content2_size - - # Exit full screen - app.exit_full_screen() - await window1_probe.wait_for_window( - "No longer full screen", - full_screen=True, - ) - - assert not app.is_full_screen - - assert not app_probe.is_full_screen(window1) - assert app_probe.content_size(window1) == initial_content1_size - - assert not app_probe.is_full_screen(window2) - assert app_probe.content_size(window2) == initial_content2_size - - # Go full screen again on window 1 - app.set_full_screen(window1) - # A longer delay to allow for genie animations - await window1_probe.wait_for_window( - "First extra window is full screen", - full_screen=True, - ) - assert app.is_full_screen - - assert app_probe.is_full_screen(window1) - assert app_probe.content_size(window1)[0] > 1000 - assert app_probe.content_size(window1)[1] > 700 - - assert not app_probe.is_full_screen(window2) - assert app_probe.content_size(window2) == initial_content2_size - - # Exit full screen by passing no windows - app.set_full_screen() - - await window1_probe.wait_for_window( - "No longer full screen", - full_screen=True, - ) - assert not app.is_full_screen - - assert not app_probe.is_full_screen(window1) - assert app_probe.content_size(window1) == initial_content1_size - - assert not app_probe.is_full_screen(window2) - assert app_probe.content_size(window2) == initial_content2_size - - finally: - window1.close() - window2.close() + window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) + window2.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) + window1_probe = window_probe(app, window1) + window2_probe = window_probe(app, window2) + + window1.show() + window2.show() + await app_probe.redraw("Extra windows are visible") + + assert not app.is_full_screen + assert not app_probe.is_full_screen(window1) + assert not app_probe.is_full_screen(window2) + initial_content1_size = app_probe.content_size(window1) + initial_content2_size = app_probe.content_size(window2) + + # Make window 2 full screen via the app + app.set_full_screen(window2) + await window2_probe.wait_for_window( + "Second extra window is full screen", + full_screen=True, + ) + assert app.is_full_screen + + assert not app_probe.is_full_screen(window1) + assert app_probe.content_size(window1) == initial_content1_size + + assert app_probe.is_full_screen(window2) + assert app_probe.content_size(window2)[0] > 1000 + assert app_probe.content_size(window2)[1] > 700 + + # Make window 1 full screen via the app, window 2 no longer full screen + app.set_full_screen(window1) + await window1_probe.wait_for_window( + "First extra window is full screen", + full_screen=True, + ) + assert app.is_full_screen + + assert app_probe.is_full_screen(window1) + assert app_probe.content_size(window1)[0] > 1000 + assert app_probe.content_size(window1)[1] > 700 + + assert not app_probe.is_full_screen(window2) + assert app_probe.content_size(window2) == initial_content2_size + + # Exit full screen + app.exit_full_screen() + await window1_probe.wait_for_window( + "No longer full screen", + full_screen=True, + ) + + assert not app.is_full_screen + + assert not app_probe.is_full_screen(window1) + assert app_probe.content_size(window1) == initial_content1_size + + assert not app_probe.is_full_screen(window2) + assert app_probe.content_size(window2) == initial_content2_size + + # Go full screen again on window 1 + app.set_full_screen(window1) + # A longer delay to allow for genie animations + await window1_probe.wait_for_window( + "First extra window is full screen", + full_screen=True, + ) + assert app.is_full_screen + + assert app_probe.is_full_screen(window1) + assert app_probe.content_size(window1)[0] > 1000 + assert app_probe.content_size(window1)[1] > 700 + + assert not app_probe.is_full_screen(window2) + assert app_probe.content_size(window2) == initial_content2_size + + # Exit full screen by passing no windows + app.set_full_screen() + + await window1_probe.wait_for_window( + "No longer full screen", + full_screen=True, + ) + assert not app.is_full_screen + + assert not app_probe.is_full_screen(window1) + assert app_probe.content_size(window1) == initial_content1_size + + assert not app_probe.is_full_screen(window2) + assert app_probe.content_size(window2) == initial_content2_size async def test_show_hide_cursor(app, app_probe): @@ -325,36 +307,29 @@ async def test_current_window(app, app_probe, main_window): window2 = toga.Window("Test Window 2", position=(400, 150), size=(200, 200)) window3 = toga.Window("Test Window 3", position=(300, 400), size=(200, 200)) - try: - window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) - window2.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) - window3.content = toga.Box(style=Pack(background_color=FIREBRICK)) - - # We don't need to probe anything window specific; we just need - # a window probe to enforce appropriate delays. - window1_probe = window_probe(app, window1) + window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) + window2.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) + window3.content = toga.Box(style=Pack(background_color=FIREBRICK)) - window1.show() - window2.show() - window3.show() + # We don't need to probe anything window specific; we just need + # a window probe to enforce appropriate delays. + window1_probe = window_probe(app, window1) - await window1_probe.wait_for_window("Extra windows added") + window1.show() + window2.show() + window3.show() - app.current_window = window2 - await window1_probe.wait_for_window("Window 2 is current") - if app_probe.supports_current_window_assignment: - assert app.current_window == window2 + await window1_probe.wait_for_window("Extra windows added") - app.current_window = window3 - await window1_probe.wait_for_window("Window 3 is current") - if app_probe.supports_current_window_assignment: - assert app.current_window == window3 + app.current_window = window2 + await window1_probe.wait_for_window("Window 2 is current") + if app_probe.supports_current_window_assignment: + assert app.current_window == window2 - # app_probe.platform tests? - finally: - window1.close() - window2.close() - window3.close() + app.current_window = window3 + await window1_probe.wait_for_window("Window 3 is current") + if app_probe.supports_current_window_assignment: + assert app.current_window == window3 async def test_session_based_app( @@ -427,9 +402,6 @@ async def test_session_based_app( app.main_window = main_window await app_probe.restore_standard_app() - if secondary_window: - secondary_window.close() - async def test_background_app( monkeypatch, @@ -489,6 +461,3 @@ async def test_background_app( finally: app.main_window = main_window await app_probe.restore_standard_app() - - if secondary_window: - secondary_window.close() diff --git a/testbed/tests/app/test_document_app.py b/testbed/tests/app/test_document_app.py new file mode 100644 index 0000000000..924f64050d --- /dev/null +++ b/testbed/tests/app/test_document_app.py @@ -0,0 +1,178 @@ +from pathlib import Path + +import pytest + +import toga +from testbed.app import ExampleDoc + +#################################################################################### +# Document API tests +#################################################################################### +if toga.platform.current_platform not in {"macOS", "windows", "linux"}: + pytest.skip( + "Document API is specific to desktop platforms", allow_module_level=True + ) + + +async def test_new_document(app, app_probe): + """A new document can be created.""" + # Create a new document + app.documents.new(ExampleDoc) + + await app_probe.redraw("New document has been created") + + assert len(app.documents) == 1 + assert len(app.windows) == 2 + + # Document has not been read + app.documents[0]._content.read.assert_not_called() + app.documents[0].title == "Example document: Untitled" + + +async def test_open_document(app, app_probe): + """A document can be opened.""" + # A document can be opened + document_path = Path(__file__).parent / "docs/example.testbed" + app.documents.open(document_path) + + await app_probe.redraw("Document has been opened") + + assert len(app.documents) == 1 + assert len(app.windows) == 2 + + # Document has been read. + app.documents[0]._content.read.assert_called_with(document_path) + + +async def test_open_missing_document(app, app_probe): + """If an attempt is made to open a missing file, an error is raised.""" + # If the file doesn't exist, an exception is raised + with pytest.raises(FileNotFoundError): + app.documents.open(Path(__file__).parent / "docs/does_not_exist.testbed") + + await app_probe.redraw("Attempt to open a missing document has been made") + + # No document or document window has been opened + assert len(app.documents) == 0 + assert len(app.windows) == 1 + + +async def test_open_bad_document(app, app_probe, capsys): + """If an error is raised reading a file, an error is raised.""" + # A document can be opened + document_path = Path(__file__).parent / "docs/broken.testbed" + with pytest.raises( + RuntimeError, + match=r"Unable to load broken document", + ): + app.documents.open(document_path) + + await app_probe.redraw("Attempt to open a bad document has been made") + + # No document or document window has been opened + assert len(app.documents) == 0 + assert len(app.windows) == 1 + + +async def test_open_initial_document(monkeypatch, app, app_probe): + """An initial document can be opened.""" + document_path = Path(__file__).parent / "docs/example.testbed" + + # Trigger the opening of the initial document. What this means is platform + # dependent, so trust the probe to validate what this means + await app_probe.open_initial_document(monkeypatch, document_path) + + assert len(app.documents) == 1 + assert len(app.windows) == 2 + + # Document has been read. + app.documents[0]._content.read.assert_called_with(document_path) + + +async def test_open_document_by_drag(app, app_probe): + """A file can be If an attempt is made to open a file by dragging, an error is raised.""" + document_path = Path(__file__).parent / "docs/example.testbed" + app_probe.open_document_by_drag(document_path) + + await app_probe.redraw("Document has been opened by drag", delay=1) + + assert len(app.documents) == 1 + assert len(app.windows) == 2 + + # Document has been read. + app.documents[0]._content.read.assert_called_with(document_path) + + +async def test_save_document(app, app_probe): + """A document can be saved.""" + # A document can be opened + document_path = Path(__file__).parent / "docs/example.testbed" + app.documents.open(document_path) + + await app_probe.redraw("Document has been opened") + + assert len(app.documents) == 1 + assert len(app.windows) == 2 + + # Document has been read. + app.documents[0]._content.read.assert_called_with(document_path) + + # Save the document + await app.documents.save() + await app_probe.redraw("Document has been saved") + + # Document has been saved. + app.documents[0]._content.write.assert_called_with(document_path) + + +async def test_save_as_document(monkeypatch, app, app_probe, tmp_path): + """A document can be saved under a new filename.""" + + # A document can be opened + document_path = Path(__file__).parent / "docs/example.testbed" + document = app.documents.open(document_path) + + # Monkeypatch the save_as dialog handling so that a dialog isn't activated. + async def mock_save_as_dialog(dialog): + return tmp_path / "new_filename.testbed" + + monkeypatch.setattr(document.main_window, "dialog", mock_save_as_dialog) + + await app_probe.redraw("Document has been opened") + + assert len(app.documents) == 1 + assert len(app.windows) == 2 + + # Document has been read. + app.documents[0]._content.read.assert_called_with(document_path) + + # Save the document in a new location + await app.documents.save_as() + await app_probe.redraw("Document has been saved with a new filename") + + # Document has been saved in a new location + app.documents[0]._content.write.assert_called_with( + tmp_path / "new_filename.testbed" + ) + + +async def test_save_all_documents(app, app_probe): + """All documents can be saved.""" + # A document can be opened + document_path = Path(__file__).parent / "docs/example.testbed" + app.documents.open(document_path) + + await app_probe.redraw("Document has been opened") + + assert len(app.documents) == 1 + assert len(app.windows) == 2 + + # Document has been read. + app.documents[0]._content.read.assert_called_with(document_path) + + # Save all windows in the app + await app.documents.save_all() + await app_probe.redraw("Save All has been invoked") + + # Document has been saved. + app.documents[0]._content.write.assert_called_with(document_path) diff --git a/testbed/tests/test_command.py b/testbed/tests/test_command.py new file mode 100644 index 0000000000..f2252fbad4 --- /dev/null +++ b/testbed/tests/test_command.py @@ -0,0 +1,9 @@ +import pytest + +from toga import Command + + +async def test_unknown_system_command(app): + """Attempting to create an unknown standard command raises an error.""" + with pytest.raises(ValueError, match=r"Unknown standard command 'mystery'"): + Command.standard(app, "mystery") diff --git a/testbed/tests/window/test_window.py b/testbed/tests/window/test_window.py index 61339fc45a..7e5049bafd 100644 --- a/testbed/tests/window/test_window.py +++ b/testbed/tests/window/test_window.py @@ -26,11 +26,7 @@ async def second_window_probe(app, app_probe, second_window): second_window.show() probe = window_probe(app, second_window) await probe.wait_for_window(f"Window ({second_window.title}) has been created") - yield probe - if second_window in app.windows: - second_window.close() - del second_window - gc.collect() + return probe async def test_title(main_window, main_window_probe): @@ -253,9 +249,6 @@ async def test_secondary_window_with_content(app): assert window_with_content.content == content finally: window_with_content.close() - await window_with_content_probe.redraw("Secondary window has been closed") - del window_with_content - gc.collect() async def test_secondary_window_cleanup(app_probe): """Memory for windows is cleaned up when windows are deleted.""" diff --git a/textual/src/toga_textual/app.py b/textual/src/toga_textual/app.py index 3c88986715..acf2b02105 100644 --- a/textual/src/toga_textual/app.py +++ b/textual/src/toga_textual/app.py @@ -1,10 +1,7 @@ import asyncio -import sys import toga from textual.app import App as TextualApp -from toga.command import Command -from toga.handlers import simple_handler from .screens import Screen as ScreenImpl @@ -22,6 +19,8 @@ def on_mount(self) -> None: class App: # Textual apps exit when the last window is closed CLOSE_ON_LAST_WINDOW = True + # Textual apps use default command line handling + HANDLES_COMMAND_LINE = False def __init__(self, interface): self.interface = interface @@ -38,15 +37,8 @@ def create(self): # Commands and menus ###################################################################### - def create_app_commands(self): - self.interface.commands.add( - Command( - simple_handler(self.interface.about), - f"About {self.interface.formal_name}", - section=sys.maxsize, - id=Command.ABOUT, - ), - ) + def create_standard_commands(self): + pass def create_menus(self): self.interface.factory.not_implemented("App.create_menus()") @@ -120,7 +112,3 @@ def enter_full_screen(self, windows): def exit_full_screen(self, windows): pass - - -class DocumentApp(App): - pass diff --git a/textual/src/toga_textual/command.py b/textual/src/toga_textual/command.py new file mode 100644 index 0000000000..c13f454b09 --- /dev/null +++ b/textual/src/toga_textual/command.py @@ -0,0 +1,32 @@ +from toga import Command as StandardCommand + + +class Command: + """Command `native` property is a list of native widgets associated with the + command.""" + + def __init__(self, interface): + self.interface = interface + self.native = [] + + @classmethod + def standard(cls, app, id): + # ---- Non-existent commands ---------------------------------- + if id in { + StandardCommand.ABOUT, + StandardCommand.EXIT, + StandardCommand.NEW, + StandardCommand.OPEN, + StandardCommand.PREFERENCES, + StandardCommand.SAVE, + StandardCommand.SAVE_AS, + StandardCommand.SAVE_ALL, + StandardCommand.VISIT_HOMEPAGE, + }: + # These commands are valid, but don't exist on textual. + return None + + raise ValueError(f"Unknown standard command {id!r}") + + def set_enabled(self, value): + pass diff --git a/textual/src/toga_textual/factory.py b/textual/src/toga_textual/factory.py index dd7ec3f442..8113fbda21 100644 --- a/textual/src/toga_textual/factory.py +++ b/textual/src/toga_textual/factory.py @@ -1,10 +1,9 @@ from toga import NotImplementedWarning from . import dialogs -from .app import App, DocumentApp +from .app import App +from .command import Command -# from .command import Command -# from .documents import Document # from .fonts import Font from .icons import Icon @@ -49,9 +48,7 @@ def not_implemented(feature): __all__ = [ "not_implemented", "App", - "DocumentApp", - # "Command", - # "Document", + "Command", # "Font", "Icon", # "Image", diff --git a/web/src/toga_web/app.py b/web/src/toga_web/app.py index 2e29edbbdb..5ca159a80f 100644 --- a/web/src/toga_web/app.py +++ b/web/src/toga_web/app.py @@ -1,7 +1,6 @@ +import asyncio + import toga -from toga.app import overridden -from toga.command import Command, Group -from toga.handlers import simple_handler from toga_web.libs import create_element, js from .screens import Screen as ScreenImpl @@ -10,11 +9,15 @@ class App: # Web apps exit when the last window is closed CLOSE_ON_LAST_WINDOW = True + # Web apps use default command line handling + HANDLES_COMMAND_LINE = False def __init__(self, interface): self.interface = interface self.interface._impl = self + self.loop = asyncio.new_event_loop() + def create(self): self.native = js.document.getElementById("app-placeholder") # self.resource_path = ??? @@ -26,27 +29,8 @@ def create(self): # Commands and menus ###################################################################### - def create_app_commands(self): - self.interface.commands.add( - # ---- Help menu ---------------------------------- - Command( - simple_handler(self.interface.about), - f"About {self.interface.formal_name}", - group=Group.HELP, - id=Command.ABOUT, - ), - ) - - # If the user has overridden preferences, provide a menu item. - if overridden(self.interface.preferences): - self.interface.commands.add( - Command( - simple_handler(self.interface.preferences), - "Preferences", - group=Group.HELP, - id=Command.PREFERENCES, - ) - ) # pragma: no cover + def create_standard_commands(self): + pass def create_menus(self): # Web menus are created on the Window. diff --git a/web/src/toga_web/command.py b/web/src/toga_web/command.py index 3378a4e760..2ac59e4fe0 100644 --- a/web/src/toga_web/command.py +++ b/web/src/toga_web/command.py @@ -1,3 +1,6 @@ +from toga import Command as StandardCommand, Group + + class Command: """Command `native` property is a list of native widgets associated with the command.""" @@ -6,6 +9,34 @@ def __init__(self, interface): self.interface = interface self.native = [] + @classmethod + def standard(cls, app, id): + # ---- Help menu ---------------------------------- + if id == StandardCommand.ABOUT: + return { + "text": f"About {app.formal_name}", + "group": Group.HELP, + } + elif id == StandardCommand.PREFERENCES: + return { + "text": "Preferences", + "group": Group.HELP, + } + # ---- Non-existent commands ---------------------------------- + elif id in { + StandardCommand.EXIT, + StandardCommand.NEW, + StandardCommand.OPEN, + StandardCommand.SAVE, + StandardCommand.SAVE_AS, + StandardCommand.SAVE_ALL, + StandardCommand.VISIT_HOMEPAGE, + }: + # These commands are valid, but don't exist on web. + return None + + raise ValueError(f"Unknown standard command {id!r}") + def dom_click(self, event): self.interface.action() diff --git a/web/src/toga_web/factory.py b/web/src/toga_web/factory.py index bef93488e7..5ba6c83056 100644 --- a/web/src/toga_web/factory.py +++ b/web/src/toga_web/factory.py @@ -4,7 +4,6 @@ from .app import App from .command import Command -# from .documents import Document # from .fonts import Font from .icons import Icon @@ -47,10 +46,8 @@ def not_implemented(feature): __all__ = [ "not_implemented", "App", - # 'DocumentApp', "Command", - # 'Document', - # # Resources + # Resources # 'Font', "Icon", # 'Image', diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index aaa9b99435..de1fd96793 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -10,11 +10,6 @@ from System.Net import SecurityProtocolType, ServicePointManager from System.Windows.Threading import Dispatcher -import toga -from toga.app import overridden -from toga.command import Command, Group -from toga.handlers import simple_handler - from .libs.proactor import WinformsProactorEventLoop from .libs.wrapper import WeakrefCallable from .screens import Screen as ScreenImpl @@ -57,6 +52,8 @@ def print_stack_trace(stack_trace_line): # pragma: no cover class App: # Winforms apps exit when the last window is closed CLOSE_ON_LAST_WINDOW = True + # Winforms apps use default command line handling + HANDLES_COMMAND_LINE = False def __init__(self, interface): self.interface = interface @@ -133,47 +130,8 @@ def create(self): # Commands and menus ###################################################################### - def create_app_commands(self): - self.interface.commands.add( - # ---- File menu ----------------------------------- - # Quit should always be the last item, in a section on its own. Invoke - # `_request_exit` rather than `exit`, because we want to trigger the "OK to - # exit?" logic. - Command( - simple_handler(self.interface._request_exit), - "Exit", - shortcut=toga.Key.MOD_1 + "q", - group=Group.FILE, - section=sys.maxsize, - id=Command.EXIT, - ), - # ---- Help menu ----------------------------------- - Command( - simple_handler(self.interface.visit_homepage), - "Visit homepage", - enabled=self.interface.home_page is not None, - group=Group.HELP, - id=Command.VISIT_HOMEPAGE, - ), - Command( - simple_handler(self.interface.about), - f"About {self.interface.formal_name}", - group=Group.HELP, - section=sys.maxsize, - id=Command.ABOUT, - ), - ) - - # If the user has overridden preferences, provide a menu item. - if overridden(self.interface.preferences): - self.interface.commands.add( - Command( - simple_handler(self.interface.preferences), - "Preferences", - group=Group.FILE, - id=Command.PREFERENCES, - ), - ) # pragma: no cover + def create_standard_commands(self): + pass def create_menus(self): # Winforms menus are created on the Window. @@ -309,17 +267,3 @@ def enter_full_screen(self, windows): def exit_full_screen(self, windows): for window in windows: window._impl.set_full_screen(False) - - -class DocumentApp(App): # pragma: no cover - def create_app_commands(self): - super().create_app_commands() - self.interface.commands.add( - Command( - lambda w: self.open_file, - text="Open...", - shortcut=toga.Key.MOD_1 + "o", - group=Group.FILE, - section=0, - ), - ) diff --git a/winforms/src/toga_winforms/command.py b/winforms/src/toga_winforms/command.py index 562eb7fb2e..489cbd5657 100644 --- a/winforms/src/toga_winforms/command.py +++ b/winforms/src/toga_winforms/command.py @@ -1,8 +1,86 @@ +import sys + +from toga import Command as StandardCommand, Group, Key + + class Command: def __init__(self, interface): self.interface = interface self.native = [] + @classmethod + def standard(self, app, id): + # ---- File menu ----------------------------------- + if id == StandardCommand.NEW: + return { + "text": "New", + "shortcut": Key.MOD_1 + "n", + "group": Group.FILE, + "section": 0, + "order": 0, + } + elif id == StandardCommand.OPEN: + return { + "text": "Open...", + "shortcut": Key.MOD_1 + "o", + "group": Group.FILE, + "section": 0, + "order": 10, + } + elif id == StandardCommand.SAVE: + return { + "text": "Save", + "shortcut": Key.MOD_1 + "s", + "group": Group.FILE, + "section": 0, + "order": 20, + } + elif id == StandardCommand.SAVE_AS: + return { + "text": "Save As...", + "shortcut": Key.MOD_1 + "S", + "group": Group.FILE, + "section": 0, + "order": 21, + } + elif id == StandardCommand.SAVE_ALL: + return { + "text": "Save All", + "shortcut": Key.MOD_1 + Key.MOD_2 + "s", + "group": Group.FILE, + "section": 0, + "order": 22, + } + elif id == StandardCommand.PREFERENCES: + # Preferences should be towards the end of the File menu. + return { + "text": "Preferences", + "group": Group.FILE, + "section": sys.maxsize - 1, + } + elif id == StandardCommand.EXIT: + # Quit should always be the last item, in a section on its own. + return { + "text": "Exit", + "group": Group.FILE, + "section": sys.maxsize, + } + # ---- Help menu ----------------------------------- + elif id == StandardCommand.VISIT_HOMEPAGE: + return { + "text": "Visit homepage", + "enabled": app.home_page is not None, + "group": Group.HELP, + } + elif id == StandardCommand.ABOUT: + return { + "text": f"About {app.formal_name}", + "group": Group.HELP, + "section": sys.maxsize, + } + + raise ValueError(f"Unknown standard command {id!r}") + def winforms_Click(self, sender, event): return self.interface.action() diff --git a/winforms/tests_backend/app.py b/winforms/tests_backend/app.py index 520421a24b..323d7d630f 100644 --- a/winforms/tests_backend/app.py +++ b/winforms/tests_backend/app.py @@ -185,6 +185,13 @@ def assert_menu_order(self, path, expected): assert item.Text == title def assert_system_menus(self): + self.assert_menu_item(["File", "New Example Document"], enabled=True) + self.assert_menu_item(["File", "New Read-only Document"], enabled=True) + self.assert_menu_item(["File", "Open..."], enabled=True) + self.assert_menu_item(["File", "Save"], enabled=True) + self.assert_menu_item(["File", "Save As..."], enabled=True) + self.assert_menu_item(["File", "Save All"], enabled=True) + self.assert_menu_item(["File", "Preferences"], enabled=False) self.assert_menu_item(["File", "Exit"]) self.assert_menu_item(["Help", "Visit homepage"]) @@ -205,3 +212,9 @@ def keystroke(self, combination): async def restore_standard_app(self): # No special handling needed to restore standard app. await self.redraw("Restore to standard app") + + async def open_initial_document(self, monkeypatch, document_path): + pytest.xfail("Winforms doesn't require initial document support") + + def open_document_by_drag(self, document_path): + pytest.xfail("Winforms doesn't support opening documents by drag")