diff --git a/android/src/toga_android/app.py b/android/src/toga_android/app.py index a85a6ca7dd..74085f3a01 100644 --- a/android/src/toga_android/app.py +++ b/android/src/toga_android/app.py @@ -9,15 +9,11 @@ from java import dynamic_proxy from org.beeware.android import IPythonApp, MainActivity +import toga from toga.command import Command, Group, Separator from .libs import events from .screens import Screen as ScreenImpl -from .window import Window - - -class MainWindow(Window): - _is_main_window = True class TogaApp(dynamic_proxy(IPythonApp)): @@ -95,6 +91,11 @@ def onOptionsItemSelected(self, menuitem): return True def onPrepareOptionsMenu(self, menu): + # If the main window doesn't have a toolbar, there's no preparation required; + # this is a simple main window, which can't have commands. + if not hasattr(self._impl.interface.main_window, "toolbar"): + return False + menu.clear() itemid = 1 # 0 is the same as Menu.NONE. groupid = 1 @@ -179,6 +180,9 @@ def onPrepareOptionsMenu(self, menu): class App: + # Android apps exit when the last window is closed + CLOSE_ON_LAST_WINDOW = True + def __init__(self, interface): self.interface = interface self.interface._impl = self @@ -190,14 +194,6 @@ def __init__(self, interface): def native(self): return self._listener.native if self._listener else None - def create(self): - # The `_listener` listens for activity event callbacks. For simplicity, - # the app's `.native` is the listener's native Java class. - self._listener = TogaApp(self) - # Call user code to populate the main window - self.interface._startup() - self.create_app_commands() - ###################################################################### # Commands and menus ###################################################################### @@ -206,14 +202,15 @@ 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( - lambda _: self.interface.about(), + self.interface._menu_about, f"About {self.interface.formal_name}", section=sys.maxsize, ), ) def create_menus(self): - self.native.invalidateOptionsMenu() # Triggers onPrepareOptionsMenu + # On Android, menus and toolbars are populated using the same mechanism. + self.interface.main_window._impl.create_toolbar() ###################################################################### # App lifecycle @@ -222,16 +219,28 @@ def create_menus(self): def exit(self): pass # pragma: no cover + def finalize(self): + self.create_app_commands() + self.create_menus() + def main_loop(self): # In order to support user asyncio code, start the Python/Android cooperative event loop. self.loop.run_forever_cooperatively() - # On Android, Toga UI integrates automatically into the main Android event loop by virtue - # of the Android Activity system. - self.create() + # On Android, Toga UI integrates automatically into the main Android event loop + # by virtue of the Android Activity system. The `_listener` listens for activity + # event callbacks. For simplicity, the app's `.native` is the listener's native + # Java class. + self._listener = TogaApp(self) + + # Call user code to populate the main window + self.interface._startup() def set_main_window(self, window): - pass + if window is None: + raise RuntimeError("Session-based apps are not supported on Android") + elif window == toga.App.BACKGROUND: + raise RuntimeError("Background apps are not supported on Android") ###################################################################### # App resources diff --git a/android/src/toga_android/factory.py b/android/src/toga_android/factory.py index 4e1c4a16d9..f1bf8621b4 100644 --- a/android/src/toga_android/factory.py +++ b/android/src/toga_android/factory.py @@ -1,7 +1,7 @@ from toga import NotImplementedWarning from . import dialogs -from .app import App, MainWindow +from .app import App from .command import Command from .fonts import Font from .hardware.camera import Camera @@ -30,7 +30,7 @@ from .widgets.textinput import TextInput from .widgets.timeinput import TimeInput from .widgets.webview import WebView -from .window import Window +from .window import MainWindow, Window def not_implemented(feature): @@ -40,7 +40,6 @@ def not_implemented(feature): __all__ = [ "App", "Command", - "MainWindow", "not_implemented", # Resources "dialogs", @@ -76,7 +75,9 @@ def not_implemented(feature): "TimeInput", # "Tree", "WebView", + # Windows "Window", + "MainWindow", ] diff --git a/android/src/toga_android/window.py b/android/src/toga_android/window.py index 6fbf0a9ea9..bfc8a16765 100644 --- a/android/src/toga_android/window.py +++ b/android/src/toga_android/window.py @@ -29,19 +29,12 @@ def onGlobalLayout(self): class Window(Container): - _is_main_window = False - def __init__(self, interface, title, position, size): super().__init__() self.interface = interface self.interface._impl = self self._initial_title = title - if not self._is_main_window: - raise RuntimeError( - "Secondary windows cannot be created on mobile platforms" - ) - ###################################################################### # Window properties ###################################################################### @@ -60,17 +53,27 @@ def close(self): pass def create_toolbar(self): - self.app.native.invalidateOptionsMenu() + # Simple windows don't have a titlebar + pass + + def _configure_titlebar(self): + # Simple windows hide the titlebar. + self.app.native.getSupportActionBar().hide() def set_app(self, app): + if len(app.interface.windows) > 1: + raise RuntimeError("Secondary windows cannot be created on Android") + self.app = app - native_parent = app.native.findViewById(R.id.content) + native_parent = self.app.native.findViewById(R.id.content) self.init_container(native_parent) native_parent.getViewTreeObserver().addOnGlobalLayoutListener( LayoutListener(self) ) self.set_title(self._initial_title) + self._configure_titlebar() + def show(self): pass @@ -155,3 +158,12 @@ def get_image_data(self): stream = ByteArrayOutputStream() bitmap.compress(Bitmap.CompressFormat.PNG, 0, stream) return bytes(stream.toByteArray()) + + +class MainWindow(Window): + def _configure_titlebar(self): + # The titlebar will be visible by default. + pass + + def create_toolbar(self): + self.app.native.invalidateOptionsMenu() diff --git a/changes/1870.feature.rst b/changes/1870.feature.rst new file mode 100644 index 0000000000..ef118bea01 --- /dev/null +++ b/changes/1870.feature.rst @@ -0,0 +1 @@ +Apps can now create a main window that does not have a menu bar. diff --git a/changes/2209.feature.rst b/changes/2209.feature.rst new file mode 100644 index 0000000000..028cfa73d7 --- /dev/null +++ b/changes/2209.feature.rst @@ -0,0 +1 @@ +The API for document-based apps has been finalized. diff --git a/changes/2209.removal.rst b/changes/2209.removal.rst new file mode 100644 index 0000000000..ec102ba62e --- /dev/null +++ b/changes/2209.removal.rst @@ -0,0 +1 @@ +The separate ``toga.DocumentApp`` base class is no longer required. All uses of ``toga.DocumentApp`` can be replaced with ``toga.App`. diff --git a/cocoa/src/toga_cocoa/app.py b/cocoa/src/toga_cocoa/app.py index d1587edaf3..6e8e15a1a7 100644 --- a/cocoa/src/toga_cocoa/app.py +++ b/cocoa/src/toga_cocoa/app.py @@ -1,25 +1,33 @@ +from __future__ import annotations + import asyncio import inspect -import os import sys from pathlib import Path from urllib.parse import unquote, urlparse +from rubicon.objc import ( + SEL, + NSObject, + objc_method, + objc_property, +) from rubicon.objc.eventloop import CocoaLifecycle, EventLoopPolicy import toga +from toga.app import overridden from toga.command import Separator -from toga.handlers import NativeHandler +from toga.handlers import DynamicHandler, NativeHandler from .keys import cocoa_key from .libs import ( NSURL, - SEL, NSAboutPanelOptionApplicationIcon, NSAboutPanelOptionApplicationName, NSAboutPanelOptionApplicationVersion, NSAboutPanelOptionVersion, NSApplication, + NSApplicationActivationPolicyAccessory, NSApplicationActivationPolicyRegular, NSBeep, NSBundle, @@ -30,26 +38,11 @@ NSMutableArray, NSMutableDictionary, NSNumber, - NSObject, NSOpenPanel, NSScreen, NSString, - objc_method, - objc_property, ) from .screens import Screen as ScreenImpl -from .window import Window - - -class MainWindow(Window): - def cocoa_windowShouldClose(self): - # Main Window close is a proxy for "Exit app". - # Defer all handling to the app's on_exit handler. - # As a result of calling that method, the app will either - # exit, or the user will cancel the exit; in which case - # the main window shouldn't close, either. - self.interface.app.on_exit() - return False class AppDelegate(NSObject): @@ -61,27 +54,39 @@ 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() - return True + def applicationOpenUntitledFile_(self, sender) -> bool: + if self.interface.document_types and self.interface.main_window is None: + # We can't use the normal "file open" logic here, because there's no + # current window to hang the dialog off. + panel = NSOpenPanel.openPanel() + fileTypes = NSMutableArray.alloc().init() + for filetype in self.interface.document_types: + fileTypes.addObject(filetype) + + NSDocumentController.sharedDocumentController.runModalOpenPanel( + panel, forTypes=fileTypes + ) + + self.application(None, openFiles=panel.URLs) + + return True + return False @objc_method - def addDocument_(self, document) -> None: # pragma: no cover + def addDocument_(self, document) -> None: # print("Add Document", document) super().addDocument_(document) @objc_method - def applicationShouldOpenUntitledFile_(self, sender) -> bool: # pragma: no cover + def applicationShouldOpenUntitledFile_(self, sender) -> bool: return True @objc_method - def application_openFiles_(self, app, filenames) -> None: # pragma: no cover + def application_openFiles_(self, app, filenames) -> None: for i in range(0, len(filenames)): filename = filenames[i] # If you start your Toga application as `python myapp.py` or @@ -103,7 +108,16 @@ def application_openFiles_(self, app, filenames) -> None: # pragma: no cover else: return - self.impl.open_document(str(fileURL.absoluteString)) + # Convert a Cocoa fileURL to a Python file path. + path = Path(unquote(urlparse(str(fileURL.absoluteString)).path)) + + # Try to open the file. + try: + self.interface.open(path) + except ValueError as e: + print(e) + except FileNotFoundError: + print("Document {filename} not found") @objc_method def selectMenuItem_(self, sender) -> None: @@ -117,7 +131,8 @@ def validateMenuItem_(self, sender) -> bool: class App: - _MAIN_WINDOW_CLASS = MainWindow + # macOS apps persist when there are no windows open + CLOSE_ON_LAST_WINDOW = False def __init__(self, interface): self.interface = interface @@ -128,43 +143,24 @@ def __init__(self, interface): asyncio.set_event_loop_policy(EventLoopPolicy()) self.loop = asyncio.new_event_loop() - # Stimulate the build of the app - self.create() - - def create(self): self.native = NSApplication.sharedApplication - self.native.setActivationPolicy(NSApplicationActivationPolicyRegular) - - self.native.setApplicationIconImage_(self.interface.icon._impl.native) + self.native.setApplicationIconImage(self.interface.icon._impl.native) - self.resource_path = os.path.dirname( - os.path.dirname(NSBundle.mainBundle.bundlePath) - ) + self.resource_path = Path(NSBundle.mainBundle.bundlePath).parent.parent self.appDelegate = AppDelegate.alloc().init() self.appDelegate.impl = self self.appDelegate.interface = self.interface self.appDelegate.native = self.native - self.native.setDelegate_(self.appDelegate) - - self.create_app_commands() + self.native.delegate = self.appDelegate # Call user code to populate the main window self.interface._startup() - # Create the lookup table of menu items, - # then force the creation of the menus. - self._menu_groups = {} - self._menu_items = {} - self.create_menus() - ###################################################################### # Commands and menus ###################################################################### - def _menu_about(self, command, **kwargs): - self.interface.about() - def _menu_close_all_windows(self, command, **kwargs): # Convert to a list to so that we're not altering a set while iterating for window in list(self.interface.windows): @@ -178,159 +174,250 @@ def _menu_minimize(self, command, **kwargs): if self.interface.current_window: self.interface.current_window._impl.native.miniaturize(None) - def _menu_quit(self, command, **kwargs): - self.interface.on_exit() - - def _menu_visit_homepage(self, command, **kwargs): - self.interface.visit_homepage() - def create_app_commands(self): - formal_name = self.interface.formal_name + # All macOS apps have some basic commands. self.interface.commands.add( # ---- App menu ----------------------------------- toga.Command( - self._menu_about, - "About " + formal_name, - group=toga.Group.APP, - ), - toga.Command( - None, - "Settings\u2026", - shortcut=toga.Key.MOD_1 + ",", - group=toga.Group.APP, - section=20, - ), - toga.Command( - NativeHandler(SEL("hide:")), - "Hide " + formal_name, - shortcut=toga.Key.MOD_1 + "h", - group=toga.Group.APP, - order=0, - section=sys.maxsize - 1, - ), - toga.Command( - NativeHandler(SEL("hideOtherApplications:")), - "Hide Others", - shortcut=toga.Key.MOD_1 + toga.Key.MOD_2 + "h", - group=toga.Group.APP, - order=1, - section=sys.maxsize - 1, - ), - toga.Command( - NativeHandler(SEL("unhideAllApplications:")), - "Show All", + self.interface._menu_about, + f"About {self.interface.formal_name}", group=toga.Group.APP, - order=2, - section=sys.maxsize - 1, ), # Quit should always be the last item, in a section on its own toga.Command( - self._menu_quit, - "Quit " + formal_name, + self.interface._menu_exit, + f"Quit {self.interface.formal_name}", shortcut=toga.Key.MOD_1 + "q", group=toga.Group.APP, section=sys.maxsize, ), - # ---- 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) - # have a "Close" item that becomes "Close All" when you press Option - # (MOD_2). That behavior isn't something we're currently set up to - # implement, so we live with a separate menu item for now. - toga.Command( - self._menu_close_window, - "Close", - shortcut=toga.Key.MOD_1 + "w", - group=toga.Group.FILE, - order=1, - section=50, - ), - toga.Command( - self._menu_close_all_windows, - "Close All", - shortcut=toga.Key.MOD_2 + toga.Key.MOD_1 + "w", - group=toga.Group.FILE, - order=2, - section=50, - ), - # ---- Edit menu ---------------------------------- - toga.Command( - NativeHandler(SEL("undo:")), - "Undo", - shortcut=toga.Key.MOD_1 + "z", - group=toga.Group.EDIT, - order=10, - ), - toga.Command( - NativeHandler(SEL("redo:")), - "Redo", - shortcut=toga.Key.SHIFT + toga.Key.MOD_1 + "z", - group=toga.Group.EDIT, - order=20, - ), - toga.Command( - NativeHandler(SEL("cut:")), - "Cut", - shortcut=toga.Key.MOD_1 + "x", - group=toga.Group.EDIT, - section=10, - order=10, - ), - toga.Command( - NativeHandler(SEL("copy:")), - "Copy", - shortcut=toga.Key.MOD_1 + "c", - group=toga.Group.EDIT, - section=10, - order=20, - ), - toga.Command( - NativeHandler(SEL("paste:")), - "Paste", - shortcut=toga.Key.MOD_1 + "v", - group=toga.Group.EDIT, - section=10, - order=30, - ), - toga.Command( - NativeHandler(SEL("pasteAsPlainText:")), - "Paste and Match Style", - shortcut=toga.Key.MOD_2 + toga.Key.SHIFT + toga.Key.MOD_1 + "v", - group=toga.Group.EDIT, - section=10, - order=40, - ), - toga.Command( - NativeHandler(SEL("delete:")), - "Delete", - group=toga.Group.EDIT, - section=10, - order=50, - ), - toga.Command( - NativeHandler(SEL("selectAll:")), - "Select All", - shortcut=toga.Key.MOD_1 + "a", - group=toga.Group.EDIT, - section=10, - order=60, - ), - # ---- Window menu ---------------------------------- - toga.Command( - self._menu_minimize, - "Minimize", - shortcut=toga.Key.MOD_1 + "m", - group=toga.Group.WINDOW, - ), # ---- Help menu ---------------------------------- toga.Command( - self._menu_visit_homepage, + self.interface._menu_visit_homepage, "Visit homepage", enabled=self.interface.home_page is not None, group=toga.Group.HELP, ), ) + # Register the Apple HIG commands for any app with a MainWindow, + # or any Document-based app (i.e., app with no main window). + if ( + isinstance(self.interface.main_window, toga.MainWindow) + or self.interface.main_window is None + ): + self.interface.commands.add( + toga.Command( + self.interface._menu_preferences, + "Settings\u2026", + shortcut=toga.Key.MOD_1 + ",", + group=toga.Group.APP, + section=20, + # For now, only enable preferences if the user defines an implementation + enabled=overridden(self.interface.preferences), + ), + toga.Command( + NativeHandler(SEL("hide:")), + "Hide " + self.interface.formal_name, + shortcut=toga.Key.MOD_1 + "h", + group=toga.Group.APP, + order=0, + section=sys.maxsize - 1, + ), + toga.Command( + NativeHandler(SEL("hideOtherApplications:")), + "Hide Others", + shortcut=toga.Key.MOD_1 + toga.Key.MOD_2 + "h", + group=toga.Group.APP, + order=1, + section=sys.maxsize - 1, + ), + toga.Command( + NativeHandler(SEL("unhideAllApplications:")), + "Show All", + group=toga.Group.APP, + order=2, + section=sys.maxsize - 1, + ), + # ---- 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) + # have a "Close" item that becomes "Close All" when you press Option + # (MOD_2). That behavior isn't something we're currently set up to + # implement, so we live with a separate menu item for now. + toga.Command( + DynamicHandler( + self._menu_close_window, + enabled=lambda: self.interface.current_window is not None, + ), + "Close", + shortcut=toga.Key.MOD_1 + "w", + group=toga.Group.FILE, + order=1, + section=50, + ), + toga.Command( + DynamicHandler( + self._menu_close_all_windows, + enabled=lambda: self.interface.current_window is not None, + ), + "Close All", + shortcut=toga.Key.MOD_2 + toga.Key.MOD_1 + "w", + group=toga.Group.FILE, + order=2, + section=50, + ), + # ---- Edit menu ---------------------------------- + toga.Command( + NativeHandler(SEL("undo:")), + "Undo", + shortcut=toga.Key.MOD_1 + "z", + group=toga.Group.EDIT, + order=10, + ), + toga.Command( + NativeHandler(SEL("redo:")), + "Redo", + shortcut=toga.Key.SHIFT + toga.Key.MOD_1 + "z", + group=toga.Group.EDIT, + order=20, + ), + toga.Command( + NativeHandler(SEL("cut:")), + "Cut", + shortcut=toga.Key.MOD_1 + "x", + group=toga.Group.EDIT, + section=10, + order=10, + ), + toga.Command( + NativeHandler(SEL("copy:")), + "Copy", + shortcut=toga.Key.MOD_1 + "c", + group=toga.Group.EDIT, + section=10, + order=20, + ), + toga.Command( + NativeHandler(SEL("paste:")), + "Paste", + shortcut=toga.Key.MOD_1 + "v", + group=toga.Group.EDIT, + section=10, + order=30, + ), + toga.Command( + NativeHandler(SEL("pasteAsPlainText:")), + "Paste and Match Style", + shortcut=toga.Key.MOD_2 + toga.Key.SHIFT + toga.Key.MOD_1 + "v", + group=toga.Group.EDIT, + section=10, + order=40, + ), + toga.Command( + NativeHandler(SEL("delete:")), + "Delete", + group=toga.Group.EDIT, + section=10, + order=50, + ), + toga.Command( + NativeHandler(SEL("selectAll:")), + "Select All", + shortcut=toga.Key.MOD_1 + "a", + group=toga.Group.EDIT, + section=10, + order=60, + ), + # ---- Window menu ---------------------------------- + toga.Command( + self._menu_minimize, + "Minimize", + shortcut=toga.Key.MOD_1 + "m", + group=toga.Group.WINDOW, + enabled=lambda: self.interface.current_window is not None, + ), + ) + + # Add a "New" menu item for each unique registered document type. + if self.interface.document_types: + for i, document_class in enumerate( + sorted(set(self.interface.document_types.values())) + ): + self.interface.commands.add( + toga.Command( + self.interface._menu_new_document(document_class), + text=f"New {document_class.document_type}", + shortcut=(toga.Key.MOD_1 + "n" if i == 0 else None), + group=toga.Group.FILE, + section=0, + ), + ) + + # If there's a user-provided open() implementation, or there are registered + # document types, add an Open menu item. + if overridden(self.interface.open) or self.interface.document_types: + self.interface.commands.add( + toga.Command( + self.interface._menu_open_file, + text="Open\u2026", + shortcut=toga.Key.MOD_1 + "o", + group=toga.Group.FILE, + section=10, + ), + ) + + # If there is a user-provided save() implementation, or there are registered + # document types, add a Save menu item. + if overridden(self.interface.save) or self.interface.document_types: + self.interface.commands.add( + toga.Command( + DynamicHandler( + self.interface._menu_save, + enabled=self.interface.can_save, + ), + text="Save", + shortcut=toga.Key.MOD_1 + "s", + group=toga.Group.FILE, + section=20, + order=10, + ), + ) + + # If there is a user-provided save_as() implementation, or there are registered + # document types, add a Save As menu item. + if overridden(self.interface.save_as) or self.interface.document_types: + self.interface.commands.add( + toga.Command( + DynamicHandler( + self.interface._menu_save_as, + enabled=self.interface.can_save, + ), + text="Save As\u2026", + shortcut=toga.Key.MOD_1 + "S", + group=toga.Group.FILE, + section=20, + order=11, + ), + ) + + # If there is a user-provided save_all() implementation, or there are registered + # document types, add a Save All menu item. + if overridden(self.interface.save_all) or self.interface.document_types: + self.interface.commands.add( + toga.Command( + DynamicHandler( + self.interface._menu_save_all, + enabled=self.interface.can_save_all, + ), + text="Save All", + shortcut=toga.Key.MOD_1 + toga.Key.MOD_2 + "s", + group=toga.Group.FILE, + section=20, + order=12, + ), + ) + def _submenu(self, group, menubar): """Obtain the submenu representing the command group. @@ -347,10 +434,11 @@ def _submenu(self, group, menubar): else: parent_menu = self._submenu(group.parent, menubar) + submenu = NSMenu.alloc().initWithTitle(group.text) + menu_item = parent_menu.addItemWithTitle( group.text, action=None, keyEquivalent="" ) - submenu = NSMenu.alloc().initWithTitle(group.text) parent_menu.setSubmenu(submenu, forItem=menu_item) # Install the item in the group cache. @@ -358,17 +446,17 @@ def _submenu(self, group, menubar): return submenu def create_menus(self): - # Recreate the menu. + # Recreate the menu bar for the app. # Remove any native references to the existing menu for menu_item, cmd in self._menu_items.items(): cmd._impl.native.remove(menu_item) # Create a clean menubar instance. menubar = NSMenu.alloc().initWithTitle("MainMenu") - submenu = None self._menu_groups = {} self._menu_items = {} + submenu = None for cmd in self.interface.commands: submenu = self._submenu(cmd.group, menubar) if isinstance(cmd, Separator): @@ -418,11 +506,29 @@ def create_menus(self): def exit(self): # pragma: no cover self.loop.stop() + def finalize(self): + # Set up the lookup tables for menu items + self._menu_groups = {} + self._menu_items = {} + + # Add any platform-specific app commands. This is done during finalization to + # ensure that the main_window has been assigned, which informs which app + # commands are needed. + self.create_app_commands() + self.create_menus() + + # Cocoa *doesn't* invoke _create_initial_windows(); the NSApplication + # interface handles arguments as part of the persistent app interface. + def main_loop(self): self.loop.run_forever(lifecycle=CocoaLifecycle(self.native)) def set_main_window(self, window): - pass + # If it's a background app, don't display the app icon. + if window == toga.App.BACKGROUND: + self.native.setActivationPolicy(NSApplicationActivationPolicyAccessory) + else: + self.native.setActivationPolicy(NSApplicationActivationPolicyRegular) ###################################################################### # App resources @@ -438,12 +544,6 @@ def get_screens(self): def beep(self): NSBeep() - def open_document(self, fileURL): - """No-op when the app is not a ``DocumentApp``.""" - - def select_file(self, **kwargs): - """No-op when the app is not a ``DocumentApp``.""" - def show_about_dialog(self): options = NSMutableDictionary.alloc().init() @@ -486,7 +586,14 @@ def show_cursor(self): ###################################################################### def get_current_window(self): - return self.native.keyWindow + window = self.native.keyWindow + # When a menu is activated, the current window sometimes reports as being of + # type NSMenuWindowManagerWindow. This is an internal type; we ignore it, and + # assume there's no current window. Marked nocover because it's impossible to + # replicate in test conditions. + if not hasattr(window, "interface"): # pragma: no cover + return None + return window def set_current_window(self, window): window._impl.native.makeKeyAndOrderFront(window._impl.native) @@ -519,48 +626,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/documents.py b/cocoa/src/toga_cocoa/documents.py index efa7b91092..b44b847cc1 100644 --- a/cocoa/src/toga_cocoa/documents.py +++ b/cocoa/src/toga_cocoa/documents.py @@ -4,7 +4,7 @@ from toga_cocoa.libs import NSURL, NSDocument, objc_method, objc_property -class TogaDocument(NSDocument): # pragma: no cover +class TogaDocument(NSDocument): interface = objc_property(object, weak=True) impl = objc_property(object, weak=True) @@ -20,17 +20,16 @@ def readFromFileWrapper_ofType_error_( return True -class Document: # pragma: no cover - # macOS has multiple documents in a single app instance. - SINGLE_DOCUMENT_APP = False - +class Document: def __init__(self, interface): + self.interface = interface self.native = TogaDocument.alloc() self.native.interface = interface self.native.impl = self + def open(self): self.native.initWithContentsOfURL( - NSURL.URLWithString(f"file://{quote(os.fsdecode(interface.path))}"), - ofType=interface.document_type, + NSURL.URLWithString(f"file://{quote(os.fsdecode(self.interface.path))}"), + ofType=self.interface.document_type, error=None, ) diff --git a/cocoa/src/toga_cocoa/factory.py b/cocoa/src/toga_cocoa/factory.py index b7d8447ff6..67671f3672 100644 --- a/cocoa/src/toga_cocoa/factory.py +++ b/cocoa/src/toga_cocoa/factory.py @@ -1,7 +1,7 @@ from toga import NotImplementedWarning from . import dialogs -from .app import App, DocumentApp, MainWindow +from .app import App from .command import Command from .documents import Document from .fonts import Font @@ -34,7 +34,7 @@ from .widgets.textinput import TextInput from .widgets.tree import Tree from .widgets.webview import WebView -from .window import Window +from .window import DocumentMainWindow, MainWindow, Window def not_implemented(feature): @@ -44,8 +44,6 @@ def not_implemented(feature): __all__ = [ "not_implemented", "App", - "DocumentApp", - "MainWindow", "Command", "Document", # Resources @@ -80,6 +78,9 @@ def not_implemented(feature): "TextInput", "Tree", "WebView", + # Windows + "DocumentMainWindow", + "MainWindow", "Window", ] diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index 0bc63d99b6..c46cd88aea 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -1,5 +1,6 @@ from rubicon.objc import CGSize +from toga import App from toga.command import Command, Separator from toga_cocoa.container import Container from toga_cocoa.libs import ( @@ -33,7 +34,11 @@ class TogaWindow(NSWindow): @objc_method def windowShouldClose_(self, notification) -> bool: - return self.impl.cocoa_windowShouldClose() + # The on_close handler has a cleanup method that will enforce + # the close if the on_close handler requests it; this initial + # "should close" request can always return False. + self.interface.on_close() + return False @objc_method def windowDidResize_(self, notification) -> None: @@ -160,7 +165,10 @@ def __init__(self, interface, title, position, size): self.set_title(title) self.set_size(size) - self.set_position(position) + + # Cascade the position of new windows + pos = 100 + len(App.app.windows) * 50 + self.set_position(position if position else (pos, pos)) self.native.delegate = self.native @@ -171,25 +179,9 @@ def __init__(self, interface, title, position, size): self.native.wantsLayer = True self.container.native.backgroundColor = self.native.backgroundColor - # By default, no toolbar - self._toolbar_items = {} - self.native_toolbar = None - def __del__(self): - self.purge_toolbar() self.native.release() - ###################################################################### - # Native event handlers - ###################################################################### - - def cocoa_windowShouldClose(self): - # The on_close handler has a cleanup method that will enforce - # the close if the on_close handler requests it; this initial - # "should close" request can always return False. - self.interface.on_close() - return False - ###################################################################### # Window properties ###################################################################### @@ -207,49 +199,6 @@ def set_title(self, title): def close(self): self.native.close() - def create_toolbar(self): - # Purge any existing toolbar items - self.purge_toolbar() - - # Create the new toolbar items. - if self.interface.toolbar: - for cmd in self.interface.toolbar: - if isinstance(cmd, Command): - self._toolbar_items[toolbar_identifier(cmd)] = cmd - - self.native_toolbar = NSToolbar.alloc().initWithIdentifier( - "Toolbar-%s" % id(self) - ) - self.native_toolbar.setDelegate(self.native) - else: - self.native_toolbar = None - - self.native.setToolbar(self.native_toolbar) - - # Adding/removing a toolbar changes the size of the content window. - if self.interface.content: - self.interface.content.refresh() - - def purge_toolbar(self): - while self._toolbar_items: - dead_items = [] - _, cmd = self._toolbar_items.popitem() - # The command might have toolbar representations on multiple window - # toolbars, and may have other representations (at the very least, a menu - # item). Only clean up the representation pointing at *this* window. Do this - # in 2 passes so that we're not modifying the set of native objects while - # iterating over it. - for item_native in cmd._impl.native: - if ( - isinstance(item_native, NSToolbarItem) - and item_native.target == self.native - ): - dead_items.append(item_native) - - for item_native in dead_items: - cmd._impl.native.remove(item_native) - item_native.release() - def set_app(self, app): pass @@ -365,3 +314,68 @@ def get_image_data(self): ) ns_image = NSImage.alloc().initWithCGImage(cg_image, size=target_size) return ns_image + + +class MainWindow(Window): + def __init__(self, interface, title, position, size): + super().__init__(interface, title, position, size) + + # By default, no toolbar + self._toolbar_items = {} + self.native_toolbar = None + + def __del__(self): + self.purge_toolbar() + super().__del__() + + ###################################################################### + # Window lifecycle + ###################################################################### + + def create_toolbar(self): + # Purge any existing toolbar items + self.purge_toolbar() + + # Create the new toolbar items. + if self.interface.toolbar: + for cmd in self.interface.toolbar: + if isinstance(cmd, Command): + self._toolbar_items[toolbar_identifier(cmd)] = cmd + + self.native_toolbar = NSToolbar.alloc().initWithIdentifier( + "Toolbar-%s" % id(self) + ) + self.native_toolbar.setDelegate(self.native) + else: + self.native_toolbar = None + + self.native.setToolbar(self.native_toolbar) + + # Adding/removing a toolbar changes the size of the content window. + if self.interface.content: + self.interface.content.refresh() + + def purge_toolbar(self): + while self._toolbar_items: + dead_items = [] + _, cmd = self._toolbar_items.popitem() + # The command might have toolbar representations on multiple window + # toolbars, and may have other representations (at the very least, a menu + # item). Only clean up the representation pointing at *this* window. Do this + # in 2 passes so that we're not modifying the set of native objects while + # iterating over it. + for item_native in cmd._impl.native: + if ( + isinstance(item_native, NSToolbarItem) + and item_native.target == self.native + ): + dead_items.append(item_native) + + for item_native in dead_items: + cmd._impl.native.remove(item_native) + item_native.release() + + +class DocumentMainWindow(Window): + # On Cocoa, there's no real difference between a DocumentMainWindow and a MainWindow + pass diff --git a/cocoa/tests_backend/hardware/camera.py b/cocoa/tests_backend/hardware/camera.py index ff03209ad4..3107a6e035 100644 --- a/cocoa/tests_backend/hardware/camera.py +++ b/cocoa/tests_backend/hardware/camera.py @@ -112,9 +112,9 @@ def _removeInput(input): ) def cleanup(self): - # Ensure there are no open camrea preview windows at the end of a test. + # Ensure there are no open camera preview windows at the end of a test. for window in self.app.camera._impl.preview_windows: - window.cocoa_windowShouldClose() + window.interface.on_close() def known_cameras(self): return { @@ -200,8 +200,8 @@ async def press_shutter_button(self, photo): async def cancel_photo(self, photo): window = self.app.camera._impl.preview_windows[0] - # Close the camera window. - window._impl.cocoa_windowShouldClose() + # Trigger the close of the camera window. + window.on_close() await self.redraw("Photo cancelled") # The window has been closed and the session ended diff --git a/core/src/toga/__init__.py b/core/src/toga/__init__.py index a6fc276daf..9355aa51df 100644 --- a/core/src/toga/__init__.py +++ b/core/src/toga/__init__.py @@ -1,6 +1,6 @@ import warnings -from .app import App, DocumentApp, DocumentMainWindow, MainWindow +from .app import App, DocumentApp # Resources from .colors import hsl, hsla, rgb, rgba @@ -41,7 +41,7 @@ from .widgets.timeinput import TimeInput, TimePicker from .widgets.tree import Tree from .widgets.webview import WebView -from .window import Window +from .window import DocumentMainWindow, MainWindow, Window class NotImplementedWarning(RuntimeWarning): @@ -58,9 +58,6 @@ def warn(self, platform, feature): "NotImplementedWarning", # Applications "App", - "DocumentApp", - "MainWindow", - "DocumentMainWindow", # Commands "Command", "Group", @@ -107,9 +104,13 @@ def warn(self, platform, feature): "Tree", "WebView", "Widget", + # Windows + "DocumentMainWindow", + "MainWindow", "Window", - # Deprecated widget names + # Deprecated "DatePicker", + "DocumentApp", "TimePicker", ] diff --git a/core/src/toga/app.py b/core/src/toga/app.py index 9999591a27..4bcc0d2159 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -16,6 +16,7 @@ ValuesView, ) from email.message import Message +from pathlib import Path from typing import TYPE_CHECKING, Any, Protocol from warnings import warn from weakref import WeakValueDictionary @@ -29,7 +30,7 @@ from toga.platform import get_platform_factory from toga.screens import Screen from toga.widgets.base import Widget -from toga.window import Window +from toga.window import MainWindow, Window if TYPE_CHECKING: from toga.icons import IconContent @@ -197,113 +198,19 @@ def _remove(self, id: str) -> None: del self._registry[id] -class MainWindow(Window): - _WINDOW_CLASS = "MainWindow" +def overridable(method): + """Decorate the method as being user-overridable""" + method.__default__ = True + return method - def __init__( - self, - id: str | None = None, - title: str | None = None, - position: tuple[int, int] = (100, 100), - size: tuple[int, int] = (640, 480), - resizable: bool = True, - minimizable: bool = True, - resizeable=None, # DEPRECATED - closeable=None, # DEPRECATED - ): - """Create a new main window. - - :param id: A unique identifier for the window. If not provided, one will be - automatically generated. - :param title: Title for the window. Defaults to the formal name of the app. - :param position: Position of the window, as a tuple of ``(x, y)`` coordinates, - in :ref:`CSS pixels `. - :param size: Size of the window, as a tuple of ``(width, height)``, in :ref:`CSS - pixels `. - :param resizable: Can the window be resized by the user? - :param minimizable: Can the window be minimized by the user? - :param resizeable: **DEPRECATED** - Use ``resizable``. - :param closeable: **DEPRECATED** - Use ``closable``. - """ - super().__init__( - id=id, - title=title, - position=position, - size=size, - resizable=resizable, - closable=True, - minimizable=minimizable, - # Deprecated arguments - resizeable=resizeable, - closeable=closeable, - ) - - @property - def _default_title(self) -> str: - return App.app.formal_name - - @property - def on_close(self) -> None: - """The handler to invoke before the window is closed in response to a user - action. - - Always returns ``None``. Main windows should use :meth:`toga.App.on_exit`, - rather than ``on_close``. - - :raises ValueError: if an attempt is made to set the ``on_close`` handler. - """ - return None - - @on_close.setter - def on_close(self, handler: Any): - if handler: - raise ValueError( - "Cannot set on_close handler for the main window. " - "Use the app on_exit handler instead." - ) +def overridden(coroutine_or_method): + """Has the user overridden this method? -class DocumentMainWindow(Window): - def __init__( - self, - doc: Document, - id: str | None = None, - title: str | None = None, - position: tuple[int, int] = (100, 100), - size: tuple[int, int] = (640, 480), - resizable: bool = True, - minimizable: bool = True, - ): - """Create a new document Main 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. - - :param document: 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 tuple of ``(x, y)`` coordinates. - :param size: Size of the window, as a 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? - """ - self.doc = doc - super().__init__( - id=id, - title=title, - position=position, - size=size, - resizable=resizable, - closable=True, - minimizable=minimizable, - on_close=doc.handle_close, - ) - - @property - def _default_title(self) -> str: - return self.doc.path.name + This is based on the method *not* having a ``__default__`` attribute. Overridable + default methods have this attribute; user-defined method will not. + """ + return not hasattr(coroutine_or_method, "__default__") class App: @@ -311,6 +218,10 @@ class App: #: Toga app in a process, this is available as a class property via ``toga.App.app``. app: App = None + #: A constant that can be used as the main window to indicate that an app will + #: run in the background without a main window. + BACKGROUND = object() + def __init__( self, formal_name: str | None = None, @@ -324,6 +235,7 @@ def __init__( description: str | None = None, startup: AppStartupMethod | None = None, on_exit: OnExitHandler | None = None, + document_types: dict[str, type[Document]] = None, id=None, # DEPRECATED windows=None, # DEPRECATED ): @@ -362,6 +274,8 @@ def __init__( the metadata key ``Summary`` will be used. :param startup: A callable to run before starting the app. :param on_exit: The initial :any:`on_exit` handler. + :param document_types: A mapping of document types managed by this app, to + the :any:`Document` class managing that document type. :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 @@ -480,24 +394,21 @@ def __init__( self.on_exit = on_exit + # Set up the document types and list of documents being managed. + self._document_types = document_types + self._documents = [] + # We need the command set to exist so that startup et al can add commands; # but we don't have an impl yet, so we can't set the on_change handler self._commands = CommandSet() self._startup_method = startup - self._main_window = None self._windows = WindowSet(self) self._full_screen_windows = None - self._create_impl() - - # Now that we have an impl, set the on_change handler for commands - self.commands.on_change = self._impl.create_menus - - def _create_impl(self): - return self.factory.App(interface=self) + self.factory.App(interface=self) ###################################################################### # App properties @@ -615,25 +526,65 @@ def main_loop(self) -> None: @property def main_window(self) -> MainWindow: """The main window for the app.""" - return self._main_window + try: + return self._main_window + except AttributeError: + raise ValueError("Application has not set a main window.") @main_window.setter def main_window(self, window: MainWindow) -> None: - self._main_window = window - self._impl.set_main_window(window) + if window is None or window == App.BACKGROUND or isinstance(window, Window): + # If the app has a main window, it must be closable + if isinstance(window, Window) and not window.closable: + raise ValueError("The window used as the main window must be closable.") - def _verify_startup(self): - if not isinstance(self.main_window, MainWindow): - raise ValueError( - "Application does not have a main window. " - "Does your startup() method assign a value to self.main_window?" - ) + self._main_window = window + self._impl.set_main_window(window) + else: + raise ValueError(f"Don't know how to use {window} as a main window") def _startup(self): - # This is a wrapper around the user's startup method that performs any - # post-setup validation. + # Invoke the user's startup method (or the default implementation... self.startup() - self._verify_startup() + + # ... then validate that startup requirements have been met. + # Accessing the main window attribute will raise an exception if the app hasn't + # defined a main window + _ = self.main_window + + # The App's impl is created when the app is constructed; however, on some + # platforms, (GTK, Windows), there are some activities that can't happen until + # the app manifests in some way (usually as a result of the app loop starting). + # Call the impl to allow for this finalization activity. + self._impl.finalize() + + # Now that we have a finalized impl, set the on_change handler for commands + self.commands.on_change = self._impl.create_menus + + def _create_initial_windows(self): + """Internal utility method for creating initial windows based on command line + arguments. + + If document types are defined, try to open every argument on the command line as + a document. If no arguments were provided, or no valid filenames were provided, + open a blank document of the default document type. + + If no document types are defined, this method does nothing. + """ + if self.document_types: + for filename in sys.argv[1:]: + try: + self.open(Path(filename).absolute()) + except ValueError as e: + print(e) + except FileNotFoundError: + print(f"Document {filename} not found") + + if len(self.documents) == 0: + # The app has registered document types, but no open documents. + # Create a new document of the default document type. + default_document_type = next(iter(self.document_types.values())) + self.new(default_document_type) def startup(self) -> None: """Create and show the main window for the application. @@ -670,6 +621,21 @@ def commands(self) -> MutableSet[Command]: """The commands available in the app.""" return self._commands + @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. + """ + return self._document_types + + @property + def documents(self) -> list[Document]: + """The list of documents associated with this app.""" + return self._documents + @property def paths(self) -> Paths: """Paths for platform-appropriate locations on the user's file system. @@ -704,6 +670,71 @@ def windows(self) -> Collection[Window]: when they are created, and removed when they are closed.""" return self._windows + ###################################################################### + # Commands and menus + ###################################################################### + + def _menu_about(self, command, **kwargs): + self.about() + + def _menu_exit(self, command, **kwargs): + self.on_exit() + + def _menu_new_document(self, document_class): + async def new_document_handler(command, **kwargs): + result = self.new(document_class) + if asyncio.iscoroutine(result): + await result + + return new_document_handler + + async def _menu_open_file(self, command, **kwargs): + # The dialog needs to be opened relative to a window; use the current window. + path = await self.current_window.open_file_dialog( + self.formal_name, + file_types=self.document_types.keys() if self.document_types else None, + ) + + if path: + result = self.open(path) + if asyncio.iscoroutine(result): + await result + + async def _menu_preferences(self, command, **kwargs): + result = self.preferences() + if asyncio.iscoroutine(result): + await result + + async def _menu_save(self, command, **kwargs): + # Ideally, this would be done by disabling the menu item. However, this is + # currently only easy to do on Cocoa; on GTK/Windows it requires a focus + # handler. + if self.can_save(): + result = self.save(self.current_window) + if asyncio.iscoroutine(result): + await result + + async def _menu_save_as(self, command, **kwargs): + # Ideally, this would be done by disabling the menu item. However, this is + # currently only easy to do on Cocoa; on GTK/Windows it requires a focus + # handler. + if self.can_save(): + result = self.save_as(self.current_window) + if asyncio.iscoroutine(result): + await result + + async def _menu_save_all(self, command, **kwargs): + # Ideally, this would be done by disabling the menu item. However, this is + # currently only easy to do on Cocoa; on GTK/Windows it requires a window focus + # handler. + if self.can_save_all(): + result = self.save_all() + if asyncio.iscoroutine(result): + await result + + def _menu_visit_homepage(self, command, **kwargs): + self.visit_homepage() + ###################################################################### # App capabilities ###################################################################### @@ -720,6 +751,189 @@ def beep(self) -> None: """Play the default system notification sound.""" self._impl.beep() + def can_save(self) -> bool: + """Should the Save/Save As menu items be active? + + Depending on the capabilities of the platform, the menu item may not *appear* + disabled; on those platform, the menu option will appear active, but the save + operation itself will be a no-op. + + By default, Save/Save As is enabled if there is a currently active window, and + that window has a ``doc`` attribute. The ``doc`` object is expected to provide a + ``path`` attribute and a ``save()`` method. + """ + return self.current_window is not None and hasattr(self.current_window, "doc") + + def can_save_all(self) -> bool: + """Should the Save All menu item be active? + + Depending on the capabilities of the platform, the menu item may not *appear* + disabled; on those platform, the menu option will appear active, but the save + operation itself will be a no-op. + + By default, Save All is enabled if there is any window in the app with a ``doc`` + attribute. The ``doc`` object on a window is expected to provide a ``path`` + attribute and a ``save()`` method. + """ + return any(hasattr(window, "doc") for window in self.windows) + + @overridable + def new(self, document_type: type[Document] | None) -> None: + """Create a new document of the given type, and show the document window. + + Override this method to provide custom behavior for creating new document + windows. If the method is overridden, and there are no document types registered + with the app, a document type of ``None`` will be passed at runtime. + + :param document_type: The document type to create, or ``None`` if no document + type is registered. + """ + document = document_type(app=self) + self._documents.append(document) + document.show() + + @overridable + def open(self, path: Path | str) -> None: + """Open a document in this app, and show the document window. + + The default implementation uses registered document types to open the file. Apps + can overwrite this implementation if they wish to provide custom behavior for + opening a file path. + + :param path: The path to the document to be opened. + :raises ValueError: If the path cannot be opened. + """ + try: + path = Path(path).absolute() + 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(app=self) + document.open(path) + + self._documents.append(document) + document.show() + + @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 be disabled. However, if you override this method in your App class, + the menu item will be enabled, 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 + + async def replacement_filename(self, suggested_name: Path) -> Path | None: + """Select a new filename for a file. + + Displays a save file dialog to the user, allowing the user to select a file + name. If they provide a file name that already exists, a confirmation dialog + will be displayed. The user will be repeatedly prompted for a filename until: + + 1. They select a non-existent filename; or + 2. They confirm that it's OK to overwrite an existing filename; or + 3. They cancel the file selection dialog. + + This is the workflow that is used implement filename selection when changing the + name of a file with "Save As", or setting the initial name of an untitled file + with "Save". Custom implementations of `save()`/`save_as()` may find this + method useful. + + :param suggested name: The initial candidate filename + :returns: The path to use, or ``None`` if the user cancelled the request. + """ + while True: + new_path = await self.current_window.save_file_dialog( + "Save as", suggested_name + ) + if new_path: + if new_path.exists(): + save_ok = await self.current_window.confirm_dialog( + "Are you sure?", + f"File {new_path.name} already exists. Overwrite?", + ) + else: + # Filename doesn't exist, so saving must be ok. + save_ok = True + + if save_ok: + # Save the document and return + return new_path + else: + # User chose to cancel the save + return None + + @overridable + async def save(self, window): + """Save the contents of a window. + + The default implementation will invoke ``save()`` on the document associated + with the window. If the window doesn't have a ``doc`` attribute, the save + request will be ignored. If the document associated with a window hasn't been + saved before, the user will be prompted to provide a filename. + + If the user defines a ``save()`` method, a "Save" menu item will be included in + the app's menus, regardless of whether any document types are registered. + + :param window: The window whose content is to be saved. + """ + try: + doc = window.doc + except AttributeError: + pass + else: + if doc.path: + doc.save() + else: + suggested_name = f"Untitled{doc.default_extension}" + new_path = await self.replacement_filename(suggested_name) + if new_path: + doc.save(new_path) + + @overridable + async def save_as(self, window): + """Save the contents of a window under a new filename. + + The default implementation will prompt the user for a new filename, then invoke + ``save(new_path)`` on the document associated with the window. If the window + doesn't have a ``doc`` attribute, the save request will be ignored. + + If the user defines a ``save_as()`` method, a "Save As..." menu item will be + included in the app's menus, regardless of whether document types are registered. + + :param window: The window whose content is to be saved. + """ + try: + doc = window.doc + except AttributeError: + pass + else: + suggested_path = ( + doc.path if doc.path else f"Untitled{doc.default_extension}" + ) + new_path = await self.replacement_filename(suggested_path) + if new_path: + doc.save(new_path) + + @overridable + async def save_all(self): + """Save the state of all windows in the app. + + The default implementation will call ``save()`` on each window in the app. + This may cause the user to be prompted to provide filenames for any windows + that haven't been saved previously. + + If the user defines a ``save_all()`` method, a "Save All" menu item will be + included in the app's menus, regardless of whether document types are registered. + """ + for window in self.windows: + await self.save(window) + def visit_homepage(self) -> None: """Open the application's :any:`home_page` in the default browser. @@ -831,91 +1045,13 @@ def windows(self, windows): class DocumentApp(App): - def __init__( - self, - formal_name: str | None = None, - app_id: str | None = None, - app_name: str | None = None, - *, - icon: IconContent | 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, - on_exit: OnExitHandler | None = None, - id=None, # DEPRECATED - ): - """Create a document-based application. - - 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. - """ - if document_types is None: - raise ValueError("A document must manage at least one document type.") - - self._document_types = document_types - self._documents = [] - - 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, + def __init__(self, *args, **kwargs): + """**DEPRECATED** - :any:`toga.DocumentApp` can be replaced with + :any:`toga.App`.""" + warn( + "toga.DocumentApp is no longer required. Use toga.App instead", + DeprecationWarning, + stacklevel=2, ) - def _create_impl(self): - return self.factory.DocumentApp(interface=self) - - def _verify_startup(self): - # No post-startup validation required for DocumentApps - pass - - @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): - """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. - """ - 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() + super().__init__(*args, **kwargs) diff --git a/core/src/toga/command.py b/core/src/toga/command.py index 3963550b03..d6b66ddf2a 100644 --- a/core/src/toga/command.py +++ b/core/src/toga/command.py @@ -139,10 +139,10 @@ def key(self) -> tuple[(int, int, str)]: HELP = None #: Help commands -Group.APP = Group("*", order=0) -Group.FILE = Group("File", order=1) -Group.EDIT = Group("Edit", order=10) -Group.VIEW = Group("View", order=20) +Group.APP = Group("*", order=-100) +Group.FILE = Group("File", order=-30) +Group.EDIT = Group("Edit", order=-20) +Group.VIEW = Group("View", order=-10) Group.COMMANDS = Group("Commands", order=30) Group.WINDOW = Group("Window", order=90) Group.HELP = Group("Help", order=100) @@ -222,12 +222,19 @@ def key(self) -> tuple[(int, int, str)]: @property def enabled(self) -> bool: """Is the command currently enabled?""" - return self._enabled + try: + # If the raw action has an enabled method, it's a dynamic action; + # use the return value of that method, not the underlying property + return self.action._raw.enabled() + except AttributeError: + return self._enabled @enabled.setter def enabled(self, value: bool): - self._enabled = value and getattr(self.action, "_raw", True) is not None - self._impl.set_enabled(value) + raw_action = getattr(self.action, "_raw", True) + self._enabled = value and raw_action is not None + if not hasattr(raw_action, "enabled"): + self._impl.set_enabled(value) @property def icon(self) -> Icon | None: @@ -265,7 +272,7 @@ def __lt__(self, other: Any) -> bool: def __gt__(self, other: Any) -> bool: if not isinstance(other, (Group, Command)): return False - return other < self + return other.key < self.key def __repr__(self) -> bool: return ( diff --git a/core/src/toga/documents.py b/core/src/toga/documents.py index a4a02dd7c7..06833e4f9e 100644 --- a/core/src/toga/documents.py +++ b/core/src/toga/documents.py @@ -1,7 +1,5 @@ from __future__ import annotations -import asyncio -import warnings from abc import ABC, abstractmethod from pathlib import Path from typing import TYPE_CHECKING @@ -12,21 +10,17 @@ class Document(ABC): - def __init__( - self, - path: str | Path, - document_type: str, - app: App = None, - ): + # Subclasses should override these definitions + document_type = "Unknown Document" + default_extension = ".unknown" + + def __init__(self, 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. :param app: The application the document is associated with. """ - self._path = Path(path) - self._document_type = document_type self._app = app + self._path = None self._main_window = None # Create the visual representation of the document @@ -35,62 +29,25 @@ def __init__( # Create a platform specific implementation of the Document self._impl = app.factory.Document(interface=self) - def can_close(self) -> bool: - """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 - - async def handle_close(self, window, **kwargs): - """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 - @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) -> Path: - """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).""" return self._app + @property + def title(self) -> str: + """The title of the document. + + This will be used as the default title of a :any:`toga.DocumentMainWindow` that + contains the document. + """ + return f"{self.document_type}: {self.path.name if self.path else 'Untitled'}" + @property def main_window(self) -> Window: """The main window for the document.""" @@ -104,6 +61,31 @@ def show(self) -> None: """Show the :any:`main_window` for this document.""" self.main_window.show() + def open(self, path: str | Path): + """Open a file as a document. + + :param path: The file to open. + """ + self._path = Path(path).absolute() + self._impl.open() + + # Set the title of the document window to match the path + self._main_window.title = self._main_window._default_title + + def save(self, path: str | Path | None = None): + """Save the document as a file. + + If a path is provided, 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 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() + @abstractmethod def create(self) -> None: """Create the window (or windows) for the document. @@ -116,3 +98,7 @@ def create(self) -> None: def read(self) -> None: """Load a representation of the document into memory and populate the document window.""" + + @abstractmethod + def write(self) -> None: + """Persist a representation of the current state of the document.""" diff --git a/core/src/toga/handlers.py b/core/src/toga/handlers.py index ae2041c08d..9aba472e98 100644 --- a/core/src/toga/handlers.py +++ b/core/src/toga/handlers.py @@ -11,6 +11,15 @@ def __init__(self, handler): self.native = handler +class DynamicHandler: + def __init__(self, handler, enabled): + self.handler = handler + self.enabled = enabled + + def __call__(self, *args, **kwargs): + self.handler(*args, **kwargs) + + async def long_running_task(interface, generator, cleanup): """Run a generator as an asynchronous coroutine.""" try: @@ -72,7 +81,7 @@ def wrapped_handler(interface, handler, cleanup=None): def _handler(*args, **kwargs): if asyncio.iscoroutinefunction(handler): - asyncio.ensure_future( + return asyncio.ensure_future( handler_with_cleanup(handler, cleanup, interface, *args, **kwargs) ) else: @@ -83,7 +92,7 @@ def _handler(*args, **kwargs): traceback.print_exc() else: if inspect.isgenerator(result): - asyncio.ensure_future( + return asyncio.ensure_future( long_running_task(interface, result, cleanup) ) else: diff --git a/core/src/toga/window.py b/core/src/toga/window.py index e107307ce4..607d58352a 100644 --- a/core/src/toga/window.py +++ b/core/src/toga/window.py @@ -17,7 +17,9 @@ overload, ) +import toga from toga.command import Command, CommandSet +from toga.documents import Document from toga.handlers import AsyncResult, wrapped_handler from toga.images import Image from toga.platform import get_platform_factory @@ -121,7 +123,7 @@ def __init__( self, id: str | None = None, title: str | None = None, - position: tuple[int, int] = (100, 100), + position: tuple[int, int] | None = None, size: tuple[int, int] = (640, 480), resizable: bool = True, closable: bool = True, @@ -132,11 +134,14 @@ def __init__( ) -> None: """Create a new Window. + A base Window only contains content. It does not have a menu bar or toolbar. + :param id: A unique identifier for the window. If not provided, one will be automatically generated. :param title: Title for the window. Defaults to "Toga". :param position: Position of the window, as a tuple of ``(x, y)`` coordinates, - in :ref:`CSS pixels `. + in :ref:`CSS pixels `. If omitted, the window will assume + whatever default position is appropriate for the platform. :param size: Size of the window, as a tuple of ``(width, height)``, in :ref:`CSS pixels `. :param resizable: Can the window be resized by the user? @@ -193,9 +198,6 @@ def __init__( raise RuntimeError("Cannot create a Window before creating an App") App.app.windows.add(self) - # Create a toolbar that is linked to the app - self._toolbar = CommandSet(on_change=self._impl.create_toolbar, app=self._app) - self.on_close = on_close def __lt__(self, other) -> bool: @@ -272,9 +274,24 @@ def close(self) -> None: undefined, except for :attr:`closed` which can be used to check if the window was closed. """ - self.app.windows.discard(self) - self._impl.close() - self._closed = True + # If closing the window could trigger the app exit, don't actually + # close the window. + close_window = True + if self.app.main_window == self: + # Closing the window marked as the main window exits the app + self.app.on_exit() + close_window = False + elif self.app.main_window is None: + # If this is a session-based app, this is the last window in the app, + # and the platform exits on last window close, trigger an exit. + if len(self.app.windows) == 1 and self.app._impl.CLOSE_ON_LAST_WINDOW: + self.app.on_exit() + close_window = False + + if close_window: + self.app.windows.discard(self) + self._impl.close() + self._closed = True @property def closed(self) -> bool: @@ -317,11 +334,6 @@ def content(self, widget: Widget) -> None: # Update the geometry of the widget widget.refresh() - @property - def toolbar(self) -> MutableSet[Command]: - """Toolbar for the window.""" - return self._toolbar - @property def widgets(self) -> Mapping[str, Widget]: """The widgets contained in the window. @@ -475,7 +487,7 @@ def on_close(self) -> OnCloseHandler: @on_close.setter def on_close(self, handler: OnCloseHandler | None) -> None: - def cleanup(window: Window, should_close: bool) -> None: + def cleanup(window, should_close): if should_close or handler is None: window.close() @@ -901,3 +913,131 @@ def closeable(self) -> bool: DeprecationWarning, ) return self._closable + + +class MainWindow(Window): + _WINDOW_CLASS = "MainWindow" + + def __init__( + self, + id: str | None = None, + title: str | None = None, + position: tuple[int, int] | None = None, + size: tuple[int, int] = (640, 480), + resizable: bool = True, + closable: bool = True, + minimizable: bool = True, + on_close: OnCloseHandler | None = None, + resizeable=None, # DEPRECATED + closeable=None, # DEPRECATED + ): + """Create a new main window. + + A MainWindow is a window that can have a toolbar, and depending on the platform + may also have a menu bar. + + :param id: A unique identifier for the window. If not provided, one will be + automatically generated. + :param title: Title for the window. Defaults to the formal name of the app. + :param position: Position of the window, as a tuple of ``(x, y)`` coordinates, + in :ref:`CSS pixels `. + :param size: Size of the window, as a tuple of ``(width, height)``, in :ref:`CSS + pixels `. + :param resizable: Can the window be resized by the user? + :param closable: Can the window be closed by the user? + :param minimizable: Can the window be minimized by the user? + :param on_close: The initial :any:`on_close` handler. + :param resizeable: **DEPRECATED** - Use ``resizable``. + :param closeable: **DEPRECATED** - Use ``closable``. + """ + super().__init__( + id=id, + title=title, + position=position, + size=size, + resizable=resizable, + closable=closable, + minimizable=minimizable, + on_close=on_close, + # Deprecated arguments + resizeable=resizeable, + closeable=closeable, + ) + + # Create a toolbar that is linked to the app + self._toolbar = CommandSet(on_change=self._impl.create_toolbar, app=self._app) + + ###################################################################### + # Window properties + ###################################################################### + + @property + def _default_title(self) -> str: + return toga.App.app.formal_name + + ###################################################################### + # Window content and resources + ###################################################################### + + @property + def toolbar(self) -> MutableSet[Command]: + """Toolbar for the window.""" + return self._toolbar + + +class DocumentMainWindow(Window): + _WINDOW_CLASS = "DocumentMainWindow" + + def __init__( + self, + doc: Document, + id: str | None = None, + title: str | None = None, + position: tuple[int, int] | None = None, + size: tuple[int, int] = (640, 480), + resizable: bool = True, + minimizable: bool = True, + on_close: OnCloseHandler | None = None, + ): + """Create a new document Main 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. + + :param document: The document being managed by this window + :param id: The ID of the window. + :param title: Title for the window. Defaults to the title of the document. + :param position: Position of the window, as a tuple of ``(x, y)`` coordinates. + :param size: Size of the window, as a 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=on_close, + ) + + @property + def doc(self) -> Document: + """The document managed by this window""" + return self._doc + + @property + def _default_title(self) -> str: + return self.doc.title + + 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() diff --git a/core/tests/app/test_app.py b/core/tests/app/test_app.py index 6e73e4b2fd..330ac410b9 100644 --- a/core/tests/app/test_app.py +++ b/core/tests/app/test_app.py @@ -1,5 +1,6 @@ import asyncio import importlib.metadata +import signal import sys import webbrowser from pathlib import Path @@ -237,6 +238,17 @@ def test_create( metadata_mock.assert_called_once_with(expected_app_name) + # Preferences exist, but are disabled + assert hasattr(app._impl, "preferences_command") + assert not app._impl.preferences_command.enabled + + # Document management commands don't exist + assert not hasattr(app._impl, "open_command") + assert not hasattr(app._impl, "new_command") + assert not hasattr(app._impl, "save_command") + assert not hasattr(app._impl, "save_as_command") + assert not hasattr(app._impl, "save_all_command") + @pytest.mark.parametrize( "kwargs, exc_type, message", @@ -364,6 +376,14 @@ def test_icon(app, construct): assert app.icon.path == Path("path/to/icon") +def test_main_loop(app): + """The main loop installs signal handlers.""" + app.main_loop() + + # Assert the default signal handler has been installed + assert signal.getsignal(signal.SIGINT) == signal.SIG_DFL + + def test_current_window(app): """The current window can be set and changed.""" other_window = toga.Window() @@ -478,6 +498,33 @@ def startup(self): assert app.main_window.title == "Test App" +def test_startup_simple_window(event_loop): + """A simple window can be used as the main window""" + + class SubclassedApp(toga.App): + def startup(self): + self.main_window = toga.Window() + + app = SubclassedApp(formal_name="Test App", app_id="org.example.test") + + # The main window will exist, and will have the default name for a window + assert app.main_window.title == "Toga" + + +def test_startup_non_closeable_window(event_loop): + """The main window must be closeable""" + + class SubclassedApp(toga.App): + def startup(self): + self.main_window = toga.MainWindow(closable=False) + + with pytest.raises( + ValueError, + match=r"The window used as the main window must be closable.", + ): + SubclassedApp(formal_name="Test App", app_id="org.example.test") + + def test_startup_subclass_no_main_window(event_loop): """If a subclassed app doesn't define a main window, an error is raised.""" @@ -485,16 +532,50 @@ class SubclassedApp(toga.App): def startup(self): pass - with pytest.raises(ValueError, match=r"Application does not have a main window."): + with pytest.raises(ValueError, match=r"Application has not set a main window."): + SubclassedApp(formal_name="Test App", app_id="org.example.test") + + +def test_startup_subclass_unknown_main_window(event_loop): + """If a subclassed app uses an unknown main window type, an error is raised""" + + class SubclassedApp(toga.App): + def startup(self): + self.main_window = 42 + + with pytest.raises(ValueError, match=r"Don't know how to use 42 as a main window"): SubclassedApp(formal_name="Test App", app_id="org.example.test") +def test_about_menu(app): + """The about menu item can be activated.""" + app._impl.about_command.action() + + assert_action_performed(app, "show_about_dialog") + + def test_about(app): """The about dialog for the app can be shown.""" app.about() assert_action_performed(app, "show_about_dialog") +def test_visit_homepage_menu(monkeypatch, event_loop): + """The visit homepage menu item can be opened""" + app = toga.App( + formal_name="Test App", + app_id="org.example.test", + home_page="https://example.com/test-app", + ) + open_webbrowser = Mock() + monkeypatch.setattr(webbrowser, "open", open_webbrowser) + + # The app has a homepage, so it will be visited + app._impl.visit_homepage_command.action() + + open_webbrowser.assert_called_once_with("https://example.com/test-app") + + def test_visit_homepage(monkeypatch, event_loop): """The app's homepage can be opened.""" app = toga.App( @@ -505,12 +586,23 @@ def test_visit_homepage(monkeypatch, event_loop): open_webbrowser = Mock() monkeypatch.setattr(webbrowser, "open", open_webbrowser) - # The app has no homepage by default, so visit is a no-op + # The app has a homepage, which will be opened app.visit_homepage() open_webbrowser.assert_called_once_with("https://example.com/test-app") +def test_visit_homepage_menu_no_homepage(monkeypatch, app): + """The visit homepage menu item can be activated.""" + open_webbrowser = Mock() + monkeypatch.setattr(webbrowser, "open", open_webbrowser) + + app._impl.visit_homepage_command.action() + + # As the app doesn't have a homepage, it's a no-op + open_webbrowser.assert_not_called() + + def test_no_homepage(monkeypatch, app): """If the app doesn't have a home page, visit_homepage is a no-op.""" open_webbrowser = Mock() @@ -528,6 +620,19 @@ def test_beep(app): assert_action_performed(app, "beep") +def test_exit_menu(app): + """The exit menu can be activated""" + on_exit_handler = Mock(return_value=True) + app.on_exit = on_exit_handler + + # Exit the app via the menu + app._impl.exit_command.action() + + # The exit handler approved exit, so an exit has been performed. + on_exit_handler.assert_called_once_with(app) + assert_action_performed(app, "exit") + + def test_exit_direct(app): """An app can be exited directly.""" on_exit_handler = Mock(return_value=True) @@ -536,7 +641,7 @@ def test_exit_direct(app): # Exit the app directly app.exit() - # App has been exited, but the exit handler has *not* been invoked. + # Exit has been directly invoked. assert_action_performed(app, "exit") on_exit_handler.assert_not_called() @@ -576,6 +681,36 @@ def test_exit_rejected_handler(app): on_exit_handler.assert_called_once_with(app) +def test_no_exit_last_window_close(app): + """Windows can be created and closed without closing the app.""" + # App has 1 window initially + assert len(app.windows) == 1 + + # Create a second, non-main window + window1 = toga.Window() + window1.content = toga.Box() + window1.show() + + window2 = toga.Window() + window2.content = toga.Box() + window2.show() + + # App has 3 windows + assert len(app.windows) == 3 + + # Close one of the secondary windows + window1.close() + + # Window has been closed, but the app hasn't exited. + assert len(app.windows) == 2 + assert_action_performed(window1, "close") + assert_action_not_performed(app, "exit") + + # Closing the MainWindow kills the app + app.main_window.close() + assert_action_performed(app, "exit") + + def test_loop(app, event_loop): """The main thread's event loop can be accessed.""" assert isinstance(app.loop, asyncio.AbstractEventLoop) diff --git a/core/tests/app/test_background_app.py b/core/tests/app/test_background_app.py new file mode 100644 index 0000000000..f0d9cd21fb --- /dev/null +++ b/core/tests/app/test_background_app.py @@ -0,0 +1,52 @@ +import pytest + +import toga +from toga_dummy.utils import ( + assert_action_not_performed, + assert_action_performed, +) + + +class ExampleBackgroundApp(toga.App): + def startup(self): + self.main_window = toga.App.BACKGROUND + + +@pytest.fixture +def background_app(event_loop): + app = ExampleBackgroundApp( + "Test App", + "org.beeware.background-app", + ) + return app + + +def test_create(background_app): + """A background app can be created.""" + # App has been created + assert background_app._impl.interface == background_app + assert_action_performed(background_app, "create App") + + # App has no windows + assert len(background_app.windows) == 0 + + +def test_no_exit_last_window_close(background_app): + """Windows can be created and closed without closing the app.""" + # App has no windows initially + assert len(background_app.windows) == 0 + + window = toga.Window() + window.content = toga.Box() + window.show() + + # App has a window + assert len(background_app.windows) == 1 + + # Close the window + window.close() + + # Window has been closed, but the app hasn't exited. + assert len(background_app.windows) == 0 + assert_action_performed(window, "close") + assert_action_not_performed(background_app, "exit") diff --git a/core/tests/app/test_customized_app.py b/core/tests/app/test_customized_app.py new file mode 100644 index 0000000000..361579ee5c --- /dev/null +++ b/core/tests/app/test_customized_app.py @@ -0,0 +1,245 @@ +from pathlib import Path +from unittest.mock import Mock + +import pytest + +import toga + + +class CustomizedApp(toga.App): + def startup(self): + self.main_window = toga.Window() + + self._preferences = Mock() + + self._new = Mock() + self._open = Mock() + + self.allow_save = False + self._save = Mock() + self._save_as = Mock() + self._save_all = Mock() + + def preferences(self): + self._preferences() + + def can_save(self) -> bool: + return self.allow_save + + def can_save_all(self) -> bool: + return self.allow_save + + def new(self, doc_type): + self._new(doc_type) + + def open(self, path): + self._open(path) + + def save(self, window): + self._save(window) + + def save_as(self, window): + self._save_as(window) + + def save_all(self): + self._save_all() + + +class AsyncCustomizedApp(CustomizedApp): + # A custom app where preferences and document-management commands are user-defined + # as async handlers. + + async def preferences(self): + self._preferences() + + async def new(self, doc_type): + self._new(doc_type) + + async def open(self, path): + self._open(path) + + async def save(self, window): + self._save(window) + + async def save_as(self, window): + self._save_as(window) + + async def save_all(self): + self._save_all() + + +@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" + assert custom_app.on_exit._raw is None + + # Preferences exist and are enabled + assert hasattr(custom_app._impl, "preferences_command") + assert custom_app._impl.preferences_command.enabled + + # Document management commands (except for new) all exist because of overrides + assert hasattr(custom_app._impl, "new_commands") + assert hasattr(custom_app._impl, "open_command") + assert hasattr(custom_app._impl, "save_command") + assert hasattr(custom_app._impl, "save_as_command") + assert hasattr(custom_app._impl, "save_all_command") + + +@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") + + future = custom_app._impl.preferences_command.action() + + custom_app.loop.run_until_complete(future) + custom_app._preferences.assert_called_once_with() + + +@pytest.mark.parametrize( + "AppClass", + [ + CustomizedApp, + AsyncCustomizedApp, + ], +) +def test_new_menu(event_loop, AppClass): + """The custom new method is activated by the new menu item""" + custom_app = AppClass("Custom App", "org.beeware.customized-app") + + # As there's no document types, and an overridden new, there is + # a single new command. It uses the "none" document type. + future = custom_app._impl.new_commands[None].action() + + custom_app.loop.run_until_complete(future) + custom_app._new.assert_called_once_with(None) + + +@pytest.mark.parametrize( + "AppClass", + [ + CustomizedApp, + AsyncCustomizedApp, + ], +) +def test_open_menu(event_loop, AppClass): + """The custom open method is activated by the open menu""" + custom_app = AppClass("Custom App", "org.beeware.customized-app") + + path = Path("/path/to/file.txt") + custom_app.main_window._impl.dialog_responses["OpenFileDialog"] = [path] + + future = custom_app._impl.open_command.action() + + custom_app.loop.run_until_complete(future) + custom_app._open.assert_called_once_with(path) + + +@pytest.mark.parametrize( + "AppClass", + [ + CustomizedApp, + AsyncCustomizedApp, + ], +) +def test_open_menu_cancelled(event_loop, AppClass): + """The open action can be cancelled by not selecting a file""" + custom_app = AppClass("Custom App", "org.beeware.customized-app") + + custom_app.main_window._impl.dialog_responses["OpenFileDialog"] = [None] + + future = custom_app._impl.open_command.action() + + custom_app.loop.run_until_complete(future) + custom_app._open.assert_not_called() + + +@pytest.mark.parametrize( + "AppClass", + [ + CustomizedApp, + AsyncCustomizedApp, + ], +) +def test_save_menu(event_loop, AppClass): + """The custom save method is activated by the save menu""" + custom_app = AppClass("Custom App", "org.beeware.customized-app") + + future = custom_app._impl.save_command.action() + custom_app.loop.run_until_complete(future) + + # can_save is False; save won't be invoked + custom_app._save.assert_not_called() + + # Allow saving + custom_app.allow_save = True + + future = custom_app._impl.save_command.action() + custom_app.loop.run_until_complete(future) + custom_app._save.assert_called_once_with(custom_app.main_window) + + +@pytest.mark.parametrize( + "AppClass", + [ + CustomizedApp, + AsyncCustomizedApp, + ], +) +def test_save_as_menu(event_loop, AppClass): + """The custom save_as method is activated by the save_as menu""" + custom_app = AppClass("Custom App", "org.beeware.customized-app") + + future = custom_app._impl.save_as_command.action() + custom_app.loop.run_until_complete(future) + + # can_save is False; save won't be invoked + custom_app._save_as.assert_not_called() + + # Allow saving + custom_app.allow_save = True + + future = custom_app._impl.save_as_command.action() + custom_app.loop.run_until_complete(future) + custom_app._save_as.assert_called_once_with(custom_app.main_window) + + +@pytest.mark.parametrize( + "AppClass", + [ + CustomizedApp, + AsyncCustomizedApp, + ], +) +def test_save_all_menu(event_loop, AppClass): + """The custom save_all method is activated by the save_all menu""" + custom_app = AppClass("Custom App", "org.beeware.customized-app") + future = custom_app._impl.save_all_command.action() + custom_app.loop.run_until_complete(future) + + # can_save is False; save won't be invoked + custom_app._save_all.assert_not_called() + + # Allow saving + custom_app.allow_save = True + + future = custom_app._impl.save_all_command.action() + custom_app.loop.run_until_complete(future) + custom_app._save_all.assert_called_once_with() diff --git a/core/tests/app/test_document_app.py b/core/tests/app/test_document_app.py new file mode 100644 index 0000000000..c9a29f46ad --- /dev/null +++ b/core/tests/app/test_document_app.py @@ -0,0 +1,638 @@ +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.utils import ( + assert_action_not_performed, + assert_action_performed, +) + + +class ExampleDocument(toga.Document): + document_type = "Example Document" + default_extension = ".foobar" + + def create(self): + self.main_window = toga.DocumentMainWindow(self) + self._read = Mock(self.path) + self._write = Mock(self.path) + + def read(self): + # 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._read(self.path) + + def write(self): + # We don't actually care about the file or it's contents, but it needs to be + # writable, so we open it to verify that behavior. + self.path.parent.mkdir(parents=True, exist_ok=True) + with self.path.open("w"): + self._write(self.path) + + +class OtherDocument(toga.Document): + document_type = "Other Document" + default_extension = ".other" + + def create(self): + self.main_window = toga.DocumentMainWindow(self) + + def read(self): + pass + + def write(self): + pass + + +@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): + app = ExampleDocumentApp( + "Test App", + "org.beeware.document-app", + document_types={ + # Register ExampleDocument with 2 extensions + "foobar": ExampleDocument, + "fbr": ExampleDocument, + # Register a second document type + "other": OtherDocument, + }, + ) + # The app will have a single window; set this window as the current window + # so that dialogs have something to hang off. + app.current_window = list(app.windows)[0] + return app + + +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={"foobar": ExampleDocument}, + ) + + assert app._impl.interface == app + assert_action_performed(app, "create App") + + assert app.document_types == {"foobar": ExampleDocument} + + # With no command line, a default empty document will be created + assert len(app.documents) == 1 + assert app.documents[0].path is 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 DocumentMainWindow") + assert_action_performed(app.documents[0].main_window, "show") + + +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", str(example_file)]) + + app = ExampleDocumentApp( + "Test App", + "org.beeware.document-app", + document_types={"foobar": ExampleDocument}, + ) + + assert app._impl.interface == app + assert_action_performed(app, "create App") + + assert app.document_types == {"foobar": ExampleDocument} + + # The document is registered + assert len(app.documents) == 1 + assert isinstance(app.documents[0], ExampleDocument) + + # Document content has been read + app.documents[0]._read.assert_called_once_with(example_file) + + # 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 DocumentMainWindow") + assert_action_performed(app.documents[0].main_window, "show") + + +def test_create_with_unknown_document_type(monkeypatch, capsys): + """If the document specified at the command line is an unknown type, an exception is raised""" + monkeypatch.setattr(sys, "argv", ["app-exe", "/path/to/filename.unknown"]) + + ExampleDocumentApp( + "Test App", + "org.beeware.document-app", + document_types={"foobar": ExampleDocument}, + ) + + stdout = capsys.readouterr().out + assert "Don't know how to open documents of type .unknown" in stdout + + +def test_create_with_missing_file(monkeypatch, capsys): + """If the document specified at the command line is a known type, but not present, an exception is raised""" + monkeypatch.setattr(sys, "argv", ["app-exe", "/path/to/filename.foobar"]) + + ExampleDocumentApp( + "Test App", + "org.beeware.document-app", + document_types={"foobar": ExampleDocument}, + ) + + stdout = capsys.readouterr().out + assert "Document /path/to/filename.foobar not found" in stdout + + +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", str(example_file)]) + + app = ExampleDocumentApp( + "Test App", + "org.beeware.document-app", + document_types={ + # Register ExampleDocument with 2 extensions + "foobar": ExampleDocument, + "fbr": ExampleDocument, + # Register a second document type + "other": OtherDocument, + }, + ) + app.open(other_file) + + # There are 2 open documents + assert len(app.documents) == 2 + assert len(app.windows) == 2 + + # Close the first document window. + list(app.windows)[0].on_close() + + # One document window closed. + 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].on_close() + + # App has now exited + assert_action_performed(app, "exit") + + +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", str(example_file)]) + + app = ExampleDocumentApp( + "Test App", + "org.beeware.document-app", + document_types={ + # Register ExampleDocument with 2 extensions + "foobar": ExampleDocument, + "fbr": ExampleDocument, + # Register a second document type + "other": OtherDocument, + }, + ) + app.open(other_file) + + # There are 2 open documents + assert len(app.documents) == 2 + assert len(app.windows) == 2 + + # Close the first document window. + list(app.windows)[0].on_close() + + # One document window closed. + 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].on_close() + + # No document windows. + assert len(app.documents) == 0 + assert len(app.windows) == 0 + + # App still hasn't exited + assert_action_not_performed(app, "exit") + + +def test_new_menu(doc_app): + """The new menu exists and can create new documents""" + # Create a document of the default document type + future = doc_app._impl.new_commands[ExampleDocument].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 + + # The newest document is the one we just created + new_doc = doc_app.documents[-1] + assert isinstance(new_doc, ExampleDocument) + assert new_doc.path is None + assert new_doc.main_window.doc == new_doc + assert new_doc.main_window in doc_app.windows + + # Create a document of a secondary document type + future = doc_app._impl.new_commands[OtherDocument].action() + doc_app.loop.run_until_complete(future) + + # There are now 2 documents, and 2 windows + assert len(doc_app.documents) == 3 + assert len(doc_app.windows) == 3 + + # The newest document is the one we just created + new_doc = doc_app.documents[-1] + assert isinstance(new_doc, OtherDocument) + assert new_doc.path is None + 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): + """The open method is activated by the open menu""" + doc_app.current_window._impl.dialog_responses["OpenFileDialog"] = [example_file] + + future = doc_app._impl.open_command.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 + + # The second document is the one we just loaded + new_doc = doc_app.documents[1] + assert new_doc.path == example_file + assert new_doc.main_window.doc == new_doc + assert new_doc.main_window in doc_app.windows + + +def test_open_menu_cancel(doc_app): + """The open menu action can be cancelled by not selecting a file.""" + doc_app.current_window._impl.dialog_responses["OpenFileDialog"] = [None] + + future = doc_app._impl.open_command.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 + + +def test_save_menu_existing_document(doc_app, tmp_path): + """The save method can be triggered by menu on an existing document""" + # There is one document available; but it doesn't have a filename. + # Save it, then reset the mock + assert len(doc_app.documents) == 1 + doc = doc_app.documents[0] + doc_path = Path(tmp_path / "path/to/first.foobar") + doc.save(doc_path) + doc._write.reset_mock() + + # Save the document with the menu + future = doc_app._impl.save_command.action() + doc_app.loop.run_until_complete(future) + + # Write was invoked on the document + doc._write.assert_called_once_with(doc_path) + assert doc.path == doc_path + + +def test_save_menu_new_document(doc_app, tmp_path): + """The save method can be triggered by menu on a new document""" + # There is one document available; but it doesn't have a filename. + assert len(doc_app.documents) == 1 + doc = doc_app.documents[0] + assert doc.path is None + + # Prime the save file dialog with a response + doc_path = Path(tmp_path / "path/to/first.foobar") + doc_app.current_window._impl.dialog_responses["SaveFileDialog"] = [doc_path] + + # Save the document with the menu + future = doc_app._impl.save_command.action() + doc_app.loop.run_until_complete(future) + + # Write was invoked on the document + doc._write.assert_called_once_with(doc_path) + assert doc.path == doc_path + + +def test_save_menu_new_document_cancel(doc_app, tmp_path): + """Save on a new file can be cancelled by not selecting a file""" + # There is one document available; but it doesn't have a filename. + assert len(doc_app.documents) == 1 + doc = doc_app.documents[0] + assert doc.path is None + + # Prime the save file dialog with a cancel response + doc_app.current_window._impl.dialog_responses["SaveFileDialog"] = [None] + + # Save the document with the menu + future = doc_app._impl.save_command.action() + doc_app.loop.run_until_complete(future) + + # Write was not invoked on the document + doc._write.assert_not_called() + assert doc.path is None + + +def test_save_non_document(doc_app): + """If the current window isn't a document, save is a no-op""" + # There is one document available + assert len(doc_app.documents) == 1 + + # Create a non-document window, and set it as the current window + window = toga.Window() + window.content = toga.Box() + window.show() + doc_app.current_window = window + + # Invoke save on the non-doc window. This should be a no-op, + # but there's nothing we can do to verify that it is. + doc_app.loop.run_until_complete(doc_app.save(window)) + + # Mock Save so we can confirm it isn't invoked by the menu + doc_app.save = Mock() + + # Call save on the non-document window + future = doc_app._impl.save_command.action() + doc_app.loop.run_until_complete(future) + + # Save wasn't invoked + doc_app.save.assert_not_called() + + +def test_save_as_menu(doc_app, tmp_path): + """The save as method can be triggered by menu""" + # There is one document available, but it doesn't have a filename. + assert len(doc_app.documents) == 1 + doc = doc_app.documents[0] + assert doc.path is None + + # Prime the save file dialog with a response + doc_path_1 = Path(tmp_path / "path/to/first.foobar") + doc_path_2 = Path(tmp_path / "path/to/second.foobar") + doc_app.current_window._impl.dialog_responses["SaveFileDialog"] = [ + doc_path_1, + doc_path_2, + ] + + # Save As the document with the menu + future = doc_app._impl.save_as_command.action() + doc_app.loop.run_until_complete(future) + + # Write was invoked on the document + doc._write.assert_called_once_with(doc_path_1) + assert doc.path == doc_path_1 + + doc._write.reset_mock() + + # Save As the document a second time with a different name + future = doc_app._impl.save_as_command.action() + doc_app.loop.run_until_complete(future) + + # Write was invoked on the document + doc._write.assert_called_once_with(doc_path_2) + assert doc.path == doc_path_2 + + +def test_save_as_menu_reject_name(doc_app, tmp_path): + """Save As confirms overwrite of an existing name; if denied, a new filename is selected""" + # There is one document available; but it doesn't have a filename. + assert len(doc_app.documents) == 1 + doc = doc_app.documents[0] + assert doc.path is None + + # Prime the save file dialog with 2 responses - one for an existing file + doc_path_1 = Path(tmp_path / "path/to/first.foobar") + doc_path_1.parent.mkdir(parents=True, exist_ok=True) + doc_path_1.write_text("sample file") + + doc_path_2 = Path(tmp_path / "path/to/second.foobar") + doc_app.current_window._impl.dialog_responses["SaveFileDialog"] = [ + doc_path_1, + doc_path_2, + ] + # Prime the confirm dialog to reject the first candidate name. + doc_app.current_window._impl.dialog_responses["ConfirmDialog"] = [False] + + # Save As the document with the menu + future = doc_app._impl.save_as_command.action() + doc_app.loop.run_until_complete(future) + + # Write was invoked on the document with + doc._write.assert_called_once_with(doc_path_2) + assert doc.path == doc_path_2 + + +def test_save_as_menu_overwrite_name(doc_app, tmp_path): + """Save As confirms overwrite of an existing name; if accepted, the filename is used""" + # There is one document available; but it doesn't have a filename. + assert len(doc_app.documents) == 1 + doc = doc_app.documents[0] + assert doc.path is None + + # Prime the save file dialog with a response for an existing file + doc_path = Path(tmp_path / "path/to/first.foobar") + doc_path.parent.mkdir(parents=True, exist_ok=True) + doc_path.write_text("sample file") + + doc_app.current_window._impl.dialog_responses["SaveFileDialog"] = [doc_path] + # Prime the confirm dialog to accept the existing name + doc_app.current_window._impl.dialog_responses["ConfirmDialog"] = [True] + + # Save As the document with the menu + future = doc_app._impl.save_as_command.action() + doc_app.loop.run_until_complete(future) + + # Write was invoked on the document with + doc._write.assert_called_once_with(doc_path) + assert doc.path == doc_path + + +def test_save_as_non_document(doc_app): + """If the current window isn't a document, save as is a no-op""" + # There is one document available + assert len(doc_app.documents) == 1 + + # Create a non-document window, and set it as the current window + window = toga.Window() + window.content = toga.Box() + window.show() + doc_app.current_window = window + + # Invoke save-as on the non-doc window. This should be a no-op, + # but there's nothing we can do to verify that it is. + doc_app.loop.run_until_complete(doc_app.save_as(window)) + + # Mock save as so we can check it isn't invoked by the menu + doc_app.save_as = Mock() + + # Call save as on the non-document window + future = doc_app._impl.save_as_command.action() + doc_app.loop.run_until_complete(future) + + # Save As wasn't invoked + doc_app.save_as.assert_not_called() + + +def test_save_as_menu_cancel(doc_app, tmp_path): + """Save As can be cancelled by not selecting a file""" + # There is one document available; but it doesn't have a filename. + assert len(doc_app.documents) == 1 + doc = doc_app.documents[0] + assert doc.path is None + + # Prime the save file dialog with a cancel response + doc_app.current_window._impl.dialog_responses["SaveFileDialog"] = [None] + + # Save As the document with the menu + future = doc_app._impl.save_as_command.action() + doc_app.loop.run_until_complete(future) + + # Write was not invoked on the document + doc._write.assert_not_called() + assert doc.path is None + + +def test_save_all(doc_app, example_file, tmp_path): + """Save All can be invoked""" + # There is one pre-existing document available; but it doesn't have a filename. + doc_0 = doc_app.documents[0] + assert doc_0.path is None + + # Create a non-document window, and set it as the current window + window = toga.Window() + window.content = toga.Box() + window.show() + doc_app.current_window = window + + # Create a second document window that *does* have a filename + doc_app.open(example_file) + + # There should be 3 windows, and 2 documents + assert len(doc_app.documents) == 2 + assert len(doc_app.windows) == 3 + + # Prime the save file dialog with a response for a new filename + doc_0_path = Path(tmp_path / "path/to/first.foobar") + doc_app.current_window._impl.dialog_responses["SaveFileDialog"] = [doc_0_path] + + # Save All with the menu + future = doc_app._impl.save_all_command.action() + doc_app.loop.run_until_complete(future) + + # Document 0 was saved with the new filename + doc_app.documents[0]._write.assert_called_once_with(doc_0_path) + doc_app.documents[0].path == doc_0_path + + # Save will have been invoked on the non-document window, but + # it's a no-op with no side effects. + + # Document 1 was saved with the existing filename + doc_app.documents[1]._write.assert_called_once_with(example_file) + doc_app.documents[1].path == example_file + + +def test_save_all_disabled(doc_app, tmp_path): + """If there are no document windows, Save All is a no-op""" + # Create a non-document window, and set it as the current window + window = toga.Window() + window.content = toga.Box() + window.show() + doc_app.current_window = window + + # Close the document window + doc = doc_app.documents[0] + doc.main_window.close() + + # Mock save all so we can check it wasn't invoked + doc_app.save_all = Mock() + + # Save All the document + future = doc_app._impl.save_all_command.action() + doc_app.loop.run_until_complete(future) + + # Save all wasn't called, because there are no current document windows + doc_app.save_all.assert_not_called() + + +class DeprecatedDocumentApp(toga.DocumentApp): + def startup(self): + self.main_window = None + + +def test_deprecated_base_class(monkeypatch): + """The DocumentApp base class has been deprecated""" + monkeypatch.setattr(sys, "argv", ["app-exe"]) + + with pytest.warns( + DeprecationWarning, + match=r"toga.DocumentApp is no longer required. Use toga.App instead", + ): + app = DeprecatedDocumentApp( + "Test App", + "org.beeware.document-app", + document_types={"foobar": ExampleDocument}, + ) + + assert app._impl.interface == app + assert_action_performed(app, "create App") + + assert app.document_types == {"foobar": ExampleDocument} + + # With no command line, a default empty document will be created + assert len(app.documents) == 1 + assert app.documents[0].path is 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 DocumentMainWindow") + assert_action_performed(app.documents[0].main_window, "show") diff --git a/core/tests/app/test_documentapp.py b/core/tests/app/test_documentapp.py deleted file mode 100644 index fe596dba10..0000000000 --- a/core/tests/app/test_documentapp.py +++ /dev/null @@ -1,159 +0,0 @@ -import asyncio -import sys -from pathlib import Path -from unittest.mock import Mock - -import pytest - -import toga -from toga.platform import get_platform_factory -from toga_dummy.documents import Document as DummyDocument -from toga_dummy.utils import assert_action_performed - - -class ExampleDocument(toga.Document): - def __init__(self, path, app): - super().__init__(path=path, document_type="Example Document", app=app) - - def create(self): - self.main_window = toga.DocumentMainWindow(self) - - def read(self): - self.content = self.path - - -def test_create_no_cmdline(monkeypatch): - """A document app can be created with no command line.""" - monkeypatch.setattr(sys, "argv", ["app-exe"]) - - app = toga.DocumentApp( - "Test App", - "org.beeware.document-app", - document_types={"foobar": ExampleDocument}, - ) - app.main_loop() - - assert app._impl.interface == app - assert_action_performed(app, "create DocumentApp") - - assert app.document_types == {"foobar": ExampleDocument} - assert app.documents == [] - - -def test_create_with_cmdline(monkeypatch): - """If a document is specified at the command line, it is opened.""" - monkeypatch.setattr(sys, "argv", ["app-exe", "/path/to/filename.foobar"]) - - app = toga.DocumentApp( - "Test App", - "org.beeware.document-app", - document_types={"foobar": ExampleDocument}, - ) - app.main_loop() - - assert app._impl.interface == app - assert_action_performed(app, "create DocumentApp") - - assert app.document_types == {"foobar": ExampleDocument} - assert len(app.documents) == 1 - assert isinstance(app.documents[0], ExampleDocument) - - # Document content has been read - assert app.documents[0].content == Path("/path/to/filename.foobar") - - # Document window has been created and shown - assert_action_performed(app.documents[0].main_window, "create Window") - assert_action_performed(app.documents[0].main_window, "show") - - -def test_create_with_unknown_document_type(monkeypatch): - """If the document specified at the command line is an unknown type, an exception is - raised.""" - 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", - ): - toga.DocumentApp( - "Test App", - "org.beeware.document-app", - document_types={"foobar": ExampleDocument}, - ) - - -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") - - -def test_close_single_document_app(): - """An app in single document mode closes the app when the window is closed.""" - # Monkeypatch the dummy impl to use single document mode - DummyDocument.SINGLE_DOCUMENT_APP = True - - # Mock the app, but preserve the factory - app = Mock() - app.factory = get_platform_factory() - - doc = ExampleDocument(path=Path("/path/to/doc.txt"), app=app) - - # Window technically was prevented from closing, but the app has been exited. - # This must be run as a co-routine. - async def _do_close(): - return await doc.handle_close(Mock()) - - assert not asyncio.get_event_loop().run_until_complete(_do_close()) - app.exit.assert_called_once_with() - - -def test_close_multiple_document_app(): - """An app in multiple document mode doesn't close when the window is closed.""" - # Monkeypatch the dummy impl to use single document mode - DummyDocument.SINGLE_DOCUMENT_APP = False - - # Mock the app, but preserve the factory - app = Mock() - app.factory = get_platform_factory() - - doc = ExampleDocument(path=Path("/path/to/doc.txt"), app=app) - - # Window has closed, but app has not exited. - # This must be run as a co-routine. - async def _do_close(): - return await doc.handle_close(Mock()) - - assert asyncio.get_event_loop().run_until_complete(_do_close()) - app.exit.assert_not_called() - - -@pytest.mark.parametrize("is_single_doc_app", [True, False]) -def test_no_close(monkeypatch, is_single_doc_app): - """A document can prevent itself from being closed.""" - # Monkeypatch the dummy impl to set the app mode - DummyDocument.SINGLE_DOCUMENT_APP = is_single_doc_app - - # Monkeypatch the Example document to prevent closing. - # Define this as a co-routine to simulate an implementation that called a dialog. - async def can_close(self): - return False - - ExampleDocument.can_close = can_close - - # Mock the app, but preserve the factory - app = Mock() - app.factory = get_platform_factory() - - doc = ExampleDocument(path=Path("/path/to/doc.txt"), app=app) - - # Window was prevented from closing. - # This must be run as a co-routine. - async def _do_close(): - await doc.handle_close(Mock()) - - assert not asyncio.get_event_loop().run_until_complete(_do_close()) - app.exit.assert_not_called() diff --git a/core/tests/app/test_mainwindow.py b/core/tests/app/test_mainwindow.py deleted file mode 100644 index 18381b6fdd..0000000000 --- a/core/tests/app/test_mainwindow.py +++ /dev/null @@ -1,41 +0,0 @@ -from unittest.mock import Mock - -import pytest - -import toga -from toga_dummy.utils import assert_action_performed - - -def test_create(app): - """A MainWindow can be created with minimal arguments.""" - window = toga.MainWindow() - - assert window.app == app - assert window.content is None - - assert window._impl.interface == window - assert_action_performed(window, "create Window") - - # We can't know what the ID is, but it must be a string. - assert isinstance(window.id, str) - # Window title is the app title. - assert window.title == "Test App" - assert window.position == (100, 100) - assert window.size == (640, 480) - assert window.resizable - assert window.closable - assert window.minimizable - assert len(window.toolbar) == 0 - # No on-close handler - assert window.on_close is None - - -def test_no_close(): - """An on_close handler cannot be set on MainWindow.""" - window = toga.MainWindow() - - with pytest.raises( - ValueError, - match=r"Cannot set on_close handler for the main window. Use the app on_exit handler instead.", - ): - window.on_close = Mock() diff --git a/core/tests/command/test_command.py b/core/tests/command/test_command.py index 708f37f06f..13b2e259b0 100644 --- a/core/tests/command/test_command.py +++ b/core/tests/command/test_command.py @@ -5,7 +5,8 @@ import toga from toga.command import Separator -from toga_dummy.utils import assert_action_performed_with +from toga.handlers import DynamicHandler +from toga_dummy.utils import assert_action_not_performed, assert_action_performed_with def assert_order(*items): @@ -155,13 +156,14 @@ def test_icon(app, construct): @pytest.mark.parametrize( "action, enabled, initial_state", [ - (Mock(), True, True), - (Mock(), False, False), + (lambda cmd, **kwargs: None, True, True), + (lambda cmd, **kwargs: None, False, False), (None, True, False), (None, False, False), ], ) def test_enable(action, enabled, initial_state): + """Enabled can be set to a literal value""" cmd = toga.Command(action, text="Test command", enabled=enabled) assert cmd.enabled is initial_state @@ -183,6 +185,30 @@ def test_enable(action, enabled, initial_state): assert_action_performed_with(cmd, "set enabled", value=True) +def test_dynamic_action(): + """Enabled status can be dynamic based on the handler.""" + is_enabled = Mock(side_effect=[True, False]) + handler = Mock() + + cmd = toga.Command(DynamicHandler(handler, enabled=is_enabled), text="Test command") + + # As the action is dynamic, the underlying impl property won't have been triggered + assert_action_not_performed(cmd, "set enabled") + + # Setting the property has no effect either + cmd.enabled = True + assert_action_not_performed(cmd, "set enabled") + + # Evaluating enabled status twice in a row returns a different value + # because the side effect of the enabled method is different. + assert cmd.enabled + assert not cmd.enabled + + # The action callable is proxied through the DynamicAction object. + cmd.action("first", "second", third=3) + handler.assert_called_once_with(cmd, "first", "second", third=3) + + def test_order_by_text(): """Commands are ordered by text when group, section and order match.""" assert_order( diff --git a/core/tests/command/test_commandset.py b/core/tests/command/test_commandset.py index cf76b21108..8d62bbe182 100644 --- a/core/tests/command/test_commandset.py +++ b/core/tests/command/test_commandset.py @@ -27,11 +27,12 @@ def test_create_with_values(): @pytest.mark.parametrize("change_handler", [(None), (Mock())]) def test_add_clear(app, change_handler): """Commands can be added and removed from a commandset.""" - # Put some commands into the app + # Put some commands into the app. The base app has 3 commands before, 1 after any + # user commands. 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, cmd_b) - assert list(app.commands) == [cmd_a, cmd_b] + assert list(app.commands)[3:-1] == [cmd_a, cmd_b] # Create a standalone command set and add some commands cs = CommandSet(on_change=change_handler) @@ -48,7 +49,7 @@ def test_add_clear(app, change_handler): assert list(cs) == [cmd1b, cmd1a] # New Commands aren't known to the app - assert list(app.commands) == [cmd_a, cmd_b] + assert list(app.commands)[3:-1] == [cmd_a, cmd_b] # Clear the command set cs.clear() @@ -62,17 +63,18 @@ def test_add_clear(app, change_handler): assert list(cs) == [] # App command set hasn't changed. - assert list(app.commands) == [cmd_a, cmd_b] + assert list(app.commands)[3:-1] == [cmd_a, cmd_b] @pytest.mark.parametrize("change_handler", [(None), (Mock())]) def test_add_clear_with_app(app, change_handler): """Commands can be added and removed from a commandset that is linked to an app.""" - # Put some commands into the app + # Put some commands into the app. The base app has 3 commands before, 1 after any + # user commands. 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, cmd_b) - assert list(app.commands) == [cmd_a, cmd_b] + assert list(app.commands)[3:-1] == [cmd_a, cmd_b] # Create a command set that is linked to the app and add some commands cs = CommandSet(on_change=change_handler, app=app) @@ -89,7 +91,7 @@ def test_add_clear_with_app(app, change_handler): assert list(cs) == [cmd1b, cmd1a] # New Commands are known to the app - assert list(app.commands) == [cmd_a, cmd1b, cmd1a, cmd_b] + assert list(app.commands)[3:-1] == [cmd_a, cmd1b, cmd1a, cmd_b] # Add another command to the commandset cmd2 = toga.Command(None, text="Test command 2", order=2) @@ -104,7 +106,7 @@ def test_add_clear_with_app(app, change_handler): assert list(cs) == [cmd1b, cmd2, cmd1a] # App also knows about the command - assert list(app.commands) == [cmd_a, cmd1b, cmd2, cmd1a, cmd_b] + assert list(app.commands)[3:-1] == [cmd_a, cmd1b, cmd2, cmd1a, cmd_b] # Clear the command set cs.clear() @@ -118,7 +120,7 @@ def test_add_clear_with_app(app, change_handler): assert list(cs) == [] # App command set hasn't changed. - assert list(app.commands) == [cmd_a, cmd1b, cmd2, cmd1a, cmd_b] + assert list(app.commands)[3:-1] == [cmd_a, cmd1b, cmd2, cmd1a, cmd_b] def test_ordering( diff --git a/core/tests/test_documents.py b/core/tests/test_documents.py index a0da06a9a9..318a2652ae 100644 --- a/core/tests/test_documents.py +++ b/core/tests/test_documents.py @@ -1,4 +1,5 @@ from pathlib import Path +from unittest.mock import Mock import pytest @@ -6,47 +7,77 @@ class MyDoc(toga.Document): - def __init__(self, path, app): - super().__init__(path, "Dummy Document", app) - pass + document_type = "My Document" + default_extension = ".mydoc" def create(self): - pass + self.main_window = Mock(title="Mock Window") + self.content = None + self.written = None def read(self): - pass + self.content = "file content" + def write(self): + self.written = self.content -@pytest.mark.parametrize("path", ["/path/to/doc.mydoc", Path("/path/to/doc.mydoc")]) -def test_create_document(app, path): - doc = MyDoc(path, app) - assert doc.path == Path(path) - assert doc.app == app - assert doc.document_type == "Dummy Document" - - -class MyDeprecatedDoc(toga.Document): - def __init__(self, filename, app): - super().__init__( - path=filename, - document_type="Deprecated Document", - app=app, - ) - - def create(self): - pass +def test_create_document(app): + doc = MyDoc(app) - def read(self): - pass - - -def test_deprecated_names(app): - """Deprecated names still work.""" - doc = MyDeprecatedDoc("/path/to/doc.mydoc", app) - - with pytest.warns( - DeprecationWarning, - match=r"Document.filename has been renamed Document.path.", - ): - assert doc.filename == Path("/path/to/doc.mydoc") + assert doc.path is None + assert doc.app == app + assert doc.document_type == "My Document" + assert doc.default_extension == ".mydoc" + assert doc.title == "My Document: Untitled" + + # create() has been invoked + assert doc.content is None + assert doc.main_window.title == "Mock Window" + + # Document can be shown + doc.show() + doc.main_window.show.assert_called_once_with() + + +@pytest.mark.parametrize( + "path,expected", + [ + ("/path/to/doc.mydoc", Path("/path/to/doc.mydoc")), + (Path("/path/to/doc.mydoc"), Path("/path/to/doc.mydoc")), + ("doc.mydoc", Path.cwd() / "doc.mydoc"), + (Path("doc.mydoc"), Path.cwd() / "doc.mydoc"), + ], +) +def test_open_document(app, path, expected): + """A document can be opened""" + doc = MyDoc(app) + + doc.open(path) + + # Calling absolute() ensures the expected value is correct on Windows + assert doc.path == expected.absolute() + assert doc.content == "file content" + + +@pytest.mark.parametrize( + "path,expected", + [ + (None, Path("/path/to/doc.mydoc")), + ("/path/to/newdoc.mydoc", Path("/path/to/newdoc.mydoc")), + (Path("/path/to/newdoc.mydoc"), Path("/path/to/newdoc.mydoc")), + ("newdoc.mydoc", Path.cwd() / "newdoc.mydoc"), + (Path("newdoc.mydoc"), Path.cwd() / "newdoc.mydoc"), + ], +) +def test_save_document(app, path, expected): + """A document can be saved""" + doc = MyDoc(app) + doc.open("/path/to/doc.mydoc") + + # Calling absolute() ensures the expected value is correct on Windows + assert doc.path == Path("/path/to/doc.mydoc").absolute() + + doc.save(path) + assert doc.path == expected.absolute() + assert doc.written == "file content" diff --git a/core/tests/window/test_main_window.py b/core/tests/window/test_main_window.py new file mode 100644 index 0000000000..0bc1ca55ea --- /dev/null +++ b/core/tests/window/test_main_window.py @@ -0,0 +1,91 @@ +from unittest.mock import Mock + +import pytest + +import toga +from toga_dummy.utils import ( + assert_action_performed, +) + + +@pytest.fixture +def window(app): + return toga.MainWindow() + + +def test_window_created(app): + "A Window can be created with minimal arguments" + window = toga.MainWindow() + + assert window.app == app + assert window.content is None + + assert window._impl.interface == window + assert_action_performed(window, "create MainWindow") + + # We can't know what the ID is, but it must be a string. + assert isinstance(window.id, str) + assert window.title == "Test App" + assert window.position == (100, 100) + assert window.size == (640, 480) + assert window.resizable + assert window.closable + assert window.minimizable + assert window.on_close._raw is None + + +def test_window_created_explicit(app): + "Explicit arguments at construction are stored" + on_close_handler = Mock() + + window = toga.MainWindow( + id="my-window", + title="My Window", + position=(10, 20), + size=(200, 300), + resizable=False, + closable=False, + minimizable=False, + on_close=on_close_handler, + ) + + assert window.app == app + assert window.content is None + + assert window._impl.interface == window + assert_action_performed(window, "create MainWindow") + + assert window.id == "my-window" + assert window.title == "My Window" + assert window.position == (10, 20) + assert window.size == (200, 300) + assert not window.resizable + assert not window.closable + assert not window.minimizable + assert window.on_close._raw == on_close_handler + + +def test_toolbar_implicit_add(window, app): + """Adding an item to to a toolbar implicitly adds it to the app.""" + cmd1 = toga.Command(None, "Command 1") + cmd2 = toga.Command(None, "Command 2") + # The app has 3 commands that will come before user commands, and one after + + toolbar = window.toolbar + assert list(toolbar) == [] + assert list(app.commands)[3:-1] == [] + + # Adding a command to the toolbar automatically adds it to the app + toolbar.add(cmd1) + assert list(toolbar) == [cmd1] + assert list(app.commands)[3:-1] == [cmd1] + + # But not vice versa + app.commands.add(cmd2) + assert list(toolbar) == [cmd1] + assert list(app.commands)[3:-1] == [cmd1, cmd2] + + # Adding a command to both places does not cause a duplicate + app.commands.add(cmd1) + assert list(toolbar) == [cmd1] + assert list(app.commands)[3:-1] == [cmd1, cmd2] diff --git a/core/tests/test_window.py b/core/tests/window/test_window.py similarity index 87% rename from core/tests/test_window.py rename to core/tests/window/test_window.py index 250e2253c8..c7b13fb83c 100644 --- a/core/tests/test_window.py +++ b/core/tests/window/test_window.py @@ -34,7 +34,6 @@ def test_window_created(app): assert window.resizable assert window.closable assert window.minimizable - assert len(window.toolbar) == 0 assert window.on_close._raw is None @@ -66,7 +65,6 @@ def test_window_created_explicit(app): assert not window.resizable assert not window.closable assert not window.minimizable - assert len(window.toolbar) == 0 assert window.on_close._raw == on_close_handler @@ -115,31 +113,6 @@ def test_title(window, value, expected): assert window.title == expected -def test_toolbar_implicit_add(window, app): - """Adding an item to a toolbar implicitly adds it to the app.""" - cmd1 = toga.Command(None, "Command 1") - cmd2 = toga.Command(None, "Command 2") - - toolbar = window.toolbar - assert list(toolbar) == [] - assert list(app.commands) == [] - - # Adding a command to the toolbar automatically adds it to the app - toolbar.add(cmd1) - assert list(toolbar) == [cmd1] - assert list(app.commands) == [cmd1] - - # But not vice versa - app.commands.add(cmd2) - assert list(toolbar) == [cmd1] - assert list(app.commands) == [cmd1, cmd2] - - # Adding a command to both places does not cause a duplicate - app.commands.add(cmd1) - assert list(toolbar) == [cmd1] - assert list(app.commands) == [cmd1, cmd2] - - def test_change_content(window, app): """The content of a window can be changed.""" assert window.content is None @@ -385,6 +358,9 @@ def test_info_dialog(window, app): """An info dialog can be shown.""" on_result_handler = Mock() + # Prime the user's response + window._impl.dialog_responses["InfoDialog"] = [None] + with pytest.warns( DeprecationWarning, match=r"Synchronous `on_result` handlers have been deprecated;", @@ -401,11 +377,7 @@ def test_info_dialog(window, app): # Perform a synchronous comparison; this will raise a runtime error dialog == 1 - async def run_dialog(dialog): - dialog._impl.simulate_result(None) - return await dialog - - assert app._impl.loop.run_until_complete(run_dialog(dialog)) is None + assert app._impl.loop.run_until_complete(dialog) is None assert_action_performed_with( window, @@ -420,6 +392,9 @@ def test_question_dialog(window, app): """A question dialog can be shown.""" on_result_handler = Mock() + # Prime the user's response + window._impl.dialog_responses["QuestionDialog"] = [True] + with pytest.warns( DeprecationWarning, match=r"Synchronous `on_result` handlers have been deprecated;", @@ -436,11 +411,7 @@ def test_question_dialog(window, app): # Perform a synchronous comparison; this will raise a runtime error dialog == 1 - async def run_dialog(dialog): - dialog._impl.simulate_result(True) - return await dialog - - assert app._impl.loop.run_until_complete(run_dialog(dialog)) + assert app._impl.loop.run_until_complete(dialog) assert_action_performed_with( window, @@ -455,6 +426,9 @@ def test_confirm_dialog(window, app): """A confirm dialog can be shown.""" on_result_handler = Mock() + # Prime the user's response + window._impl.dialog_responses["ConfirmDialog"] = [True] + with pytest.warns( DeprecationWarning, match=r"Synchronous `on_result` handlers have been deprecated;", @@ -471,11 +445,7 @@ def test_confirm_dialog(window, app): # Perform a synchronous comparison; this will raise a runtime error dialog == 1 - async def run_dialog(dialog): - dialog._impl.simulate_result(True) - return await dialog - - assert app._impl.loop.run_until_complete(run_dialog(dialog)) + assert app._impl.loop.run_until_complete(dialog) assert_action_performed_with( window, @@ -490,6 +460,9 @@ def test_error_dialog(window, app): """An error dialog can be shown.""" on_result_handler = Mock() + # Prime the user's response + window._impl.dialog_responses["ErrorDialog"] = [None] + with pytest.warns( DeprecationWarning, match=r"Synchronous `on_result` handlers have been deprecated;", @@ -506,11 +479,7 @@ def test_error_dialog(window, app): # Perform a synchronous comparison; this will raise a runtime error dialog == 1 - async def run_dialog(dialog): - dialog._impl.simulate_result(None) - return await dialog - - assert app._impl.loop.run_until_complete(run_dialog(dialog)) is None + assert app._impl.loop.run_until_complete(dialog) is None assert_action_performed_with( window, @@ -525,6 +494,9 @@ def test_stack_trace_dialog(window, app): """A stack trace dialog can be shown.""" on_result_handler = Mock() + # Prime the user's response + window._impl.dialog_responses["StackTraceDialog"] = [None] + with pytest.warns( DeprecationWarning, match=r"Synchronous `on_result` handlers have been deprecated;", @@ -546,11 +518,7 @@ def test_stack_trace_dialog(window, app): # Perform a synchronous comparison; this will raise a runtime error dialog == 1 - async def run_dialog(dialog): - dialog._impl.simulate_result(None) - return await dialog - - assert app._impl.loop.run_until_complete(run_dialog(dialog)) is None + assert app._impl.loop.run_until_complete(dialog) is None assert_action_performed_with( window, @@ -567,6 +535,10 @@ def test_save_file_dialog(window, app): """A save file dialog can be shown.""" on_result_handler = Mock() + # Prime the user's response + saved_file = Path("/saved/path/filename.txt") + window._impl.dialog_responses["SaveFileDialog"] = [saved_file] + with pytest.warns( DeprecationWarning, match=r"Synchronous `on_result` handlers have been deprecated;", @@ -587,13 +559,7 @@ def test_save_file_dialog(window, app): # Perform a synchronous comparison; this will raise a runtime error dialog == 1 - saved_file = Path("/saved/path/filename.txt") - - async def run_dialog(dialog): - dialog._impl.simulate_result(saved_file) - return await dialog - - assert app._impl.loop.run_until_complete(run_dialog(dialog)) is saved_file + assert app._impl.loop.run_until_complete(dialog) is saved_file assert_action_performed_with( window, @@ -610,6 +576,10 @@ def test_save_file_dialog_default_directory(window, app): """If no path is provided, a save file dialog will use the default directory.""" on_result_handler = Mock() + # Prime the user's response + saved_file = Path("/saved/path/filename.txt") + window._impl.dialog_responses["SaveFileDialog"] = [saved_file] + with pytest.warns( DeprecationWarning, match=r"Synchronous `on_result` handlers have been deprecated;", @@ -631,13 +601,7 @@ def test_save_file_dialog_default_directory(window, app): # Perform a synchronous comparison; this will raise a runtime error dialog == 1 - saved_file = Path("/saved/path/filename.txt") - - async def run_dialog(dialog): - dialog._impl.simulate_result(saved_file) - return await dialog - - assert app._impl.loop.run_until_complete(run_dialog(dialog)) is saved_file + assert app._impl.loop.run_until_complete(dialog) is saved_file assert_action_performed_with( window, @@ -654,6 +618,10 @@ def test_open_file_dialog(window, app): """A open file dialog can be shown.""" on_result_handler = Mock() + # Prime the user's response + opened_file = Path("/opened/path/filename.txt") + window._impl.dialog_responses["OpenFileDialog"] = [opened_file] + with pytest.warns( DeprecationWarning, match=r"Synchronous `on_result` handlers have been deprecated;", @@ -674,13 +642,7 @@ def test_open_file_dialog(window, app): # Perform a synchronous comparison; this will raise a runtime error dialog == 1 - opened_file = Path("/opened/path/filename.txt") - - async def run_dialog(dialog): - dialog._impl.simulate_result(opened_file) - return await dialog - - assert app._impl.loop.run_until_complete(run_dialog(dialog)) is opened_file + assert app._impl.loop.run_until_complete(dialog) is opened_file assert_action_performed_with( window, @@ -697,6 +659,13 @@ def test_open_file_dialog_default_directory(window, app): """If no path is provided, a open file dialog will use the default directory.""" on_result_handler = Mock() + # Prime the user's response + opened_files = [ + Path("/opened/path/filename.txt"), + Path("/other/path/filename2.txt"), + ] + window._impl.dialog_responses["OpenFileDialog"] = [opened_files] + with pytest.warns( DeprecationWarning, match=r"Synchronous `on_result` handlers have been deprecated;", @@ -718,16 +687,7 @@ def test_open_file_dialog_default_directory(window, app): # Perform a synchronous comparison; this will raise a runtime error dialog == 1 - opened_files = [ - Path("/opened/path/filename.txt"), - Path("/other/path/filename2.txt"), - ] - - async def run_dialog(dialog): - dialog._impl.simulate_result(opened_files) - return await dialog - - assert app._impl.loop.run_until_complete(run_dialog(dialog)) is opened_files + assert app._impl.loop.run_until_complete(dialog) is opened_files assert_action_performed_with( window, @@ -744,6 +704,10 @@ def test_select_folder_dialog(window, app): """A select folder dialog can be shown.""" on_result_handler = Mock() + # Prime the user's response + opened_file = Path("/opened/path/filename.txt") + window._impl.dialog_responses["SelectFolderDialog"] = [opened_file] + with pytest.warns( DeprecationWarning, match=r"Synchronous `on_result` handlers have been deprecated;", @@ -764,13 +728,7 @@ def test_select_folder_dialog(window, app): # Perform a synchronous comparison; this will raise a runtime error dialog == 1 - opened_file = Path("/opened/path/filename.txt") - - async def run_dialog(dialog): - dialog._impl.simulate_result(opened_file) - return await dialog - - assert app._impl.loop.run_until_complete(run_dialog(dialog)) is opened_file + assert app._impl.loop.run_until_complete(dialog) is opened_file assert_action_performed_with( window, @@ -786,6 +744,13 @@ def test_select_folder_dialog_default_directory(window, app): """If no path is provided, a select folder dialog will use the default directory.""" on_result_handler = Mock() + # Prime the user's response + opened_files = [ + Path("/opened/path/filename.txt"), + Path("/other/path/filename2.txt"), + ] + window._impl.dialog_responses["SelectFolderDialog"] = [opened_files] + with pytest.warns( DeprecationWarning, match=r"Synchronous `on_result` handlers have been deprecated;", @@ -806,16 +771,7 @@ def test_select_folder_dialog_default_directory(window, app): # Perform a synchronous comparison; this will raise a runtime error dialog == 1 - opened_files = [ - Path("/opened/path/filename.txt"), - Path("/other/path/filename2.txt"), - ] - - async def run_dialog(dialog): - dialog._impl.simulate_result(opened_files) - return await dialog - - assert app._impl.loop.run_until_complete(run_dialog(dialog)) is opened_files + assert app._impl.loop.run_until_complete(dialog) is opened_files assert_action_performed_with( window, @@ -831,6 +787,10 @@ def test_deprecated_names_open_file_dialog(window, app): """Deprecated names still work on open file dialogs.""" on_result_handler = Mock() + # Prime the user's response + opened_files = [Path("/opened/path/filename.txt")] + window._impl.dialog_responses["OpenFileDialog"] = [opened_files] + with pytest.warns( DeprecationWarning, match=r"open_file_dialog\(multiselect\) has been renamed multiple_select", @@ -845,13 +805,7 @@ def test_deprecated_names_open_file_dialog(window, app): on_result=on_result_handler, ) - opened_files = [Path("/opened/path/filename.txt")] - - async def run_dialog(dialog): - dialog._impl.simulate_result(opened_files) - return await dialog - - assert app._impl.loop.run_until_complete(run_dialog(dialog)) is opened_files + assert app._impl.loop.run_until_complete(dialog) is opened_files assert_action_performed_with( window, @@ -868,6 +822,10 @@ def test_deprecated_names_select_folder_dialog(window, app): """Deprecated names still work on open file dialogs.""" on_result_handler = Mock() + # Prime the user's response + opened_files = [Path("/opened/path")] + window._impl.dialog_responses["SelectFolderDialog"] = [opened_files] + with pytest.warns( DeprecationWarning, match=r"select_folder_dialog\(multiselect\) has been renamed multiple_select", @@ -882,13 +840,7 @@ def test_deprecated_names_select_folder_dialog(window, app): on_result=on_result_handler, ) - opened_files = [Path("/opened/path")] - - async def run_dialog(dialog): - dialog._impl.simulate_result(opened_files) - return await dialog - - assert app._impl.loop.run_until_complete(run_dialog(dialog)) is opened_files + assert app._impl.loop.run_until_complete(dialog) is opened_files assert_action_performed_with( window, diff --git a/docs/reference/api/app.rst b/docs/reference/api/app.rst index 44505248dc..eeaa5d2033 100644 --- a/docs/reference/api/app.rst +++ b/docs/reference/api/app.rst @@ -8,17 +8,16 @@ The top-level representation of an application. :header-rows: 1 :file: ../data/widgets_by_platform.csv :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!(Application|Component))'} + :exclude: {0: '(?!(Application))', 1:'(?!(Core Component))'} Usage ----- The App class is the top level representation of all application activity. It is a -singleton object - any given process can only have a single App. That -application may manage multiple windows, but it is guaranteed to have at least one -window (called the :attr:`~toga.App.main_window`); when the App's -:attr:`~toga.App.main_window` is closed, the application will exit. +singleton object - any given process can only have a single App. That application may +manage multiple windows, but it will generally have at least one window (called the +:attr:`~toga.App.main_window`). The application is started by calling :meth:`~toga.App.main_loop()`. This will invoke the :meth:`~toga.App.startup()` method of the app. @@ -46,8 +45,8 @@ that will be added to the main window of the app. This approach to app construction is most useful with simple apps. For most complex apps, you should subclass :class:`toga.App`, and provide an implementation of -:meth:`~toga.App.startup()`. This implementation *must* create and assign a -``main_window`` for the app. +:meth:`~toga.App.startup()`. This implementation must assign a ``main_window`` for the +app. .. code-block:: python @@ -70,9 +69,94 @@ details, along with many of the other constructor arguments, as packaging metada format compatible with :any:`importlib.metadata`. If you deploy your app with `Briefcase `__, this will be done automatically. +Assigning a main window +----------------------- + +An app *must* assign ``main_window`` as part of the startup process. However, the value +that is assigned as the main window will affect the behavior of the app. + +Normal app +~~~~~~~~~~ + +The most common type of app will assign a :any:`toga.MainWindow` instance as the main +window. On platforms that have menu bars inside their windows, this will create a main +window with a menu bar, populated with the default app commands. The window assigned as +the main window will have its title set to the formal name of the app. A +:any:`toga.MainWindow` may also have a toolbar. When the main window is closed, the app +will exit. + +This is the type of app that will be created if you use an instance of :any:`toga.App` +passing in a `startup` argument to the constructor. + +Simple app +~~~~~~~~~~ + +To create an app that *doesn't* have these default menu items, you can assign an +instance of :any:`toga.Window` as the main window. A :any:`toga.Window` does not have a +menu bar; and as a result, neither will your app. As with a normal app, the window +assigned as the main window will have its title set to the formal name of the app; and +when the main window is closed, the app will exit. + +.. _session-based-app: + +Session-based app +~~~~~~~~~~~~~~~~~ + +A session-based app is an app that doesn't have a single "main" window. Instead, the app +will have a number of windows, with each window corresponding to a "session" of +interaction with the app. This session might be a editing a single document; or it could +be a particular view of data - web browsers or file browsers would be examples of +session-based apps, with each window representing a view of a URL, or a view of the +file system. + +To define a session-based app, you assign a value of ``None`` as the main window. You can +then create new windows as required by your app. + +The exact behavior of a session-based app is slightly different on each platform, +reflecting platform differences. + +macOS +^^^^^ + +On macOS, there is only ever a single instance of an App 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 app, it will be opened in the existing app instance. Closing +all documents will not cause the app to exit; the app will keep executing until +explicitly exited. + +If the app is started without an explicit file reference (e.g., by specifying a filename +at the command line, or dragging a file onto the app's icon), 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. 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, a single app instance app can also manage multiple open documents; +however when the last document being managed by an app instance is closed, the app +instance will exit. If the App is started without an explicit file reference, an empty +document of the default document type will be opened. + +Background app +~~~~~~~~~~~~~~ + +To create an app without *any* main window, assign ``toga.BACKGROUND`` as the main +window. This will allow your app to persist even if it doesn't have any open windows. It +will also hide any app-level icon from your taskbar. + +Document type handling +---------------------- + +.. TODO: Describe document types, open/save/save_as/save_all interface + Notes ----- +* On macOS, menus are tied to the app, not the window; and a menu is mandatory. + Therefore, a minimal macOS app (i.e., an app using a :any:`toga.Window` as the main + window) will still have a menu, but it will only have the bare minimum of menu items. + * Apps executed under Wayland on Linux environment may not show the app's formal name correctly. Wayland considers many aspects of app operation to be the domain of the windowing environment, not the app; as a result, some API requests will be ignored diff --git a/docs/reference/api/containers/box.rst b/docs/reference/api/containers/box.rst index e2f79747cb..c802993a01 100644 --- a/docs/reference/api/containers/box.rst +++ b/docs/reference/api/containers/box.rst @@ -8,7 +8,7 @@ A generic container for other widgets. Used to construct layouts. :header-rows: 1 :file: ../../data/widgets_by_platform.csv :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!(Box|Component))'} + :exclude: {0: '(?!(Box))', 1:'(?!(Layout Widget))'} Usage ----- 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/documentmainwindow.rst b/docs/reference/api/documentmainwindow.rst new file mode 100644 index 0000000000..2faafe8a52 --- /dev/null +++ b/docs/reference/api/documentmainwindow.rst @@ -0,0 +1,55 @@ +DocumentMainWindow +================== + +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 DocumentMainWindow is the same as a :any:`toga.MainWindow`, except that it is bound to +a :any:`toga.Document` instance, exposed as the :any:`toga.DocumentMainWindow.doc` +attribute. + +Instances of DocumentMainWindow should be created as part of the ``create()`` method of +an implementation of :any:`toga.Document`. + +Reference +--------- + +.. autoclass:: toga.DocumentMainWindow diff --git a/docs/reference/api/hardware/camera.rst b/docs/reference/api/hardware/camera.rst index 935fd913bb..0836f48fd9 100644 --- a/docs/reference/api/hardware/camera.rst +++ b/docs/reference/api/hardware/camera.rst @@ -8,7 +8,7 @@ A sensor that can capture photos and/or video. :header-rows: 1 :file: ../../data/widgets_by_platform.csv :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!(Camera|Hardware))'} + :exclude: {0: '(?!(Camera))', 1:'(?!(Hardware))'} Usage ----- diff --git a/docs/reference/api/index.rst b/docs/reference/api/index.rst index 5876003629..467b5bb774 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 ` The main window of the application. -================================================= =================================================== +=============================================================== ========================================================================== + 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 be used as the main interface to an app. + :doc:`DocumentMainWindow ` A window that can be used as the main interface to a document-based app. +=============================================================== ========================================================================== General widgets --------------- @@ -80,6 +80,8 @@ Resources for an application. :doc:`Command ` A representation of app functionality that the user can invoke from menus or toolbars. + :doc:`Document ` A representation of a file on disk that will be displayed in one or + more windows. :doc:`Font ` Fonts :doc:`Icon ` An icon for buttons, menus, etc :doc:`Image ` An image @@ -115,9 +117,9 @@ Other :hidden: app - documentapp window mainwindow + documentmainwindow containers/index hardware/index resources/index diff --git a/docs/reference/api/mainwindow.rst b/docs/reference/api/mainwindow.rst index 7dc17936d7..c4ce4926e4 100644 --- a/docs/reference/api/mainwindow.rst +++ b/docs/reference/api/mainwindow.rst @@ -1,7 +1,7 @@ MainWindow ========== -The main window of the application. +A window that can be used as the main interface to an app. .. tabs:: @@ -54,23 +54,23 @@ The main window of the application. Usage ----- -The main window of an application is a normal :class:`toga.Window`, with one exception - -when the main window is closed, the application exits. +A main window of an application is a :class:`toga.Window` that can serve as the main +interface to an application. If the platform places menus inside windows, a +:class:`toga.MainWindow` instance will display a menu bar that contains the app control +commands (such as About, Quit, and anything else required by the platform's HIG). It may +also contain a toolbar. + +The title of the window will default to the formal name of the app. .. code-block:: python import toga - main_window = toga.MainWindow(title='My Application') + main_window = toga.MainWindow() self.toga.App.main_window = main_window main_window.show() -As the main window is closely bound to the App, a main window *cannot* define an -``on_close`` handler. Instead, if you want to prevent the main window from exiting, you -should use an ``on_exit`` handler on the :class:`toga.App` that the main window is -associated with. - Reference --------- diff --git a/docs/reference/api/resources/app_paths.rst b/docs/reference/api/resources/app_paths.rst index 12fa3e48d0..e111c18112 100644 --- a/docs/reference/api/resources/app_paths.rst +++ b/docs/reference/api/resources/app_paths.rst @@ -9,7 +9,7 @@ application. :header-rows: 1 :file: ../../data/widgets_by_platform.csv :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!(App Paths|Component)$)'} + :exclude: {0: '(?!(App Paths))', 1:'(?!(Resource))'} Usage ----- diff --git a/docs/reference/api/resources/command.rst b/docs/reference/api/resources/command.rst index 9eb805f59e..07d0c9c0b0 100644 --- a/docs/reference/api/resources/command.rst +++ b/docs/reference/api/resources/command.rst @@ -8,7 +8,7 @@ A representation of app functionality that the user can invoke from menus or too :header-rows: 1 :file: ../../data/widgets_by_platform.csv :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!(Command|Component))'} + :exclude: {0: '(?!(Command))', 1:'(?!(Resource))'} Usage @@ -23,7 +23,7 @@ they invoke it. It doesn't matter if they select a menu item, press a button on toolbar, or use a key combination - the functionality is wrapped up in a Command. Commands are added to an app using the properties :any:`toga.App.commands` and -:any:`toga.Window.toolbar`. Toga then takes control of ensuring that the +: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, this may result in a command being added to a menu. diff --git a/docs/reference/api/resources/document.rst b/docs/reference/api/resources/document.rst new file mode 100644 index 0000000000..923791694f --- /dev/null +++ b/docs/reference/api/resources/document.rst @@ -0,0 +1,85 @@ +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 + :exclude: {0: '(?!(Document))', 1:'(?!(Resource))'} + + +Usage +----- + +A common requirement for apps is to view or edit a particular type of file. To write +this sort of app with Toga, you define a :class:`toga.Document` class to represent each +type of content that your app can manipulate. This :class:`~toga.Document` class is then +registered with your App when the App is created. + +The :class:`toga.Document` class describes how your document can be read and displayed. In +this example, the code declares an "Example Document" document type, whose main window +contains a MultilineTextInput.: + +.. code-block:: python + + import toga + + class ExampleDocument(toga.Document): + document_type = "Example Document" + default_extension = ".example" + + 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.main_window.content.value = f.read() + + def write(self): + # Put your logic to save the document here. For example: + with self.path.open("w") as f: + f.write(self.main_window.content.value) + + +This document class can then be registered with an app instance. The constructor for +:any:`toga.App` allows you to declare the full list of document types that your app +supports; the first declared document type is treated as the default document type for +your app. + +In the following example, the ``ExampleDocument`` class is set as the default content +type, and is registered as representing documents with extension ``mydoc``; the app will +also support documents with the extension ``otherdoc``. The app is configured as a +:ref:`session-based app `; it will used ``ExampleDocument`` as the default document type: + +.. code-block:: python + + import toga + + class ExampleApp(toga.App): + def startup(self): + # Make this a session-based app. + self.main_window = None + + app = ExampleApp( + "Document App", + "com.example.documentapp", + document_types={ + "mydoc": ExampleDocument, + "otherdoc": OtherDocument, + } + ) + + app.main_loop() + +Reference +--------- + +.. autoclass:: toga.Document + :members: + :undoc-members: diff --git a/docs/reference/api/resources/fonts.rst b/docs/reference/api/resources/fonts.rst index 86bd1b35ff..d24f3cb7bf 100644 --- a/docs/reference/api/resources/fonts.rst +++ b/docs/reference/api/resources/fonts.rst @@ -8,7 +8,7 @@ A font for displaying text. :header-rows: 1 :file: ../../data/widgets_by_platform.csv :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!(Font|Component))'} + :exclude: {0: '(?!(Font))', 1:'(?!(Resource))'} Usage ----- diff --git a/docs/reference/api/resources/icons.rst b/docs/reference/api/resources/icons.rst index 26fa3de9ea..199bd5ed58 100644 --- a/docs/reference/api/resources/icons.rst +++ b/docs/reference/api/resources/icons.rst @@ -8,7 +8,7 @@ A small, square image, used to provide easily identifiable visual context to a w :header-rows: 1 :file: ../../data/widgets_by_platform.csv :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!(Icon|Component))'} + :exclude: {0: '(?!(Icon))', 1:'(?!(Resource))'} Usage ----- diff --git a/docs/reference/api/resources/images.rst b/docs/reference/api/resources/images.rst index 59aa204451..837b426ce4 100644 --- a/docs/reference/api/resources/images.rst +++ b/docs/reference/api/resources/images.rst @@ -8,7 +8,7 @@ Graphical content of arbitrary size. :header-rows: 1 :file: ../../data/widgets_by_platform.csv :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!(Image|Component)$)'} + :exclude: {0: '(?!(Image))', 1:'(?!(Resource))'} Usage ----- diff --git a/docs/reference/api/resources/index.rst b/docs/reference/api/resources/index.rst index 0544de2680..8a9724403a 100644 --- a/docs/reference/api/resources/index.rst +++ b/docs/reference/api/resources/index.rst @@ -4,8 +4,9 @@ Resources .. toctree:: app_paths - fonts command + document + fonts icons images sources/source diff --git a/docs/reference/api/screens.rst b/docs/reference/api/screens.rst index 57d9dcaa0f..de9e925def 100644 --- a/docs/reference/api/screens.rst +++ b/docs/reference/api/screens.rst @@ -8,7 +8,7 @@ A representation of a screen attached to a device. :header-rows: 1 :file: ../data/widgets_by_platform.csv :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!(Screen|Device)$)'} + :exclude: {0: '(?!(Screen))', 1:'(?!(Hardware))'} Usage ----- diff --git a/docs/reference/api/widgets/widget.rst b/docs/reference/api/widgets/widget.rst index 51c1162470..384dfe36a8 100644 --- a/docs/reference/api/widgets/widget.rst +++ b/docs/reference/api/widgets/widget.rst @@ -8,7 +8,7 @@ The abstract base class of all widgets. This class should not be be instantiated :header-rows: 1 :file: ../../data/widgets_by_platform.csv :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!^(Widget|Component)$)'} + :exclude: {0: '(?!(Widget))', 1:'(?!(General Widget))'} Reference diff --git a/docs/reference/api/window.rst b/docs/reference/api/window.rst index 2f5cb2a368..be5f715323 100644 --- a/docs/reference/api/window.rst +++ b/docs/reference/api/window.rst @@ -23,13 +23,17 @@ An operating system-managed container of widgets. :align: center :width: 300px - .. group-tab:: Android |no| + .. group-tab:: Android - Not supported + .. figure:: /reference/images/window-android.png + :align: center + :width: 300px - .. group-tab:: iOS |no| + .. group-tab:: iOS - Not supported + .. figure:: /reference/images/window-iOS.png + :align: center + :width: 300px .. group-tab:: Web |no| @@ -43,7 +47,7 @@ Usage ----- A window is the top-level container that the operating system uses to display widgets. A -window may also have other decorations, such as a title bar or toolbar. +window does not contain a menu bar or toolbar. When first created, a window is not visible. To display it, call the :meth:`~toga.Window.show` method. @@ -85,11 +89,11 @@ Notes avoid making UI design decisions that are dependent on specific size and placement of windows. -* A mobile application can only have a single window (the :class:`~toga.MainWindow`), - and that window cannot be moved, resized, hidden, or made full screen. Toga will raise - an exception if you attempt to create a secondary window on a mobile platform. If you - try to modify the size, position, or visibility of the main window, the request will - be ignored. +* A mobile application can only have a single window (the ``main_window``), and that + window cannot be moved, resized, hidden, or made full screen. Toga will raise an + exception if you attempt to create a secondary window on a mobile platform. If you try + to modify the size, position, or visibility of the main window, the request will be + ignored. Reference --------- diff --git a/docs/reference/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index 343fc0afd6..2801ef849f 100644 --- a/docs/reference/data/widgets_by_platform.csv +++ b/docs/reference/data/widgets_by_platform.csv @@ -1,8 +1,8 @@ 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|,,,, -MainWindow,Core Component,:class:`~toga.MainWindow`,The main window of the application.,|y|,|y|,|y|,|y|,|y|,|b|,|b| +Window,Core Component,:class:`~toga.Window`,An operating system-managed container of widgets.,|y|,|y|,|y|,|y|,,, +MainWindow,Core Component,:class:`~toga.MainWindow`,A window that can be used as the main interface to an app.,|y|,|y|,|y|,|y|,|y|,|b|,|b| +DocumentMainWindow,Core Component,:class:`~toga.DocumentMainWindow`,A window that can be used as the main interface to a document-based app.,|y|,|y|,|y|,,,, 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| Canvas,General Widget,:class:`~toga.Canvas`,A drawing area for 2D vector graphics.,|y|,|y|,|y|,|y|,|y|,, @@ -32,6 +32,7 @@ OptionContainer,Layout Widget,:class:`~toga.OptionContainer`,A container that ca Camera,Hardware,:class:`~toga.hardware.camera.Camera`,A sensor that can capture photos and/or video.,|y|,,,|y|,|y|,, Screen,Hardware,:class:`~toga.screens.Screen`,A representation of a screen attached to a device.,|y|,|y|,|y|,|y|,|y|,|b|,|b| App Paths,Resource,:class:`~toga.paths.Paths`,A mechanism for obtaining platform-appropriate filesystem locations for an application.,|y|,|y|,|y|,|y|,|y|,,|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|,,,, Command,Resource,:class:`~toga.Command`,Command,|y|,|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| diff --git a/docs/reference/images/window-android.png b/docs/reference/images/window-android.png new file mode 100644 index 0000000000..540a86c039 Binary files /dev/null and b/docs/reference/images/window-android.png differ diff --git a/docs/reference/images/window-iOS.png b/docs/reference/images/window-iOS.png new file mode 100644 index 0000000000..812d712646 Binary files /dev/null and b/docs/reference/images/window-iOS.png differ diff --git a/dummy/src/toga_dummy/app.py b/dummy/src/toga_dummy/app.py index 7e95948625..842d33bf0f 100644 --- a/dummy/src/toga_dummy/app.py +++ b/dummy/src/toga_dummy/app.py @@ -1,72 +1,162 @@ import asyncio -import sys -from pathlib import Path + +import toga +from toga.app import overridden from .screens import Screen as ScreenImpl from .utils import LoggedObject -from .window import Window - - -class MainWindow(Window): - pass class App(LoggedObject): + # Dummy apps close on the last window close + CLOSE_ON_LAST_WINDOW = True + def __init__(self, interface): super().__init__() self.interface = interface - self.interface._impl = self + self.interface._impl = self self.loop = asyncio.get_event_loop() - self.create() - def create(self): self._action("create App") self.interface._startup() + ###################################################################### + # Commands and menus + ###################################################################### + + def create_app_commands(self): + self.about_command = toga.Command( + self.interface._menu_about, + "About", + group=toga.Group.APP, + ) + self.exit_command = toga.Command( + self.interface._menu_exit, + "Exit", + group=toga.Group.APP, + ) + self.visit_homepage_command = toga.Command( + self.interface._menu_visit_homepage, + "Visit homepage", + enabled=self.interface.home_page is not None, + group=toga.Group.HELP, + ) + + self.interface.commands.add( + self.about_command, + self.exit_command, + self.visit_homepage_command, + ) + + # Register the commands for any app with a MainWindow, + # or any Session-based app (i.e., app with no main window), + # or any app that explicitly defines `preferences()` + if ( + isinstance(self.interface.main_window, toga.MainWindow) + or self.interface.main_window is None + or overridden(self.interface.preferences) + ): + self.preferences_command = toga.Command( + self.interface._menu_preferences, + "Preferences", + group=toga.Group.APP, + # For now, only enable preferences if the user defines an implementation + enabled=overridden(self.interface.preferences), + ) + self.interface.commands.add(self.preferences_command) + + # Add a "New" menu item for each unique registered document type; or, if there's + # an overridden new method. + if self.interface.document_types: + self.new_commands = {} + for document_class in self.interface.document_types.values(): + if document_class not in self.new_commands: + self.new_commands[document_class] = toga.Command( + self.interface._menu_new_document(document_class), + text=f"New {document_class.document_type}", + group=toga.Group.FILE, + ) + self.interface.commands.add(self.new_commands[document_class]) + elif overridden(self.interface.new): + self.new_commands = { + None: toga.Command( + self.interface._menu_new_document(None), + text="New", + group=toga.Group.FILE, + ) + } + self.interface.commands.add(self.new_commands[None]) + + # If there's a user-provided open() implementation, or there are registered + # document types, add an Open menu item. + if overridden(self.interface.open) or self.interface.document_types: + self.open_command = toga.Command( + self.interface._menu_open_file, + text="Open", + group=toga.Group.FILE, + ) + self.interface.commands.add(self.open_command) + + # If there is a user-provided save() implementation, or there are registered + # document types, add a Save menu item. + if overridden(self.interface.save) or self.interface.document_types: + self.save_command = toga.Command( + self.interface._menu_save, + text="Save", + group=toga.Group.FILE, + ) + self.interface.commands.add(self.save_command) + + # If there is a user-provided save_as() implementation, or there are registered + # document types, add a Save As menu item. + if overridden(self.interface.save_as) or self.interface.document_types: + self.save_as_command = toga.Command( + self.interface._menu_save_as, + text="Save As", + group=toga.Group.FILE, + ) + self.interface.commands.add(self.save_as_command) + + # If there is a user-provided save_all() implementation, or there are registered + # document types, add a Save All menu item. + if overridden(self.interface.save_all) or self.interface.document_types: + self.save_all_command = toga.Command( + self.interface._menu_save_all, + text="Save All", + group=toga.Group.FILE, + ) + self.interface.commands.add(self.save_all_command) + def create_menus(self): self._action("create App menus") - def main_loop(self): - print("Starting app using Dummy backend.") - self._action("main loop") - - def set_main_window(self, window): - self._action("set_main_window", window=window) - - def show_about_dialog(self): - self._action("show_about_dialog") - - def beep(self): - self._action("beep") + ###################################################################### + # App lifecycle + ###################################################################### def exit(self): self._action("exit") - def get_current_window(self): - try: - return self._get_value("current_window", self.interface.main_window._impl) - except AttributeError: - return None + def finalize(self): + self._action("finalize creation") - def set_current_window(self, window): - self._action("set_current_window", window=window) - self._set_value("current_window", window._impl) - - def enter_full_screen(self, windows): - self._action("enter_full_screen", windows=windows) + self.create_app_commands() + self.create_menus() - def exit_full_screen(self, windows): - self._action("exit_full_screen", windows=windows) + # Process any command line arguments to open documents, etc + self.interface._create_initial_windows() - def show_cursor(self): - self._action("show_cursor") + def main_loop(self): + print("Starting app using Dummy backend.") + self._action("main loop") - def hide_cursor(self): - self._action("hide_cursor") + def set_main_window(self, window): + self._action("set_main_window", window=window) - def simulate_exit(self): - self.interface.on_exit() + ###################################################################### + # App resources + ###################################################################### def get_screens(self): # _________________________________________________ @@ -95,14 +185,55 @@ def get_screens(self): ScreenImpl(native=("Secondary Screen", (-1366, -768), (1366, 768))), ] + ###################################################################### + # App capabilities + ###################################################################### -class DocumentApp(App): - def create(self): - self._action("create DocumentApp") - self.interface._startup() + def beep(self): + self._action("beep") + + def show_about_dialog(self): + self._action("show_about_dialog") + + ###################################################################### + # Cursor control + ###################################################################### + + def hide_cursor(self): + self._action("hide_cursor") + + def show_cursor(self): + self._action("show_cursor") + + ###################################################################### + # Window control + ###################################################################### + def get_current_window(self): try: - # Create and show the document instance - self.interface._open(Path(sys.argv[1])) - except IndexError: - pass + main_window = self.interface.main_window._impl + except AttributeError: + main_window = None + + return self._get_value("current_window", main_window) + + def set_current_window(self, window): + self._action("set_current_window", window=window) + self._set_value("current_window", window._impl) + + ###################################################################### + # Full screen control + ###################################################################### + + def enter_full_screen(self, windows): + self._action("enter_full_screen", windows=windows) + + def exit_full_screen(self, windows): + self._action("exit_full_screen", windows=windows) + + ###################################################################### + # Simulation interface + ###################################################################### + + def simulate_exit(self): + self.interface.on_exit() diff --git a/dummy/src/toga_dummy/dialogs.py b/dummy/src/toga_dummy/dialogs.py index 01846c8d8e..5f19da6593 100644 --- a/dummy/src/toga_dummy/dialogs.py +++ b/dummy/src/toga_dummy/dialogs.py @@ -3,6 +3,20 @@ def __init__(self, interface): self.interface = interface self.interface._impl = self + try: + result = interface.window._impl.dialog_responses[ + self.__class__.__name__ + ].pop(0) + self.simulate_result(result) + except KeyError: + raise RuntimeError( + f"Was not expecting responses for {self.__class__.__name__}" + ) + except IndexError: + raise RuntimeError( + f"Ran out of prepared responses for {self.__class__.__name__}" + ) + def simulate_result(self, result): self.interface.set_result(result) diff --git a/dummy/src/toga_dummy/documents.py b/dummy/src/toga_dummy/documents.py index 9de290b969..fb33f8329e 100644 --- a/dummy/src/toga_dummy/documents.py +++ b/dummy/src/toga_dummy/documents.py @@ -5,4 +5,7 @@ class Document(LoggedObject): def __init__(self, interface): super().__init__() self.interface = interface + + def open(self): + self._action("open document") self.interface.read() diff --git a/dummy/src/toga_dummy/factory.py b/dummy/src/toga_dummy/factory.py index 2165446e74..36a89f5315 100644 --- a/dummy/src/toga_dummy/factory.py +++ b/dummy/src/toga_dummy/factory.py @@ -1,7 +1,7 @@ from toga import NotImplementedWarning from . import dialogs -from .app import App, DocumentApp, MainWindow +from .app import App from .command import Command from .documents import Document from .fonts import Font @@ -35,7 +35,7 @@ from .widgets.timeinput import TimeInput from .widgets.tree import Tree from .widgets.webview import WebView -from .window import Window +from .window import DocumentMainWindow, MainWindow, Window def not_implemented(feature): @@ -45,8 +45,6 @@ def not_implemented(feature): __all__ = [ "not_implemented", "App", - "DocumentApp", - "MainWindow", "Command", "Document", "Font", @@ -82,6 +80,9 @@ def not_implemented(feature): "TimeInput", "Tree", "WebView", + # Window + "DocumentMainWindow", + "MainWindow", "Window", # Widget is also required for testing purposes # Real backends shouldn't expose Widget. diff --git a/dummy/src/toga_dummy/utils.py b/dummy/src/toga_dummy/utils.py index e5c3a772ac..2cb1ce3358 100644 --- a/dummy/src/toga_dummy/utils.py +++ b/dummy/src/toga_dummy/utils.py @@ -173,7 +173,7 @@ def __init__(self, logtype, instance, **context): self.context = context def __repr__(self): - return f"=1.0.0', +] + +[tool.briefcase.app.simpleapp.linux] +requires = [ + '../../gtk', +] + +[tool.briefcase.app.simpleapp.windows] +requires = [ + '../../winforms', +] + +# Mobile deployments +[tool.briefcase.app.simpleapp.iOS] +requires = [ + '../../iOS', + 'std-nslog>=1.0.0', +] + +[tool.briefcase.app.simpleapp.android] +requires = [ + '../../android', +] + +base_theme = "Theme.MaterialComponents.Light.DarkActionBar" + +build_gradle_dependencies = [ + "androidx.appcompat:appcompat:1.6.1", + "com.google.android.material:material:1.11.0", + "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0", +] + +# Web deployment +[tool.briefcase.app.simpleapp.web] +requires = [ + "../../web", +] +style_framework = "Shoelace v2.3" diff --git a/examples/simpleapp/simpleapp/__init__.py b/examples/simpleapp/simpleapp/__init__.py new file mode 100644 index 0000000000..86826e638c --- /dev/null +++ b/examples/simpleapp/simpleapp/__init__.py @@ -0,0 +1,9 @@ +# Examples of valid version strings +# __version__ = '1.2.3.dev1' # Development release 1 +# __version__ = '1.2.3a1' # Alpha Release 1 +# __version__ = '1.2.3b1' # Beta Release 1 +# __version__ = '1.2.3rc1' # RC Release 1 +# __version__ = '1.2.3' # Final Release +# __version__ = '1.2.3.post1' # Post Release 1 + +__version__ = "0.0.1" diff --git a/examples/simpleapp/simpleapp/__main__.py b/examples/simpleapp/simpleapp/__main__.py new file mode 100644 index 0000000000..ed8add8616 --- /dev/null +++ b/examples/simpleapp/simpleapp/__main__.py @@ -0,0 +1,4 @@ +from simpleapp.app import main + +if __name__ == "__main__": + main().main_loop() diff --git a/examples/simpleapp/simpleapp/app.py b/examples/simpleapp/simpleapp/app.py new file mode 100644 index 0000000000..da4a729528 --- /dev/null +++ b/examples/simpleapp/simpleapp/app.py @@ -0,0 +1,32 @@ +import toga +from toga.style import Pack + + +class ExampleSimpleApp(toga.App): + def startup(self): + # Set up a minimalist main window + self.main_window = toga.Window() + + # Label to show responses. + self.label = toga.Label("Ready.") + + # Outermost box + outer_box = toga.Box( + children=[self.label], + style=Pack(padding=10), + ) + + # Add the content on the main window + self.main_window.content = outer_box + + # Show the main window + self.main_window.show() + + +def main(): + return ExampleSimpleApp("Simple App", "org.beeware.simpleapp") + + +if __name__ == "__main__": + app = main() + app.main_loop() diff --git a/examples/statusiconapp/README.rst b/examples/statusiconapp/README.rst new file mode 100644 index 0000000000..3be953726d --- /dev/null +++ b/examples/statusiconapp/README.rst @@ -0,0 +1,12 @@ +Status Icon App +=============== + +Test app for the Status Icon App widget. + +Quickstart +~~~~~~~~~~ + +To run this example: + + $ pip install toga + $ python -m statusiconapp diff --git a/examples/statusiconapp/pyproject.toml b/examples/statusiconapp/pyproject.toml new file mode 100644 index 0000000000..99ba3931ac --- /dev/null +++ b/examples/statusiconapp/pyproject.toml @@ -0,0 +1,63 @@ +[build-system] +requires = ["briefcase"] + +[tool.briefcase] +project_name = "Status Icon App" +bundle = "org.beeware.toga.examples" +version = "0.0.1" +url = "https://beeware.org" +license = "BSD license" +author = 'Tiberius Yak' +author_email = "tiberius@beeware.org" + +[tool.briefcase.app.statusiconapp] +formal_name = "Status Icon App" +description = "A testing app" +sources = ['statusiconapp'] +requires = [ + '../../core', +] + + +[tool.briefcase.app.statusiconapp.macOS] +requires = [ + '../../cocoa', + 'std-nslog>=1.0.0', +] + +[tool.briefcase.app.statusiconapp.linux] +requires = [ + '../../gtk', +] + +[tool.briefcase.app.statusiconapp.windows] +requires = [ + '../../winforms', +] + +# Mobile deployments +[tool.briefcase.app.statusiconapp.iOS] +requires = [ + '../../iOS', + 'std-nslog>=1.0.0', +] + +[tool.briefcase.app.statusiconapp.android] +requires = [ + '../../android', +] + +base_theme = "Theme.MaterialComponents.Light.DarkActionBar" + +build_gradle_dependencies = [ + "androidx.appcompat:appcompat:1.6.1", + "com.google.android.material:material:1.11.0", + "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0", +] + +# Web deployment +[tool.briefcase.app.statusiconapp.web] +requires = [ + "../../web", +] +style_framework = "Shoelace v2.3" diff --git a/examples/statusiconapp/statusiconapp/__init__.py b/examples/statusiconapp/statusiconapp/__init__.py new file mode 100644 index 0000000000..86826e638c --- /dev/null +++ b/examples/statusiconapp/statusiconapp/__init__.py @@ -0,0 +1,9 @@ +# Examples of valid version strings +# __version__ = '1.2.3.dev1' # Development release 1 +# __version__ = '1.2.3a1' # Alpha Release 1 +# __version__ = '1.2.3b1' # Beta Release 1 +# __version__ = '1.2.3rc1' # RC Release 1 +# __version__ = '1.2.3' # Final Release +# __version__ = '1.2.3.post1' # Post Release 1 + +__version__ = "0.0.1" diff --git a/examples/statusiconapp/statusiconapp/__main__.py b/examples/statusiconapp/statusiconapp/__main__.py new file mode 100644 index 0000000000..f13f1823f9 --- /dev/null +++ b/examples/statusiconapp/statusiconapp/__main__.py @@ -0,0 +1,4 @@ +from statusiconapp.app import main + +if __name__ == "__main__": + main().main_loop() diff --git a/examples/statusiconapp/statusiconapp/app.py b/examples/statusiconapp/statusiconapp/app.py new file mode 100644 index 0000000000..a618777d49 --- /dev/null +++ b/examples/statusiconapp/statusiconapp/app.py @@ -0,0 +1,23 @@ +import toga + + +class ExampleStatusIconApp(toga.App): + def startup(self): + # Set app to be a background app + self.main_window = toga.App.BACKGROUND + + # This app has no user interface at present. It exists to demonstrate how you + # can build an app that persists in the background with no main window. + # + # Support for defining status icons is coming soon (See #97) + + +def main(): + return ExampleStatusIconApp( + "Status Icon App", "org.beeware.toga.examples.statusiconapp" + ) + + +if __name__ == "__main__": + app = main() + app.main_loop() diff --git a/examples/statusiconapp/statusiconapp/resources/README b/examples/statusiconapp/statusiconapp/resources/README new file mode 100644 index 0000000000..84f0abfa08 --- /dev/null +++ b/examples/statusiconapp/statusiconapp/resources/README @@ -0,0 +1 @@ +Put any icons or images in this directory. diff --git a/gtk/src/toga_gtk/app.py b/gtk/src/toga_gtk/app.py index 4b700ff02c..4c5045ae7b 100644 --- a/gtk/src/toga_gtk/app.py +++ b/gtk/src/toga_gtk/app.py @@ -2,46 +2,21 @@ import os import signal import sys -from pathlib import Path import gbulb import toga -from toga import App as toga_App +from toga.app import overridden from toga.command import Command, Separator from .keys import gtk_accel from .libs import TOGA_DEFAULT_STYLES, Gdk, Gio, GLib, Gtk from .screens import Screen as ScreenImpl -from .window import Window - - -class MainWindow(Window): - def create(self): - self.native = Gtk.ApplicationWindow() - self.native.set_role("MainWindow") - icon_impl = toga_App.app.icon._impl - self.native.set_icon(icon_impl.native_72) - - def gtk_delete_event(self, *args): - # Return value of the GTK on_close handler indicates - # whether the event has been fully handled. Returning - # False indicates the event handling is *not* complete, - # so further event processing (including actually - # closing the window) should be performed; so - # "should_exit == True" must be converted to a return - # value of False. - self.interface.app.on_exit() - return True class App: - """ - Todo: - * Creation of Menus is not working. - * Disabling of menu items is not working. - * App Icon is not showing up - """ + # GTK apps exit when the last window is closed + CLOSE_ON_LAST_WINDOW = True def __init__(self, interface): self.interface = interface @@ -50,9 +25,6 @@ def __init__(self, interface): gbulb.install(gtk=True) self.loop = asyncio.new_event_loop() - self.create() - - def create(self): # Stimulate the build of the app self.native = Gtk.Application( application_id=self.interface.app_id, @@ -62,6 +34,7 @@ def create(self): # Connect the GTK signal that will cause app startup to occur self.native.connect("startup", self.gtk_startup) + # Activate is a no-op, but GTK complains if you don't implement it self.native.connect("activate", self.gtk_activate) self.actions = None @@ -70,59 +43,117 @@ def gtk_activate(self, data=None): pass def gtk_startup(self, data=None): - # Set up the default commands for the interface. - self.create_app_commands() - self.interface._startup() - # Create the lookup table of menu items, - # then force the creation of the menus. - self.create_menus() - - # Now that we have menus, make the app take responsibility for - # showing the menubar. - # This is required because of inconsistencies in how the Gnome - # shell operates on different windowing environments; - # see #872 for details. - settings = Gtk.Settings.get_default() - settings.set_property("gtk-shell-shows-menubar", False) - - # Set any custom styles - css_provider = Gtk.CssProvider() - css_provider.load_from_data(TOGA_DEFAULT_STYLES) - - context = Gtk.StyleContext() - context.add_provider_for_screen( - Gdk.Screen.get_default(), css_provider, Gtk.STYLE_PROVIDER_PRIORITY_USER - ) - ###################################################################### # Commands and menus ###################################################################### - def _menu_about(self, command, **kwargs): - self.interface.about() + def create_app_commands(self): + # Set up the default commands for the interface. + if ( + isinstance(self.interface.main_window, toga.MainWindow) + or self.interface.main_window is None + ): + self.interface.commands.add( + Command( + self.interface._menu_about, + "About " + self.interface.formal_name, + group=toga.Group.HELP, + ), + Command( + self.interface._menu_visit_homepage, + "Visit homepage", + enabled=self.interface.home_page is not None, + group=toga.Group.HELP, + ), + # Preferences should be the last section of the edit menu. + Command( + self.interface._menu_preferences, + "Preferences", + group=toga.Group.EDIT, + section=sys.maxsize, + # For now, only enable preferences if the user defines an implementation + enabled=overridden(self.interface.preferences), + ), + # Quit should always be the last item, in a section on its own + Command( + self.interface._menu_exit, + "Quit", + shortcut=toga.Key.MOD_1 + "q", + group=toga.Group.FILE, + section=sys.maxsize, + ), + ) - def _menu_quit(self, command, **kwargs): - self.interface.on_exit() + # Add a "New" menu item for each unique registered document type. + if self.interface.document_types: + for document_class in self.interface.document_types.values(): + self.interface.commands.add( + toga.Command( + self.interface._menu_new_document(document_class), + text=f"New {document_class.document_type}", + shortcut=( + toga.Key.MOD_1 + "n" + if document_class == self.interface.main_window + else None + ), + group=toga.Group.FILE, + section=0, + ), + ) + + # If there's a user-provided open() implementation, or there are registered + # document types, add an Open menu item. + if overridden(self.interface.open) or self.interface.document_types: + self.interface.commands.add( + toga.Command( + self.interface._menu_open_file, + text="Open\u2026", + shortcut=toga.Key.MOD_1 + "o", + group=toga.Group.FILE, + section=10, + ), + ) - def create_app_commands(self): - self.interface.commands.add( - Command( - self._menu_about, - "About " + self.interface.formal_name, - group=toga.Group.HELP, - ), - Command(None, "Preferences", group=toga.Group.APP), - # Quit should always be the last item, in a section on its own - Command( - self._menu_quit, - "Quit " + self.interface.formal_name, - shortcut=toga.Key.MOD_1 + "q", - group=toga.Group.APP, - section=sys.maxsize, - ), - ) + # If there is a user-provided save() implementation, or there are registered + # document types, add a Save menu item. + if overridden(self.interface.save) or self.interface.document_types: + self.interface.commands.add( + toga.Command( + self.interface._menu_save, + text="Save", + shortcut=toga.Key.MOD_1 + "s", + group=toga.Group.FILE, + section=20, + ), + ) + + # If there is a user-provided save_as() implementation, or there are registered + # document types, add a Save As menu item. + if overridden(self.interface.save_as) or self.interface.document_types: + self.interface.commands.add( + toga.Command( + self.interface._menu_save_as, + text="Save As\u2026", + group=toga.Group.FILE, + section=20, + order=10, + ), + ) + + # If there is a user-provided save_all() implementation, or there are registered + # document types, add a Save All menu item. + if overridden(self.interface.save_all) or self.interface.document_types: + self.interface.commands.add( + toga.Command( + self.interface._menu_save_all, + text="Save All", + group=toga.Group.FILE, + section=20, + order=20, + ), + ) def _submenu(self, group, menubar): try: @@ -183,6 +214,14 @@ def create_menus(self): # Set the menu for the app. self.native.set_menubar(menubar) + # Now that we have menus, make the app take responsibility for + # showing the menubar. + # This is required because of inconsistencies in how the Gnome + # shell operates on different windowing environments; + # see #872 for details. + settings = Gtk.Settings.get_default() + settings.set_property("gtk-shell-shows-menubar", False) + ###################################################################### # App lifecycle ###################################################################### @@ -191,14 +230,38 @@ def create_menus(self): def exit(self): # pragma: no cover self.native.quit() + def finalize(self): + # Set any custom styles + css_provider = Gtk.CssProvider() + css_provider.load_from_data(TOGA_DEFAULT_STYLES) + + context = Gtk.StyleContext() + context.add_provider_for_screen( + Gdk.Screen.get_default(), css_provider, Gtk.STYLE_PROVIDER_PRIORITY_USER + ) + + # Create the app commands and populate app menus. + self.create_app_commands() + self.create_menus() + + # Process any command line arguments to open documents, etc + self.interface._create_initial_windows() + def main_loop(self): # Modify signal handlers to make sure Ctrl-C is caught and handled. signal.signal(signal.SIGINT, signal.SIG_DFL) + # Retain a reference to the app so that no-window apps can exist + self.native.hold() + self.loop.run_forever(application=self.native) + # Release the reference to the app + self.native.release() + def set_main_window(self, window): - pass + if isinstance(window, toga.Window): + window._impl.native.set_role("MainWindow") ###################################################################### # App resources @@ -228,7 +291,7 @@ def get_screens(self): def beep(self): Gdk.beep() - def _close_about(self, dialog): + def _close_about(self, dialog, *args): self.native_about_dialog.destroy() self.native_about_dialog = None @@ -236,7 +299,7 @@ def show_about_dialog(self): self.native_about_dialog = Gtk.AboutDialog() self.native_about_dialog.set_modal(True) - icon_impl = toga_App.app.icon._impl + icon_impl = toga.App.app.icon._impl self.native_about_dialog.set_logo(icon_impl.native_72) self.native_about_dialog.set_program_name(self.interface.formal_name) @@ -251,6 +314,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 +348,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( - toga.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..510ab4d417 100644 --- a/gtk/src/toga_gtk/command.py +++ b/gtk/src/toga_gtk/command.py @@ -9,7 +9,7 @@ def __init__(self, interface): self.interface = interface self.native = [] - def gtk_activate(self, action, data): + def gtk_activate(self, action, data=None): self.interface.action() def gtk_clicked(self, action): diff --git a/gtk/src/toga_gtk/documents.py b/gtk/src/toga_gtk/documents.py index 9000fc5ce5..efaa0924c1 100644 --- a/gtk/src/toga_gtk/documents.py +++ b/gtk/src/toga_gtk/documents.py @@ -1,7 +1,6 @@ -class Document: # pragma: no cover - # GTK has 1-1 correspondence between document and app instances. - SINGLE_DOCUMENT_APP = True - +class Document: def __init__(self, interface): self.interface = interface + + def open(self): self.interface.read() diff --git a/gtk/src/toga_gtk/factory.py b/gtk/src/toga_gtk/factory.py index a0d48f0af1..43d15a723c 100644 --- a/gtk/src/toga_gtk/factory.py +++ b/gtk/src/toga_gtk/factory.py @@ -1,7 +1,7 @@ from toga import NotImplementedWarning from . import dialogs -from .app import App, DocumentApp, MainWindow +from .app import App from .command import Command from .documents import Document from .fonts import Font @@ -31,7 +31,7 @@ from .widgets.textinput import TextInput from .widgets.tree import Tree from .widgets.webview import WebView -from .window import Window +from .window import DocumentMainWindow, MainWindow, Window def not_implemented(feature): @@ -41,8 +41,6 @@ def not_implemented(feature): __all__ = [ "not_implemented", "App", - "DocumentApp", - "MainWindow", "Command", "Document", # Resources @@ -75,6 +73,9 @@ def not_implemented(feature): "TextInput", "Tree", "WebView", + # Windows + "DocumentMainWindow", + "MainWindow", "Window", ] diff --git a/gtk/src/toga_gtk/window.py b/gtk/src/toga_gtk/window.py index 0273f62424..e3d1434baa 100644 --- a/gtk/src/toga_gtk/window.py +++ b/gtk/src/toga_gtk/window.py @@ -1,3 +1,4 @@ +import toga from toga.command import Separator from .container import TogaContainer @@ -10,19 +11,22 @@ def __init__(self, interface, title, position, size): self.interface = interface self.interface._impl = self - self._is_closing = False - self.layout = None self.create() self.native._impl = self - self.native.connect("delete-event", self.gtk_delete_event) + self._delete_handler = self.native.connect( + "delete-event", + self.gtk_delete_event, + ) self.native.set_default_size(size[0], size[1]) self.set_title(title) - self.set_position(position) + + pos = 100 + len(toga.App.app.windows) * 50 + self.set_position(position if position else (pos, pos)) # Set the window deletable/closable. self.native.set_deletable(self.interface.closable) @@ -41,7 +45,6 @@ def __init__(self, interface, title, position, size): self.native_toolbar.set_visible(False) self.toolbar_items = {} self.toolbar_separators = set() - self.layout.pack_start(self.native_toolbar, expand=False, fill=False, padding=0) # Because expand and fill are True, the container will fill the available # space, and will get a size_allocate callback if the window is resized. @@ -52,23 +55,21 @@ def __init__(self, interface, title, position, size): def create(self): self.native = Gtk.Window() + icon_impl = toga.App.app.icon._impl + self.native.set_icon(icon_impl.native_72) ###################################################################### # Native event handlers ###################################################################### def gtk_delete_event(self, widget, data): - if self._is_closing: - should_close = True - else: - should_close = self.interface.on_close() - # Return value of the GTK on_close handler indicates # whether the event has been fully handled. Returning # False indicates the event handling is *not* complete, # so further event processing (including actually # closing the window) should be performed. - return not should_close + self.interface.on_close() + return True ###################################################################### # Window properties @@ -85,61 +86,10 @@ def set_title(self, title): ###################################################################### def close(self): - self._is_closing = True + # Disconnect the delete handler so that we can perform the actual close. + self.native.disconnect(self._delete_handler) self.native.close() - def create_toolbar(self): - # If there's an existing toolbar, hide it until we know we need it. - if self.toolbar_items: - self.native_toolbar.set_visible(False) - - # Deregister any toolbar buttons from their commands, and remove them from the toolbar - for cmd, item_impl in self.toolbar_items.items(): - self.native_toolbar.remove(item_impl) - cmd._impl.native.remove(item_impl) - # Remove any toolbar separators - for sep in self.toolbar_separators: - self.native_toolbar.remove(sep) - - # Create the new toolbar items - self.toolbar_items = {} - self.toolbar_separators = set() - prev_group = None - for cmd in self.interface.toolbar: - if isinstance(cmd, Separator): - item_impl = Gtk.SeparatorToolItem() - item_impl.set_draw(False) - self.toolbar_separators.add(item_impl) - prev_group = None - else: - # A change in group requires adding a toolbar separator - if prev_group is not None and prev_group != cmd.group: - group_sep = Gtk.SeparatorToolItem() - group_sep.set_draw(True) - self.toolbar_separators.add(group_sep) - self.native_toolbar.insert(group_sep, -1) - prev_group = None - else: - prev_group = cmd.group - - item_impl = Gtk.ToolButton() - if cmd.icon: - item_impl.set_icon_widget( - Gtk.Image.new_from_pixbuf(cmd.icon._impl.native_32) - ) - item_impl.set_label(cmd.text) - if cmd.tooltip: - item_impl.set_tooltip_text(cmd.tooltip) - item_impl.connect("clicked", cmd._impl.gtk_clicked) - cmd._impl.native.append(item_impl) - self.toolbar_items[cmd] = item_impl - - self.native_toolbar.insert(item_impl, -1) - - if self.toolbar_items: - self.native_toolbar.set_visible(True) - self.native_toolbar.show_all() - def set_app(self, app): app.native.add_window(self.native) @@ -233,3 +183,70 @@ def get_image_data(self): else: # pragma: nocover # This shouldn't ever happen, and it's difficult to manufacture in test conditions raise ValueError(f"Unable to generate screenshot of {self}") + + +class MainWindow(Window): + def create(self): + self.native = Gtk.ApplicationWindow() + icon_impl = toga.App.app.icon._impl + self.native.set_icon(icon_impl.native_72) + + def create_toolbar(self): + # If there's an existing toolbar, remove it until we know we need it. + self.layout.remove(self.native_toolbar) + + # Deregister any toolbar buttons from their commands, and remove them from the toolbar + for cmd, item_impl in self.toolbar_items.items(): + self.native_toolbar.remove(item_impl) + cmd._impl.native.remove(item_impl) + + # Remove any toolbar separators + for sep in self.toolbar_separators: + self.native_toolbar.remove(sep) + + # Create the new toolbar items + self.toolbar_items = {} + self.toolbar_separators = set() + prev_group = None + for cmd in self.interface.toolbar: + if isinstance(cmd, Separator): + item_impl = Gtk.SeparatorToolItem() + item_impl.set_draw(False) + self.toolbar_separators.add(item_impl) + prev_group = None + else: + # A change in group requires adding a toolbar separator + if prev_group is not None and prev_group != cmd.group: + group_sep = Gtk.SeparatorToolItem() + group_sep.set_draw(True) + self.toolbar_separators.add(group_sep) + self.native_toolbar.insert(group_sep, -1) + prev_group = None + else: + prev_group = cmd.group + + item_impl = Gtk.ToolButton() + if cmd.icon: + item_impl.set_icon_widget( + Gtk.Image.new_from_pixbuf(cmd.icon._impl.native_32) + ) + item_impl.set_label(cmd.text) + if cmd.tooltip: + item_impl.set_tooltip_text(cmd.tooltip) + item_impl.connect("clicked", cmd._impl.gtk_clicked) + cmd._impl.native.append(item_impl) + self.toolbar_items[cmd] = item_impl + + self.native_toolbar.insert(item_impl, -1) + + if self.toolbar_items: + # We have toolbar items; add the toolbar to the top of the layout. + self.layout.pack_start( + self.native_toolbar, expand=False, fill=False, 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/iOS/src/toga_iOS/app.py b/iOS/src/toga_iOS/app.py index b9e71ddc26..12a494b53c 100644 --- a/iOS/src/toga_iOS/app.py +++ b/iOS/src/toga_iOS/app.py @@ -3,16 +3,12 @@ from rubicon.objc import objc_method from rubicon.objc.eventloop import EventLoopPolicy, iOSLifecycle +import toga from toga_iOS.libs import UIResponder, UIScreen, av_foundation -from toga_iOS.window import Window from .screens import Screen as ScreenImpl -class MainWindow(Window): - _is_main_window = True - - class PythonAppDelegate(UIResponder): @objc_method def applicationDidBecomeActive_(self, application) -> None: @@ -53,6 +49,9 @@ def application_didChangeStatusBarOrientation_( class App: + # iOS apps exit when the last window is closed + CLOSE_ON_LAST_WINDOW = True + def __init__(self, interface): self.interface = interface self.interface._impl = self @@ -66,13 +65,17 @@ def __init__(self, interface): self.loop = asyncio.new_event_loop() def create(self): - """Calls the startup method on the interface.""" + # Call the app's startup method self.interface._startup() ###################################################################### # Commands and menus ###################################################################### + def create_app_commands(self): + # No commands on an iOS app (for now) + pass + def create_menus(self): # No menus on an iOS app (for now) pass @@ -85,6 +88,10 @@ def exit(self): # pragma: no cover # Mobile apps can't be exited, but the entry point needs to exist pass + def finalize(self): + self.create_app_commands() + self.create_menus() + def main_loop(self): # Main loop is non-blocking on iOS. The app loop is integrated with the # main iOS event loop, so this call will return; however, it will leave @@ -93,7 +100,10 @@ def main_loop(self): self.loop.run_forever_cooperatively(lifecycle=iOSLifecycle()) def set_main_window(self, window): - pass + if window is None: + raise RuntimeError("Session-based apps are not supported on iOS") + elif window == toga.App.BACKGROUND: + raise RuntimeError("Background apps are not supported on iOS") ###################################################################### # App resources @@ -111,10 +121,6 @@ def beep(self): # sounding like a single strike of a bell. av_foundation.AudioServicesPlayAlertSound(1013) - def open_document(self, fileURL): # pragma: no cover - """Add a new document to this app.""" - pass - def show_about_dialog(self): self.interface.factory.not_implemented("App.show_about_dialog()") diff --git a/iOS/src/toga_iOS/container.py b/iOS/src/toga_iOS/container.py index c3e9516209..de3ada2344 100644 --- a/iOS/src/toga_iOS/container.py +++ b/iOS/src/toga_iOS/container.py @@ -124,7 +124,54 @@ def __init__( layout_native=None, on_refresh=None, ): + """A bare content container. + + :param content: The widget impl that is the container's initial content. + :param layout_native: The native widget that should be used to provide + size hints to the layout. This will usually be the container widget + itself; however, for widgets like ScrollContainer where the layout + needs to be computed based on a different size to what will be + rendered, the source of the size can be different. + :param on_refresh: The callback to be notified when this container's layout is + refreshed. """ + super().__init__( + content=content, + layout_native=layout_native, + on_refresh=on_refresh, + ) + + # Construct a UIViewController to hold the root content + self.controller = UIViewController.alloc().init() + + # Set the controller's view to be the root content widget + self.controller.view = self.native + + @property + def height(self): + return self.layout_native.bounds.size.height - self.top_offset + + @property + def top_offset(self): + return UIApplication.sharedApplication.statusBarFrame.size.height + + @property + def title(self): + return self._title + + @title.setter + def title(self, value): + self._title = value + + +class NavigationContainer(Container): + def __init__( + self, + content=None, + layout_native=None, + on_refresh=None, + ): + """A container that provides a navigation bar. :param content: The widget impl that is the container's initial content. :param layout_native: The native widget that should be used to provide size hints to the layout. This will usually be the container widget diff --git a/iOS/src/toga_iOS/factory.py b/iOS/src/toga_iOS/factory.py index e15b63935c..df6f65119d 100644 --- a/iOS/src/toga_iOS/factory.py +++ b/iOS/src/toga_iOS/factory.py @@ -1,7 +1,7 @@ from toga import NotImplementedWarning from . import dialogs -from .app import App, MainWindow +from .app import App from .colors import native_color from .command import Command from .fonts import Font @@ -35,7 +35,7 @@ # from .widgets.tree import Tree from .widgets.webview import WebView -from .window import Window +from .window import MainWindow, Window def not_implemented(feature): @@ -45,7 +45,6 @@ def not_implemented(feature): __all__ = [ "not_implemented", "App", - "MainWindow", "Command", # Resources "native_color", # colors @@ -78,6 +77,8 @@ def not_implemented(feature): "TextInput", # 'Tree', "WebView", + # Windows + "MainWindow", "Window", ] diff --git a/iOS/src/toga_iOS/window.py b/iOS/src/toga_iOS/window.py index 7ab0129e6d..4f3c9993c2 100644 --- a/iOS/src/toga_iOS/window.py +++ b/iOS/src/toga_iOS/window.py @@ -6,7 +6,7 @@ objc_id, ) -from toga_iOS.container import RootContainer +from toga_iOS.container import NavigationContainer, RootContainer from toga_iOS.images import nsdata_to_bytes from toga_iOS.libs import ( NSData, @@ -23,22 +23,14 @@ class Window: - _is_main_window = False - def __init__(self, interface, title, position, size): self.interface = interface self.interface._impl = self - if not self._is_main_window: - raise RuntimeError( - "Secondary windows cannot be created on mobile platforms" - ) - self.native = UIWindow.alloc().initWithFrame(UIScreen.mainScreen.bounds) # Set up a container for the window's content - # RootContainer provides a titlebar for the window. - self.container = RootContainer(on_refresh=self.content_refreshed) + self.create_container() # Set the size of the content to the size of the window self.container.native.frame = self.native.bounds @@ -56,6 +48,10 @@ def __init__(self, interface, title, position, size): self.set_title(title) + def create_container(self): + # RootContainer provides a container without a titlebar. + self.container = RootContainer(on_refresh=self.content_refreshed) + ###################################################################### # Window properties ###################################################################### @@ -73,11 +69,9 @@ def set_title(self, title): def close(self): pass - def create_toolbar(self): - pass # pragma: no cover - def set_app(self, app): - pass + if len(app.interface.windows) > 1: + raise RuntimeError("Secondary windows cannot be created on iOS platform") def show(self): self.native.makeKeyAndVisible() @@ -221,3 +215,12 @@ def render(context): final_image = UIImage.imageWithCGImage(cropped_image) # Convert into PNG data. return nsdata_to_bytes(NSData(uikit.UIImagePNGRepresentation(final_image))) + + +class MainWindow(Window): + def create_container(self): + # NavigationContainer provides a titlebar for the window. + self.container = NavigationContainer(on_refresh=self.content_refreshed) + + def create_toolbar(self): + pass # pragma: no cover diff --git a/testbed/src/testbed/app.py b/testbed/src/testbed/app.py index 0fd0edb59b..83de47b0fb 100644 --- a/testbed/src/testbed/app.py +++ b/testbed/src/testbed/app.py @@ -1,4 +1,4 @@ -from unittest.mock import Mock +from unittest.mock import MagicMock import toga @@ -14,7 +14,7 @@ def startup(self): # with destroying commands, so we create all the commands up front for the app # to use. - self.cmd_action = Mock() + self.cmd_action = MagicMock(spec=[]) # A command with everything, in a group group = toga.Group("Other") self.cmd1 = toga.Command( diff --git a/testbed/tests/app/test_app.py b/testbed/tests/app/test_app.py index 067c6466cf..84cdab9736 100644 --- a/testbed/tests/app/test_app.py +++ b/testbed/tests/app/test_app.py @@ -6,7 +6,7 @@ from toga.colors import CORNFLOWERBLUE, FIREBRICK, REBECCAPURPLE from toga.style.pack import Pack -from ..test_window import window_probe +from ..window.test_window import window_probe @pytest.fixture diff --git a/testbed/tests/window/__init__.py b/testbed/tests/window/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testbed/tests/window/test_dialogs.py b/testbed/tests/window/test_dialogs.py new file mode 100644 index 0000000000..82a53a5170 --- /dev/null +++ b/testbed/tests/window/test_dialogs.py @@ -0,0 +1,267 @@ +import io +import traceback +from asyncio import wait_for +from importlib import import_module +from pathlib import Path +from unittest.mock import Mock + +import pytest + + +def window_probe(app, window): + module = import_module("tests_backend.window") + return module.WindowProbe(app, window) + + +TESTS_DIR = Path(__file__).parent + + +async def assert_dialog_result(window, dialog, on_result, expected): + actual = await wait_for(dialog, timeout=1) + if callable(expected): + assert expected(actual) + else: + assert actual == expected + + on_result.assert_called_once_with(window, actual) + + +async def test_info_dialog(main_window, main_window_probe): + """An info dialog can be displayed and acknowledged.""" + on_result_handler = Mock() + with pytest.warns( + DeprecationWarning, + match=r"Synchronous `on_result` handlers have been deprecated;", + ): + dialog_result = main_window.info_dialog( + "Info", "Some info", on_result=on_result_handler + ) + await main_window_probe.redraw("Info dialog displayed") + await main_window_probe.close_info_dialog(dialog_result._impl) + await assert_dialog_result(main_window, dialog_result, on_result_handler, None) + + +@pytest.mark.parametrize("result", [False, True]) +async def test_question_dialog(main_window, main_window_probe, result): + """An question dialog can be displayed and acknowledged.""" + on_result_handler = Mock() + with pytest.warns( + DeprecationWarning, + match=r"Synchronous `on_result` handlers have been deprecated;", + ): + dialog_result = main_window.question_dialog( + "Question", + "Some question", + on_result=on_result_handler, + ) + await main_window_probe.redraw("Question dialog displayed") + await main_window_probe.close_question_dialog(dialog_result._impl, result) + await assert_dialog_result(main_window, dialog_result, on_result_handler, result) + + +@pytest.mark.parametrize("result", [False, True]) +async def test_confirm_dialog(main_window, main_window_probe, result): + """A confirmation dialog can be displayed and acknowledged.""" + on_result_handler = Mock() + with pytest.warns( + DeprecationWarning, + match=r"Synchronous `on_result` handlers have been deprecated;", + ): + dialog_result = main_window.confirm_dialog( + "Confirm", + "Some confirmation", + on_result=on_result_handler, + ) + await main_window_probe.redraw("Confirmation dialog displayed") + await main_window_probe.close_confirm_dialog(dialog_result._impl, result) + await assert_dialog_result(main_window, dialog_result, on_result_handler, result) + + +async def test_error_dialog(main_window, main_window_probe): + """An error dialog can be displayed and acknowledged.""" + on_result_handler = Mock() + with pytest.warns( + DeprecationWarning, + match=r"Synchronous `on_result` handlers have been deprecated;", + ): + dialog_result = main_window.error_dialog( + "Error", "Some error", on_result=on_result_handler + ) + await main_window_probe.redraw("Error dialog displayed") + await main_window_probe.close_error_dialog(dialog_result._impl) + await assert_dialog_result(main_window, dialog_result, on_result_handler, None) + + +@pytest.mark.parametrize("result", [None, False, True]) +async def test_stack_trace_dialog(main_window, main_window_probe, result): + """A confirmation dialog can be displayed and acknowledged.""" + on_result_handler = Mock() + stack = io.StringIO() + traceback.print_stack(file=stack) + with pytest.warns( + DeprecationWarning, + match=r"Synchronous `on_result` handlers have been deprecated;", + ): + dialog_result = main_window.stack_trace_dialog( + "Stack Trace", + "Some stack trace", + stack.getvalue(), + retry=result is not None, + on_result=on_result_handler, + ) + await main_window_probe.redraw( + f"Stack trace dialog (with{'out' if result is None else ''} retry) displayed" + ) + await main_window_probe.close_stack_trace_dialog(dialog_result._impl, result) + await assert_dialog_result(main_window, dialog_result, on_result_handler, result) + + +@pytest.mark.parametrize( + "filename, file_types, result", + [ + ("/path/to/file.txt", None, Path("/path/to/file.txt")), + ("/path/to/file.txt", None, None), + ("/path/to/file.txt", ["txt", "doc"], Path("/path/to/file.txt")), + ("/path/to/file.txt", ["txt", "doc"], None), + ], +) +async def test_save_file_dialog( + main_window, + main_window_probe, + filename, + file_types, + result, +): + """A file open dialog can be displayed and acknowledged.""" + on_result_handler = Mock() + with pytest.warns( + DeprecationWarning, + match=r"Synchronous `on_result` handlers have been deprecated;", + ): + dialog_result = main_window.save_file_dialog( + "Save file", + suggested_filename=filename, + file_types=file_types, + on_result=on_result_handler, + ) + await main_window_probe.redraw("Save File dialog displayed") + await main_window_probe.close_save_file_dialog(dialog_result._impl, result) + + # The directory where the file dialog is opened can't be 100% predicted + # so we need to modify the check to only inspect the filename. + await assert_dialog_result( + main_window, + dialog_result, + on_result_handler, + None if result is None else (lambda actual: actual.name == result.name), + ) + + +@pytest.mark.parametrize( + "initial_directory, file_types, multiple_select, result", + [ + # Successful single select + (TESTS_DIR, None, False, TESTS_DIR / "data.py"), + # Cancelled single select + (TESTS_DIR, None, False, None), + # Successful single select with no initial directory + (None, None, False, TESTS_DIR / "data.py"), + # Successful single select with file types + (TESTS_DIR, ["txt"], False, TESTS_DIR / "data.py"), + # Successful multiple selection + ( + TESTS_DIR, + None, + True, + [TESTS_DIR / "conftest.py", TESTS_DIR / "data.py"], + ), + # Successful multiple selection of one item + (TESTS_DIR, None, True, [TESTS_DIR / "data.py"]), + # Cancelled multiple selection + (TESTS_DIR, None, True, None), + # Successful multiple selection with no initial directory + (None, None, True, [TESTS_DIR / "conftest.py", TESTS_DIR / "data.py"]), + # Successful multiple selection with file types + ( + TESTS_DIR, + ["txt", "doc"], + True, + [TESTS_DIR / "conftest.py", TESTS_DIR / "data.py"], + ), + ], +) +async def test_open_file_dialog( + main_window, + main_window_probe, + initial_directory, + file_types, + multiple_select, + result, +): + """A file open dialog can be displayed and acknowledged.""" + on_result_handler = Mock() + with pytest.warns( + DeprecationWarning, + match=r"Synchronous `on_result` handlers have been deprecated;", + ): + dialog_result = main_window.open_file_dialog( + "Open file", + initial_directory=initial_directory, + file_types=file_types, + multiple_select=multiple_select, + on_result=on_result_handler, + ) + await main_window_probe.redraw("Open File dialog displayed") + await main_window_probe.close_open_file_dialog( + dialog_result._impl, result, multiple_select + ) + await assert_dialog_result(main_window, dialog_result, on_result_handler, result) + + +@pytest.mark.parametrize( + "initial_directory, multiple_select, result", + [ + # Successful single select + (TESTS_DIR, False, TESTS_DIR / "widgets"), + # Cancelled single select + (TESTS_DIR, False, None), + # Successful single select with no initial directory + (None, False, TESTS_DIR / "widgets"), + # Successful multiple selection + (TESTS_DIR, True, [TESTS_DIR, TESTS_DIR / "widgets"]), + # Successful multiple selection with one item + (TESTS_DIR, True, [TESTS_DIR / "widgets"]), + # Cancelled multiple selection + (TESTS_DIR, True, None), + ], +) +async def test_select_folder_dialog( + main_window, + main_window_probe, + initial_directory, + multiple_select, + result, +): + """A folder selection dialog can be displayed and acknowledged.""" + on_result_handler = Mock() + with pytest.warns( + DeprecationWarning, + match=r"Synchronous `on_result` handlers have been deprecated;", + ): + dialog_result = main_window.select_folder_dialog( + "Select folder", + initial_directory=initial_directory, + multiple_select=multiple_select, + on_result=on_result_handler, + ) + await main_window_probe.redraw("Select Folder dialog displayed") + await main_window_probe.close_select_folder_dialog( + dialog_result._impl, result, multiple_select + ) + + if ( + isinstance(result, list) + and not main_window_probe.supports_multiple_select_folder + ): + result = result[-1:] + await assert_dialog_result(main_window, dialog_result, on_result_handler, result) diff --git a/testbed/tests/window/test_mainwindow.py b/testbed/tests/window/test_mainwindow.py new file mode 100644 index 0000000000..e73a25edbf --- /dev/null +++ b/testbed/tests/window/test_mainwindow.py @@ -0,0 +1,192 @@ +import re +from importlib import import_module + +import pytest + +import toga +from toga.colors import CORNFLOWERBLUE, GOLDENROD, REBECCAPURPLE +from toga.style.pack import COLUMN, Pack + + +def window_probe(app, window): + module = import_module("tests_backend.window") + return module.WindowProbe(app, window) + + +@pytest.fixture +async def second_window(second_window_kwargs): + yield toga.MainWindow(**second_window_kwargs) + + +@pytest.fixture +def default_window_title(): + return "Toga Testbed" + + +@pytest.fixture +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() + + +async def test_title(main_window, main_window_probe): + """The title of the main window can be changed""" + original_title = main_window.title + assert original_title == "Toga Testbed" + await main_window_probe.wait_for_window("Main Window title can be retrieved") + + try: + main_window.title = "A Different Title" + assert main_window.title == "A Different Title" + await main_window_probe.wait_for_window("Main Window title can be changed") + finally: + main_window.title = original_title + assert main_window.title == "Toga Testbed" + await main_window_probe.wait_for_window("Main Window title can be reverted") + + +# Mobile platforms have different windowing characterics, so they have different tests. +if toga.platform.current_platform in {"iOS", "android"}: + #################################################################################### + # Mobile platform tests + #################################################################################### + + async def test_secondary_window(): + """A secondary main window cannot be created""" + with pytest.raises( + RuntimeError, + match=r"Secondary windows cannot be created on mobile platforms", + ): + toga.MainWindow() + + async def test_move_and_resize(main_window, main_window_probe, capsys): + """Move and resize are no-ops on mobile.""" + initial_size = main_window.size + content_size = main_window_probe.content_size + assert initial_size[0] > 300 + assert initial_size[1] > 500 + + assert main_window.position == (0, 0) + + main_window.position = (150, 50) + await main_window_probe.wait_for_window("Main window can't be moved") + assert main_window.size == initial_size + assert main_window.position == (0, 0) + + main_window.size = (200, 150) + await main_window_probe.wait_for_window("Main window cannot be resized") + assert main_window.size == initial_size + assert main_window.position == (0, 0) + + try: + orig_content = main_window.content + + box1 = toga.Box( + style=Pack(background_color=REBECCAPURPLE, width=10, height=10) + ) + box2 = toga.Box(style=Pack(background_color=GOLDENROD, width=10, height=20)) + main_window.content = toga.Box( + children=[box1, box2], + style=Pack(direction=COLUMN, background_color=CORNFLOWERBLUE), + ) + await main_window_probe.wait_for_window("Main window content has been set") + assert main_window.size == initial_size + assert main_window_probe.content_size == content_size + + # Alter the content width to exceed window width + box1.style.width = 1000 + await main_window_probe.wait_for_window( + "Content is too wide for the window" + ) + assert main_window.size == initial_size + assert main_window_probe.content_size == content_size + + space_warning = ( + r"Warning: Window content \([\d.]+, [\d.]+\) " + r"exceeds available space \([\d.]+, [\d.]+\)" + ) + assert re.search(space_warning, capsys.readouterr().out) + + # Resize content to fit + box1.style.width = 100 + await main_window_probe.wait_for_window("Content fits in window") + assert main_window.size == initial_size + assert main_window_probe.content_size == content_size + assert not re.search(space_warning, capsys.readouterr().out) + + # Alter the content width to exceed window height + box1.style.height = 2000 + await main_window_probe.wait_for_window( + "Content is too tall for the window" + ) + assert main_window.size == initial_size + assert main_window_probe.content_size == content_size + assert re.search(space_warning, capsys.readouterr().out) + + finally: + main_window.content = orig_content + + async def test_full_screen(main_window, main_window_probe): + """Window can be made full screen""" + main_window.full_screen = True + await main_window_probe.wait_for_window("Full screen is a no-op") + + main_window.full_screen = False + await main_window_probe.wait_for_window("Full screen is a no-op") + + async def test_screen(main_window, main_window_probe): + """The window can be relocated to another screen, using both absolute and relative screen positions.""" + assert main_window.screen.origin == (0, 0) + initial_size = main_window.size + main_window.position = (150, 50) + await main_window_probe.wait_for_window("Main window can't be moved") + assert main_window.size == initial_size + assert main_window.position == (0, 0) + assert main_window.screen_position == (0, 0) + +else: + #################################################################################### + # Desktop platform tests + #################################################################################### + + from .test_window import ( # noqa: F401 + test_full_screen, + test_move_and_resize, + test_non_closable, + test_non_minimizable, + test_non_resizable, + test_screen, + test_secondary_window, + test_secondary_window_cleanup, + test_secondary_window_with_args, + test_visibility, + ) + + @pytest.mark.parametrize( + "second_window_kwargs", + [dict(title="Secondary Window", position=(200, 300), size=(400, 200))], + ) + async def test_secondary_window_toolbar(app, second_window, second_window_probe): + """A toolbar can be added to a secondary window""" + second_window.toolbar.add(app.cmd1) + + # Window doesn't have content. This is intentional. + second_window.show() + + assert second_window_probe.has_toolbar() + await second_window_probe.redraw("Secondary window has a toolbar") + + +async def test_as_image(main_window, main_window_probe): + """The window can be captured as a screenshot""" + + screenshot = main_window.as_image() + main_window_probe.assert_image_size( + screenshot.size, + main_window_probe.content_size, + screen=main_window.screen, + ) diff --git a/testbed/tests/test_window.py b/testbed/tests/window/test_window.py similarity index 51% rename from testbed/tests/test_window.py rename to testbed/tests/window/test_window.py index 19d5a2fd95..3d3894d9b5 100644 --- a/testbed/tests/test_window.py +++ b/testbed/tests/window/test_window.py @@ -1,11 +1,6 @@ import gc -import io -import re -import traceback import weakref -from asyncio import wait_for from importlib import import_module -from pathlib import Path from unittest.mock import Mock import pytest @@ -25,6 +20,11 @@ async def second_window(second_window_kwargs): yield toga.Window(**second_window_kwargs) +@pytest.fixture +def default_window_title(): + return "Toga" + + @pytest.fixture async def second_window_probe(app, app_probe, second_window): second_window.show() @@ -35,20 +35,21 @@ async def second_window_probe(app, app_probe, second_window): second_window.close() -async def test_title(main_window, main_window_probe): - """The title of a window can be changed""" - original_title = main_window.title - assert original_title == "Toga Testbed" - await main_window_probe.wait_for_window("Window title can be retrieved") +@pytest.mark.parametrize("second_window_kwargs", [{}]) +async def test_title(second_window, second_window_probe): + """The title of a simple window can be changed""" + original_title = second_window.title + assert original_title == "Toga" + await second_window_probe.wait_for_window("Window title can be retrieved") try: - main_window.title = "A Different Title" - assert main_window.title == "A Different Title" - await main_window_probe.wait_for_window("Window title can be changed") + second_window.title = "A Different Title" + assert second_window.title == "A Different Title" + await second_window_probe.wait_for_window("Window title can be changed") finally: - main_window.title = original_title - assert main_window.title == "Toga Testbed" - await main_window_probe.wait_for_window("Window title can be reverted") + second_window.title = original_title + assert second_window.title == "Toga" + await second_window_probe.wait_for_window("Window title can be reverted") # Mobile platforms have different windowing characterics, so they have different tests. @@ -57,18 +58,6 @@ async def test_title(main_window, main_window_probe): # Mobile platform tests #################################################################################### - async def test_visibility(main_window, main_window_probe): - """Hide and close are no-ops on mobile""" - assert main_window.visible - - main_window.hide() - await main_window_probe.wait_for_window("Window.hide is a no-op") - assert main_window.visible - - main_window.close() - await main_window_probe.wait_for_window("Window.close is a no-op") - assert main_window.visible - async def test_secondary_window(): """A secondary window cannot be created""" with pytest.raises( @@ -77,105 +66,27 @@ async def test_secondary_window(): ): toga.Window() - async def test_move_and_resize(main_window, main_window_probe, capsys): - """Move and resize are no-ops on mobile.""" - initial_size = main_window.size - content_size = main_window_probe.content_size - assert initial_size[0] > 300 - assert initial_size[1] > 500 - - assert main_window.position == (0, 0) - - main_window.position = (150, 50) - await main_window_probe.wait_for_window("Main window can't be moved") - assert main_window.size == initial_size - assert main_window.position == (0, 0) - - main_window.size = (200, 150) - await main_window_probe.wait_for_window("Main window cannot be resized") - assert main_window.size == initial_size - assert main_window.position == (0, 0) - - try: - orig_content = main_window.content - - box1 = toga.Box( - style=Pack(background_color=REBECCAPURPLE, width=10, height=10) - ) - box2 = toga.Box(style=Pack(background_color=GOLDENROD, width=10, height=20)) - main_window.content = toga.Box( - children=[box1, box2], - style=Pack(direction=COLUMN, background_color=CORNFLOWERBLUE), - ) - await main_window_probe.wait_for_window("Main window content has been set") - assert main_window.size == initial_size - assert main_window_probe.content_size == content_size - - # Alter the content width to exceed window width - box1.style.width = 1000 - await main_window_probe.wait_for_window( - "Content is too wide for the window" - ) - assert main_window.size == initial_size - assert main_window_probe.content_size == content_size - - space_warning = ( - r"Warning: Window content \([\d.]+, [\d.]+\) " - r"exceeds available space \([\d.]+, [\d.]+\)" - ) - assert re.search(space_warning, capsys.readouterr().out) - - # Resize content to fit - box1.style.width = 100 - await main_window_probe.wait_for_window("Content fits in window") - assert main_window.size == initial_size - assert main_window_probe.content_size == content_size - assert not re.search(space_warning, capsys.readouterr().out) - - # Alter the content width to exceed window height - box1.style.height = 2000 - await main_window_probe.wait_for_window( - "Content is too tall for the window" - ) - assert main_window.size == initial_size - assert main_window_probe.content_size == content_size - assert re.search(space_warning, capsys.readouterr().out) - - finally: - main_window.content = orig_content - - async def test_full_screen(main_window, main_window_probe): - """Window can be made full screen""" - main_window.full_screen = True - await main_window_probe.wait_for_window("Full screen is a no-op") - - main_window.full_screen = False - await main_window_probe.wait_for_window("Full screen is a no-op") - - async def test_screen(main_window, main_window_probe): - """The window can be relocated to another screen, using both absolute and relative screen positions.""" - assert main_window.screen.origin == (0, 0) - initial_size = main_window.size - main_window.position = (150, 50) - await main_window_probe.wait_for_window("Main window can't be moved") - assert main_window.size == initial_size - assert main_window.position == (0, 0) - assert main_window.screen_position == (0, 0) - else: #################################################################################### # Desktop platform tests #################################################################################### @pytest.mark.parametrize("second_window_kwargs", [{}]) - async def test_secondary_window(app, second_window, second_window_probe): + async def test_secondary_window( + app, second_window, second_window_probe, default_window_title + ): """A secondary window can be created""" assert second_window.app == app assert second_window in app.windows - assert second_window.title == "Toga" + assert second_window.title == default_window_title assert second_window.size == (640, 480) - assert second_window.position == (100, 100) + # Position will be cascaded; the exact position depends on the platform, + # and how many windows have been created. As long as it's not (100,100), + # and the x an y coordinate are the same + assert second_window.position[0] > 110 + assert second_window.position[1] == second_window.position[0] + assert second_window_probe.is_resizable if second_window_probe.supports_closable: assert second_window_probe.is_closable @@ -257,14 +168,9 @@ async def test_secondary_window_cleanup(app_probe): [dict(title="Secondary Window", position=(200, 300), size=(400, 200))], ) async def test_secondary_window_toolbar(app, second_window, second_window_probe): - """A toolbar can be added to a secondary window""" - second_window.toolbar.add(app.cmd1) - - # Window doesn't have content. This is intentional. - second_window.show() - - assert second_window_probe.has_toolbar() - await second_window_probe.redraw("Secondary window has a toolbar") + """Simple windows don't have toolbars""" + with pytest.raises(AttributeError): + second_window.toolbar.add(app.cmd1) @pytest.mark.parametrize( "second_window_kwargs", @@ -518,273 +424,3 @@ async def test_screen(second_window, second_window_probe): second_window.position[0] - screen.origin[0], second_window.position[1] - screen.origin[1], ) - - -async def test_as_image(main_window, main_window_probe): - """The window can be captured as a screenshot""" - - screenshot = main_window.as_image() - main_window_probe.assert_image_size( - screenshot.size, - main_window_probe.content_size, - screen=main_window.screen, - ) - - -######################################################################################## -# Dialog tests -######################################################################################## - - -TESTS_DIR = Path(__file__).parent - - -async def assert_dialog_result(window, dialog, on_result, expected): - actual = await wait_for(dialog, timeout=1) - if callable(expected): - assert expected(actual) - else: - assert actual == expected - - on_result.assert_called_once_with(window, actual) - - -async def test_info_dialog(main_window, main_window_probe): - """An info dialog can be displayed and acknowledged.""" - on_result_handler = Mock() - with pytest.warns( - DeprecationWarning, - match=r"Synchronous `on_result` handlers have been deprecated;", - ): - dialog_result = main_window.info_dialog( - "Info", "Some info", on_result=on_result_handler - ) - await main_window_probe.redraw("Info dialog displayed") - await main_window_probe.close_info_dialog(dialog_result._impl) - await assert_dialog_result(main_window, dialog_result, on_result_handler, None) - - -@pytest.mark.parametrize("result", [False, True]) -async def test_question_dialog(main_window, main_window_probe, result): - """An question dialog can be displayed and acknowledged.""" - on_result_handler = Mock() - with pytest.warns( - DeprecationWarning, - match=r"Synchronous `on_result` handlers have been deprecated;", - ): - dialog_result = main_window.question_dialog( - "Question", - "Some question", - on_result=on_result_handler, - ) - await main_window_probe.redraw("Question dialog displayed") - await main_window_probe.close_question_dialog(dialog_result._impl, result) - await assert_dialog_result(main_window, dialog_result, on_result_handler, result) - - -@pytest.mark.parametrize("result", [False, True]) -async def test_confirm_dialog(main_window, main_window_probe, result): - """A confirmation dialog can be displayed and acknowledged.""" - on_result_handler = Mock() - with pytest.warns( - DeprecationWarning, - match=r"Synchronous `on_result` handlers have been deprecated;", - ): - dialog_result = main_window.confirm_dialog( - "Confirm", - "Some confirmation", - on_result=on_result_handler, - ) - await main_window_probe.redraw("Confirmation dialog displayed") - await main_window_probe.close_confirm_dialog(dialog_result._impl, result) - await assert_dialog_result(main_window, dialog_result, on_result_handler, result) - - -async def test_error_dialog(main_window, main_window_probe): - """An error dialog can be displayed and acknowledged.""" - on_result_handler = Mock() - with pytest.warns( - DeprecationWarning, - match=r"Synchronous `on_result` handlers have been deprecated;", - ): - dialog_result = main_window.error_dialog( - "Error", "Some error", on_result=on_result_handler - ) - await main_window_probe.redraw("Error dialog displayed") - await main_window_probe.close_error_dialog(dialog_result._impl) - await assert_dialog_result(main_window, dialog_result, on_result_handler, None) - - -@pytest.mark.parametrize("result", [None, False, True]) -async def test_stack_trace_dialog(main_window, main_window_probe, result): - """A confirmation dialog can be displayed and acknowledged.""" - on_result_handler = Mock() - stack = io.StringIO() - traceback.print_stack(file=stack) - with pytest.warns( - DeprecationWarning, - match=r"Synchronous `on_result` handlers have been deprecated;", - ): - dialog_result = main_window.stack_trace_dialog( - "Stack Trace", - "Some stack trace", - stack.getvalue(), - retry=result is not None, - on_result=on_result_handler, - ) - await main_window_probe.redraw( - f"Stack trace dialog (with{'out' if result is None else ''} retry) displayed" - ) - await main_window_probe.close_stack_trace_dialog(dialog_result._impl, result) - await assert_dialog_result(main_window, dialog_result, on_result_handler, result) - - -@pytest.mark.parametrize( - "filename, file_types, result", - [ - ("/path/to/file.txt", None, Path("/path/to/file.txt")), - ("/path/to/file.txt", None, None), - ("/path/to/file.txt", ["txt", "doc"], Path("/path/to/file.txt")), - ("/path/to/file.txt", ["txt", "doc"], None), - ], -) -async def test_save_file_dialog( - main_window, - main_window_probe, - filename, - file_types, - result, -): - """A file open dialog can be displayed and acknowledged.""" - on_result_handler = Mock() - with pytest.warns( - DeprecationWarning, - match=r"Synchronous `on_result` handlers have been deprecated;", - ): - dialog_result = main_window.save_file_dialog( - "Save file", - suggested_filename=filename, - file_types=file_types, - on_result=on_result_handler, - ) - await main_window_probe.redraw("Save File dialog displayed") - await main_window_probe.close_save_file_dialog(dialog_result._impl, result) - - # The directory where the file dialog is opened can't be 100% predicted - # so we need to modify the check to only inspect the filename. - await assert_dialog_result( - main_window, - dialog_result, - on_result_handler, - None if result is None else (lambda actual: actual.name == result.name), - ) - - -@pytest.mark.parametrize( - "initial_directory, file_types, multiple_select, result", - [ - # Successful single select - (TESTS_DIR, None, False, TESTS_DIR / "data.py"), - # Cancelled single select - (TESTS_DIR, None, False, None), - # Successful single select with no initial directory - (None, None, False, TESTS_DIR / "data.py"), - # Successful single select with file types - (TESTS_DIR, ["txt"], False, TESTS_DIR / "data.py"), - # Successful multiple selection - ( - TESTS_DIR, - None, - True, - [TESTS_DIR / "conftest.py", TESTS_DIR / "data.py"], - ), - # Successful multiple selection of one item - (TESTS_DIR, None, True, [TESTS_DIR / "data.py"]), - # Cancelled multiple selection - (TESTS_DIR, None, True, None), - # Successful multiple selection with no initial directory - (None, None, True, [TESTS_DIR / "conftest.py", TESTS_DIR / "data.py"]), - # Successful multiple selection with file types - ( - TESTS_DIR, - ["txt", "doc"], - True, - [TESTS_DIR / "conftest.py", TESTS_DIR / "data.py"], - ), - ], -) -async def test_open_file_dialog( - main_window, - main_window_probe, - initial_directory, - file_types, - multiple_select, - result, -): - """A file open dialog can be displayed and acknowledged.""" - on_result_handler = Mock() - with pytest.warns( - DeprecationWarning, - match=r"Synchronous `on_result` handlers have been deprecated;", - ): - dialog_result = main_window.open_file_dialog( - "Open file", - initial_directory=initial_directory, - file_types=file_types, - multiple_select=multiple_select, - on_result=on_result_handler, - ) - await main_window_probe.redraw("Open File dialog displayed") - await main_window_probe.close_open_file_dialog( - dialog_result._impl, result, multiple_select - ) - await assert_dialog_result(main_window, dialog_result, on_result_handler, result) - - -@pytest.mark.parametrize( - "initial_directory, multiple_select, result", - [ - # Successful single select - (TESTS_DIR, False, TESTS_DIR / "widgets"), - # Cancelled single select - (TESTS_DIR, False, None), - # Successful single select with no initial directory - (None, False, TESTS_DIR / "widgets"), - # Successful multiple selection - (TESTS_DIR, True, [TESTS_DIR, TESTS_DIR / "widgets"]), - # Successful multiple selection with one item - (TESTS_DIR, True, [TESTS_DIR / "widgets"]), - # Cancelled multiple selection - (TESTS_DIR, True, None), - ], -) -async def test_select_folder_dialog( - main_window, - main_window_probe, - initial_directory, - multiple_select, - result, -): - """A folder selection dialog can be displayed and acknowledged.""" - on_result_handler = Mock() - with pytest.warns( - DeprecationWarning, - match=r"Synchronous `on_result` handlers have been deprecated;", - ): - dialog_result = main_window.select_folder_dialog( - "Select folder", - initial_directory=initial_directory, - multiple_select=multiple_select, - on_result=on_result_handler, - ) - await main_window_probe.redraw("Select Folder dialog displayed") - await main_window_probe.close_select_folder_dialog( - dialog_result._impl, result, multiple_select - ) - - if ( - isinstance(result, list) - and not main_window_probe.supports_multiple_select_folder - ): - result = result[-1:] - await assert_dialog_result(main_window, dialog_result, on_result_handler, result) diff --git a/textual/src/toga_textual/app.py b/textual/src/toga_textual/app.py index 9bd33dd859..e2353d7c60 100644 --- a/textual/src/toga_textual/app.py +++ b/textual/src/toga_textual/app.py @@ -1,14 +1,9 @@ import asyncio +import toga from textual.app import App as TextualApp from .screens import Screen as ScreenImpl -from .window import Window - - -class MainWindow(Window): - def textual_close(self): - self.interface.app.on_exit() class TogaApp(TextualApp): @@ -22,6 +17,9 @@ def on_mount(self) -> None: class App: + # Textual apps exit when the last window is closed + CLOSE_ON_LAST_WINDOW = True + def __init__(self, interface): self.interface = interface self.interface._impl = self @@ -37,6 +35,9 @@ def create(self): # Commands and menus ###################################################################### + def create_app_commands(self): + pass + def create_menus(self): pass @@ -47,10 +48,19 @@ def create_menus(self): def exit(self): self.native.exit() + def finalize(self): + self.create_app_commands() + self.create_menus() + def main_loop(self): self.native.run() def set_main_window(self, window): + if window is None: + raise RuntimeError("Session-based apps are not supported on Textual") + elif window == toga.App.BACKGROUND: + raise RuntimeError("Background apps are not supported on Textual") + self.native.push_screen(self.interface.main_window.id) ###################################################################### diff --git a/textual/src/toga_textual/factory.py b/textual/src/toga_textual/factory.py index 06217c2350..72703f557e 100644 --- a/textual/src/toga_textual/factory.py +++ b/textual/src/toga_textual/factory.py @@ -1,7 +1,7 @@ from toga import NotImplementedWarning from . import dialogs -from .app import App, DocumentApp, MainWindow +from .app import App # from .command import Command # from .documents import Document @@ -39,7 +39,7 @@ # from .widgets.timeinput import TimeInput # from .widgets.tree import Tree # from .widgets.webview import WebView -from .window import Window +from .window import MainWindow, Window def not_implemented(feature): @@ -49,8 +49,6 @@ def not_implemented(feature): __all__ = [ "not_implemented", "App", - "DocumentApp", - "MainWindow", # "Command", # "Document", # "Font", @@ -83,6 +81,8 @@ def not_implemented(feature): # "TimeInput", # "Tree", # "WebView", + # Windows + "MainWindow", "Window", ] diff --git a/textual/src/toga_textual/window.py b/textual/src/toga_textual/window.py index 017aac5d8e..277f18f8b9 100644 --- a/textual/src/toga_textual/window.py +++ b/textual/src/toga_textual/window.py @@ -10,105 +10,11 @@ from .screens import Screen as ScreenImpl -class WindowCloseButton(TextualButton): - DEFAULT_CSS = """ - WindowCloseButton { - dock: left; - border: none; - min-width: 3; - height: 1; - background: white 10%; - color: white; - } - WindowCloseButton:hover { - background: black; - border: none; - } - WindowCloseButton:focus { - text-style: bold; - } - WindowCloseButton.-active { - border: none; - } - """ - - def __init__(self): - super().__init__("✕") - - def on_button_pressed(self, event): - self.screen.impl.textual_close() - event.stop() - - -class TitleSpacer(TextualWidget): - DEFAULT_CSS = """ - TitleSpacer { - dock: right; - padding: 0 1; - width: 3; - content-align: right middle; - } - """ - - def render(self) -> RenderResult: - return "" - - -class TitleText(TextualWidget): - DEFAULT_CSS = """ - TitleText { - content-align: center middle; - width: 100%; - } - """ - text: Reactive[str] = Reactive("") - - def __init__(self, text): - super().__init__() - self.text = text - - def render(self) -> RenderResult: - return Text(self.text, no_wrap=True, overflow="ellipsis") - - -class TitleBar(TextualWidget): - DEFAULT_CSS = """ - TitleBar { - dock: top; - width: 100%; - background: $foreground 5%; - color: $text; - height: 1; - } - """ - - def __init__(self, title): - super().__init__() - self.title = TitleText(title) - - @property - def text(self): - return self.title.text - - @text.setter - def text(self, value): - self.title.text = value - - def compose(self): - yield WindowCloseButton() - yield self.title - yield TitleSpacer() - - class TogaWindow(TextualScreen): - def __init__(self, impl, title): + def __init__(self, impl): super().__init__() self.interface = impl.interface self.impl = impl - self.titlebar = TitleBar(title) - - def on_mount(self) -> None: - self.mount(self.titlebar) def on_resize(self, event) -> None: self.interface.content.refresh() @@ -117,8 +23,12 @@ def on_resize(self, event) -> None: class Window: def __init__(self, interface, title, position, size): self.interface = interface - self.native = TogaWindow(self, title) + self.create() self.container = Container(self.native) + self.set_title(title) + + def create(self): + self.native = TogaWindow(self) ###################################################################### # Native event handlers @@ -132,10 +42,10 @@ def textual_close(self): ###################################################################### def get_title(self): - return self.native.titlebar.text + return self.title def set_title(self, title): - self.native.titlebar.text = title + self.title = title ###################################################################### # Window lifecycle @@ -211,3 +121,117 @@ def set_full_screen(self, is_full_screen): def get_image_data(self): self.interface.factory.not_implemented("Window.get_image_data()") + + +class WindowCloseButton(TextualButton): + DEFAULT_CSS = """ + WindowCloseButton { + dock: left; + border: none; + min-width: 3; + height: 1; + background: white 10%; + color: white; + } + WindowCloseButton:hover { + background: black; + border: none; + } + WindowCloseButton:focus { + text-style: bold; + } + WindowCloseButton.-active { + border: none; + } + """ + + def __init__(self): + super().__init__("✕") + + def on_button_pressed(self, event): + self.screen.impl.textual_close() + event.stop() + + +class TitleSpacer(TextualWidget): + DEFAULT_CSS = """ + TitleSpacer { + dock: right; + padding: 0 1; + width: 3; + content-align: right middle; + } + """ + + def render(self) -> RenderResult: + return "" + + +class TitleText(TextualWidget): + DEFAULT_CSS = """ + TitleText { + content-align: center middle; + width: 100%; + } + """ + text: Reactive[str] = Reactive("") + + def __init__(self, text): + super().__init__() + self.text = text + + def render(self) -> RenderResult: + return Text(self.text, no_wrap=True, overflow="ellipsis") + + +class TitleBar(TextualWidget): + DEFAULT_CSS = """ + TitleBar { + dock: top; + width: 100%; + background: $foreground 5%; + color: $text; + height: 1; + } + """ + + def __init__(self): + super().__init__() + self.title = TitleText("Toga") + + @property + def text(self): + return self.title.text + + @text.setter + def text(self, value): + self.title.text = value + + def compose(self): + yield WindowCloseButton() + yield self.title + yield TitleSpacer() + + +class TogaMainWindow(TogaWindow): + def __init__(self, impl): + super().__init__(impl) + self.titlebar = TitleBar() + + def on_mount(self) -> None: + self.mount(self.titlebar) + + +class MainWindow(Window): + def create(self): + self.native = TogaMainWindow(self) + + ###################################################################### + # Window properties + ###################################################################### + + def get_title(self): + return self.native.titlebar.text + + def set_title(self, title): + self.native.titlebar.text = title diff --git a/web/src/toga_web/app.py b/web/src/toga_web/app.py index 9c1dac062d..9c6eeb60cc 100644 --- a/web/src/toga_web/app.py +++ b/web/src/toga_web/app.py @@ -1,32 +1,34 @@ import toga -from toga.command import Separator from toga_web.libs import create_element, js -from toga_web.window import Window from .screens import Screen as ScreenImpl -class MainWindow(Window): - def on_close(self, *args): - pass - - class App: + # Web apps exit when the last window is closed + CLOSE_ON_LAST_WINDOW = True + def __init__(self, interface): self.interface = interface self.interface._impl = self def create(self): - # self.resource_path = os.path.dirname(os.path.dirname(NSBundle.mainBundle.bundlePath)) self.native = js.document.getElementById("app-placeholder") + # self.resource_path = os.path.dirname(os.path.dirname(NSBundle.mainBundle.bundlePath)) - formal_name = self.interface.formal_name + # Call user code to populate the main window + self.interface._startup() + ###################################################################### + # Commands and menus + ###################################################################### + + def create_app_commands(self): self.interface.commands.add( # ---- Help menu ---------------------------------- toga.Command( - self._menu_about, - "About " + formal_name, + self.interface._menu_about, + f"About {self.interface.formal_name}", group=toga.Group.HELP, ), toga.Command( @@ -36,111 +38,9 @@ def create(self): ), ) - # Create the menus. This is done before main window content to ensure - # the
for the menubar is inserted before the
for the - # main window. - self.create_menus() - - # Call user code to populate the main window - self.interface._startup() - - ###################################################################### - # Commands and menus - ###################################################################### - - def _create_submenu(self, group, items): - submenu = create_element( - "sl-dropdown", - children=[ - create_element( - "span", - id=f"menu-{id(group)}", - classes=["menu"], - slot="trigger", - content=group.text, - ), - create_element( - "sl-menu", - children=items, - ), - ], - ) - return submenu - - def _menu_about(self, command, **kwargs): - self.interface.about() - def create_menus(self): - self._menu_groups = {} - submenu = None - - for cmd in self.interface.commands: - if isinstance(cmd, Separator): - # TODO - add a section break - pass - else: - # TODO - this doesn't handle submenus properly; - # all menu items will appear in their root group. - submenu = self._menu_groups.setdefault(cmd.group, []) - - menu_item = create_element( - "sl-menu-item", - content=cmd.text, - disabled=not cmd.enabled, - ) - menu_item.onclick = cmd._impl.dom_click - - submenu.append(menu_item) - - menu_container = create_element("nav", classes=["menubar"]) - help_menu_container = create_element("nav", classes=["menubar"]) - - # If there isn't an explicit app menu group, add an inert placeholder - if toga.Group.APP not in self._menu_groups: - menu_container.appendChild( - create_element( - "span", - classes=["app"], - content=self.interface.formal_name, - ) - ) - - for group, items in self._menu_groups.items(): - submenu = self._create_submenu(group, items) - if group != toga.Group.HELP: - menu_container.appendChild(submenu) - else: - help_menu_container.appendChild(submenu) - - menubar_id = f"{self.interface.app_id}-header" - self.menubar = create_element( - "header", - id=menubar_id, - classes=["toga"], - children=[ - create_element( - "a", - classes=["brand"], - children=[ - create_element( - "img", - src="static/logo-32.png", - alt=f"{self.interface.formal_name} logo", - loading="lazy", - ) - ], - ), - menu_container, - help_menu_container, - ], - ) - - # If there's an existing menubar, replace it. - old_menubar = js.document.getElementById(menubar_id) - if old_menubar: - old_menubar.replaceWith(self.menubar) - else: - self.native.append(self.menubar) + for window in self.interface.windows: + window._impl.create_menus() ###################################################################### # App lifecycle @@ -149,11 +49,18 @@ def create_menus(self): def exit(self): pass + def finalize(self): + self.create_app_commands() + self.create_menus() + def main_loop(self): self.create() def set_main_window(self, window): - pass + if window is None: + raise RuntimeError("Session-based apps are not supported on Web") + elif window == toga.App.BACKGROUND: + raise RuntimeError("Background apps are not supported on Web") ###################################################################### # App resources diff --git a/web/src/toga_web/factory.py b/web/src/toga_web/factory.py index bef184f1d9..5d7b1dd1e7 100644 --- a/web/src/toga_web/factory.py +++ b/web/src/toga_web/factory.py @@ -1,7 +1,7 @@ from toga import NotImplementedWarning from . import dialogs -from .app import App, MainWindow # DocumentApp +from .app import App from .command import Command # from .documents import Document @@ -37,7 +37,7 @@ # from .widgets.tree import Tree # from .widgets.webview import WebView -# from .window import Window +from .window import MainWindow, Window def not_implemented(feature): @@ -47,8 +47,6 @@ def not_implemented(feature): __all__ = [ "not_implemented", "App", - "MainWindow", - # 'DocumentApp', "Command", # 'Document', # # Resources @@ -80,7 +78,9 @@ def not_implemented(feature): "TextInput", # 'Tree', # 'WebView', - # 'Window', + # Windows + "MainWindow", + "Window", ] diff --git a/web/src/toga_web/window.py b/web/src/toga_web/window.py index 29eca4532e..707ea06e82 100644 --- a/web/src/toga_web/window.py +++ b/web/src/toga_web/window.py @@ -1,3 +1,5 @@ +import toga +from toga.command import Separator from toga_web.libs import create_element, js from .screens import Screen as ScreenImpl @@ -15,9 +17,6 @@ def __init__(self, interface, title, position, size): role="main", ) - app_placeholder = js.document.getElementById("app-placeholder") - app_placeholder.appendChild(self.native) - self.set_title(title) ###################################################################### @@ -47,11 +46,15 @@ def set_title(self, title): def close(self): self.interface.factory.not_implemented("Window.close()") - def create_toolbar(self): - self.interface.factory.not_implemented("Window.create_toolbar()") + def create_menus(self): + # Simple windows don't have menus + pass def set_app(self, app): - pass + if len(app.interface.windows) > 1: + raise RuntimeError("Secondary windows cannot be created on Web") + + app.native.appendChild(self.native) def show(self): self.native.style = "visibility: visible;" @@ -121,3 +124,106 @@ def set_full_screen(self, is_full_screen): def get_image_data(self): self.interface.factory.not_implemented("Window.get_image_data()") + + +class MainWindow(Window): + def __init__(self, interface, title, position, size): + super().__init__(interface, title, position, size) + + # Create the window titlebar, with placeholders for the menu items. + self.native_menu_container = create_element("nav", classes=["menubar"]) + self.native_help_menu_container = create_element("nav", classes=["menubar"]) + self.native_titlebar = create_element( + "header", + classes=["toga"], + children=[ + create_element( + "a", + classes=["brand"], + children=[ + create_element( + "img", + src="static/logo-32.png", + alt="Logo", + loading="lazy", + ) + ], + ), + self.native_menu_container, + self.native_help_menu_container, + ], + ) + + def set_app(self, app): + super().set_app(app) + app.native.insertBefore(self.native_titlebar, self.native) + + def _create_submenu(self, group, items): + submenu = create_element( + "sl-dropdown", + children=[ + create_element( + "span", + id=f"menu-{id(group)}", + classes=["menu"], + slot="trigger", + content=group.text, + ), + create_element( + "sl-menu", + children=items, + ), + ], + ) + return submenu + + def create_menus(self): + self._menu_groups = {} + submenu = None + + for cmd in self.interface.app.commands: + if isinstance(cmd, Separator): + # TODO - add a section break + pass + else: + # TODO - this doesn't handle submenus properly; + # all menu items will appear in their root group. + submenu = self._menu_groups.setdefault(cmd.group, []) + + menu_item = create_element( + "sl-menu-item", + content=cmd.text, + disabled=not cmd.enabled, + ) + menu_item.onclick = cmd._impl.dom_click + + submenu.append(menu_item) + + menu_container = create_element("nav", classes=["menubar"]) + help_menu_container = create_element("nav", classes=["menubar"]) + + # If there isn't an explicit app menu group, add an inert placeholder + if toga.Group.APP not in self._menu_groups: + menu_container.appendChild( + create_element( + "span", + classes=["app"], + content=self.interface.app.formal_name, + ) + ) + + for group, items in self._menu_groups.items(): + submenu = self._create_submenu(group, items) + if group != toga.Group.HELP: + menu_container.appendChild(submenu) + else: + help_menu_container.appendChild(submenu) + + self.native_menu_container.replaceWith(menu_container) + self.native_menu_container = menu_container + + self.native_help_menu_container.replaceWith(help_menu_container) + self.native_help_menu_container = help_menu_container + + def create_toolbar(self): + self.interface.factory.not_implemented("Window.create_toolbar()") diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index eb5f5b5d24..ca96a0dbf7 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -6,32 +6,17 @@ import System.Windows.Forms as WinForms from System import Environment, Threading -from System.ComponentModel import InvalidEnumArgumentException from System.Media import SystemSounds from System.Net import SecurityProtocolType, ServicePointManager from System.Windows.Threading import Dispatcher import toga from toga import Key -from toga.command import Separator +from toga.app import overridden -from .keys import toga_to_winforms_key, toga_to_winforms_shortcut from .libs.proactor import WinformsProactorEventLoop from .libs.wrapper import WeakrefCallable from .screens import Screen as ScreenImpl -from .window import Window - - -class MainWindow(Window): - def winforms_FormClosing(self, sender, event): - # Differentiate between the handling that occurs when the user - # requests the app to exit, and the actual application exiting. - if not self.interface.app._impl._is_exiting: # pragma: no branch - # If there's an event handler, process it. The decision to - # actually exit the app will be processed in the on_exit handler. - # If there's no exit handler, assume the close/exit can proceed. - self.interface.app.on_exit() - event.Cancel = True def winforms_thread_exception(sender, winforms_exc): # pragma: no cover @@ -69,19 +54,19 @@ def print_stack_trace(stack_trace_line): # pragma: no cover class App: - _MAIN_WINDOW_CLASS = MainWindow + # Winforms apps exit when the last window is closed + CLOSE_ON_LAST_WINDOW = True def __init__(self, interface): self.interface = interface self.interface._impl = self - # Winforms app exit is tightly bound to the close of the MainWindow. - # The FormClosing message on MainWindow triggers the "on_exit" handler - # (which might abort the exit). However, on success, it will request the - # app (and thus the Main Window) to close, causing another close event. - # So - we have a flag that is only ever sent once a request has been - # made to exit the native app. This flag can be used to shortcut any - # window-level close handling. + # Winforms app exit is tightly bound to the close of the main window. The + # FormClosing message on the main window triggers the "on_exit" handler (which + # might abort the exit). However, on success, it will request the app (and thus + # the Main Window) to close, causing another close event. So - we have a flag + # that is only ever sent once a request has been made to exit the native app. + # This flag can be used to shortcut any window-level close handling. self._is_exiting = False # Winforms cursor visibility is a stack; If you call hide N times, you @@ -93,6 +78,8 @@ def __init__(self, interface): asyncio.set_event_loop(self.loop) def create(self): + # The winforms App impl's create() is deferred so that it can run inside the GUI + # thread. This means the app isn't created until the main loop is running. self.native = WinForms.Application self.app_context = WinForms.ApplicationContext() self.app_dispatcher = Dispatcher.CurrentDispatcher @@ -146,118 +133,124 @@ def create(self): # Call user code to populate the main window self.interface._startup() - self.create_app_commands() - self.create_menus() - self.interface.main_window._impl.set_app(self) ###################################################################### # Commands and menus ###################################################################### def create_app_commands(self): - self.interface.commands.add( - # About should be the last item in the Help menu, in a section on its own. - toga.Command( - lambda _: self.interface.about(), - f"About {self.interface.formal_name}", - group=toga.Group.HELP, - section=sys.maxsize, - ), - # - toga.Command(None, "Preferences", group=toga.Group.FILE), - # - # On Windows, the Exit command doesn't usually contain the app name. It - # should be the last item in the File menu, in a section on its own. - toga.Command( - lambda _: self.interface.on_exit(), - "Exit", - shortcut=Key.MOD_1 + "q", - group=toga.Group.FILE, - section=sys.maxsize, - ), - # - toga.Command( - lambda _: self.interface.visit_homepage(), - "Visit homepage", - enabled=self.interface.home_page is not None, - group=toga.Group.HELP, - ), - ) + # Set up the default commands for the interface. + if ( + isinstance(self.interface.main_window, toga.MainWindow) + or self.interface.main_window is None + ): + self.interface.commands.add( + # + toga.Command( + self.interface._menu_preferences, + "Preferences", + group=toga.Group.FILE, + # For now, only enable preferences if the user defines an implementation + enabled=overridden(self.interface.preferences), + ), + # + # On Windows, the Exit command doesn't usually contain the app name. It + # should be the last item in the File menu, in a section on its own. + toga.Command( + self.interface._menu_exit, + "Exit", + shortcut=Key.MOD_1 + "q", + group=toga.Group.FILE, + section=sys.maxsize, + ), + # + toga.Command( + self.interface._menu_visit_homepage, + "Visit homepage", + enabled=self.interface.home_page is not None, + group=toga.Group.HELP, + ), + # About should be the last item in the Help menu, in a section on its own. + toga.Command( + self.interface._menu_about, + f"About {self.interface.formal_name}", + group=toga.Group.HELP, + section=sys.maxsize, + ), + ) - def _submenu(self, group, menubar): - try: - return self._menu_groups[group] - except KeyError: - if group is None: - submenu = menubar - else: - parent_menu = self._submenu(group.parent, menubar) + # Add a "New" menu item for each unique registered document type. + if self.interface.document_types: + for document_class in self.interface.document_types.values(): + self.interface.commands.add( + toga.Command( + self.interface._menu_new_document(document_class), + text=f"New {document_class.document_type}", + shortcut=( + toga.Key.MOD_1 + "n" + if document_class == self.interface.main_window + else None + ), + group=toga.Group.FILE, + section=0, + ), + ) - submenu = WinForms.ToolStripMenuItem(group.text) + # If there's a user-provided open() implementation, or there are registered + # document types, add an Open menu item. + if overridden(self.interface.open) or self.interface.document_types: + self.interface.commands.add( + toga.Command( + self.interface._menu_open_file, + text="Open\u2026", + shortcut=toga.Key.MOD_1 + "o", + group=toga.Group.FILE, + section=10, + ), + ) - # Top level menus are added in a different way to submenus - if group.parent is None: - parent_menu.Items.Add(submenu) - else: - parent_menu.DropDownItems.Add(submenu) + # If there is a user-provided save() implementation, or there are registered + # document types, add a Save menu item. + if overridden(self.interface.save) or self.interface.document_types: + self.interface.commands.add( + toga.Command( + self.interface._menu_save, + text="Save", + shortcut=toga.Key.MOD_1 + "s", + group=toga.Group.FILE, + section=20, + ), + ) - self._menu_groups[group] = submenu - return submenu + # If there is a user-provided save_as() implementation, or there are registered + # document types, add a Save As menu item. + if overridden(self.interface.save_as) or self.interface.document_types: + self.interface.commands.add( + toga.Command( + self.interface._menu_save_as, + text="Save As\u2026", + group=toga.Group.FILE, + section=20, + order=10, + ), + ) + + # If there is a user-provided save_all() implementation, or there are registered + # document types, add a Save All menu item. + if overridden(self.interface.save_all) or self.interface.document_types: + self.interface.commands.add( + toga.Command( + self.interface._menu_save_all, + text="Save All", + group=toga.Group.FILE, + section=20, + order=20, + ), + ) def create_menus(self): - if self.interface.main_window is None: # pragma: no branch - # The startup method may create commands before creating the window, so - # we'll call create_menus again after it returns. - return - - window = self.interface.main_window._impl - menubar = window.native.MainMenuStrip - if menubar: - menubar.Items.Clear() - else: - # The menu bar doesn't need to be positioned, because its `Dock` property - # defaults to `Top`. - menubar = WinForms.MenuStrip() - window.native.Controls.Add(menubar) - window.native.MainMenuStrip = menubar - menubar.SendToBack() # In a dock, "back" means "top". - - # The File menu should come before all user-created menus. - self._menu_groups = {} - toga.Group.FILE.order = -1 - - submenu = None - for cmd in self.interface.commands: - submenu = self._submenu(cmd.group, menubar) - if isinstance(cmd, Separator): - submenu.DropDownItems.Add("-") - else: - submenu = self._submenu(cmd.group, menubar) - item = WinForms.ToolStripMenuItem(cmd.text) - item.Click += WeakrefCallable(cmd._impl.winforms_Click) - if cmd.shortcut is not None: - try: - item.ShortcutKeys = toga_to_winforms_key(cmd.shortcut) - # The Winforms key enum is... daft. The "oem" key - # values render as "Oem" or "Oemcomma", so we need to - # *manually* set the display text for the key shortcut. - item.ShortcutKeyDisplayString = toga_to_winforms_shortcut( - cmd.shortcut - ) - except ( - ValueError, - InvalidEnumArgumentException, - ) as e: # pragma: no cover - # Make this a non-fatal warning, because different backends may - # accept different shortcuts. - print(f"WARNING: invalid shortcut {cmd.shortcut!r}: {e}") - - item.Enabled = cmd.enabled - - cmd._impl.native.append(item) - submenu.DropDownItems.Add(item) - - window.resize_content() + for window in self.interface.windows: + window._impl.create_menus() ###################################################################### # App lifecycle @@ -267,6 +260,13 @@ def exit(self): # pragma: no cover self._is_exiting = True self.native.Exit() + def finalize(self): + self.create_app_commands() + self.create_menus() + + # Process any command line arguments to open documents, etc + self.interface._create_initial_windows() + def _run_app(self): # pragma: no cover # Enable coverage tracing on this non-Python-created thread # (https://github.com/nedbat/coveragepy/issues/686). @@ -302,7 +302,8 @@ def main_loop(self): raise self._exception def set_main_window(self, window): - self.app_context.MainForm = window._impl.native + if isinstance(window, toga.Window): + self.app_context.MainForm = window._impl.native ###################################################################### # App resources diff --git a/winforms/src/toga_winforms/dialogs.py b/winforms/src/toga_winforms/dialogs.py index cd09b6e98e..1dbf2148bc 100644 --- a/winforms/src/toga_winforms/dialogs.py +++ b/winforms/src/toga_winforms/dialogs.py @@ -18,14 +18,16 @@ class BaseDialog(ABC): def __init__(self, interface): self.interface = interface - self.interface._impl = self + if self.interface is not None: + self.interface._impl = self # See libs/proactor.py def start_inner_loop(self, callback, *args): asyncio.get_event_loop().start_inner_loop(callback, *args) def set_result(self, result): - self.interface.set_result(result) + if self.interface is not None: + self.interface.set_result(result) class MessageDialog(BaseDialog): diff --git a/winforms/src/toga_winforms/documents.py b/winforms/src/toga_winforms/documents.py new file mode 100644 index 0000000000..efaa0924c1 --- /dev/null +++ b/winforms/src/toga_winforms/documents.py @@ -0,0 +1,6 @@ +class Document: + def __init__(self, interface): + self.interface = interface + + def open(self): + self.interface.read() diff --git a/winforms/src/toga_winforms/factory.py b/winforms/src/toga_winforms/factory.py index 80f097ea10..c75818f31f 100644 --- a/winforms/src/toga_winforms/factory.py +++ b/winforms/src/toga_winforms/factory.py @@ -1,8 +1,9 @@ from toga import NotImplementedWarning from . import dialogs -from .app import App, MainWindow +from .app import App from .command import Command +from .documents import Document from .fonts import Font from .icons import Icon from .images import Image @@ -30,7 +31,7 @@ from .widgets.textinput import TextInput from .widgets.timeinput import TimeInput from .widgets.webview import WebView -from .window import Window +from .window import DocumentMainWindow, MainWindow, Window def not_implemented(feature): @@ -40,9 +41,9 @@ def not_implemented(feature): __all__ = [ "not_implemented", "App", - "MainWindow", "Command", # Resources + "Document", "Font", "Icon", "Image", @@ -72,6 +73,9 @@ def not_implemented(feature): "TextInput", "TimeInput", "WebView", + # Windows + "DocumentMainWindow", + "MainWindow", "Window", ] diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index 9407c80ef4..737a148c85 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -1,4 +1,5 @@ import System.Windows.Forms as WinForms +from System.ComponentModel import InvalidEnumArgumentException from System.Drawing import Bitmap, Graphics, Point, Size from System.Drawing.Imaging import ImageFormat from System.IO import MemoryStream @@ -6,6 +7,7 @@ from toga.command import Separator from .container import Container +from .keys import toga_to_winforms_key, toga_to_winforms_shortcut from .libs.wrapper import WeakrefCallable from .screens import Screen as ScreenImpl from .widgets.base import Scalable @@ -32,7 +34,8 @@ def __init__(self, interface, title, position, size): self.set_title(title) self.set_size(size) - self.set_position(position) + if position: + self.set_position(position) self.toolbar_native = None @@ -78,45 +81,9 @@ def close(self): self._is_closing = True self.native.Close() - def create_toolbar(self): - if self.interface.toolbar: - if self.toolbar_native: - self.toolbar_native.Items.Clear() - else: - # The toolbar doesn't need to be positioned, because its `Dock` property - # defaults to `Top`. - self.toolbar_native = WinForms.ToolStrip() - self.native.Controls.Add(self.toolbar_native) - self.toolbar_native.BringToFront() # In a dock, "front" means "bottom". - - prev_group = None - for cmd in self.interface.toolbar: - if isinstance(cmd, Separator): - item = WinForms.ToolStripSeparator() - prev_group = None - else: - # A change in group requires adding a toolbar separator - if prev_group is not None and prev_group != cmd.group: - self.toolbar_native.Items.Add(WinForms.ToolStripSeparator()) - prev_group = None - else: - prev_group = cmd.group - - item = WinForms.ToolStripMenuItem(cmd.text) - if cmd.tooltip is not None: - item.ToolTipText = cmd.tooltip - if cmd.icon is not None: - item.Image = cmd.icon._impl.native.ToBitmap() - item.Enabled = cmd.enabled - item.Click += WeakrefCallable(cmd._impl.winforms_Click) - cmd._impl.native.append(item) - self.toolbar_native.Items.Add(item) - - elif self.toolbar_native: - self.native.Controls.Remove(self.toolbar_native) - self.toolbar_native = None - - self.resize_content() + def create_menus(self): + # Base Window doesn't have menus + pass def set_app(self, app): icon_impl = app.interface.icon._impl @@ -243,3 +210,119 @@ def get_image_data(self): stream = MemoryStream() bitmap.Save(stream, ImageFormat.Png) return bytes(stream.ToArray()) + + +class MainWindow(Window): + def create_toolbar(self): + if self.interface.toolbar: + if self.toolbar_native: + self.toolbar_native.Items.Clear() + else: + # The toolbar doesn't need to be positioned, because its `Dock` property + # defaults to `Top`. + self.toolbar_native = WinForms.ToolStrip() + self.native.Controls.Add(self.toolbar_native) + self.toolbar_native.BringToFront() # In a dock, "front" means "bottom". + + prev_group = None + for cmd in self.interface.toolbar: + if isinstance(cmd, Separator): + item = WinForms.ToolStripSeparator() + prev_group = None + else: + # A change in group requires adding a toolbar separator + if prev_group is not None and prev_group != cmd.group: + self.toolbar_native.Items.Add(WinForms.ToolStripSeparator()) + prev_group = None + else: + prev_group = cmd.group + + item = WinForms.ToolStripMenuItem(cmd.text) + if cmd.tooltip is not None: + item.ToolTipText = cmd.tooltip + if cmd.icon is not None: + item.Image = cmd.icon._impl.native.ToBitmap() + item.Enabled = cmd.enabled + item.Click += WeakrefCallable(cmd._impl.winforms_Click) + cmd._impl.native.append(item) + self.toolbar_native.Items.Add(item) + + elif self.toolbar_native: + self.native.Controls.Remove(self.toolbar_native) + self.toolbar_native = None + + self.resize_content() + + def _submenu(self, group, menubar): + try: + return self._menu_groups[group] + except KeyError: + if group is None: + submenu = menubar + else: + parent_menu = self._submenu(group.parent, menubar) + + # Top level menus are added in a different way to submenus + submenu = WinForms.ToolStripMenuItem(group.text) + if group.parent is None: + parent_menu.Items.Add(submenu) + else: + parent_menu.DropDownItems.Add(submenu) + + self._menu_groups[group] = submenu + return submenu + + def create_menus(self): + # Reset the menubar + menubar = self.native.MainMenuStrip + if menubar: + menubar.Items.Clear() + else: + # The menu bar doesn't need to be positioned, because its `Dock` property + # defaults to `Top`. + menubar = WinForms.MenuStrip() + self.native.Controls.Add(menubar) + self.native.MainMenuStrip = menubar + menubar.SendToBack() # In a dock, "back" means "top". + + # The File menu should come before all user-created menus. + self._menu_groups = {} + + submenu = None + for cmd in self.interface.app.commands: + submenu = self._submenu(cmd.group, menubar) + if isinstance(cmd, Separator): + submenu.DropDownItems.Add("-") + else: + item = WinForms.ToolStripMenuItem(cmd.text) + + item.Click += WeakrefCallable(cmd._impl.winforms_Click) + if cmd.shortcut is not None: + try: + item.ShortcutKeys = toga_to_winforms_key(cmd.shortcut) + # The Winforms key enum is... daft. The "oem" key + # values render as "Oem" or "Oemcomma", so we need to + # *manually* set the display text for the key shortcut. + item.ShortcutKeyDisplayString = toga_to_winforms_shortcut( + cmd.shortcut + ) + except ( + ValueError, + InvalidEnumArgumentException, + ) as e: # pragma: no cover + # Make this a non-fatal warning, because different backends may + # accept different shortcuts. + print(f"WARNING: invalid shortcut {cmd.shortcut!r}: {e}") + + item.Enabled = cmd.enabled + + cmd._impl.native.append(item) + + submenu.DropDownItems.Add(item) + + self.resize_content() + + +class DocumentMainWindow(MainWindow): + # On Winforms, there's no real difference between a DocumentMainWindow and a MainWindow + pass