diff --git a/android/src/toga_android/app.py b/android/src/toga_android/app.py index b8589c7f56..74085f3a01 100644 --- a/android/src/toga_android/app.py +++ b/android/src/toga_android/app.py @@ -202,7 +202,7 @@ 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, ), diff --git a/cocoa/src/toga_cocoa/app.py b/cocoa/src/toga_cocoa/app.py index 0eed5505b2..ad9896dde2 100644 --- a/cocoa/src/toga_cocoa/app.py +++ b/cocoa/src/toga_cocoa/app.py @@ -15,8 +15,9 @@ from rubicon.objc.eventloop import CocoaLifecycle, EventLoopPolicy import toga +from toga.app import overridden from toga.command import Separator -from toga.handlers import NativeHandler, wrapped_handler +from toga.handlers import NativeHandler from .keys import cocoa_key from .libs import ( @@ -59,7 +60,19 @@ def applicationSupportsSecureRestorableState_(self, app) -> bool: @objc_method def applicationOpenUntitledFile_(self, sender) -> bool: if self.interface.document_types and self.interface.main_window is None: - self.impl._select_file() + # 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 @@ -97,9 +110,14 @@ def application_openFiles_(self, app, filenames) -> None: # Convert a Cocoa fileURL to a Python file path. path = Path(unquote(urlparse(str(fileURL.absoluteString)).path)) - # If the user has provided an `open()` method on the app, use it. - # Otherwise, fall back to the default implementation. - getattr(self.interface, "open", self.interface._open)(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: @@ -143,9 +161,6 @@ def __init__(self, interface): # 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): @@ -159,87 +174,18 @@ def _menu_minimize(self, command, **kwargs): if self.interface.current_window: self.interface.current_window._impl.native.miniaturize(None) - def _menu_new_document(self, document_class): - def new_file_handler(app, **kwargs): - self.interface._new(document_class) - - return new_file_handler - - def _menu_open_file(self, app, **kwargs): - self._select_file() - - def _menu_quit(self, command, **kwargs): - self.interface.on_exit() - - async def _menu_save(self, command, **kwargs): - try: - # If the user defined a save() method, use it - save = wrapped_handler(self.interface.save) - except AttributeError: - # If the document has a path, save it. If it doesn't, use the save-as - # implementation. We know there is a current window with a document because - # the save menu item has a dynamic enabled handler that only enables if this - # condition is true. - if self.interface.current_window.doc.path: - self.interface.current_window.doc.save() - else: - # If the document hasn't got a path, it's untitled, so we need to use - # save as - await self._menu_save_as(command, **kwargs) - else: - save() - - async def _menu_save_as(self, command, **kwargs): - try: - # If the user defined a save_as() method, use it - save_as = wrapped_handler(self.interface.save_as) - except AttributeError: - # Prompt the user for a new filename, and save the document at that path. We - # know there is a current window with a document because the save-as menu - # item has a dynamic enabled handler that only enables if this condition is - # true. - new_path = await self.interface.current_window.save_file_dialog( - "Save as", self.interface.current_window.doc.path - ) - if new_path: - self.interface.current_window.doc.save(new_path) - else: - save_as() - - async def _menu_save_all(self, command, **kwargs): - try: - # If the user defined a save_all() method, use it - save_all = wrapped_handler(self.interface.save_all) - except AttributeError: - # Iterate over every document managed by the app, and save it. If the - # document doesn't have a path, prompt the user to provide one. - for document in self.interface.documents: - if document.path: - document.save() - else: - new_path = await self.interface.current_window.save_file_dialog( - "Save as", f"New Document.{document.default_extension}" - ) - if new_path: - document.save(new_path) - else: - save_all() - - def _menu_visit_homepage(self, command, **kwargs): - self.interface.visit_homepage() - def create_app_commands(self): # All macOS apps have some basic commands. self.interface.commands.add( # ---- App menu ----------------------------------- toga.Command( - self._menu_about, + self.interface._menu_about, f"About {self.interface.formal_name}", group=toga.Group.APP, ), # Quit should always be the last item, in a section on its own toga.Command( - self._menu_quit, + self.interface._menu_exit, f"Quit {self.interface.formal_name}", shortcut=toga.Key.MOD_1 + "q", group=toga.Group.APP, @@ -247,7 +193,7 @@ def create_app_commands(self): ), # ---- 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, @@ -392,7 +338,7 @@ def create_app_commands(self): for document_class in sorted(set(self.interface.document_types.values())): self.interface.commands.add( toga.Command( - self._menu_new_document(document_class), + self.interface._menu_new_document(document_class), text=f"New {document_class.document_type}", shortcut=( toga.Key.MOD_1 + "n" @@ -406,10 +352,10 @@ def create_app_commands(self): # If there's a user-provided open() implementation, or there are registered # document types, add an Open menu item. - if hasattr(self.interface, "open") or self.interface.document_types: + if overridden(self.interface.open) or self.interface.document_types: self.interface.commands.add( toga.Command( - self._menu_open_file, + self.interface._menu_open_file, text="Open\u2026", shortcut=toga.Key.MOD_1 + "o", group=toga.Group.FILE, @@ -419,10 +365,10 @@ def create_app_commands(self): # If there is a user-provided save() implementation, or there are registered # document types, add a Save menu item. - if hasattr(self.interface, "save") or self.interface.document_types: + if overridden(self.interface.save) or self.interface.document_types: self.interface.commands.add( toga.Command( - self._menu_save, + self.interface._menu_save, text="Save", shortcut=toga.Key.MOD_1 + "s", group=toga.Group.FILE, @@ -434,10 +380,10 @@ def create_app_commands(self): # If there is a user-provided save_as() implementation, or there are registered # document types, add a Save As menu item. - if hasattr(self.interface, "save_as") or self.interface.document_types: + if overridden(self.interface.save_as) or self.interface.document_types: self.interface.commands.add( toga.Command( - self._menu_save_as, + self.interface._menu_save_as, text="Save As\u2026", shortcut=toga.Key.MOD_1 + "S", group=toga.Group.FILE, @@ -449,15 +395,16 @@ def create_app_commands(self): # If there is a user-provided save_all() implementation, or there are registered # document types, add a Save All menu item. - if hasattr(self.interface, "save_all") or self.interface.document_types: + if overridden(self.interface.save_all) or self.interface.document_types: self.interface.commands.add( toga.Command( - self._menu_save_all, + self.interface._menu_save_all, text="Save All", shortcut=toga.Key.MOD_1 + toga.Key.MOD_2 + "s", group=toga.Group.FILE, section=20, order=12, + enabled=self.interface.can_save, ), ) @@ -558,9 +505,11 @@ def finalize(self): # 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)) @@ -582,26 +531,6 @@ def get_screens(self): # App capabilities ###################################################################### - 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(None, openFiles=panel.URLs) - def beep(self): NSBeep() @@ -647,7 +576,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) diff --git a/core/src/toga/app.py b/core/src/toga/app.py index fc573e781a..7d4eaa88da 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -198,6 +198,21 @@ def _remove(self, id: str) -> None: del self._registry[id] +def overridable(method): + """Decorate the method as being user-overridable""" + method.__default__ = True + return method + + +def overridden(coroutine_or_method): + """Has the user overridden this method? + + 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: #: The currently running :class:`~toga.App`. Since there can only be one running #: Toga app in a process, this is available as a class property via ``toga.App.app``. @@ -546,6 +561,31 @@ def _startup(self): # 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("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. @@ -630,31 +670,101 @@ 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): + def new_document_handler(command, **kwargs): + self.new(document_class) + + 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 path: + self.open(path) + + async def _menu_save(self, command, **kwargs): + result = self.save(self.current_window) + if asyncio.iscoroutine(result): + asyncio.ensure_future(result) + + async def _menu_save_as(self, command, **kwargs): + result = self.save_as(self.current_window) + if asyncio.iscoroutine(result): + asyncio.ensure_future(result) + + async def _menu_save_all(self, command, **kwargs): + result = self.save_all() + if asyncio.iscoroutine(result): + asyncio.ensure_future(result) + + def _menu_visit_homepage(self, command, **kwargs): + self.visit_homepage() + ###################################################################### # App capabilities ###################################################################### - def _new(self, document_type: type[Document]) -> Document: - """Create a new document, and show the document window. + def about(self) -> None: + """Display the About dialog for the app. + + Default implementation shows a platform-appropriate about dialog using app + metadata. Override if you want to display a custom About dialog. + """ + self._impl.show_about_dialog() + + def beep(self) -> None: + """Play the default system notification sound.""" + self._impl.beep() + + def can_save(self) -> bool: + """Can the application currently save? + + This controls the activation status of the Save menu items, if present. + + By default, save is enabled if the current window has a ``doc`` attribute. The + object stored at this attribute must have a ``save()`` method. Apps can override + this method if they wish; this is strongly advised if the app provides a custom + ``save()`` method. + """ + return self.current_window is not None and hasattr(self.current_window, "doc") + + @overridable + def new(self, document_type: type[Document]) -> None: + """Create a new document of the given type, and show the document window. - Users can define a ``new()`` method to override this method. + Override this method to provide custom behavior for creating new document + windows. :param document_type: The document type to create. - :returns: The newly created document """ document = document_type(app=self) self._documents.append(document) document.show() - return document - - def _open(self, path: Path) -> Document: + @overridable + def open(self, path: Path) -> 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. - :returns: The document that has been opened - :raises ValueError: If the document is of a type that can't be opened. Backends can - suppress this exception if necessary to presere platform-native behavior. + :raises ValueError: If the path cannot be opened. """ try: DocType = self.document_types[path.suffix[1:]] @@ -667,34 +777,104 @@ def _open(self, path: Path) -> Document: self._documents.append(document) document.show() - return document + async def replacement_filename(self, suggested_name: Path) -> Path | None: + """Select a new filename for a file. - def about(self) -> None: - """Display the About dialog for the app. + 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: - Default implementation shows a platform-appropriate about dialog using app - metadata. Override if you want to display a custom About dialog. + 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. + + :param suggested name: The initial candidate filename + :returns: The path to use, or ``None`` if the user cancelled the request. """ - self._impl.show_about_dialog() + 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) + doc.save(new_path) - def beep(self) -> None: - """Play the default system notification sound.""" - self._impl.beep() + @overridable + async def save_as(self, window): + """Save the contents of a window under a new filename. - def _can_save(self) -> bool: - return self.current_window is not None and hasattr(self.current_window, "doc") + 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. - def can_save(self) -> bool: - """Can the application currently save? + 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. - This controls the activation status of the Save menu items, if present. + :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) + doc.save(new_path) - By default, save is enabled if the current window has a ``doc`` attribute. The - object stored at this attribute must have a ``save()`` method. Apps can override - this method if they wish; this is strongly advised if the app provides a custom - ``save()`` method. + @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. """ - return self._can_save() + 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. diff --git a/gtk/src/toga_gtk/app.py b/gtk/src/toga_gtk/app.py index e6ad5d5a45..02f0e7c47f 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,115 @@ 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( + None, + "Preferences", + group=toga.Group.EDIT, + section=sys.maxsize, + ), + # 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: @@ -154,10 +183,10 @@ def create_menus(self): menubar = Gio.Menu() section = None for cmd in self.interface.commands: + submenu, created = self._submenu(cmd.group, menubar) if isinstance(cmd, Separator): section = None else: - submenu, created = self._submenu(cmd.group, menubar) if created: section = None @@ -185,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 ###################################################################### @@ -193,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 @@ -230,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 @@ -238,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) @@ -253,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 @@ -286,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 ed2143d7cb..a97dabf311 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 @@ -30,7 +30,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): @@ -40,8 +40,6 @@ def not_implemented(feature): __all__ = [ "not_implemented", "App", - "DocumentApp", - "MainWindow", "Command", "Document", # Resources @@ -73,6 +71,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/web/src/toga_web/app.py b/web/src/toga_web/app.py index 67a0ae0baf..9c6eeb60cc 100644 --- a/web/src/toga_web/app.py +++ b/web/src/toga_web/app.py @@ -23,14 +23,11 @@ def create(self): # Commands and menus ###################################################################### - def _menu_about(self, command, **kwargs): - self.interface.about() - def create_app_commands(self): self.interface.commands.add( # ---- Help menu ---------------------------------- toga.Command( - self._menu_about, + self.interface._menu_about, f"About {self.interface.formal_name}", group=toga.Group.HELP, ), diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index eb5f5b5d24..6a273ba94d 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,118 @@ 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(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( + 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 +254,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 +296,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 e4ce4bba59..3f1301af22 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 @@ -29,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 DocumentMainWindow, MainWindow, Window def not_implemented(feature): @@ -39,9 +40,9 @@ def not_implemented(feature): __all__ = [ "not_implemented", "App", - "MainWindow", "Command", # Resources + "Document", "Font", "Icon", "Image", @@ -70,6 +71,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